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 config import settings, validate_settings import pyodbc from contextlib import contextmanager import pywintypes logger = logging.getLogger(__name__) class SageConnector: 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 self.sql_server = "OV-FDDDC6\\SAGE100" self.sql_database = "BIJOU" self.sql_conn_string = ( f"DRIVER={{ODBC Driver 17 for SQL Server}};" f"SERVER={self.sql_server};" f"DATABASE={self.sql_database};" f"Trusted_Connection=yes;" f"Encrypt=no;" ) self._lock_com = threading.RLock() # Thread-local storage pour COM self._thread_local = threading.local() # ========================================================================= # GESTION COM THREAD-SAFE # ========================================================================= @contextmanager def _com_context(self): # 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 @contextmanager def _get_sql_connection(self): """Context manager pour connexions SQL""" conn = None try: conn = pyodbc.connect(self.sql_conn_string, timeout=10) yield conn except pyodbc.Error as e: logger.error(f"❌ Erreur SQL: {e}") raise RuntimeError(f"Erreur SQL: {str(e)}") finally: if conn: conn.close() def _safe_strip(self, value): """Strip sécurisé pour valeurs SQL""" if value is None: return None if isinstance(value, str): return value.strip() return value 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 - VERSION HYBRIDE""" try: # ======================================== # CONNEXION COM (pour écritures) # ======================================== 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 COM Sage réussie: {self.chemin_base}") # ======================================== # TEST CONNEXION SQL (pour lectures) # ======================================== try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM F_COMPTET") nb_tiers = cursor.fetchone()[0] logger.info(f"✅ Connexion SQL réussie: {nb_tiers} tiers détectés") except Exception as e: logger.warning(f"⚠️ SQL non disponible: {e}") logger.warning(" Les lectures utiliseront COM (plus lent)") return True except Exception as e: logger.error(f"❌ Erreur connexion Sage: {e}", exc_info=True) return False def deconnecter(self): """Déconnexion propre""" if self.cial: try: with self._com_context(): self.cial.Close() logger.info("Connexion Sage fermée") except: pass def lister_tous_fournisseurs(self, filtre=""): try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Contact FROM F_COMPTET WHERE CT_Type = 1 """ params = [] if filtre: query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY CT_Intitule" cursor.execute(query, params) rows = cursor.fetchall() fournisseurs = [] for row in rows: fournisseurs.append( { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "type": 1, # Fournisseur "est_fournisseur": True, "qualite": self._safe_strip(row.CT_Qualite), "adresse": self._safe_strip(row.CT_Adresse), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "pays": self._safe_strip(row.CT_Pays), "telephone": self._safe_strip(row.CT_Telephone), "email": self._safe_strip(row.CT_EMail), "siret": self._safe_strip(row.CT_Siret), "tva_intra": self._safe_strip(row.CT_Identifiant), "est_actif": (row.CT_Sommeil == 0), "contact": self._safe_strip(row.CT_Contact), } ) logger.info(f"✅ SQL: {len(fournisseurs)} fournisseurs") return fournisseurs except Exception as e: logger.error(f"❌ Erreur SQL fournisseurs: {e}") return [] def lire_fournisseur(self, code): try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Complement, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_Portable, CT_EMail, CT_Telecopie, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Contact, CT_FormeJuridique FROM F_COMPTET WHERE CT_Num = ? AND CT_Type = 1 """, (code.upper(),), ) row = cursor.fetchone() if not row: return None return { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "type": 1, "est_fournisseur": True, "qualite": self._safe_strip(row.CT_Qualite), "adresse": self._safe_strip(row.CT_Adresse), "complement": self._safe_strip(row.CT_Complement), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "pays": self._safe_strip(row.CT_Pays), "telephone": self._safe_strip(row.CT_Telephone), "portable": self._safe_strip(row.CT_Portable), "email": self._safe_strip(row.CT_EMail), "telecopie": self._safe_strip(row.CT_Telecopie), "siret": self._safe_strip(row.CT_Siret), "tva_intra": self._safe_strip(row.CT_Identifiant), "est_actif": (row.CT_Sommeil == 0), "contact": self._safe_strip(row.CT_Contact), "forme_juridique": self._safe_strip(row.CT_FormeJuridique), } except Exception as e: logger.error(f"❌ Erreur SQL fournisseur {code}: {e}") return None def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: 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: 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 lister_tous_clients(self, filtre=""): try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Prospect, CT_Contact FROM F_COMPTET WHERE CT_Type = 0 """ params = [] if filtre: query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY CT_Intitule" cursor.execute(query, params) rows = cursor.fetchall() clients = [] for row in rows: clients.append( { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "type": row.CT_Type, "qualite": self._safe_strip(row.CT_Qualite), "adresse": self._safe_strip(row.CT_Adresse), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "pays": self._safe_strip(row.CT_Pays), "telephone": self._safe_strip(row.CT_Telephone), "email": self._safe_strip(row.CT_EMail), "siret": self._safe_strip(row.CT_Siret), "tva_intra": self._safe_strip(row.CT_Identifiant), "est_actif": (row.CT_Sommeil == 0), "est_prospect": (row.CT_Prospect == 1), "contact": self._safe_strip(row.CT_Contact), } ) logger.info(f"✅ SQL: {len(clients)} clients") return clients except Exception as e: logger.error(f"❌ Erreur SQL clients: {e}") raise RuntimeError(f"Erreur lecture clients: {str(e)}") def lire_client(self, code_client): try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ✅ MÊME REQUÊTE que lister_tous_clients (colonnes existantes uniquement) cursor.execute( """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Prospect, CT_Contact FROM F_COMPTET WHERE CT_Num = ? """, (code_client.upper(),), ) row = cursor.fetchone() if not row: return None return { "numero": self._safe_strip(row[0]), "intitule": self._safe_strip(row[1]), "type": row[2], "qualite": self._safe_strip(row[3]), "adresse": self._safe_strip(row[4]), "ville": self._safe_strip(row[5]), "code_postal": self._safe_strip(row[6]), "pays": self._safe_strip(row[7]), "telephone": self._safe_strip(row[8]), "email": self._safe_strip(row[9]), "siret": self._safe_strip(row[10]), "tva_intra": self._safe_strip(row[11]), "est_actif": (row[12] == 0), "est_prospect": (row[13] == 1), "contact": self._safe_strip(row[14]), } except Exception as e: logger.error(f"❌ Erreur SQL client {code_client}: {e}") return None def lister_tous_articles(self, filtre="", avec_stock=True): try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ======================================== # ÉTAPE 1 : LIRE LES ARTICLES DE BASE # ======================================== query = """ SELECT AR_Ref, AR_Design, AR_PrixVen, AR_PrixAch, AR_UniteVen, FA_CodeFamille, AR_Sommeil, AR_CodeBarre, AR_Type FROM F_ARTICLE WHERE 1=1 """ params = [] if filtre: query += " AND (AR_Ref LIKE ? OR AR_Design LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY AR_Ref" cursor.execute(query, params) rows = cursor.fetchall() articles = [] for row in rows: article = { "reference": self._safe_strip(row[0]), "designation": self._safe_strip(row[1]), "prix_vente": float(row[2]) if row[2] is not None else 0.0, "prix_achat": float(row[3]) if row[3] is not None else 0.0, "unite_vente": ( str(row[4]).strip() if row[4] is not None else "" ), "famille_code": self._safe_strip(row[5]), "est_actif": (row[6] == 0), "code_ean": self._safe_strip(row[7]), "type_article": row[8] if row[8] is not None else 0, # ✅ CORRECTION : Pas de AR_Stock dans ta base ! "stock_reel": 0.0, "stock_mini": 0.0, "stock_maxi": 0.0, "stock_reserve": 0.0, "stock_commande": 0.0, "stock_disponible": 0.0, } article["code_barre"] = article["code_ean"] articles.append(article) # ======================================== # ÉTAPE 2 : ENRICHIR AVEC STOCK DEPUIS F_ARTSTOCK (OBLIGATOIRE) # ======================================== if avec_stock and articles: logger.info( f"📦 Enrichissement stock depuis F_ARTSTOCK pour {len(articles)} articles..." ) try: # Créer un mapping des références references = [ a["reference"] for a in articles if a["reference"] ] if not references: return articles # Requête pour récupérer TOUS les stocks en une fois placeholders = ",".join(["?"] * len(references)) stock_query = f""" SELECT AR_Ref, SUM(ISNULL(AS_QteSto, 0)) as Stock_Total, MIN(ISNULL(AS_QteMini, 0)) as Stock_Mini, MAX(ISNULL(AS_QteMaxi, 0)) as Stock_Maxi, SUM(ISNULL(AS_QteRes, 0)) as Stock_Reserve, SUM(ISNULL(AS_QteCom, 0)) as Stock_Commande FROM F_ARTSTOCK WHERE AR_Ref IN ({placeholders}) GROUP BY AR_Ref """ cursor.execute(stock_query, references) stock_rows = cursor.fetchall() stock_map = {} for stock_row in stock_rows: ref = self._safe_strip(stock_row[0]) if ref: stock_map[ref] = { "stock_reel": ( float(stock_row[1]) if stock_row[1] else 0.0 ), "stock_mini": ( float(stock_row[2]) if stock_row[2] else 0.0 ), "stock_maxi": ( float(stock_row[3]) if stock_row[3] else 0.0 ), "stock_reserve": ( float(stock_row[4]) if stock_row[4] else 0.0 ), "stock_commande": ( float(stock_row[5]) if stock_row[5] else 0.0 ), } logger.info( f"✅ Stocks chargés pour {len(stock_map)} articles depuis F_ARTSTOCK" ) # Enrichir les articles for article in articles: if article["reference"] in stock_map: stock_data = stock_map[article["reference"]] article.update(stock_data) article["stock_disponible"] = ( article["stock_reel"] - article["stock_reserve"] ) else: # Article sans stock enregistré article["stock_reel"] = 0.0 article["stock_mini"] = 0.0 article["stock_maxi"] = 0.0 article["stock_reserve"] = 0.0 article["stock_commande"] = 0.0 article["stock_disponible"] = 0.0 except Exception as e: logger.error( f"❌ Erreur lecture F_ARTSTOCK: {e}", exc_info=True ) # Ne pas lever d'exception, retourner les articles sans stock logger.info( f"✅ SQL: {len(articles)} articles (stock={'oui' if avec_stock else 'non'})" ) return articles except Exception as e: logger.error(f"❌ Erreur SQL articles: {e}", exc_info=True) raise RuntimeError(f"Erreur lecture articles: {str(e)}") def lire_article(self, reference): try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT AR_Ref, AR_Design, AR_PrixVen, AR_PrixAch, AR_UniteVen, FA_CodeFamille, AR_Sommeil, AR_CodeBarre, AR_Type FROM F_ARTICLE WHERE AR_Ref = ? """, (reference.upper(),), ) row = cursor.fetchone() if not row: return None article = { "reference": self._safe_strip(row[0]), "designation": self._safe_strip(row[1]), "prix_vente": float(row[2]) if row[2] is not None else 0.0, "prix_achat": float(row[3]) if row[3] is not None else 0.0, "unite_vente": str(row[4]).strip() if row[4] is not None else "", "famille_code": self._safe_strip(row[5]), "est_actif": (row[6] == 0), "code_ean": self._safe_strip(row[7]), "code_barre": self._safe_strip(row[7]), "type_article": row[8] if row[8] is not None else 0, "type_article_libelle": { 0: "Article", 1: "Prestation", 2: "Divers", }.get(row[8] if row[8] is not None else 0, "Article"), # Champs optionnels (initialisés à vide/valeur par défaut) "description": "", "designation_complementaire": "", "poids": 0.0, "volume": 0.0, "tva_code": "", "date_creation": "", "date_modification": "", # Stock initialisé à 0 - sera mis à jour depuis F_ARTSTOCK "stock_reel": 0.0, "stock_mini": 0.0, "stock_maxi": 0.0, "stock_reserve": 0.0, "stock_commande": 0.0, "stock_disponible": 0.0, } # TVA taux (par défaut 20%) article["tva_taux"] = 20.0 # ======================================== # ÉTAPE 2 : LIRE LE STOCK DEPUIS F_ARTSTOCK (OBLIGATOIRE) # ======================================== logger.info(f"📦 Lecture stock depuis F_ARTSTOCK pour {reference}...") try: cursor.execute( """ SELECT SUM(ISNULL(AS_QteSto, 0)) as Stock_Total, MIN(ISNULL(AS_QteMini, 0)) as Stock_Mini, MAX(ISNULL(AS_QteMaxi, 0)) as Stock_Maxi, SUM(ISNULL(AS_QteRes, 0)) as Stock_Reserve, SUM(ISNULL(AS_QteCom, 0)) as Stock_Commande FROM F_ARTSTOCK WHERE AR_Ref = ? GROUP BY AR_Ref """, (reference.upper(),), ) stock_row = cursor.fetchone() if stock_row: # ✅ STOCK DEPUIS F_ARTSTOCK article["stock_reel"] = ( float(stock_row[0]) if stock_row[0] else 0.0 ) article["stock_mini"] = ( float(stock_row[1]) if stock_row[1] else 0.0 ) article["stock_maxi"] = ( float(stock_row[2]) if stock_row[2] else 0.0 ) # Priorité aux réserves/commandes de F_ARTSTOCK si disponibles stock_reserve_artstock = ( float(stock_row[3]) if stock_row[3] else 0.0 ) stock_commande_artstock = ( float(stock_row[4]) if stock_row[4] else 0.0 ) if stock_reserve_artstock > 0: article["stock_reserve"] = stock_reserve_artstock if stock_commande_artstock > 0: article["stock_commande"] = stock_commande_artstock article["stock_disponible"] = ( article["stock_reel"] - article["stock_reserve"] ) logger.info( f"✅ Stock trouvé dans F_ARTSTOCK: {article['stock_reel']} unités" ) else: logger.info( f"⚠️ Aucun stock trouvé dans F_ARTSTOCK pour {reference}" ) except Exception as e: logger.error(f"❌ Erreur lecture F_ARTSTOCK pour {reference}: {e}") # ======================================== # ÉTAPE 3 : ENRICHIR AVEC LIBELLÉ FAMILLE # ======================================== if article["famille_code"]: try: cursor.execute( "SELECT FA_Intitule FROM F_FAMILLE WHERE FA_CodeFamille = ?", (article["famille_code"],), ) famille_row = cursor.fetchone() if famille_row: article["famille_libelle"] = self._safe_strip( famille_row[0] ) else: article["famille_libelle"] = "" except: article["famille_libelle"] = "" else: article["famille_libelle"] = "" return article except Exception as e: logger.error(f"❌ Erreur SQL article {reference}: {e}") return None def _convertir_type_pour_sql(self, type_doc: int) -> int: """COM → SQL : 0, 10, 20, 30... → 0, 1, 2, 3...""" mapping = {0: 0, 10: 1, 20: 2, 30: 3, 40: 4, 50: 5, 60: 6} return mapping.get(type_doc, type_doc) def _convertir_type_depuis_sql(self, type_sql: int) -> int: """SQL → COM : 0, 1, 2, 3... → 0, 10, 20, 30...""" mapping = {0: 0, 1: 10, 2: 20, 3: 30, 4: 40, 5: 50, 6: 60} return mapping.get(type_sql, type_sql) def _lire_document_sql(self, numero: str, type_doc: int): """ Lit un document spécifique par son numéro. PAS de filtre par préfixe car on cherche un document précis. """ try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ======================================== # LIRE L'ENTÊTE (recherche directe, sans filtres restrictifs) # ======================================== query = """ SELECT d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, d.DO_Statut, d.DO_Tiers, d.DO_DateLivr, d.DO_DateExpedition, d.DO_Contact, d.DO_TotalHTNet, d.DO_NetAPayer, d.DO_MontantRegle, d.DO_Reliquat, d.DO_TxEscompte, d.DO_Escompte, d.DO_Taxe1, d.DO_Taxe2, d.DO_Taxe3, d.DO_CodeTaxe1, d.DO_CodeTaxe2, d.DO_CodeTaxe3, d.DO_EStatut, d.DO_Imprim, d.DO_Valide, d.DO_Cloture, d.DO_Transfere, d.DO_Souche, d.DO_PieceOrig, d.DO_GUID, d.CA_Num, d.CG_Num, d.DO_Expedit, d.DO_Condition, d.DO_Tarif, d.DO_TypeFrais, d.DO_ValFrais, d.DO_TypeFranco, d.DO_ValFranco, c.CT_Intitule, c.CT_Adresse, c.CT_CodePostal, c.CT_Ville, c.CT_Telephone, c.CT_EMail FROM F_DOCENTETE d LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num WHERE d.DO_Piece = ? AND d.DO_Type = ? """ logger.info(f"[SQL READ] Lecture directe de {numero} (type={type_doc})") cursor.execute(query, (numero, type_doc)) row = cursor.fetchone() if not row: logger.warning( f"[SQL READ] ❌ Document {numero} (type={type_doc}) INTROUVABLE dans F_DOCENTETE" ) return None numero_piece = self._safe_strip(row[0]) logger.info(f"[SQL READ] ✅ Document trouvé: {numero_piece}") # ======================================== # PAS DE FILTRE PAR PRÉFIXE ICI ! # On retourne le document demandé, qu'il soit transformé ou non # ======================================== # ======================================== # CONSTRUIRE L'OBJET DOCUMENT # ======================================== doc = { # Informations de base (indices 0-9) "numero": numero_piece, "reference": self._safe_strip(row[2]), # DO_Ref "date": str(row[1]) if row[1] else "", # DO_Date "date_livraison": (str(row[7]) if row[7] else ""), # DO_DateLivr "date_expedition": ( str(row[8]) if row[8] else "" ), # DO_DateExpedition # Client (indices 6 et 39-44) "client_code": self._safe_strip(row[6]), # DO_Tiers "client_intitule": self._safe_strip(row[39]), # CT_Intitule "client_adresse": self._safe_strip(row[40]), # CT_Adresse "client_code_postal": self._safe_strip(row[41]), # CT_CodePostal "client_ville": self._safe_strip(row[42]), # CT_Ville "client_telephone": self._safe_strip(row[43]), # CT_Telephone "client_email": self._safe_strip(row[44]), # CT_EMail "contact": self._safe_strip(row[9]), # DO_Contact # Totaux (indices 3-4, 10-13) "total_ht": float(row[3]) if row[3] else 0.0, # DO_TotalHT "total_ht_net": float(row[10]) if row[10] else 0.0, # DO_TotalHTNet "total_ttc": float(row[4]) if row[4] else 0.0, # DO_TotalTTC "net_a_payer": float(row[11]) if row[11] else 0.0, # DO_NetAPayer "montant_regle": ( float(row[12]) if row[12] else 0.0 ), # DO_MontantRegle "reliquat": float(row[13]) if row[13] else 0.0, # DO_Reliquat # Taxes (indices 14-21) "taux_escompte": ( float(row[14]) if row[14] else 0.0 ), # DO_TxEscompte "escompte": float(row[15]) if row[15] else 0.0, # DO_Escompte "taxe1": float(row[16]) if row[16] else 0.0, # DO_Taxe1 "taxe2": float(row[17]) if row[17] else 0.0, # DO_Taxe2 "taxe3": float(row[18]) if row[18] else 0.0, # DO_Taxe3 "code_taxe1": self._safe_strip(row[19]), # DO_CodeTaxe1 "code_taxe2": self._safe_strip(row[20]), # DO_CodeTaxe2 "code_taxe3": self._safe_strip(row[21]), # DO_CodeTaxe3 # Statuts (indices 5, 22-26) "statut": int(row[5]) if row[5] is not None else 0, # DO_Statut "statut_estatut": ( int(row[22]) if row[22] is not None else 0 ), # DO_EStatut "imprime": int(row[23]) if row[23] is not None else 0, # DO_Imprim "valide": int(row[24]) if row[24] is not None else 0, # DO_Valide "cloture": int(row[25]) if row[25] is not None else 0, # DO_Cloture "transfere": ( int(row[26]) if row[26] is not None else 0 ), # DO_Transfere # Autres (indices 27-38) "souche": int(row[27]) if row[27] is not None else 0, # DO_Souche "piece_origine": self._safe_strip(row[28]), # DO_PieceOrig "guid": self._safe_strip(row[29]), # DO_GUID "ca_num": self._safe_strip(row[30]), # CA_Num "cg_num": self._safe_strip(row[31]), # CG_Num "expedition": ( int(row[32]) if row[32] is not None else 1 ), # DO_Expedit "condition": ( int(row[33]) if row[33] is not None else 1 ), # DO_Condition "tarif": int(row[34]) if row[34] is not None else 1, # DO_Tarif "type_frais": ( int(row[35]) if row[35] is not None else 0 ), # DO_TypeFrais "valeur_frais": float(row[36]) if row[36] else 0.0, # DO_ValFrais "type_franco": ( int(row[37]) if row[37] is not None else 0 ), # DO_TypeFranco "valeur_franco": float(row[38]) if row[38] else 0.0, # DO_ValFranco } # ======================================== # CHARGER LES LIGNES # ======================================== cursor.execute( """ SELECT dl.*, a.AR_Design, a.FA_CodeFamille, a.AR_PrixTTC, a.AR_PrixVen, a.AR_PrixAch, a.AR_Gamme1, a.AR_Gamme2, a.AR_CodeBarre, a.AR_CoutStd, a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_UniteVen, a.AR_Type, a.AR_Nature, a.AR_Escompte, a.AR_Garantie FROM F_DOCLIGNE dl LEFT JOIN F_ARTICLE a ON dl.AR_Ref = a.AR_Ref WHERE dl.DO_Piece = ? AND dl.DO_Type = ? ORDER BY dl.DL_Ligne """, (numero, type_doc), ) lignes = [] for ligne_row in cursor.fetchall(): montant_ht = ( float(ligne_row.DL_MontantHT) if ligne_row.DL_MontantHT else 0.0 ) montant_net = ( float(ligne_row.DL_MontantNet) if hasattr(ligne_row, "DL_MontantNet") and ligne_row.DL_MontantNet else montant_ht ) taux_taxe1 = ( float(ligne_row.DL_Taxe1) if hasattr(ligne_row, "DL_Taxe1") and ligne_row.DL_Taxe1 else 0.0 ) taux_taxe2 = ( float(ligne_row.DL_Taxe2) if hasattr(ligne_row, "DL_Taxe2") and ligne_row.DL_Taxe2 else 0.0 ) taux_taxe3 = ( float(ligne_row.DL_Taxe3) if hasattr(ligne_row, "DL_Taxe3") and ligne_row.DL_Taxe3 else 0.0 ) total_taux_taxes = taux_taxe1 + taux_taxe2 + taux_taxe3 montant_ttc = montant_net * (1 + total_taux_taxes / 100) montant_taxe1 = montant_net * (taux_taxe1 / 100) montant_taxe2 = montant_net * (taux_taxe2 / 100) montant_taxe3 = montant_net * (taux_taxe3 / 100) ligne = { "numero_ligne": ( int(ligne_row.DL_Ligne) if ligne_row.DL_Ligne else 0 ), "article_code": self._safe_strip(ligne_row.AR_Ref), "designation": self._safe_strip(ligne_row.DL_Design), "designation_article": self._safe_strip(ligne_row.AR_Design), "quantite": ( float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0 ), "quantite_livree": ( float(ligne_row.DL_QteLiv) if hasattr(ligne_row, "DL_QteLiv") and ligne_row.DL_QteLiv else 0.0 ), "quantite_reservee": ( float(ligne_row.DL_QteRes) if hasattr(ligne_row, "DL_QteRes") and ligne_row.DL_QteRes else 0.0 ), "unite": ( self._safe_strip(ligne_row.DL_Unite) if hasattr(ligne_row, "DL_Unite") else "" ), "prix_unitaire_ht": ( float(ligne_row.DL_PrixUnitaire) if ligne_row.DL_PrixUnitaire else 0.0 ), "prix_unitaire_achat": ( float(ligne_row.AR_PrixAch) if ligne_row.AR_PrixAch else 0.0 ), "prix_unitaire_vente": ( float(ligne_row.AR_PrixVen) if ligne_row.AR_PrixVen else 0.0 ), "prix_unitaire_ttc": ( float(ligne_row.AR_PrixTTC) if ligne_row.AR_PrixTTC else 0.0 ), "montant_ligne_ht": montant_ht, "montant_ligne_net": montant_net, "montant_ligne_ttc": montant_ttc, "remise_valeur1": ( float(ligne_row.DL_Remise01REM_Valeur) if hasattr(ligne_row, "DL_Remise01REM_Valeur") and ligne_row.DL_Remise01REM_Valeur else 0.0 ), "remise_type1": ( int(ligne_row.DL_Remise01REM_Type) if hasattr(ligne_row, "DL_Remise01REM_Type") and ligne_row.DL_Remise01REM_Type else 0 ), "remise_valeur2": ( float(ligne_row.DL_Remise02REM_Valeur) if hasattr(ligne_row, "DL_Remise02REM_Valeur") and ligne_row.DL_Remise02REM_Valeur else 0.0 ), "remise_type2": ( int(ligne_row.DL_Remise02REM_Type) if hasattr(ligne_row, "DL_Remise02REM_Type") and ligne_row.DL_Remise02REM_Type else 0 ), "remise_article": ( float(ligne_row.AR_Escompte) if ligne_row.AR_Escompte else 0.0 ), "taux_taxe1": taux_taxe1, "montant_taxe1": montant_taxe1, "taux_taxe2": taux_taxe2, "montant_taxe2": montant_taxe2, "taux_taxe3": taux_taxe3, "montant_taxe3": montant_taxe3, "total_taxes": montant_taxe1 + montant_taxe2 + montant_taxe3, "famille_article": self._safe_strip(ligne_row.FA_CodeFamille), "gamme1": self._safe_strip(ligne_row.AR_Gamme1), "gamme2": self._safe_strip(ligne_row.AR_Gamme2), "code_barre": self._safe_strip(ligne_row.AR_CodeBarre), "type_article": self._safe_strip(ligne_row.AR_Type), "nature_article": self._safe_strip(ligne_row.AR_Nature), "garantie": self._safe_strip(ligne_row.AR_Garantie), "cout_standard": ( float(ligne_row.AR_CoutStd) if ligne_row.AR_CoutStd else 0.0 ), "poids_net": ( float(ligne_row.AR_PoidsNet) if ligne_row.AR_PoidsNet else 0.0 ), "poids_brut": ( float(ligne_row.AR_PoidsBrut) if ligne_row.AR_PoidsBrut else 0.0 ), "unite_vente": self._safe_strip(ligne_row.AR_UniteVen), "date_livraison_ligne": ( str(ligne_row.DL_DateLivr) if hasattr(ligne_row, "DL_DateLivr") and ligne_row.DL_DateLivr else "" ), "statut_ligne": ( int(ligne_row.DL_Statut) if hasattr(ligne_row, "DL_Statut") and ligne_row.DL_Statut is not None else 0 ), "depot": ( self._safe_strip(ligne_row.DE_No) if hasattr(ligne_row, "DE_No") else "" ), "numero_commande": ( self._safe_strip(ligne_row.DL_NoColis) if hasattr(ligne_row, "DL_NoColis") else "" ), "num_colis": ( self._safe_strip(ligne_row.DL_Colis) if hasattr(ligne_row, "DL_Colis") else "" ), } lignes.append(ligne) doc["lignes"] = lignes doc["nb_lignes"] = len(lignes) # Totaux calculés total_ht_calcule = sum(l.get("montant_ligne_ht", 0) for l in lignes) total_ttc_calcule = sum(l.get("montant_ligne_ttc", 0) for l in lignes) total_taxes_calcule = sum(l.get("total_taxes", 0) for l in lignes) doc["total_ht_calcule"] = total_ht_calcule doc["total_ttc_calcule"] = total_ttc_calcule doc["total_taxes_calcule"] = total_taxes_calcule return doc except Exception as e: logger.error(f"❌ Erreur SQL lecture document {numero}: {e}", exc_info=True) return None def _lister_documents_avec_lignes_sql( self, type_doc: int, filtre: str = "", limit: int = None, inclure_liaisons: bool = False, calculer_transformations: bool = True, ): """Liste les documents avec leurs lignes.""" try: # Convertir le type pour SQL type_doc_sql = self._convertir_type_pour_sql(type_doc) logger.info(f"[SQL LIST] ═══ Type COM {type_doc} → SQL {type_doc_sql} ═══") with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT DISTINCT d.DO_Piece, d.DO_Type, d.DO_Date, d.DO_Ref, d.DO_Tiers, d.DO_TotalHT, d.DO_TotalTTC, d.DO_NetAPayer, d.DO_Statut, d.DO_DateLivr, d.DO_DateExpedition, d.DO_Contact, d.DO_TotalHTNet, d.DO_MontantRegle, d.DO_Reliquat, d.DO_TxEscompte, d.DO_Escompte, d.DO_Taxe1, d.DO_Taxe2, d.DO_Taxe3, d.DO_CodeTaxe1, d.DO_CodeTaxe2, d.DO_CodeTaxe3, d.DO_EStatut, d.DO_Imprim, d.DO_Valide, d.DO_Cloture, d.DO_Transfere, d.DO_Souche, d.DO_PieceOrig, d.DO_GUID, d.CA_Num, d.CG_Num, d.DO_Expedit, d.DO_Condition, d.DO_Tarif, d.DO_TypeFrais, d.DO_ValFrais, d.DO_TypeFranco, d.DO_ValFranco, c.CT_Intitule, c.CT_Adresse, c.CT_CodePostal, c.CT_Ville, c.CT_Telephone, c.CT_EMail FROM F_DOCENTETE d LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num WHERE d.DO_Type = ? """ params = [type_doc_sql] if filtre: query += " AND (d.DO_Piece LIKE ? OR c.CT_Intitule LIKE ? OR d.DO_Ref LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY d.DO_Date DESC" if limit: query = f"SELECT TOP ({limit}) * FROM ({query}) AS subquery" cursor.execute(query, params) entetes = cursor.fetchall() logger.info(f"[SQL LIST] 📊 {len(entetes)} documents SQL") documents = [] stats = { "total": len(entetes), "exclus_prefixe": 0, "erreur_construction": 0, "erreur_lignes": 0, "erreur_transformations": 0, "erreur_liaisons": 0, "succes": 0, } for idx, entete in enumerate(entetes): numero = self._safe_strip(entete.DO_Piece) logger.info( f"[SQL LIST] [{idx+1}/{len(entetes)}] 🔄 Traitement {numero}..." ) try: # ======================================== # ÉTAPE 1 : FILTRE PRÉFIXE # ======================================== prefixes_vente = { 0: ["DE"], 10: ["BC"], 30: ["BL"], 50: ["AV", "AR"], 60: ["FA", "FC"], } prefixes_acceptes = prefixes_vente.get(type_doc, []) if prefixes_acceptes: est_vente = any( numero.upper().startswith(p) for p in prefixes_acceptes ) if not est_vente: logger.info( f"[SQL LIST] ❌ {numero} : exclu (préfixe achat)" ) stats["exclus_prefixe"] += 1 continue logger.debug(f"[SQL LIST] ✅ {numero} : préfixe OK") # ======================================== # ÉTAPE 2 : CONSTRUIRE DOCUMENT DE BASE # ======================================== try: type_doc_depuis_sql = self._convertir_type_depuis_sql( int(entete.DO_Type) ) doc = { "numero": numero, "type": type_doc_depuis_sql, "reference": self._safe_strip(entete.DO_Ref), "date": str(entete.DO_Date) if entete.DO_Date else "", "date_livraison": ( str(entete.DO_DateLivr) if entete.DO_DateLivr else "" ), "date_expedition": ( str(entete.DO_DateExpedition) if entete.DO_DateExpedition else "" ), "client_code": self._safe_strip(entete.DO_Tiers), "client_intitule": self._safe_strip(entete.CT_Intitule), "client_adresse": self._safe_strip(entete.CT_Adresse), "client_code_postal": self._safe_strip( entete.CT_CodePostal ), "client_ville": self._safe_strip(entete.CT_Ville), "client_telephone": self._safe_strip( entete.CT_Telephone ), "client_email": self._safe_strip(entete.CT_EMail), "contact": self._safe_strip(entete.DO_Contact), "total_ht": ( float(entete.DO_TotalHT) if entete.DO_TotalHT else 0.0 ), "total_ht_net": ( float(entete.DO_TotalHTNet) if entete.DO_TotalHTNet else 0.0 ), "total_ttc": ( float(entete.DO_TotalTTC) if entete.DO_TotalTTC else 0.0 ), "net_a_payer": ( float(entete.DO_NetAPayer) if entete.DO_NetAPayer else 0.0 ), "montant_regle": ( float(entete.DO_MontantRegle) if entete.DO_MontantRegle else 0.0 ), "reliquat": ( float(entete.DO_Reliquat) if entete.DO_Reliquat else 0.0 ), "taux_escompte": ( float(entete.DO_TxEscompte) if entete.DO_TxEscompte else 0.0 ), "escompte": ( float(entete.DO_Escompte) if entete.DO_Escompte else 0.0 ), "taxe1": ( float(entete.DO_Taxe1) if entete.DO_Taxe1 else 0.0 ), "taxe2": ( float(entete.DO_Taxe2) if entete.DO_Taxe2 else 0.0 ), "taxe3": ( float(entete.DO_Taxe3) if entete.DO_Taxe3 else 0.0 ), "code_taxe1": self._safe_strip(entete.DO_CodeTaxe1), "code_taxe2": self._safe_strip(entete.DO_CodeTaxe2), "code_taxe3": self._safe_strip(entete.DO_CodeTaxe3), "statut": ( int(entete.DO_Statut) if entete.DO_Statut is not None else 0 ), "statut_estatut": ( int(entete.DO_EStatut) if entete.DO_EStatut is not None else 0 ), "imprime": ( int(entete.DO_Imprim) if entete.DO_Imprim is not None else 0 ), "valide": ( int(entete.DO_Valide) if entete.DO_Valide is not None else 0 ), "cloture": ( int(entete.DO_Cloture) if entete.DO_Cloture is not None else 0 ), "transfere": ( int(entete.DO_Transfere) if entete.DO_Transfere is not None else 0 ), "souche": self._safe_strip(entete.DO_Souche), "piece_origine": self._safe_strip(entete.DO_PieceOrig), "guid": self._safe_strip(entete.DO_GUID), "ca_num": self._safe_strip(entete.CA_Num), "cg_num": self._safe_strip(entete.CG_Num), "expedition": self._safe_strip(entete.DO_Expedit), "condition": self._safe_strip(entete.DO_Condition), "tarif": self._safe_strip(entete.DO_Tarif), "type_frais": ( int(entete.DO_TypeFrais) if entete.DO_TypeFrais is not None else 0 ), "valeur_frais": ( float(entete.DO_ValFrais) if entete.DO_ValFrais else 0.0 ), "type_franco": ( int(entete.DO_TypeFranco) if entete.DO_TypeFranco is not None else 0 ), "valeur_franco": ( float(entete.DO_ValFranco) if entete.DO_ValFranco else 0.0 ), "lignes": [], } logger.debug( f"[SQL LIST] ✅ {numero} : document de base créé" ) except Exception as e: logger.error( f"[SQL LIST] ❌ {numero} : ERREUR construction base: {e}", exc_info=True, ) stats["erreur_construction"] += 1 continue # ======================================== # ÉTAPE 3 : CHARGER LES LIGNES # ======================================== try: cursor.execute( """ SELECT dl.*, a.AR_Design, a.FA_CodeFamille, a.AR_PrixTTC, a.AR_PrixVen, a.AR_PrixAch, a.AR_Gamme1, a.AR_Gamme2, a.AR_CodeBarre, a.AR_CoutStd, a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_UniteVen, a.AR_Type, a.AR_Nature, a.AR_Escompte, a.AR_Garantie FROM F_DOCLIGNE dl LEFT JOIN F_ARTICLE a ON dl.AR_Ref = a.AR_Ref WHERE dl.DO_Piece = ? AND dl.DO_Type = ? ORDER BY dl.DL_Ligne """, (numero, type_doc_sql), ) for ligne_row in cursor.fetchall(): montant_ht = ( float(ligne_row.DL_MontantHT) if ligne_row.DL_MontantHT else 0.0 ) montant_net = ( float(ligne_row.DL_MontantNet) if hasattr(ligne_row, "DL_MontantNet") and ligne_row.DL_MontantNet else montant_ht ) taux_taxe1 = ( float(ligne_row.DL_Taxe1) if hasattr(ligne_row, "DL_Taxe1") and ligne_row.DL_Taxe1 else 0.0 ) taux_taxe2 = ( float(ligne_row.DL_Taxe2) if hasattr(ligne_row, "DL_Taxe2") and ligne_row.DL_Taxe2 else 0.0 ) taux_taxe3 = ( float(ligne_row.DL_Taxe3) if hasattr(ligne_row, "DL_Taxe3") and ligne_row.DL_Taxe3 else 0.0 ) total_taux_taxes = taux_taxe1 + taux_taxe2 + taux_taxe3 montant_ttc = montant_net * (1 + total_taux_taxes / 100) montant_taxe1 = montant_net * (taux_taxe1 / 100) montant_taxe2 = montant_net * (taux_taxe2 / 100) montant_taxe3 = montant_net * (taux_taxe3 / 100) ligne = { "numero_ligne": ( int(ligne_row.DL_Ligne) if ligne_row.DL_Ligne else 0 ), "article_code": self._safe_strip(ligne_row.AR_Ref), "designation": self._safe_strip( ligne_row.DL_Design ), "designation_article": self._safe_strip( ligne_row.AR_Design ), "quantite": ( float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0 ), "quantite_livree": ( float(ligne_row.DL_QteLiv) if hasattr(ligne_row, "DL_QteLiv") and ligne_row.DL_QteLiv else 0.0 ), "quantite_reservee": ( float(ligne_row.DL_QteRes) if hasattr(ligne_row, "DL_QteRes") and ligne_row.DL_QteRes else 0.0 ), "unite": ( self._safe_strip(ligne_row.DL_Unite) if hasattr(ligne_row, "DL_Unite") else "" ), "prix_unitaire_ht": ( float(ligne_row.DL_PrixUnitaire) if ligne_row.DL_PrixUnitaire else 0.0 ), "prix_unitaire_achat": ( float(ligne_row.AR_PrixAch) if ligne_row.AR_PrixAch else 0.0 ), "prix_unitaire_vente": ( float(ligne_row.AR_PrixVen) if ligne_row.AR_PrixVen else 0.0 ), "prix_unitaire_ttc": ( float(ligne_row.AR_PrixTTC) if ligne_row.AR_PrixTTC else 0.0 ), "montant_ligne_ht": montant_ht, "montant_ligne_net": montant_net, "montant_ligne_ttc": montant_ttc, "remise_valeur1": ( float(ligne_row.DL_Remise01REM_Valeur) if hasattr(ligne_row, "DL_Remise01REM_Valeur") and ligne_row.DL_Remise01REM_Valeur else 0.0 ), "remise_type1": ( int(ligne_row.DL_Remise01REM_Type) if hasattr(ligne_row, "DL_Remise01REM_Type") and ligne_row.DL_Remise01REM_Type else 0 ), "remise_valeur2": ( float(ligne_row.DL_Remise02REM_Valeur) if hasattr(ligne_row, "DL_Remise02REM_Valeur") and ligne_row.DL_Remise02REM_Valeur else 0.0 ), "remise_type2": ( int(ligne_row.DL_Remise02REM_Type) if hasattr(ligne_row, "DL_Remise02REM_Type") and ligne_row.DL_Remise02REM_Type else 0 ), "remise_article": ( float(ligne_row.AR_Escompte) if ligne_row.AR_Escompte else 0.0 ), "taux_taxe1": taux_taxe1, "montant_taxe1": montant_taxe1, "taux_taxe2": taux_taxe2, "montant_taxe2": montant_taxe2, "taux_taxe3": taux_taxe3, "montant_taxe3": montant_taxe3, "total_taxes": montant_taxe1 + montant_taxe2 + montant_taxe3, "famille_article": self._safe_strip( ligne_row.FA_CodeFamille ), "gamme1": self._safe_strip(ligne_row.AR_Gamme1), "gamme2": self._safe_strip(ligne_row.AR_Gamme2), "code_barre": self._safe_strip( ligne_row.AR_CodeBarre ), "type_article": self._safe_strip(ligne_row.AR_Type), "nature_article": self._safe_strip( ligne_row.AR_Nature ), "garantie": self._safe_strip(ligne_row.AR_Garantie), "cout_standard": ( float(ligne_row.AR_CoutStd) if ligne_row.AR_CoutStd else 0.0 ), "poids_net": ( float(ligne_row.AR_PoidsNet) if ligne_row.AR_PoidsNet else 0.0 ), "poids_brut": ( float(ligne_row.AR_PoidsBrut) if ligne_row.AR_PoidsBrut else 0.0 ), "unite_vente": self._safe_strip( ligne_row.AR_UniteVen ), "date_livraison_ligne": ( str(ligne_row.DL_DateLivr) if hasattr(ligne_row, "DL_DateLivr") and ligne_row.DL_DateLivr else "" ), "statut_ligne": ( int(ligne_row.DL_Statut) if hasattr(ligne_row, "DL_Statut") and ligne_row.DL_Statut is not None else 0 ), "depot": ( self._safe_strip(ligne_row.DE_No) if hasattr(ligne_row, "DE_No") else "" ), "numero_commande": ( self._safe_strip(ligne_row.DL_NoColis) if hasattr(ligne_row, "DL_NoColis") else "" ), "num_colis": ( self._safe_strip(ligne_row.DL_Colis) if hasattr(ligne_row, "DL_Colis") else "" ), } doc["lignes"].append(ligne) doc["nb_lignes"] = len(doc["lignes"]) doc["total_ht_calcule"] = sum( l.get("montant_ligne_ht", 0) for l in doc["lignes"] ) doc["total_ttc_calcule"] = sum( l.get("montant_ligne_ttc", 0) for l in doc["lignes"] ) doc["total_taxes_calcule"] = sum( l.get("total_taxes", 0) for l in doc["lignes"] ) logger.debug( f"[SQL LIST] ✅ {numero} : {doc['nb_lignes']} lignes chargées" ) except Exception as e: logger.error( f"[SQL LIST] ⚠️ {numero} : ERREUR lignes: {e}", exc_info=True, ) stats["erreur_lignes"] += 1 # Continuer quand même avec 0 lignes # ======================================== # ÉTAPE 6 : AJOUT DU DOCUMENT # ======================================== documents.append(doc) stats["succes"] += 1 logger.info( f"[SQL LIST] ✅✅✅ {numero} : AJOUTÉ à la liste (total: {len(documents)})" ) except Exception as e: logger.error( f"[SQL LIST] ❌❌❌ {numero} : EXCEPTION GLOBALE - DOCUMENT EXCLU: {e}", exc_info=True, ) continue # ======================================== # RÉSUMÉ FINAL # ======================================== logger.info(f"[SQL LIST] ═══════════════════════════") logger.info(f"[SQL LIST] 📊 STATISTIQUES FINALES:") logger.info(f"[SQL LIST] Total SQL: {stats['total']}") logger.info(f"[SQL LIST] Exclus préfixe: {stats['exclus_prefixe']}") logger.info( f"[SQL LIST] Erreur construction: {stats['erreur_construction']}" ) logger.info(f"[SQL LIST] Erreur lignes: {stats['erreur_lignes']}") logger.info( f"[SQL LIST] Erreur transformations: {stats['erreur_transformations']}" ) logger.info(f"[SQL LIST] Erreur liaisons: {stats['erreur_liaisons']}") logger.info(f"[SQL LIST] ✅ SUCCÈS: {stats['succes']}") logger.info(f"[SQL LIST] 📦 Documents retournés: {len(documents)}") logger.info(f"[SQL LIST] ═══════════════════════════") return documents except Exception as e: logger.error(f"❌ Erreur GLOBALE listage: {e}", exc_info=True) return [] def lister_tous_devis_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=0, filtre=filtre) def lire_devis_cache(self, numero): return self._lire_document_sql(numero, type_doc=0) def lister_toutes_commandes_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=1, filtre=filtre) def lire_commande_cache(self, numero): return self._lire_document_sql(numero, type_doc=1) def lister_toutes_factures_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=6, filtre=filtre) def lire_facture_cache(self, numero): return self._lire_document_sql(numero, type_doc=6) def lister_tous_fournisseurs_cache(self, filtre=""): return self.lister_tous_fournisseurs() def lire_fournisseur_cache(self, code): return self.lire_fournisseur() def lister_toutes_livraisons_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=3, filtre=filtre) def lire_livraison_cache(self, numero): return self._lire_document_sql(numero, type_doc=3) def lister_tous_avoirs_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=5, filtre=filtre) def lire_avoir_cache(self, numero): return self._lire_document_sql(numero, type_doc=5) # ========================================================================= # 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 def _extraire_client(self, client_obj): 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 DE BASE === data = { "numero": numero, "intitule": intitule, } # === 3. TYPE DE TIERS (CLIENT/PROSPECT/FOURNISSEUR) === # CT_Qualite : 0=Client, 1=Fournisseur, 2=Client+Fournisseur, 3=Salarié, 4=Prospect try: qualite_code = getattr(client_obj, "CT_Qualite", None) # Mapper les codes vers des libellés qualite_map = { 0: "CLI", # Client 1: "FOU", # Fournisseur 2: "CLIFOU", # Client + Fournisseur 3: "SAL", # Salarié 4: "PRO", # Prospect } data["qualite"] = qualite_map.get(qualite_code, "CLI") data["est_fournisseur"] = qualite_code in [1, 2] except: data["qualite"] = "CLI" data["est_fournisseur"] = False # CT_Prospect : 0=Non, 1=Oui try: data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1 except: data["est_prospect"] = False # Déterminer le type_tiers principal if data["est_prospect"]: data["type_tiers"] = "prospect" elif data["est_fournisseur"] and data["qualite"] != "CLIFOU": data["type_tiers"] = "fournisseur" elif data["qualite"] == "CLIFOU": data["type_tiers"] = "client_fournisseur" else: data["type_tiers"] = "client" # === 4. STATUT (ACTIF / SOMMEIL) === try: sommeil = getattr(client_obj, "CT_Sommeil", 0) data["est_actif"] = sommeil == 0 data["est_en_sommeil"] = sommeil == 1 except: data["est_actif"] = True data["est_en_sommeil"] = False # === 5. TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) === try: forme_juridique = getattr(client_obj, "CT_FormeJuridique", "").strip() data["forme_juridique"] = forme_juridique data["est_entreprise"] = bool(forme_juridique) data["est_particulier"] = not bool(forme_juridique) except: data["forme_juridique"] = "" data["est_entreprise"] = False data["est_particulier"] = True # === 6. IDENTITÉ PERSONNE PHYSIQUE (SI PARTICULIER) === try: data["civilite"] = getattr(client_obj, "CT_Civilite", "").strip() except: data["civilite"] = "" try: data["nom"] = getattr(client_obj, "CT_Nom", "").strip() except: data["nom"] = "" try: data["prenom"] = getattr(client_obj, "CT_Prenom", "").strip() except: data["prenom"] = "" # Nom complet formaté (pour particuliers) if data.get("nom") or data.get("prenom"): parts = [] if data.get("civilite"): parts.append(data["civilite"]) if data.get("prenom"): parts.append(data["prenom"]) if data.get("nom"): parts.append(data["nom"]) data["nom_complet"] = " ".join(parts) else: data["nom_complet"] = "" # === 7. CONTACT PRINCIPAL === try: data["contact"] = getattr(client_obj, "CT_Contact", "").strip() except: data["contact"] = "" # === 8. ADRESSE COMPLÈTE === try: adresse_obj = getattr(client_obj, "Adresse", None) if adresse_obj: try: data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() except: data["adresse"] = "" try: data["complement"] = getattr( adresse_obj, "Complement", "" ).strip() except: data["complement"] = "" try: data["code_postal"] = getattr( adresse_obj, "CodePostal", "" ).strip() except: data["code_postal"] = "" try: data["ville"] = getattr(adresse_obj, "Ville", "").strip() except: data["ville"] = "" try: data["region"] = getattr(adresse_obj, "Region", "").strip() except: data["region"] = "" try: data["pays"] = getattr(adresse_obj, "Pays", "").strip() except: data["pays"] = "" else: data["adresse"] = "" data["complement"] = "" data["code_postal"] = "" data["ville"] = "" data["region"] = "" data["pays"] = "" except Exception as e: logger.debug(f"⚠️ Erreur adresse sur {numero}: {e}") data["adresse"] = "" data["complement"] = "" data["code_postal"] = "" data["ville"] = "" data["region"] = "" data["pays"] = "" # === 9. TÉLÉCOMMUNICATIONS (DISTINCTION FIXE/MOBILE) === try: telecom = getattr(client_obj, "Telecom", None) if telecom: # Téléphone FIXE try: data["telephone"] = getattr(telecom, "Telephone", "").strip() except: data["telephone"] = "" # Téléphone MOBILE try: data["portable"] = getattr(telecom, "Portable", "").strip() except: data["portable"] = "" # FAX try: data["telecopie"] = getattr(telecom, "Telecopie", "").strip() except: data["telecopie"] = "" # EMAIL try: data["email"] = getattr(telecom, "EMail", "").strip() except: data["email"] = "" # SITE WEB try: site = ( getattr(telecom, "Site", None) or getattr(telecom, "Web", None) or getattr(telecom, "SiteWeb", "") ) data["site_web"] = str(site).strip() if site else "" except: data["site_web"] = "" else: data["telephone"] = "" data["portable"] = "" data["telecopie"] = "" data["email"] = "" data["site_web"] = "" except Exception as e: logger.debug(f"⚠️ Erreur telecom sur {numero}: {e}") data["telephone"] = "" data["portable"] = "" data["telecopie"] = "" data["email"] = "" data["site_web"] = "" # === 10. INFORMATIONS JURIDIQUES (ENTREPRISES) === try: data["siret"] = getattr(client_obj, "CT_Siret", "").strip() except: data["siret"] = "" try: data["siren"] = getattr(client_obj, "CT_Siren", "").strip() except: data["siren"] = "" try: data["tva_intra"] = getattr(client_obj, "CT_Identifiant", "").strip() except: data["tva_intra"] = "" try: data["code_naf"] = ( getattr(client_obj, "CT_CodeNAF", "").strip() or getattr(client_obj, "CT_APE", "").strip() ) except: data["code_naf"] = "" # === 11. INFORMATIONS COMMERCIALES === try: data["secteur"] = getattr(client_obj, "CT_Secteur", "").strip() except: data["secteur"] = "" try: effectif = getattr(client_obj, "CT_Effectif", None) data["effectif"] = int(effectif) if effectif is not None else None except: data["effectif"] = None try: ca = getattr(client_obj, "CT_ChiffreAffaire", None) data["ca_annuel"] = float(ca) if ca is not None else None except: data["ca_annuel"] = None # Commercial rattaché try: data["commercial_code"] = getattr(client_obj, "CO_No", "").strip() except: try: data["commercial_code"] = getattr( client_obj, "CT_Commercial", "" ).strip() except: data["commercial_code"] = "" if data.get("commercial_code"): try: commercial_obj = getattr(client_obj, "Commercial", None) if commercial_obj: commercial_obj.Read() data["commercial_nom"] = getattr( commercial_obj, "CO_Nom", "" ).strip() else: data["commercial_nom"] = "" except: data["commercial_nom"] = "" else: data["commercial_nom"] = "" # === 12. CATÉGORIES === try: data["categorie_tarifaire"] = getattr(client_obj, "N_CatTarif", None) except: data["categorie_tarifaire"] = None try: data["categorie_comptable"] = getattr(client_obj, "N_CatCompta", None) except: data["categorie_comptable"] = None # === 13. INFORMATIONS FINANCIÈRES === try: data["encours_autorise"] = float(getattr(client_obj, "CT_Encours", 0.0)) except: data["encours_autorise"] = 0.0 try: data["assurance_credit"] = float( getattr(client_obj, "CT_Assurance", 0.0) ) except: data["assurance_credit"] = 0.0 try: data["compte_general"] = getattr(client_obj, "CG_Num", "").strip() except: data["compte_general"] = "" # === 14. DATES === try: date_creation = getattr(client_obj, "CT_DateCreate", None) data["date_creation"] = str(date_creation) if date_creation else "" except: data["date_creation"] = "" try: date_modif = getattr(client_obj, "CT_DateModif", None) data["date_modification"] = str(date_modif) if date_modif else "" except: data["date_modification"] = "" 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): try: data = { # === IDENTIFICATION === "reference": getattr(article_obj, "AR_Ref", "").strip(), "designation": getattr(article_obj, "AR_Design", "").strip(), } # === CODE EAN / CODE-BARRES === data["code_ean"] = "" data["code_barre"] = "" try: # Essayer AR_CodeBarre (champ principal) code_barre = getattr(article_obj, "AR_CodeBarre", "").strip() if code_barre: data["code_ean"] = code_barre data["code_barre"] = code_barre # Sinon essayer AR_CodeBarre1 if not data["code_ean"]: code_barre1 = getattr(article_obj, "AR_CodeBarre1", "").strip() if code_barre1: data["code_ean"] = code_barre1 data["code_barre"] = code_barre1 except: pass # === PRIX === try: data["prix_vente"] = float(getattr(article_obj, "AR_PrixVen", 0.0)) except: data["prix_vente"] = 0.0 try: data["prix_achat"] = float(getattr(article_obj, "AR_PrixAch", 0.0)) except: data["prix_achat"] = 0.0 try: data["prix_revient"] = float( getattr(article_obj, "AR_PrixRevient", 0.0) ) except: data["prix_revient"] = 0.0 # === STOCK === try: data["stock_reel"] = float(getattr(article_obj, "AR_Stock", 0.0)) except: data["stock_reel"] = 0.0 try: data["stock_mini"] = float(getattr(article_obj, "AR_StockMini", 0.0)) except: data["stock_mini"] = 0.0 try: data["stock_maxi"] = float(getattr(article_obj, "AR_StockMaxi", 0.0)) except: data["stock_maxi"] = 0.0 # Stock réservé (en commande client) try: data["stock_reserve"] = float(getattr(article_obj, "AR_QteCom", 0.0)) except: data["stock_reserve"] = 0.0 # Stock en commande fournisseur try: data["stock_commande"] = float( getattr(article_obj, "AR_QteComFou", 0.0) ) except: data["stock_commande"] = 0.0 # Stock disponible (réel - réservé) try: data["stock_disponible"] = data["stock_reel"] - data["stock_reserve"] except: data["stock_disponible"] = data["stock_reel"] # === DESCRIPTIONS === # Commentaire / Description détaillée try: commentaire = getattr(article_obj, "AR_Commentaire", "").strip() data["description"] = commentaire except: data["description"] = "" # Désignation complémentaire try: design2 = getattr(article_obj, "AR_Design2", "").strip() data["designation_complementaire"] = design2 except: data["designation_complementaire"] = "" # === CLASSIFICATION === # Type d'article (0=Article, 1=Prestation, 2=Divers) try: type_art = getattr(article_obj, "AR_Type", 0) data["type_article"] = type_art data["type_article_libelle"] = { 0: "Article", 1: "Prestation", 2: "Divers", }.get(type_art, "Inconnu") except: data["type_article"] = 0 data["type_article_libelle"] = "Article" # Famille try: famille_code = getattr(article_obj, "FA_CodeFamille", "").strip() data["famille_code"] = famille_code # Charger le libellé de la famille si disponible if famille_code: try: famille_obj = getattr(article_obj, "Famille", None) if famille_obj: famille_obj.Read() data["famille_libelle"] = getattr( famille_obj, "FA_Intitule", "" ).strip() else: data["famille_libelle"] = "" except: data["famille_libelle"] = "" else: data["famille_libelle"] = "" except: data["famille_code"] = "" data["famille_libelle"] = "" # === FOURNISSEUR PRINCIPAL === try: fournisseur_code = getattr(article_obj, "CT_Num", "").strip() data["fournisseur_principal"] = fournisseur_code # Charger le nom du fournisseur si disponible if fournisseur_code: try: fourn_obj = getattr(article_obj, "Fournisseur", None) if fourn_obj: fourn_obj.Read() data["fournisseur_nom"] = getattr( fourn_obj, "CT_Intitule", "" ).strip() else: data["fournisseur_nom"] = "" except: data["fournisseur_nom"] = "" else: data["fournisseur_nom"] = "" except: data["fournisseur_principal"] = "" data["fournisseur_nom"] = "" # === UNITÉS === try: data["unite_vente"] = getattr(article_obj, "AR_UniteVen", "").strip() except: data["unite_vente"] = "" try: data["unite_achat"] = getattr(article_obj, "AR_UniteAch", "").strip() except: data["unite_achat"] = "" # === CARACTÉRISTIQUES PHYSIQUES === try: data["poids"] = float(getattr(article_obj, "AR_Poids", 0.0)) except: data["poids"] = 0.0 try: data["volume"] = float(getattr(article_obj, "AR_Volume", 0.0)) except: data["volume"] = 0.0 # === STATUT === try: sommeil = getattr(article_obj, "AR_Sommeil", 0) data["est_actif"] = sommeil == 0 data["en_sommeil"] = sommeil == 1 except: data["est_actif"] = True data["en_sommeil"] = False # === TVA === try: tva_code = getattr(article_obj, "TA_Code", "").strip() data["tva_code"] = tva_code # Essayer de charger le taux try: tva_obj = getattr(article_obj, "Taxe1", None) if tva_obj: tva_obj.Read() data["tva_taux"] = float(getattr(tva_obj, "TA_Taux", 20.0)) else: data["tva_taux"] = 20.0 except: data["tva_taux"] = 20.0 except: data["tva_code"] = "" data["tva_taux"] = 20.0 # === DATES === try: date_creation = getattr(article_obj, "AR_DateCreate", None) data["date_creation"] = str(date_creation) if date_creation else "" except: data["date_creation"] = "" try: date_modif = getattr(article_obj, "AR_DateModif", None) data["date_modification"] = str(date_modif) if date_modif else "" except: data["date_modification"] = "" return data except Exception as e: logger.error(f"❌ Erreur extraction article: {e}", exc_info=True) # Retourner structure minimale en cas d'erreur return { "reference": getattr(article_obj, "AR_Ref", "").strip(), "designation": getattr(article_obj, "AR_Design", "").strip(), "prix_vente": 0.0, "stock_reel": 0.0, "code_ean": "", "description": "", "designation_complementaire": "", "prix_achat": 0.0, "prix_revient": 0.0, "stock_mini": 0.0, "stock_maxi": 0.0, "stock_reserve": 0.0, "stock_commande": 0.0, "stock_disponible": 0.0, "code_barre": "", "type_article": 0, "type_article_libelle": "Article", "famille_code": "", "famille_libelle": "", "fournisseur_principal": "", "fournisseur_nom": "", "unite_vente": "", "unite_achat": "", "poids": 0.0, "volume": 0.0, "est_actif": True, "en_sommeil": False, "tva_code": "", "tva_taux": 20.0, "date_creation": "", "date_modification": "", } def _extraire_fournisseur_enrichi(self, fourn_obj): try: # === IDENTIFICATION === numero = getattr(fourn_obj, "CT_Num", "").strip() if not numero: return None intitule = getattr(fourn_obj, "CT_Intitule", "").strip() data = { "numero": numero, "intitule": intitule, "type": 1, # Fournisseur "est_fournisseur": True, } # === STATUT === try: sommeil = getattr(fourn_obj, "CT_Sommeil", 0) data["est_actif"] = sommeil == 0 data["en_sommeil"] = sommeil == 1 except: data["est_actif"] = True data["en_sommeil"] = False # === ADRESSE PRINCIPALE === try: adresse_obj = getattr(fourn_obj, "Adresse", None) if adresse_obj: data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() data["complement"] = getattr(adresse_obj, "Complement", "").strip() data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip() data["ville"] = getattr(adresse_obj, "Ville", "").strip() data["region"] = getattr(adresse_obj, "Region", "").strip() data["pays"] = getattr(adresse_obj, "Pays", "").strip() # Adresse formatée complète parties_adresse = [] if data["adresse"]: parties_adresse.append(data["adresse"]) if data["complement"]: parties_adresse.append(data["complement"]) if data["code_postal"] or data["ville"]: ville_cp = f"{data['code_postal']} {data['ville']}".strip() if ville_cp: parties_adresse.append(ville_cp) if data["pays"]: parties_adresse.append(data["pays"]) data["adresse_complete"] = ", ".join(parties_adresse) else: data["adresse"] = "" data["complement"] = "" data["code_postal"] = "" data["ville"] = "" data["region"] = "" data["pays"] = "" data["adresse_complete"] = "" except Exception as e: logger.debug(f"⚠️ Erreur adresse fournisseur {numero}: {e}") data["adresse"] = "" data["complement"] = "" data["code_postal"] = "" data["ville"] = "" data["region"] = "" data["pays"] = "" data["adresse_complete"] = "" # === TÉLÉCOMMUNICATIONS === try: telecom_obj = getattr(fourn_obj, "Telecom", None) if telecom_obj: data["telephone"] = getattr(telecom_obj, "Telephone", "").strip() data["portable"] = getattr(telecom_obj, "Portable", "").strip() data["telecopie"] = getattr(telecom_obj, "Telecopie", "").strip() data["email"] = getattr(telecom_obj, "EMail", "").strip() # Site web try: site = ( getattr(telecom_obj, "Site", None) or getattr(telecom_obj, "Web", None) or getattr(telecom_obj, "SiteWeb", "") ) data["site_web"] = str(site).strip() if site else "" except: data["site_web"] = "" else: data["telephone"] = "" data["portable"] = "" data["telecopie"] = "" data["email"] = "" data["site_web"] = "" except Exception as e: logger.debug(f"⚠️ Erreur telecom fournisseur {numero}: {e}") data["telephone"] = "" data["portable"] = "" data["telecopie"] = "" data["email"] = "" data["site_web"] = "" # === INFORMATIONS FISCALES === # SIRET try: data["siret"] = getattr(fourn_obj, "CT_Siret", "").strip() except: data["siret"] = "" # SIREN (extrait du SIRET si disponible) try: if data["siret"] and len(data["siret"]) >= 9: data["siren"] = data["siret"][:9] else: data["siren"] = getattr(fourn_obj, "CT_Siren", "").strip() except: data["siren"] = "" # TVA Intracommunautaire try: data["tva_intra"] = getattr(fourn_obj, "CT_Identifiant", "").strip() except: data["tva_intra"] = "" # Code NAF/APE try: data["code_naf"] = ( getattr(fourn_obj, "CT_CodeNAF", "").strip() or getattr(fourn_obj, "CT_APE", "").strip() ) except: data["code_naf"] = "" # Forme juridique try: data["forme_juridique"] = getattr( fourn_obj, "CT_FormeJuridique", "" ).strip() except: data["forme_juridique"] = "" # === CATÉGORIES === # Catégorie tarifaire try: cat_tarif = getattr(fourn_obj, "N_CatTarif", None) data["categorie_tarifaire"] = ( int(cat_tarif) if cat_tarif is not None else None ) except: data["categorie_tarifaire"] = None # Catégorie comptable try: cat_compta = getattr(fourn_obj, "N_CatCompta", None) data["categorie_comptable"] = ( int(cat_compta) if cat_compta is not None else None ) except: data["categorie_comptable"] = None # === CONDITIONS DE RÈGLEMENT === try: cond_regl = getattr(fourn_obj, "CT_CondRegl", "").strip() data["conditions_reglement_code"] = cond_regl # Charger le libellé si disponible if cond_regl: try: # Essayer de charger l'objet ConditionReglement cond_obj = getattr(fourn_obj, "ConditionReglement", None) if cond_obj: cond_obj.Read() data["conditions_reglement_libelle"] = getattr( cond_obj, "C_Intitule", "" ).strip() else: data["conditions_reglement_libelle"] = "" except: data["conditions_reglement_libelle"] = "" else: data["conditions_reglement_libelle"] = "" except: data["conditions_reglement_code"] = "" data["conditions_reglement_libelle"] = "" # Mode de règlement try: mode_regl = getattr(fourn_obj, "CT_ModeRegl", "").strip() data["mode_reglement_code"] = mode_regl # Libellé du mode de règlement if mode_regl: try: mode_obj = getattr(fourn_obj, "ModeReglement", None) if mode_obj: mode_obj.Read() data["mode_reglement_libelle"] = getattr( mode_obj, "M_Intitule", "" ).strip() else: data["mode_reglement_libelle"] = "" except: data["mode_reglement_libelle"] = "" else: data["mode_reglement_libelle"] = "" except: data["mode_reglement_code"] = "" data["mode_reglement_libelle"] = "" # === COORDONNÉES BANCAIRES (IBAN) === data["coordonnees_bancaires"] = [] try: # Sage peut avoir plusieurs comptes bancaires factory_banque = getattr(fourn_obj, "FactoryBanque", None) if factory_banque: index = 1 while index <= 5: # Max 5 comptes bancaires try: banque_persist = factory_banque.List(index) if banque_persist is None: break banque = win32com.client.CastTo( banque_persist, "IBOBanque3" ) banque.Read() compte_bancaire = { "banque_nom": getattr( banque, "BI_Intitule", "" ).strip(), "iban": getattr(banque, "RIB_Iban", "").strip(), "bic": getattr(banque, "RIB_Bic", "").strip(), "code_banque": getattr( banque, "RIB_Banque", "" ).strip(), "code_guichet": getattr( banque, "RIB_Guichet", "" ).strip(), "numero_compte": getattr( banque, "RIB_Compte", "" ).strip(), "cle_rib": getattr(banque, "RIB_Cle", "").strip(), } # Ne garder que si IBAN ou RIB complet if ( compte_bancaire["iban"] or compte_bancaire["numero_compte"] ): data["coordonnees_bancaires"].append(compte_bancaire) index += 1 except: break except Exception as e: logger.debug( f"⚠️ Erreur coordonnées bancaires fournisseur {numero}: {e}" ) # IBAN principal (premier de la liste) if data["coordonnees_bancaires"]: data["iban_principal"] = data["coordonnees_bancaires"][0].get( "iban", "" ) data["bic_principal"] = data["coordonnees_bancaires"][0].get("bic", "") else: data["iban_principal"] = "" data["bic_principal"] = "" # === CONTACTS === data["contacts"] = [] try: factory_contact = getattr(fourn_obj, "FactoryContact", None) if factory_contact: index = 1 while index <= 20: # Max 20 contacts try: contact_persist = factory_contact.List(index) if contact_persist is None: break contact = win32com.client.CastTo( contact_persist, "IBOContact3" ) contact.Read() contact_data = { "nom": getattr(contact, "CO_Nom", "").strip(), "prenom": getattr(contact, "CO_Prenom", "").strip(), "fonction": getattr(contact, "CO_Fonction", "").strip(), "service": getattr(contact, "CO_Service", "").strip(), "telephone": getattr( contact, "CO_Telephone", "" ).strip(), "portable": getattr(contact, "CO_Portable", "").strip(), "email": getattr(contact, "CO_EMail", "").strip(), } # Nom complet formaté nom_complet = f"{contact_data['prenom']} {contact_data['nom']}".strip() if nom_complet: contact_data["nom_complet"] = nom_complet else: contact_data["nom_complet"] = contact_data["nom"] # Ne garder que si nom existe if contact_data["nom"]: data["contacts"].append(contact_data) index += 1 except: break except Exception as e: logger.debug(f"⚠️ Erreur contacts fournisseur {numero}: {e}") # Nombre de contacts data["nb_contacts"] = len(data["contacts"]) # Contact principal (premier de la liste) if data["contacts"]: data["contact_principal"] = data["contacts"][0] else: data["contact_principal"] = None # === STATISTIQUES & INFORMATIONS COMMERCIALES === # Encours autorisé try: data["encours_autorise"] = float(getattr(fourn_obj, "CT_Encours", 0.0)) except: data["encours_autorise"] = 0.0 # Chiffre d'affaires annuel try: data["ca_annuel"] = float(getattr(fourn_obj, "CT_ChiffreAffaire", 0.0)) except: data["ca_annuel"] = 0.0 # Compte général try: data["compte_general"] = getattr(fourn_obj, "CG_Num", "").strip() except: data["compte_general"] = "" # === DATES === try: date_creation = getattr(fourn_obj, "CT_DateCreate", None) data["date_creation"] = str(date_creation) if date_creation else "" except: data["date_creation"] = "" try: date_modif = getattr(fourn_obj, "CT_DateModif", None) data["date_modification"] = str(date_modif) if date_modif else "" except: data["date_modification"] = "" return data except Exception as e: logger.error(f"❌ Erreur extraction fournisseur: {e}", exc_info=True) # Retourner structure minimale en cas d'erreur return { "numero": getattr(fourn_obj, "CT_Num", "").strip(), "intitule": getattr(fourn_obj, "CT_Intitule", "").strip(), "type": 1, "est_fournisseur": True, "est_actif": True, "en_sommeil": False, "adresse": "", "complement": "", "code_postal": "", "ville": "", "region": "", "pays": "", "adresse_complete": "", "telephone": "", "portable": "", "telecopie": "", "email": "", "site_web": "", "siret": "", "siren": "", "tva_intra": "", "code_naf": "", "forme_juridique": "", "categorie_tarifaire": None, "categorie_comptable": None, "conditions_reglement_code": "", "conditions_reglement_libelle": "", "mode_reglement_code": "", "mode_reglement_libelle": "", "iban_principal": "", "bic_principal": "", "coordonnees_bancaires": [], "contacts": [], "nb_contacts": 0, "contact_principal": None, "encours_autorise": 0.0, "ca_annuel": 0.0, "compte_general": "", "date_creation": "", "date_modification": "", } def normaliser_date(self, valeur): if isinstance(valeur, str): try: return datetime.fromisoformat(valeur) except ValueError: return datetime.now() elif isinstance(valeur, date): return datetime.combine(valeur, datetime.min.time()) elif isinstance(valeur, datetime): return valeur else: return datetime.now() def creer_devis_enrichi(self, devis_data: dict, forcer_brouillon: bool = False): """ Crée un devis dans Sage avec support de la référence et des dates. """ 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éé") # ===== DATES ===== doc.DO_Date = pywintypes.Time( self.normaliser_date(devis_data.get("date_devis")) ) if "date_livraison" in devis_data and devis_data["date_livraison"]: try: if hasattr(doc, 'DO_DateLivr'): doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(devis_data["date_livraison"]) ) logger.info( f"📅 Date livraison: {devis_data['date_livraison']}" ) else: logger.warning( "⚠️ DO_DateLivr non disponible dans l'API COM Sage" ) except AttributeError as e: logger.warning( f"⚠️ Impossible de définir DO_DateLivr: {e}" ) except Exception as e: logger.warning( f"⚠️ Erreur lors de la définition de la date livraison: {e}" ) # ===== 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) logger.info(f"👤 Client {devis_data['client']['code']} associé") # ===== STATUT ===== if forcer_brouillon: doc.DO_Statut = 0 logger.info("📊 Statut défini: 0 (Brouillon)") else: doc.DO_Statut = 2 logger.info("📊 Statut défini: 2 (Accepté)") doc.Write() # ===== 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 ===== if not forcer_brouillon: logger.info("🔄 Lancement Process()...") process.Process() else: try: process.Process() logger.info("✅ Process() appelé (brouillon)") except: logger.debug("⚠️ Process() ignoré pour brouillon") # ===== RÉCUPÉRATION NUMÉRO ===== numero_devis = self._recuperer_numero_devis(process, doc) 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 # ===== ATTENTE POUR STABILISATION ===== import time time.sleep(0.5) # ===== RÉFÉRENCE (RECHARGER D'ABORD LE DOCUMENT) ===== if "reference" in devis_data and devis_data["reference"]: try: logger.info( f"🔖 Application de la référence: {devis_data['reference']}" ) # RECHARGER le document par son numéro doc_reload = self._charger_devis(numero_devis) # Appliquer la référence nouvelle_reference = devis_data["reference"] doc_reload.DO_Ref = ( str(nouvelle_reference) if nouvelle_reference else "" ) doc_reload.Write() time.sleep(0.5) doc_reload.Read() logger.info(f"✅ Référence définie: {nouvelle_reference}") except Exception as e: logger.warning( f"⚠️ Impossible de définir la référence: {e}", exc_info=True, ) # ===== RELECTURE FINALE ===== time.sleep(0.5) doc_final_data = self._relire_devis( numero_devis, devis_data, forcer_brouillon ) logger.info( f"✅ ✅ ✅ DEVIS CRÉÉ: {numero_devis} - {doc_final_data['total_ttc']}€ TTC ✅ ✅ ✅" ) return doc_final_data 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)}") def _recuperer_numero_devis(self, process, doc): """Récupère le numéro du devis créé via plusieurs méthodes.""" 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 return numero_devis def _relire_devis(self, numero_devis, devis_data, forcer_brouillon): """Relit le devis créé et extrait les informations finales.""" factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece(0, numero_devis) if not persist_reread: logger.debug("ReadPiece échoué, recherche dans List()...") persist_reread = self._rechercher_devis_dans_liste( numero_devis, factory_doc ) # Extraction des informations 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) reference_final = getattr(doc_final, "DO_Ref", "") # Dates date_livraison_final = None try: date_livr = getattr(doc_final, "DO_DateLivr", None) if date_livr: date_livraison_final = date_livr.strftime("%Y-%m-%d") except: pass 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 reference_final = devis_data.get("reference", "") date_livraison_final = devis_data.get("date_livraison") logger.info(f"💰 Total HT: {total_ht}€") logger.info(f"💰 Total TTC: {total_ttc}€") logger.info(f"📊 Statut final: {statut_final}") if reference_final: logger.info(f"🔖 Référence: {reference_final}") if date_livraison_final: logger.info(f"📅 Date livraison: {date_livraison_final}") 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(devis_data.get("date_devis", "")), "date_livraison": date_livraison_final, "reference": reference_final, "statut": statut_final, } def _rechercher_devis_dans_liste(self, numero_devis, factory_doc): """Recherche un devis dans les 100 premiers éléments de la liste.""" 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 ): logger.info(f"✅ Document trouvé à l'index {index}") return persist_test index += 1 except: index += 1 return None def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: """Modifie un devis existant dans Sage.""" if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"🔍 Recherche devis {numero}...") doc = self._charger_devis(numero) logger.info(f"✅ Devis {numero} trouvé") self._verifier_devis_non_transforme(numero, doc) champs_modifies = [] # ===== EXTRAIRE référence et statut pour les traiter à la fin ===== devis_data_temp = devis_data.copy() reference_a_modifier = None statut_a_modifier = None if "reference" in devis_data_temp: reference_a_modifier = devis_data_temp.pop("reference") logger.info( "🔖 Modification de la référence reportée après les lignes" ) if "lignes" in devis_data and devis_data["lignes"] is not None: if "statut" in devis_data_temp: statut_a_modifier = devis_data_temp.pop("statut") logger.info( "📊 Modification du statut reportée après les lignes" ) # ===== MODIFIER CHAMPS SIMPLES (sauf référence et statut) ===== champs_modifies = self._modifier_champs_simples(doc, devis_data_temp) # ===== MODIFICATION DES LIGNES ===== if "lignes" in devis_data and devis_data["lignes"] is not None: self._modifier_lignes_devis(doc, devis_data["lignes"]) champs_modifies.append("lignes") logger.info("💾 Sauvegarde après modification des lignes...") doc.Write() import time time.sleep(0.5) doc.Read() # ===== MODIFIER LA RÉFÉRENCE (APRÈS les lignes) ===== if reference_a_modifier is not None: try: ancienne_reference = getattr(doc, "DO_Ref", "") nouvelle_reference = ( str(reference_a_modifier) if reference_a_modifier else "" ) doc.DO_Ref = nouvelle_reference doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("reference") logger.info( f"🔖 Référence modifiée: '{ancienne_reference}' → '{nouvelle_reference}'" ) except Exception as e: logger.warning(f"⚠️ Impossible de modifier la référence: {e}") # ===== MODIFIER LE STATUT (EN DERNIER) ===== if statut_a_modifier is not None: try: statut_actuel = getattr(doc, "DO_Statut", 0) nouveau_statut = int(statut_a_modifier) if nouveau_statut != statut_actuel and nouveau_statut in [ 0, 1, 2, 3, ]: doc.DO_Statut = nouveau_statut doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("statut") logger.info( f"📊 Statut modifié: {statut_actuel} → {nouveau_statut}" ) except Exception as e: logger.warning(f"⚠️ Impossible de modifier le statut: {e}") # ===== VALIDATION FINALE ===== logger.info("💾 Validation finale...") try: doc.Write() except: pass import time time.sleep(0.5) doc.Read() # ===== RÉSULTAT ===== resultat = self._extraire_infos_devis(doc, numero, champs_modifies) logger.info(f"✅✅✅ DEVIS MODIFIÉ: {numero} ✅✅✅") logger.info( f"💰 Totaux: {resultat['total_ht']}€ HT / {resultat['total_ttc']}€ TTC" ) return resultat 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 _charger_devis(self, numero: str): """Charge un devis depuis Sage.""" factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(0, numero) if not persist: # Recherche dans la liste si ReadPiece échoue persist = self._rechercher_devis_par_numero(numero, factory) if not persist: raise ValueError(f"Devis {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() return doc def _rechercher_devis_par_numero(self, numero: str, factory): """Recherche un devis par son numéro dans la liste.""" 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 ): return persist_test index += 1 except: index += 1 return None def _verifier_devis_non_transforme(self, numero: str, doc): """Vérifie que le devis n'a pas déjà été transformé.""" verification = self.verifier_si_deja_transforme_sql(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)") def _modifier_champs_simples(self, doc, devis_data: Dict) -> list: """Modifie les champs simples du devis (date, dates expédition/livraison, référence, statut).""" champs_modifies = [] # IMPORTANT: Relire le document pour s'assurer qu'il est à jour try: doc.Read() except: pass # DATE DEVIS - Modifier et sauvegarder immédiatement if "date_devis" in devis_data: try: doc.DO_Date = pywintypes.Time( self.normaliser_date(devis_data.get("date_devis")) ) doc.Write() doc.Read() champs_modifies.append("date") logger.info(f"📅 Date devis modifiée: {devis_data['date_devis']}") except Exception as e: logger.warning(f"⚠️ Impossible de modifier la date: {e}") if "date_livraison" in devis_data: try: if devis_data["date_livraison"]: doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(devis_data["date_livraison"]) ) logger.info( f"📅 Date livraison modifiée: {devis_data['date_livraison']}" ) else: # Si None ou vide, effacer la date try: doc.DO_DateLivr = None logger.info("📅 Date livraison effacée") except: pass doc.Write() doc.Read() champs_modifies.append("date_livraison") except Exception as e: logger.warning(f"⚠️ Impossible de modifier la date de livraison: {e}") # RÉFÉRENCE - Modifier et sauvegarder immédiatement if "reference" in devis_data: try: nouvelle_reference = devis_data["reference"] ancienne_reference = getattr(doc, "DO_Ref", "") doc.DO_Ref = str(nouvelle_reference) if nouvelle_reference else "" doc.Write() doc.Read() champs_modifies.append("reference") logger.info( f"🔖 Référence: '{ancienne_reference}' → '{nouvelle_reference}'" ) except Exception as e: logger.warning(f"⚠️ Impossible de modifier la référence: {e}") # STATUT - Modifier avec précaution (à la fin seulement) # Le statut ne doit être modifié QUE si aucune autre modification n'est en cours if "statut" in devis_data and "lignes" not in devis_data: try: statut_actuel = getattr(doc, "DO_Statut", 0) nouveau_statut = int(devis_data["statut"]) # Vérifier que le changement de statut est valide if nouveau_statut != statut_actuel: # Statuts valides: 0=Brouillon, 1=Refusé, 2=Accepté, 3=Confirmé, 5=Transformé if nouveau_statut in [0, 1, 2, 3]: doc.DO_Statut = nouveau_statut doc.Write() doc.Read() champs_modifies.append("statut") logger.info( f"📊 Statut modifié: {statut_actuel} → {nouveau_statut}" ) else: logger.warning( f"⚠️ Statut {nouveau_statut} invalide ou non modifiable" ) except Exception as e: logger.warning(f"⚠️ Impossible de modifier le statut: {e}") return champs_modifies def _modifier_lignes_devis(self, doc, nouvelles_lignes: list): """Modifie intelligemment les lignes du devis.""" logger.info(f"🔄 Modification intelligente des lignes...") # Relire le document pour s'assurer qu'il est à jour try: doc.Read() except: pass nb_nouvelles = len(nouvelles_lignes) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # Compter les lignes existantes nb_existantes = self._compter_lignes_existantes(factory_lignes) logger.info(f"📊 {nb_existantes} existantes → {nb_nouvelles} nouvelles") # STRATÉGIE : Supprimer d'abord, puis recréer # C'est plus sûr que de modifier en place if nb_nouvelles != nb_existantes: logger.info("🔄 Stratégie: Suppression puis recréation des lignes") # Supprimer toutes les lignes existantes self._supprimer_toutes_les_lignes(factory_lignes, nb_existantes) # Sauvegarder après suppression doc.Write() doc.Read() # Recréer toutes les lignes for idx, ligne_data in enumerate(nouvelles_lignes, 1): self._ajouter_nouvelle_ligne( factory_lignes, factory_article, ligne_data, idx ) else: # STRATÉGIE : Modification en place si même nombre de lignes logger.info("🔄 Stratégie: Modification en place") for idx in range(1, nb_nouvelles + 1): self._modifier_ligne_existante( factory_lignes, factory_article, idx, nouvelles_lignes[idx - 1] ) def _supprimer_toutes_les_lignes(self, factory_lignes, nb_existantes: int): """Supprime toutes les lignes du devis.""" logger.info(f"🗑️ Suppression de {nb_existantes} lignes...") # Supprimer en partant de la fin pour éviter les problèmes d'index for idx in range(nb_existantes, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: try: ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") except: ligne = win32com.client.CastTo( ligne_p, "IBODocumentVenteLigne3" ) ligne.Read() try: ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except AttributeError: # Si Remove() n'existe pas, essayer WriteDefault() try: ligne.WriteDefault() logger.debug( f" ⚠️ Ligne {idx} réinitialisée (Remove indisponible)" ) except: logger.warning( f" ⚠️ Impossible de supprimer la ligne {idx}" ) except Exception as e: logger.warning(f" ⚠️ Erreur suppression ligne {idx}: {e}") except Exception as e: logger.debug(f" ⚠️ Ligne {idx} non accessible: {e}") def _modifier_ligne_existante( self, factory_lignes, factory_article, idx: int, ligne_data: dict ): """Modifie une ligne existante du devis.""" try: ligne_p = factory_lignes.List(idx) if not ligne_p: raise ValueError(f"Ligne {idx} introuvable") try: ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") except: ligne = win32com.client.CastTo(ligne_p, "IBODocumentVenteLigne3") 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() # Réinitialiser la ligne try: ligne.WriteDefault() except: pass quantite = float(ligne_data["quantite"]) # Définir l'article 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 # Prix if ligne_data.get("prix_unitaire_ht"): ligne.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) # Remise 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: {ligne_data['article_code']}") except Exception as e: logger.error(f" ❌ Erreur modification ligne {idx}: {e}") raise def _compter_lignes_existantes(self, factory_lignes) -> int: """Compte le nombre de lignes existantes dans le document.""" 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 return nb_existantes def _ajouter_nouvelle_ligne( self, factory_lignes, factory_article, ligne_data: dict, idx: int ): """Ajoute une nouvelle ligne au devis.""" 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"]) # Définir l'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 ligne_obj.Write() logger.debug(f" ✅ Ligne {idx} ajoutée") def _supprimer_lignes_en_trop( self, factory_lignes, nb_existantes: int, nb_nouvelles: int ): """Supprime les lignes en trop du devis.""" 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 def _extraire_infos_devis(self, doc, numero: str, champs_modifies: list) -> Dict: """Extrait les informations du devis modifié.""" total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) statut = getattr(doc, "DO_Statut", 0) reference = getattr(doc, "DO_Ref", "") # Extraction des dates date_livraison = None try: date_livr = getattr(doc, "DO_DateLivr", None) if date_livr: date_livraison = date_livr.strftime("%Y-%m-%d") except: pass return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "reference": reference, "date_livraison": date_livraison, "champs_modifies": champs_modifies, "statut": statut, } def lire_devis(self, numero_devis): try: # Lire le devis via SQL devis = self._lire_document_sql(numero_devis, type_doc=0) if not devis: return None return devis except Exception as e: logger.error(f"❌ Erreur SQL lecture devis {numero_devis}: {e}") return None def lire_document(self, numero, type_doc): return self._lire_document_sql(numero, type_doc) def verifier_si_deja_transforme_sql(self, numero_source, type_source): """Version corrigée avec normalisation des types""" logger.info( f"[VERIF] Vérification transformations de {numero_source} (type {type_source})" ) logger.info( f"[VERIF] Vérification transformations de {numero_source} (type {type_source})" ) # DEBUG COMPLET logger.info(f"[DEBUG] Type source brut: {type_source}") logger.info( f"[DEBUG] Type source après normalisation: {self._normaliser_type_document(type_source)}" ) logger.info( f"[DEBUG] Type source après normalisation SQL: {self._convertir_type_pour_sql(type_source)}" ) # NORMALISER le type source type_source = self._convertir_type_pour_sql(type_source) champ_liaison_mapping = { 0: "DL_PieceDE", 1: "DL_PieceBC", 3: "DL_PieceBL", } champ_liaison = champ_liaison_mapping.get(type_source) if not champ_liaison: logger.warning(f"[VERIF] Type source {type_source} non géré") return {"deja_transforme": False, "documents_cibles": []} try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = f""" SELECT DISTINCT dc.DO_Piece, dc.DO_Type, dc.DO_Statut, (SELECT COUNT(*) FROM F_DOCLIGNE WHERE DO_Piece = dc.DO_Piece AND DO_Type = dc.DO_Type) as NbLignes FROM F_DOCENTETE dc INNER JOIN F_DOCLIGNE dl ON dc.DO_Piece = dl.DO_Piece AND dc.DO_Type = dl.DO_Type WHERE dl.{champ_liaison} = ? ORDER BY dc.DO_Type, dc.DO_Piece """ cursor.execute(query, (numero_source,)) resultats = cursor.fetchall() documents_cibles = [] for row in resultats: type_brut = int(row.DO_Type) type_normalise = self._convertir_type_depuis_sql(type_brut) doc = { "numero": row.DO_Piece.strip() if row.DO_Piece else "", "type": type_normalise, # ← TYPE NORMALISÉ "type_brut": type_brut, # Garder aussi le type original "type_libelle": self._get_type_libelle(type_brut), "statut": int(row.DO_Statut) if row.DO_Statut else 0, "nb_lignes": int(row.NbLignes) if row.NbLignes else 0, } documents_cibles.append(doc) logger.info( f"[VERIF] Trouvé: {doc['numero']} " f"(type {type_brut}→{type_normalise} - {doc['type_libelle']}) " f"- {doc['nb_lignes']} lignes" ) deja_transforme = len(documents_cibles) > 0 if deja_transforme: logger.info( f"[VERIF] ✅ Document {numero_source} a {len(documents_cibles)} transformation(s)" ) else: logger.info( f"[VERIF] ℹ️ Document {numero_source} pas encore transformé" ) return { "deja_transforme": deja_transforme, "documents_cibles": documents_cibles, } except Exception as e: logger.error(f"[VERIF] Erreur vérification: {e}") return {"deja_transforme": False, "documents_cibles": []} def peut_etre_transforme(self, numero_source, type_source, type_cible): """Version corrigée avec normalisation""" # NORMALISER les types type_source = self._normaliser_type_document(type_source) type_cible = self._normaliser_type_document(type_cible) logger.info( f"[VERIF_TRANSFO] {numero_source} " f"(type {type_source}) → type {type_cible}" ) verif = self.verifier_si_deja_transforme_sql(numero_source, type_source) # Comparer avec le type NORMALISÉ docs_meme_type = [ d for d in verif["documents_cibles"] if d["type"] == type_cible ] if docs_meme_type: nums = [d["numero"] for d in docs_meme_type] return { "possible": False, "raison": f"Document déjà transformé en {self._get_type_libelle(type_cible)}", "documents_existants": docs_meme_type, "message_detaille": f"Document(s) existant(s): {', '.join(nums)}", } return { "possible": True, "raison": "Transformation possible", "documents_existants": [], } def obtenir_chaine_transformation_complete(self, numero_document, type_document): """ Obtient toute la chaîne de transformation d'un document (ascendante et descendante). Exemple: Pour une commande BC00001 - Ascendant: Devis DE00123 - Descendant: BL BL00045, Facture FA00067 Returns: dict: { "document_actuel": {...}, "origine": {...}, # Document source (peut être None) "descendants": [...], # Documents créés à partir de celui-ci "chaine_complete": [...] # Toute la chaîne du devis à la facture } """ logger.info( f"[CHAINE] Analyse chaîne pour {numero_document} (type {type_document})" ) try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ======================================== # 1. Infos du document actuel # ======================================== cursor.execute( """ SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut FROM F_DOCENTETE WHERE DO_Piece = ? AND DO_Type = ? """, (numero_document, type_document), ) doc_actuel_row = cursor.fetchone() if not doc_actuel_row: raise ValueError( f"Document {numero_document} (type {type_document}) introuvable" ) doc_actuel = { "numero": doc_actuel_row.DO_Piece.strip(), "type": int(doc_actuel_row.DO_Type), "type_libelle": self._get_type_libelle(int(doc_actuel_row.DO_Type)), "ref": ( doc_actuel_row.DO_Ref.strip() if doc_actuel_row.DO_Ref else "" ), "date": doc_actuel_row.DO_Date, "total_ht": ( float(doc_actuel_row.DO_TotalHT) if doc_actuel_row.DO_TotalHT else 0.0 ), "statut": ( int(doc_actuel_row.DO_Statut) if doc_actuel_row.DO_Statut else 0 ), } # ======================================== # 2. Chercher le document source (ascendant) # ======================================== origine = None # Chercher dans les lignes du document actuel cursor.execute( """ SELECT DISTINCT DL_PieceDE, DL_DateDE, DL_PieceBC, DL_DateBC, DL_PieceBL, DL_DateBL FROM F_DOCLIGNE WHERE DO_Piece = ? AND DO_Type = ? """, (numero_document, type_document), ) lignes = cursor.fetchall() for ligne in lignes: # Vérifier chaque champ de liaison possible if ligne.DL_PieceDE and ligne.DL_PieceDE.strip(): piece_source = ligne.DL_PieceDE.strip() type_source = 0 # Devis break elif ligne.DL_PieceBC and ligne.DL_PieceBC.strip(): piece_source = ligne.DL_PieceBC.strip() type_source = 1 # Commande break elif ligne.DL_PieceBL and ligne.DL_PieceBL.strip(): piece_source = ligne.DL_PieceBL.strip() type_source = 3 # BL break else: piece_source = None if piece_source: # Récupérer les infos du document source cursor.execute( """ SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut FROM F_DOCENTETE WHERE DO_Piece = ? AND DO_Type = ? """, (piece_source, type_source), ) source_row = cursor.fetchone() if source_row: origine = { "numero": source_row.DO_Piece.strip(), "type": int(source_row.DO_Type), "type_libelle": self._get_type_libelle( int(source_row.DO_Type) ), "ref": ( source_row.DO_Ref.strip() if source_row.DO_Ref else "" ), "date": source_row.DO_Date, "total_ht": ( float(source_row.DO_TotalHT) if source_row.DO_TotalHT else 0.0 ), "statut": ( int(source_row.DO_Statut) if source_row.DO_Statut else 0 ), } logger.info( f"[CHAINE] Origine trouvée: {origine['numero']} ({origine['type_libelle']})" ) # ======================================== # 3. Chercher les documents descendants # ======================================== verif = self.verifier_si_deja_transforme_sql( numero_document, type_document ) descendants = verif["documents_cibles"] # Enrichir avec les détails for desc in descendants: cursor.execute( """ SELECT DO_Ref, DO_Date, DO_TotalHT FROM F_DOCENTETE WHERE DO_Piece = ? AND DO_Type = ? """, (desc["numero"], desc["type"]), ) desc_row = cursor.fetchone() if desc_row: desc["ref"] = desc_row.DO_Ref.strip() if desc_row.DO_Ref else "" desc["date"] = desc_row.DO_Date desc["total_ht"] = ( float(desc_row.DO_TotalHT) if desc_row.DO_TotalHT else 0.0 ) # ======================================== # 4. Construire la chaîne complète # ======================================== chaine_complete = [] # Remonter récursivement jusqu'au devis doc_temp = origine while doc_temp: chaine_complete.insert(0, doc_temp) # Chercher l'origine de ce document verif_temp = self.verifier_si_deja_transforme_sql( doc_temp["numero"], doc_temp["type"] ) # Remonter (chercher dans les lignes) cursor.execute( """ SELECT DISTINCT DL_PieceDE, DL_PieceBC, DL_PieceBL FROM F_DOCLIGNE WHERE DO_Piece = ? AND DO_Type = ? """, (doc_temp["numero"], doc_temp["type"]), ) ligne_temp = cursor.fetchone() if ligne_temp: if ligne_temp.DL_PieceDE and ligne_temp.DL_PieceDE.strip(): piece_parent = ligne_temp.DL_PieceDE.strip() type_parent = 0 elif ligne_temp.DL_PieceBC and ligne_temp.DL_PieceBC.strip(): piece_parent = ligne_temp.DL_PieceBC.strip() type_parent = 10 elif ligne_temp.DL_PieceBL and ligne_temp.DL_PieceBL.strip(): piece_parent = ligne_temp.DL_PieceBL.strip() type_parent = 30 else: break # Récupérer infos parent cursor.execute( """ SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut FROM F_DOCENTETE WHERE DO_Piece = ? AND DO_Type = ? """, (piece_parent, type_parent), ) parent_row = cursor.fetchone() if parent_row: doc_temp = { "numero": parent_row.DO_Piece.strip(), "type": int(parent_row.DO_Type), "type_libelle": self._get_type_libelle( int(parent_row.DO_Type) ), "ref": ( parent_row.DO_Ref.strip() if parent_row.DO_Ref else "" ), "date": parent_row.DO_Date, "total_ht": ( float(parent_row.DO_TotalHT) if parent_row.DO_TotalHT else 0.0 ), "statut": ( int(parent_row.DO_Statut) if parent_row.DO_Statut else 0 ), } else: break else: break # Ajouter le document actuel chaine_complete.append(doc_actuel) # Ajouter les descendants récursivement def ajouter_descendants(doc, profondeur=0): if profondeur > 10: # Sécurité contre boucles infinies return verif = self.verifier_si_deja_transforme_sql( doc["numero"], doc["type"] ) for desc in verif["documents_cibles"]: # Récupérer infos complètes cursor.execute( """ SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut FROM F_DOCENTETE WHERE DO_Piece = ? AND DO_Type = ? """, (desc["numero"], desc["type"]), ) desc_row = cursor.fetchone() if desc_row: desc_complet = { "numero": desc_row.DO_Piece.strip(), "type": int(desc_row.DO_Type), "type_libelle": self._get_type_libelle( int(desc_row.DO_Type) ), "ref": ( desc_row.DO_Ref.strip() if desc_row.DO_Ref else "" ), "date": desc_row.DO_Date, "total_ht": ( float(desc_row.DO_TotalHT) if desc_row.DO_TotalHT else 0.0 ), "statut": ( int(desc_row.DO_Statut) if desc_row.DO_Statut else 0 ), } if desc_complet not in chaine_complete: chaine_complete.append(desc_complet) ajouter_descendants(desc_complet, profondeur + 1) ajouter_descendants(doc_actuel) # ======================================== # Résultat # ======================================== logger.info( f"[CHAINE] Chaîne complète: {len(chaine_complete)} document(s)" ) for i, doc in enumerate(chaine_complete): logger.info( f"[CHAINE] {i+1}. {doc['numero']} ({doc['type_libelle']}) - " f"{doc['total_ht']}€ HT" ) return { "document_actuel": doc_actuel, "origine": origine, "descendants": descendants, "chaine_complete": chaine_complete, } except Exception as e: logger.error(f"[CHAINE] Erreur analyse chaîne: {e}", exc_info=True) return { "document_actuel": None, "origine": None, "descendants": [], "chaine_complete": [], } def _get_type_libelle(self, type_doc: int) -> str: """ Retourne le libellé d'un type de document. Gère les 2 formats : valeur réelle Sage (0,10,20...) ET valeur affichée (0,1,2...) """ # Mapping principal (valeurs Sage officielles) types_officiels = { 0: "Devis", 10: "Bon de commande", 20: "Préparation", 30: "Bon de livraison", 40: "Bon de retour", 50: "Bon d'avoir", 60: "Facture", } # Mapping alternatif (parfois Sage stocke 1 au lieu de 10, 2 au lieu de 20, etc.) types_alternatifs = { 1: "Bon de commande", 2: "Préparation", 3: "Bon de livraison", 4: "Bon de retour", 5: "Bon d'avoir", 6: "Facture", } # Essayer d'abord le mapping officiel if type_doc in types_officiels: return types_officiels[type_doc] # Puis le mapping alternatif if type_doc in types_alternatifs: return types_alternatifs[type_doc] return f"Type {type_doc}" def _normaliser_type_document(self, type_doc: int) -> int: """ Normalise le type de document vers la valeur officielle Sage. Convertit 1→10, 2→20, etc. si nécessaire """ # Si c'est déjà un type officiel, le retourner tel quel logger.info(f"[INFO] TYPE RECU{type_doc}") if type_doc in [0, 10, 20, 30, 40, 50, 60]: return type_doc # Sinon, essayer de convertir mapping_normalisation = { 1: 10, # Commande 2: 20, # Préparation 3: 30, # BL 4: 40, # Retour 5: 50, # Avoir 6: 60, # Facture } return mapping_normalisation.get(type_doc, type_doc) def transformer_document( self, numero_source, type_source, type_cible, ignorer_controle_stock=False, conserver_document_source=True, verifier_doublons=True, ): if not self.cial: raise RuntimeError("Connexion Sage non établie") type_source = int(type_source) type_cible = int(type_cible) logger.info( f"[TRANSFORM] 🔄 Transformation: {numero_source} ({type_source}) → type {type_cible}" ) # ======================================== # VALIDATION DES TYPES # ======================================== transformations_valides = { (0, 10): ("Vente", "CreateProcess_Commander"), (0, 60): ("Vente", "CreateProcess_Facturer"), (10, 30): ("Vente", "CreateProcess_Livrer"), (10, 60): ("Vente", "CreateProcess_Facturer"), (30, 60): ("Vente", "CreateProcess_Facturer"), } if (type_source, type_cible) not in transformations_valides: raise ValueError( f"Transformation non autorisée: " f"{self._get_type_libelle(type_source)} → {self._get_type_libelle(type_cible)}" ) module, methode = transformations_valides[(type_source, type_cible)] logger.info(f"[TRANSFORM] Méthode: Transformation.{module}.{methode}()") # ======================================== # VÉRIFICATION OPTIONNELLE DES DOUBLONS # ======================================== if verifier_doublons: logger.info("[TRANSFORM] 🔍 Vérification des doublons...") verif = self.peut_etre_transforme(numero_source, type_source, type_cible) if not verif["possible"]: docs = [d["numero"] for d in verif.get("documents_existants", [])] raise ValueError( f"{verif['raison']}. Document(s) existant(s): {', '.join(docs)}" ) logger.info("[TRANSFORM] ✅ Aucun doublon détecté") try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentVente # ======================================== # ÉTAPE 1 : LIRE LE DOCUMENT SOURCE # ======================================== logger.info(f"[TRANSFORM] 📄 Lecture de {numero_source}...") if not factory.ExistPiece(type_source, numero_source): raise ValueError(f"Document {numero_source} introuvable") persist_source = factory.ReadPiece(type_source, numero_source) if not persist_source: raise ValueError(f"Impossible de lire {numero_source}") doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3") doc_source.Read() # Informations du document source statut_source = getattr(doc_source, "DO_Statut", 0) nb_lignes_source = 0 try: factory_lignes = getattr(doc_source, "FactoryDocumentLigne", None) if factory_lignes: lignes_list = factory_lignes.List nb_lignes_source = lignes_list.Count if lignes_list else 0 except: pass logger.info( f"[TRANSFORM] Source: statut={statut_source}, {nb_lignes_source} ligne(s)" ) if nb_lignes_source == 0: raise ValueError(f"Document {numero_source} vide (0 lignes)") # ======================================== # ÉTAPE 2 : CRÉER LE TRANSFORMER # ======================================== logger.info("[TRANSFORM] 🔧 Création du transformer...") transformation = getattr(self.cial, "Transformation", None) if not transformation: raise RuntimeError("API Transformation non disponible") module_obj = getattr(transformation, module, None) if not module_obj: raise RuntimeError(f"Module {module} non disponible") methode_func = getattr(module_obj, methode, None) if not methode_func: raise RuntimeError(f"Méthode {methode} non disponible") transformer = methode_func() if not transformer: raise RuntimeError("Échec création transformer") logger.info("[TRANSFORM] ✅ Transformer créé") # ======================================== # ÉTAPE 3 : CONFIGURATION # ======================================== logger.info("[TRANSFORM] ⚙️ Configuration...") # Tenter de définir ConserveDocuments if hasattr(transformer, "ConserveDocuments"): try: transformer.ConserveDocuments = conserver_document_source logger.info( f"[TRANSFORM] ConserveDocuments = {conserver_document_source}" ) except Exception as e: logger.warning( f"[TRANSFORM] ConserveDocuments non modifiable: {e}" ) # ======================================== # ÉTAPE 4 : AJOUTER LE DOCUMENT # ======================================== logger.info("[TRANSFORM] ➕ Ajout du document...") try: transformer.AddDocument(doc_source) logger.info("[TRANSFORM] ✅ Document ajouté") except Exception as e: raise RuntimeError(f"Impossible d'ajouter le document: {e}") # ======================================== # ÉTAPE 5 : VÉRIFIER CANPROCESS # ======================================== try: can_process = getattr(transformer, "CanProcess", False) logger.info(f"[TRANSFORM] CanProcess: {can_process}") except: can_process = True if not can_process: erreurs = self.lire_erreurs_sage(transformer, "Transformer") if erreurs: msgs = [f"{e['field']}: {e['description']}" for e in erreurs] raise RuntimeError( f"Transformation impossible: {' | '.join(msgs)}" ) raise RuntimeError("Transformation impossible (CanProcess=False)") # ======================================== # ÉTAPE 6 : TRANSACTION (optionnelle) # ======================================== transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("[TRANSFORM] Transaction démarrée") except: pass try: # ======================================== # ÉTAPE 7 : PROCESS (TRANSFORMATION) # ======================================== logger.info("[TRANSFORM] ⚙️ Process()...") try: transformer.Process() logger.info("[TRANSFORM] ✅ Process() réussi") except Exception as e: logger.error(f"[TRANSFORM] ❌ Erreur Process(): {e}") erreurs = self.lire_erreurs_sage(transformer, "Transformer") if erreurs: msgs = [ f"{e['field']}: {e['description']}" for e in erreurs ] raise RuntimeError(f"Échec: {' | '.join(msgs)}") raise RuntimeError(f"Échec transformation: {e}") # ======================================== # ÉTAPE 8 : RÉCUPÉRER LES RÉSULTATS # ======================================== logger.info("[TRANSFORM] 📦 Récupération des résultats...") list_results = getattr(transformer, "ListDocumentsResult", None) if not list_results: raise RuntimeError("ListDocumentsResult non disponible") documents_crees = [] index = 1 while index <= 100: try: doc_result = list_results.Item(index) if doc_result is None: break doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_cible = getattr(doc_result, "DO_Piece", "").strip() total_ht = float(getattr(doc_result, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_result, "DO_TotalTTC", 0.0)) # Compter les lignes via COM nb_lignes = 0 try: factory_lignes_result = getattr( doc_result, "FactoryDocumentLigne", None ) if factory_lignes_result: lignes_list = factory_lignes_result.List nb_lignes = lignes_list.Count if lignes_list else 0 except: pass documents_crees.append( { "numero": numero_cible, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": nb_lignes, } ) logger.info( f"[TRANSFORM] Document créé: {numero_cible} " f"({nb_lignes} lignes, {total_ht}€ HT)" ) index += 1 except Exception as e: logger.debug(f"Fin de liste à index {index}") break if not documents_crees: raise RuntimeError("Aucun document créé après Process()") # ======================================== # ÉTAPE 9 : COMMIT # ======================================== if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.debug("[TRANSFORM] Transaction committée") except: pass # Pause de sécurité time.sleep(1.5) # ======================================== # RÉSULTAT # ======================================== doc_principal = documents_crees[0] logger.info( f"[TRANSFORM] ✅ SUCCÈS: {numero_source} → {doc_principal['numero']}" ) logger.info( f"[TRANSFORM] • {doc_principal['nb_lignes']} ligne(s)" ) logger.info( f"[TRANSFORM] • {doc_principal['total_ht']}€ HT / " f"{doc_principal['total_ttc']}€ TTC" ) return { "success": True, "document_source": numero_source, "document_cible": doc_principal["numero"], "type_source": type_source, "type_cible": type_cible, "nb_documents_crees": len(documents_crees), "documents": documents_crees, "nb_lignes": doc_principal["nb_lignes"], "total_ht": doc_principal["total_ht"], "total_ttc": doc_principal["total_ttc"], "methode_transformation": f"{module}.{methode}", } except Exception as e: # Rollback en cas d'erreur if transaction_active: try: self.cial.CptaApplication.RollbackTrans() logger.error("[TRANSFORM] Transaction annulée (rollback)") except: pass raise except ValueError as e: # Erreur métier (validation, doublon, etc.) logger.error(f"[TRANSFORM] ❌ Erreur métier: {e}") raise except RuntimeError as e: # Erreur technique Sage logger.error(f"[TRANSFORM] ❌ Erreur technique: {e}") raise except Exception as e: # Erreur inattendue logger.error(f"[TRANSFORM] ❌ Erreur inattendue: {e}", exc_info=True) raise RuntimeError(f"Échec transformation: {str(e)}") def lire_erreurs_sage(self, obj, nom_obj=""): """ Lit toutes les erreurs d'un objet Sage COM. Utilisé pour diagnostiquer les échecs de Process(). """ erreurs = [] try: if not hasattr(obj, "Errors") or obj.Errors is None: return erreurs nb_erreurs = 0 try: nb_erreurs = obj.Errors.Count except: return erreurs if nb_erreurs == 0: return erreurs for i in range(1, nb_erreurs + 1): try: err = None # Plusieurs façons d'accéder aux erreurs selon la version Sage try: err = obj.Errors.Item(i) except: try: err = obj.Errors(i) except: try: err = obj.Errors.Item(i - 1) except: pass if err is not None: description = "" field = "" number = "" # Description for attr in ["Description", "Descr", "Message", "Text"]: try: val = getattr(err, attr, None) if val: description = str(val) break except: pass # Champ concerné for attr in ["Field", "FieldName", "Champ", "Property"]: try: val = getattr(err, attr, None) if val: field = str(val) break except: pass # Numéro d'erreur for attr in ["Number", "Code", "ErrorCode", "Numero"]: try: val = getattr(err, attr, None) if val is not None: number = str(val) break except: pass if description or field or number: erreurs.append( { "source": nom_obj, "index": i, "description": description or "Erreur inconnue", "field": field or "?", "number": number or "?", } ) except Exception as e: logger.debug(f"Erreur lecture erreur {i}: {e}") continue except Exception as e: logger.debug(f"Erreur globale lecture erreurs {nom_obj}: {e}") return erreurs 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 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 def lire_contact_principal_client(self, code_client): 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 def mettre_a_jour_derniere_relance(self, doc_id, type_doc): 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 ) def lister_tous_prospects(self, filtre=""): try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT CT_Num, CT_Intitule, CT_Adresse, CT_Ville, CT_CodePostal, CT_Telephone, CT_EMail FROM F_COMPTET WHERE CT_Type = 0 AND CT_Prospect = 1 """ params = [] if filtre: query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY CT_Intitule" cursor.execute(query, params) rows = cursor.fetchall() prospects = [] for row in rows: prospects.append( { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "adresse": self._safe_strip(row.CT_Adresse), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "telephone": self._safe_strip(row.CT_Telephone), "email": self._safe_strip(row.CT_EMail), "type": 0, "est_prospect": True, } ) logger.info(f"✅ SQL: {len(prospects)} prospects") return prospects except Exception as e: logger.error(f"❌ Erreur SQL prospects: {e}") return [] def lire_prospect(self, code_prospect): try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Complement, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_Portable, CT_EMail, CT_Telecopie, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Prospect, CT_Contact, CT_FormeJuridique, CT_Secteur FROM F_COMPTET WHERE CT_Num = ? AND CT_Type = 0 AND CT_Prospect = 1 """, (code_prospect.upper(),), ) row = cursor.fetchone() if not row: return None return { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "type": 0, "qualite": self._safe_strip(row.CT_Qualite), "est_prospect": True, "adresse": self._safe_strip(row.CT_Adresse), "complement": self._safe_strip(row.CT_Complement), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "pays": self._safe_strip(row.CT_Pays), "telephone": self._safe_strip(row.CT_Telephone), "portable": self._safe_strip(row.CT_Portable), "email": self._safe_strip(row.CT_EMail), "telecopie": self._safe_strip(row.CT_Telecopie), "siret": self._safe_strip(row.CT_Siret), "tva_intra": self._safe_strip(row.CT_Identifiant), "est_actif": (row.CT_Sommeil == 0), "contact": self._safe_strip(row.CT_Contact), "forme_juridique": self._safe_strip(row.CT_FormeJuridique), "secteur": self._safe_strip(row.CT_Secteur), } except Exception as e: logger.error(f"❌ Erreur SQL prospect {code_prospect}: {e}") return None def lister_avoirs(self, limit=100, statut=None): try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = f""" SELECT TOP ({limit}) d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, d.DO_Statut, d.CT_Num, c.CT_Intitule FROM F_DOCENTETE d LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num WHERE d.DO_Type = 50 """ params = [] if statut is not None: query += " AND d.DO_Statut = ?" params.append(statut) query += " ORDER BY d.DO_Date DESC" cursor.execute(query, params) rows = cursor.fetchall() avoirs = [] for row in rows: avoirs.append( { "numero": self._safe_strip(row.DO_Piece), "reference": self._safe_strip(row.DO_Ref), "date": str(row.DO_Date) if row.DO_Date else "", "client_code": self._safe_strip(row.CT_Num), "client_intitule": self._safe_strip(row.CT_Intitule), "total_ht": ( float(row.DO_TotalHT) if row.DO_TotalHT else 0.0 ), "total_ttc": ( float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0 ), "statut": row.DO_Statut if row.DO_Statut is not None else 0, } ) return avoirs except Exception as e: logger.error(f"❌ Erreur SQL avoirs: {e}") return [] def lire_avoir(self, numero): return self._lire_document_sql(numero, type_doc=50) def lister_livraisons(self, limit=100, statut=None): """📖 Liste les livraisons via SQL (méthode legacy)""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = f""" SELECT TOP ({limit}) d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, d.DO_Statut, d.CT_Num, c.CT_Intitule FROM F_DOCENTETE d LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num WHERE d.DO_Type = 30 """ params = [] if statut is not None: query += " AND d.DO_Statut = ?" params.append(statut) query += " ORDER BY d.DO_Date DESC" cursor.execute(query, params) rows = cursor.fetchall() livraisons = [] for row in rows: livraisons.append( { "numero": self._safe_strip(row.DO_Piece), "reference": self._safe_strip(row.DO_Ref), "date": str(row.DO_Date) if row.DO_Date else "", "client_code": self._safe_strip(row.CT_Num), "client_intitule": self._safe_strip(row.CT_Intitule), "total_ht": ( float(row.DO_TotalHT) if row.DO_TotalHT else 0.0 ), "total_ttc": ( float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0 ), "statut": row.DO_Statut if row.DO_Statut is not None else 0, } ) return livraisons except Exception as e: logger.error(f"❌ Erreur SQL livraisons: {e}") return [] def lire_livraison(self, numero): """📖 Lit UNE livraison via SQL (avec lignes)""" return self._lire_document_sql(numero, type_doc=30) def creer_client(self, client_data: Dict) -> Dict: 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 # ======================================== 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: 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 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 creer_commande_enrichi(self, commande_data: dict) -> Dict: """ Crée une commande dans Sage avec support des dates. Args: commande_data: dict contenant: - client: {code: str} - date_commande: str ou date - date_livraison: str ou date (optionnel) - reference: str (optionnel) - lignes: list[dict] """ 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éé") # ===== DATES ===== doc.DO_Date = pywintypes.Time( self.normaliser_date(commande_data.get("date_commande")) ) if ("date_livraison" in commande_data and commande_data["date_livraison"]): doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(commande_data["date_livraison"]) ) logger.info( f"📅 Date livraison: {commande_data['date_livraison']}" ) # ===== 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)) reference_finale = getattr(doc_final, "DO_Ref", "") date_livraison_final = None try: date_livr = getattr(doc_final, "DO_DateLivr", None) if date_livr: date_livraison_final = date_livr.strftime("%Y-%m-%d") except: pass else: total_ht = 0.0 total_ttc = 0.0 reference_finale = commande_data.get("reference", "") date_livraison_final = commande_data.get("date_livraison") logger.info( f"✅✅✅ COMMANDE CRÉÉE: {numero_commande} - {total_ttc}€ TTC ✅✅✅" ) if date_livraison_final: logger.info(f"📅 Date livraison: {date_livraison_final}") 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( self.normaliser_date(commande_data.get("date_commande")) ), "date_livraison": date_livraison_final, "reference": reference_finale, } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except: pass raise except Exception as e: logger.error(f"❌ Erreur création commande: {e}", exc_info=True) raise RuntimeError(f"Échec création commande: {str(e)}") def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: """ Modifie une commande existante dans Sage. Args: numero: Numéro de la commande commande_data: dict contenant les champs à modifier: - date_commande: str ou date (optionnel) - date_livraison: str ou date (optionnel) - reference: str (optionnel) - statut: int (optionnel) - lignes: list[dict] (optionnel) """ 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_date_livraison = "date_livraison" 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 commande: {modif_date}") logger.info(f" Date livraison: {modif_date_livraison}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ===== EXTRAIRE référence et statut pour les traiter à la fin si lignes modifiées ===== commande_data_temp = commande_data.copy() reference_a_modifier = None statut_a_modifier = None if modif_lignes: if modif_ref: reference_a_modifier = commande_data_temp.pop("reference") logger.info( "🔖 Modification de la référence reportée après les lignes" ) modif_ref = False if modif_statut: statut_a_modifier = commande_data_temp.pop("statut") logger.info( "📊 Modification du statut reportée après les lignes" ) modif_statut = False # ======================================== # É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_date_livraison or modif_statut or modif_ref ): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: logger.info(" 📅 Modification date commande...") doc.DO_Date = pywintypes.Time( self.normaliser_date( commande_data_temp.get("date_commande") ) ) champs_modifies.append("date") if modif_date_livraison: logger.info(" 📅 Modification date livraison...") doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(commande_data_temp["date_livraison"]) ) logger.info( f" ✅ Date livraison: {commande_data_temp['date_livraison']}" ) champs_modifies.append("date_livraison") if modif_statut: logger.info(" 📊 Modification statut...") nouveau_statut = commande_data_temp["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_temp["reference"] champs_modifies.append("reference") logger.info( f" ✅ Référence définie: {commande_data_temp['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...") # D'abord modifier les dates si demandées if modif_date: doc.DO_Date = pywintypes.Time( self.normaliser_date( commande_data_temp.get("date_commande") ) ) champs_modifies.append("date") logger.info(" 📅 Date commande modifiée") if modif_date_livraison: doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(commande_data_temp["date_livraison"]) ) logger.info(" 📅 Date livraison modifiée") champs_modifies.append("date_livraison") 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() 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") import time time.sleep(0.5) 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 6.5 : MODIFIER RÉFÉRENCE (APRÈS les lignes) # ======================================== if reference_a_modifier is not None: try: ancienne_reference = getattr(doc, "DO_Ref", "") nouvelle_reference = ( str(reference_a_modifier) if reference_a_modifier else "" ) doc.DO_Ref = nouvelle_reference doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("reference") logger.info( f"🔖 Référence modifiée: '{ancienne_reference}' → '{nouvelle_reference}'" ) except Exception as e: logger.warning(f"⚠️ Impossible de modifier la référence: {e}") # ======================================== # ÉTAPE 6.6 : MODIFIER STATUT (EN DERNIER) # ======================================== if statut_a_modifier is not None: try: statut_actuel = getattr(doc, "DO_Statut", 0) nouveau_statut = int(statut_a_modifier) if nouveau_statut != statut_actuel: doc.DO_Statut = nouveau_statut doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("statut") logger.info( f"📊 Statut modifié: {statut_actuel} → {nouveau_statut}" ) except Exception as e: logger.warning(f"⚠️ Impossible de modifier le statut: {e}") # ======================================== # ÉTAPE 7 : RELECTURE ET RETOUR # ======================================== logger.info("📊 Relecture finale...") import time time.sleep(0.5) 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)) reference_finale = getattr(doc, "DO_Ref", "") date_livraison_final = None try: date_livr = getattr(doc, "DO_DateLivr", None) if date_livr: date_livraison_final = date_livr.strftime("%Y-%m-%d") except: pass 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" 🔖 Référence: {reference_finale}") if date_livraison_final: logger.info(f" 📅 Date livraison: {date_livraison_final}") logger.info(f" 📝 Champs modifiés: {champs_modifies}") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "reference": reference_finale, "date_livraison": date_livraison_final, "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ée une livraison dans Sage avec support des dates. Args: livraison_data: dict contenant: - client: {code: str} - date_livraison: str ou date (date du document) - date_livraison_prevue: str ou date (optionnel - date prévue livraison client) - reference: str (optionnel) - lignes: list[dict] """ 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éé") # ===== DATES ===== # Date du document (DO_Date) doc.DO_Date = pywintypes.Time( self.normaliser_date(livraison_data.get("date_livraison")) ) # Date de livraison prévue chez le client (DO_DateLivr) if ( "date_livraison_prevue" in livraison_data and livraison_data["date_livraison_prevue"] ): doc.DO_DateLivr = pywintypes.Time( self.normaliser_date( livraison_data["date_livraison_prevue"] ) ) logger.info( f"📅 Date livraison prévue: {livraison_data['date_livraison_prevue']}" ) # ===== 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)) reference_finale = getattr(doc_final, "DO_Ref", "") # Dates date_livraison_prevue_final = None try: date_livr = getattr(doc_final, "DO_DateLivr", None) if date_livr: date_livraison_prevue_final = date_livr.strftime( "%Y-%m-%d" ) except: pass else: total_ht = 0.0 total_ttc = 0.0 reference_finale = livraison_data.get("reference", "") date_livraison_prevue_final = livraison_data.get( "date_livraison_prevue" ) logger.info( f"✅✅✅ LIVRAISON CRÉÉE: {numero_livraison} - {total_ttc}€ TTC ✅✅✅" ) if date_livraison_prevue_final: logger.info( f"📅 Date livraison prévue: {date_livraison_prevue_final}" ) 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( self.normaliser_date(livraison_data.get("date_livraison")) ), "date_livraison_prevue": date_livraison_prevue_final, "reference": reference_finale, } 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: """ Modifie une livraison existante dans Sage. Args: numero: Numéro de la livraison livraison_data: dict contenant les champs à modifier: - date_livraison: str ou date (optionnel - date du document) - date_livraison_prevue: str ou date (optionnel - date prévue livraison client) - reference: str (optionnel) - statut: int (optionnel) - lignes: list[dict] (optionnel) """ 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_date_livraison_prevue = "date_livraison_prevue" 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 livraison: {modif_date}") logger.info(f" Date livraison prévue: {modif_date_livraison_prevue}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ===== EXTRAIRE référence et statut pour les traiter à la fin si lignes modifiées ===== livraison_data_temp = livraison_data.copy() reference_a_modifier = None statut_a_modifier = None if modif_lignes: if modif_ref: reference_a_modifier = livraison_data_temp.pop("reference") logger.info( "🔖 Modification de la référence reportée après les lignes" ) modif_ref = False if modif_statut: statut_a_modifier = livraison_data_temp.pop("statut") logger.info( "📊 Modification du statut reportée après les lignes" ) modif_statut = False # ======================================== # ÉTAPE 3 : MODIFICATIONS SIMPLES (pas de lignes) # ======================================== if not modif_lignes and ( modif_date or modif_date_livraison_prevue or modif_statut or modif_ref ): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: logger.info(" 📅 Modification date livraison...") doc.DO_Date = pywintypes.Time( self.normaliser_date( livraison_data_temp.get("date_livraison") ) ) champs_modifies.append("date") if modif_date_livraison_prevue: logger.info(" 📅 Modification date livraison prévue...") doc.DO_DateLivr = pywintypes.Time( self.normaliser_date( livraison_data_temp["date_livraison_prevue"] ) ) logger.info( f" ✅ Date livraison prévue: {livraison_data_temp['date_livraison_prevue']}" ) champs_modifies.append("date_livraison_prevue") if modif_statut: logger.info(" 📊 Modification statut...") nouveau_statut = livraison_data_temp["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 = livraison_data_temp["reference"] champs_modifies.append("reference") logger.info( f" ✅ Référence définie: {livraison_data_temp['reference']}" ) except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") logger.info(" 💾 Write()...") doc.Write() logger.info(" ✅ Write() réussi") # ======================================== # ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES # ======================================== elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") # D'abord modifier les dates si demandées if modif_date: doc.DO_Date = pywintypes.Time( self.normaliser_date( livraison_data_temp.get("date_livraison") ) ) champs_modifies.append("date") logger.info(" 📅 Date livraison modifiée") if modif_date_livraison_prevue: doc.DO_DateLivr = pywintypes.Time( self.normaliser_date( livraison_data_temp["date_livraison_prevue"] ) ) logger.info(" 📅 Date livraison prévue modifiée") champs_modifies.append("date_livraison_prevue") 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 # ============================================ # SOUS-ÉTAPE 1 : SUPPRESSION TOUTES LES LIGNES # ============================================ if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") # 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() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: logger.warning( f" ⚠️ Erreur suppression ligne {idx}: {e}" ) # Continuer même si une suppression échoue logger.info(" ✅ Toutes les lignes supprimées") # ============================================ # SOUS-ÉTAPE 2 : AJOUT 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") import time time.sleep(0.5) doc.Read() champs_modifies.append("lignes") # ======================================== # ÉTAPE 4.5 : MODIFIER RÉFÉRENCE (APRÈS les lignes) # ======================================== if reference_a_modifier is not None: try: ancienne_reference = getattr(doc, "DO_Ref", "") nouvelle_reference = ( str(reference_a_modifier) if reference_a_modifier else "" ) logger.info( f"🔖 Modification référence: '{ancienne_reference}' → '{nouvelle_reference}'" ) doc.DO_Ref = nouvelle_reference doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("reference") logger.info(f" ✅ Référence modifiée avec succès") except Exception as e: logger.warning(f"⚠️ Impossible de modifier la référence: {e}") # ======================================== # ÉTAPE 4.6 : MODIFIER STATUT (EN DERNIER) # ======================================== if statut_a_modifier is not None: try: statut_actuel = getattr(doc, "DO_Statut", 0) nouveau_statut = int(statut_a_modifier) if nouveau_statut != statut_actuel: logger.info( f"📊 Modification statut: {statut_actuel} → {nouveau_statut}" ) doc.DO_Statut = nouveau_statut doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("statut") logger.info(f" ✅ Statut modifié avec succès") except Exception as e: logger.warning(f"⚠️ Impossible de modifier le statut: {e}") # ======================================== # ÉTAPE 5 : RELECTURE ET RETOUR # ======================================== logger.info("📊 Relecture finale...") import time time.sleep(0.5) doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) reference_finale = getattr(doc, "DO_Ref", "") statut_final = getattr(doc, "DO_Statut", 0) # Extraire les dates date_livraison_prevue_final = None try: date_livr = getattr(doc, "DO_DateLivr", None) if date_livr: date_livraison_prevue_final = date_livr.strftime("%Y-%m-%d") except: pass logger.info(f"✅ ✅ ✅ SUCCÈS: {numero} modifiée ✅ ✅ ✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") logger.info(f" 🔖 Référence: {reference_finale}") logger.info(f" 📊 Statut: {statut_final}") if date_livraison_prevue_final: logger.info( f" 📅 Date livraison prévue: {date_livraison_prevue_final}" ) logger.info(f" 📝 Champs modifiés: {champs_modifies}") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "reference": reference_finale, "date_livraison_prevue": date_livraison_prevue_final, "champs_modifies": champs_modifies, "statut": statut_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_avoir_enrichi(self, avoir_data: dict) -> Dict: """ Crée un avoir dans Sage avec support des dates. Args: avoir_data: dict contenant: - client: {code: str} - date_avoir: str ou date - date_livraison: str ou date (optionnel) - reference: str (optionnel) - lignes: list[dict] """ 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éé") # ===== DATES ===== doc.DO_Date = pywintypes.Time( self.normaliser_date(avoir_data.get("date_avoir")) ) if "date_livraison" in avoir_data and avoir_data["date_livraison"]: doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(avoir_data["date_livraison"]) ) logger.info( f"📅 Date livraison: {avoir_data['date_livraison']}" ) # ===== 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)) reference_finale = getattr(doc_final, "DO_Ref", "") # Dates date_livraison_final = None try: date_livr = getattr(doc_final, "DO_DateLivr", None) if date_livr: date_livraison_final = date_livr.strftime("%Y-%m-%d") except: pass else: total_ht = 0.0 total_ttc = 0.0 reference_finale = avoir_data.get("reference", "") date_livraison_final = avoir_data.get("date_livraison") logger.info( f"✅✅✅ AVOIR CRÉÉ: {numero_avoir} - {total_ttc}€ TTC ✅✅✅" ) if date_livraison_final: logger.info(f"📅 Date livraison: {date_livraison_final}") 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( self.normaliser_date(avoir_data.get("date_avoir")) ), "date_livraison": date_livraison_final, "reference": reference_finale, } 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: """ Modifie un avoir existant dans Sage. Args: numero: Numéro de l'avoir avoir_data: dict contenant les champs à modifier: - date_avoir: str ou date (optionnel) - date_livraison: str ou date (optionnel) - reference: str (optionnel) - statut: int (optionnel) - lignes: list[dict] (optionnel) """ 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) type_reel = getattr(doc, "DO_Type", -1) logger.info(f" 📊 Type={type_reel}, Statut={statut_actuel}") # Vérifier qu'il n'est pas transformé ou annulé 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é") # ======================================== # É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_avoir" in avoir_data modif_date_livraison = "date_livraison" 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 avoir: {modif_date}") logger.info(f" Date livraison: {modif_date_livraison}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ===== EXTRAIRE référence et statut pour les traiter à la fin si lignes modifiées ===== avoir_data_temp = avoir_data.copy() reference_a_modifier = None statut_a_modifier = None if modif_lignes: if modif_ref: reference_a_modifier = avoir_data_temp.pop("reference") logger.info( "🔖 Modification de la référence reportée après les lignes" ) modif_ref = False if modif_statut: statut_a_modifier = avoir_data_temp.pop("statut") logger.info( "📊 Modification du statut reportée après les lignes" ) modif_statut = False # ======================================== # É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_date_livraison or modif_statut or modif_ref ): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: logger.info(" 📅 Modification date avoir...") doc.DO_Date = pywintypes.Time( self.normaliser_date(avoir_data_temp.get("date_avoir")) ) champs_modifies.append("date") if modif_date_livraison: logger.info(" 📅 Modification date livraison...") doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(avoir_data_temp["date_livraison"]) ) logger.info( f" ✅ Date livraison: {avoir_data_temp['date_livraison']}" ) champs_modifies.append("date_livraison") if modif_statut: logger.info(" 📊 Modification statut...") nouveau_statut = avoir_data_temp["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 = avoir_data_temp["reference"] champs_modifies.append("reference") logger.info( f" ✅ Référence définie: {avoir_data_temp['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...") # D'abord modifier les dates si demandées if modif_date: doc.DO_Date = pywintypes.Time( self.normaliser_date(avoir_data_temp.get("date_avoir")) ) champs_modifies.append("date") logger.info(" 📅 Date avoir modifiée") if modif_date_livraison: doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(avoir_data_temp["date_livraison"]) ) logger.info(" 📅 Date livraison modifiée") champs_modifies.append("date_livraison") nouvelles_lignes = avoir_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() 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") import time time.sleep(0.5) 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 6.5 : MODIFIER RÉFÉRENCE (APRÈS les lignes) # ======================================== if reference_a_modifier is not None: try: ancienne_reference = getattr(doc, "DO_Ref", "") nouvelle_reference = ( str(reference_a_modifier) if reference_a_modifier else "" ) logger.info( f"🔖 Modification référence: '{ancienne_reference}' → '{nouvelle_reference}'" ) doc.DO_Ref = nouvelle_reference doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("reference") logger.info(f" ✅ Référence modifiée avec succès") except Exception as e: logger.warning(f"⚠️ Impossible de modifier la référence: {e}") # ======================================== # ÉTAPE 6.6 : MODIFIER STATUT (EN DERNIER) # ======================================== if statut_a_modifier is not None: try: statut_actuel = getattr(doc, "DO_Statut", 0) nouveau_statut = int(statut_a_modifier) if nouveau_statut != statut_actuel: logger.info( f"📊 Modification statut: {statut_actuel} → {nouveau_statut}" ) doc.DO_Statut = nouveau_statut doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("statut") logger.info(f" ✅ Statut modifié avec succès") except Exception as e: logger.warning(f"⚠️ Impossible de modifier le statut: {e}") # ======================================== # ÉTAPE 7 : RELECTURE ET RETOUR # ======================================== logger.info("📊 Relecture finale...") import time time.sleep(0.5) 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)) reference_finale = getattr(doc, "DO_Ref", "") statut_final = getattr(doc, "DO_Statut", 0) # Extraire les dates date_livraison_final = None try: date_livr = getattr(doc, "DO_DateLivr", None) if date_livr: date_livraison_final = date_livr.strftime("%Y-%m-%d") except: pass logger.info(f"✅ ✅ ✅ SUCCÈS: {numero} modifié ✅ ✅ ✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") logger.info(f" 👤 Client final: {client_final}") logger.info(f" 🔖 Référence: {reference_finale}") logger.info(f" 📊 Statut: {statut_final}") if date_livraison_final: logger.info(f" 📅 Date livraison: {date_livraison_final}") logger.info(f" 📝 Champs modifiés: {champs_modifies}") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "reference": reference_finale, "date_livraison": date_livraison_final, "champs_modifies": champs_modifies, "statut": statut_final, "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_facture_enrichi(self, facture_data: dict) -> Dict: """ Crée une facture dans Sage avec support des dates. Args: facture_data: dict contenant: - client: {code: str} - date_facture: str ou date - date_livraison: str ou date (optionnel) - reference: str (optionnel) - lignes: list[dict] """ 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éé") # ===== DATES ===== doc.DO_Date = pywintypes.Time( self.normaliser_date(facture_data.get("date_facture")) ) if ( "date_livraison" in facture_data and facture_data["date_livraison"] ): doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(facture_data["date_livraison"]) ) logger.info( f"📅 Date livraison: {facture_data['date_livraison']}" ) # ===== 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)) reference_finale = getattr(doc_final, "DO_Ref", "") # Dates date_livraison_final = None try: date_livr = getattr(doc_final, "DO_DateLivr", None) if date_livr: date_livraison_final = date_livr.strftime("%Y-%m-%d") except: pass else: total_ht = 0.0 total_ttc = 0.0 reference_finale = facture_data.get("reference", "") date_livraison_final = facture_data.get("date_livraison") logger.info( f"✅✅✅ FACTURE CRÉÉE: {numero_facture} - {total_ttc}€ TTC ✅✅✅" ) if date_livraison_final: logger.info(f"📅 Date livraison: {date_livraison_final}") 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( self.normaliser_date(facture_data.get("date_facture")) ), "date_livraison": date_livraison_final, "reference": reference_finale, } 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: """ Modifie une facture existante dans Sage. Args: numero: Numéro de la facture facture_data: dict contenant les champs à modifier: - date_facture: str ou date (optionnel) - date_livraison: str ou date (optionnel) - reference: str (optionnel) - statut: int (optionnel) - lignes: list[dict] (optionnel) """ 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) type_reel = getattr(doc, "DO_Type", -1) logger.info(f" 📊 Type={type_reel}, 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") # ======================================== # É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_facture" in facture_data modif_date_livraison = "date_livraison" 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 facture: {modif_date}") logger.info(f" Date livraison: {modif_date_livraison}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ===== EXTRAIRE référence et statut pour les traiter à la fin si lignes modifiées ===== facture_data_temp = facture_data.copy() reference_a_modifier = None statut_a_modifier = None if modif_lignes: if modif_ref: reference_a_modifier = facture_data_temp.pop("reference") logger.info( "🔖 Modification de la référence reportée après les lignes" ) modif_ref = False if modif_statut: statut_a_modifier = facture_data_temp.pop("statut") logger.info( "📊 Modification du statut reportée après les lignes" ) modif_statut = False # ======================================== # É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_date_livraison or modif_statut or modif_ref ): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: logger.info(" 📅 Modification date facture...") doc.DO_Date = pywintypes.Time( self.normaliser_date(facture_data_temp.get("date_facture")) ) champs_modifies.append("date") if modif_date_livraison: logger.info(" 📅 Modification date livraison...") doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(facture_data_temp["date_livraison"]) ) logger.info( f" ✅ Date livraison: {facture_data_temp['date_livraison']}" ) champs_modifies.append("date_livraison") if modif_statut: logger.info(" 📊 Modification statut...") nouveau_statut = facture_data_temp["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 = facture_data_temp["reference"] champs_modifies.append("reference") logger.info( f" ✅ Référence définie: {facture_data_temp['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...") # D'abord modifier les dates si demandées if modif_date: doc.DO_Date = pywintypes.Time( self.normaliser_date(facture_data_temp.get("date_facture")) ) champs_modifies.append("date") logger.info(" 📅 Date facture modifiée") if modif_date_livraison: doc.DO_DateLivr = pywintypes.Time( self.normaliser_date(facture_data_temp["date_livraison"]) ) logger.info(" 📅 Date livraison modifiée") champs_modifies.append("date_livraison") nouvelles_lignes = facture_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() 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") import time time.sleep(0.5) 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 6.5 : MODIFIER RÉFÉRENCE (APRÈS les lignes) # ======================================== if reference_a_modifier is not None: try: ancienne_reference = getattr(doc, "DO_Ref", "") nouvelle_reference = ( str(reference_a_modifier) if reference_a_modifier else "" ) logger.info( f"🔖 Modification référence: '{ancienne_reference}' → '{nouvelle_reference}'" ) doc.DO_Ref = nouvelle_reference doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("reference") logger.info(f" ✅ Référence modifiée avec succès") except Exception as e: logger.warning(f"⚠️ Impossible de modifier la référence: {e}") # ======================================== # ÉTAPE 6.6 : MODIFIER STATUT (EN DERNIER) # ======================================== if statut_a_modifier is not None: try: statut_actuel = getattr(doc, "DO_Statut", 0) nouveau_statut = int(statut_a_modifier) if nouveau_statut != statut_actuel: logger.info( f"📊 Modification statut: {statut_actuel} → {nouveau_statut}" ) doc.DO_Statut = nouveau_statut doc.Write() import time time.sleep(0.5) doc.Read() champs_modifies.append("statut") logger.info(f" ✅ Statut modifié avec succès") except Exception as e: logger.warning(f"⚠️ Impossible de modifier le statut: {e}") # ======================================== # ÉTAPE 7 : RELECTURE ET RETOUR # ======================================== logger.info("📊 Relecture finale...") import time time.sleep(0.5) 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)) reference_finale = getattr(doc, "DO_Ref", "") statut_final = getattr(doc, "DO_Statut", 0) # Extraire les dates date_livraison_final = None try: date_livr = getattr(doc, "DO_DateLivr", None) if date_livr: date_livraison_final = date_livr.strftime("%Y-%m-%d") except: pass 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" 🔖 Référence: {reference_finale}") logger.info(f" 📊 Statut: {statut_final}") if date_livraison_final: logger.info(f" 📅 Date livraison: {date_livraison_final}") logger.info(f" 📝 Champs modifiés: {champs_modifies}") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "reference": reference_finale, "date_livraison": date_livraison_final, "champs_modifies": champs_modifies, "statut": statut_final, "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_article(self, article_data: dict) -> dict: with self._com_context(), self._lock_com: try: logger.info("[ARTICLE] === CREATION ARTICLE ===") # Transaction transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("Transaction Sage démarrée") except Exception as e: logger.debug(f"BeginTrans non disponible : {e}") try: # ======================================== # ÉTAPE 0 : DÉCOUVRIR DÉPÔTS # ======================================== depots_disponibles = [] depot_a_utiliser = None depot_code_demande = article_data.get("depot_code") try: factory_depot = self.cial.FactoryDepot index = 1 while index <= 100: try: persist = factory_depot.List(index) if persist is None: break depot_obj = win32com.client.CastTo(persist, "IBODepot3") depot_obj.Read() code = getattr(depot_obj, "DE_Code", "").strip() if not code: index += 1 continue numero = int(getattr(depot_obj, "Compteur", 0)) intitule = getattr( depot_obj, "DE_Intitule", f"Depot {code}" ) depot_info = { "code": code, "numero": numero, "intitule": intitule, "objet": depot_obj, } depots_disponibles.append(depot_info) if depot_code_demande and code == depot_code_demande: depot_a_utiliser = depot_info elif not depot_code_demande and not depot_a_utiliser: depot_a_utiliser = depot_info index += 1 except Exception as e: if "Acces refuse" in str(e): break index += 1 except Exception as e: logger.warning(f"[DEPOT] Erreur découverte dépôts : {e}") if not depots_disponibles: raise ValueError( "Aucun dépôt trouvé dans Sage. Créez d'abord un dépôt." ) if not depot_a_utiliser: depot_a_utiliser = depots_disponibles[0] logger.info( f"[DEPOT] Utilisation : '{depot_a_utiliser['code']}' ({depot_a_utiliser['intitule']})" ) # ======================================== # ÉTAPE 1 : VALIDATION & NETTOYAGE # ======================================== reference = article_data.get("reference", "").upper().strip() if not reference: raise ValueError("La référence est obligatoire") if len(reference) > 18: raise ValueError( "La référence ne peut pas dépasser 18 caractères" ) designation = article_data.get("designation", "").strip() if not designation: raise ValueError("La désignation est obligatoire") if len(designation) > 69: designation = designation[:69] # Récupération des STOCKS stock_reel = article_data.get("stock_reel", 0.0) stock_mini = article_data.get("stock_mini", 0.0) stock_maxi = article_data.get("stock_maxi", 0.0) logger.info(f"[ARTICLE] Référence : {reference}") logger.info(f"[ARTICLE] Désignation : {designation}") logger.info(f"[ARTICLE] Stock réel demandé : {stock_reel}") logger.info(f"[ARTICLE] Stock mini demandé : {stock_mini}") logger.info(f"[ARTICLE] Stock maxi demandé : {stock_maxi}") # ======================================== # ÉTAPE 2 : VÉRIFIER SI EXISTE DÉJÀ # ======================================== factory = self.cial.FactoryArticle try: article_existant = factory.ReadReference(reference) if article_existant: raise ValueError(f"L'article {reference} existe déjà") except Exception as e: error_msg = str(e) if ( "Enregistrement non trouve" in error_msg or "non trouve" in error_msg or "-2607" in error_msg ): logger.debug( f"[ARTICLE] {reference} n'existe pas encore, création possible" ) else: logger.error(f"[ARTICLE] Erreur vérification : {e}") raise # ======================================== # ÉTAPE 3 : CRÉER L'ARTICLE # ======================================== persist = factory.Create() article = win32com.client.CastTo(persist, "IBOArticle3") article.SetDefault() # Champs de base article.AR_Ref = reference article.AR_Design = designation # ======================================== # ÉTAPE 4 : TROUVER ARTICLE MODÈLE VIA SQL # ======================================== logger.info("[MODELE] Recherche article modèle via SQL...") article_modele_ref = None article_modele = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT TOP 1 AR_Ref FROM F_ARTICLE WHERE AR_Sommeil = 0 ORDER BY AR_Ref """ ) row = cursor.fetchone() if row: article_modele_ref = self._safe_strip(row.AR_Ref) logger.info( f" [SQL] Article modèle trouvé : {article_modele_ref}" ) except Exception as e: logger.warning(f" [SQL] Erreur recherche article : {e}") # Charger l'article modèle via COM if article_modele_ref: try: persist_modele = factory.ReadReference(article_modele_ref) if persist_modele: article_modele = win32com.client.CastTo( persist_modele, "IBOArticle3" ) article_modele.Read() logger.info( f" [OK] Article modèle chargé : {article_modele_ref}" ) except Exception as e: logger.warning(f" [WARN] Erreur chargement modèle : {e}") article_modele = None if not article_modele: raise ValueError( "Aucun article modèle trouvé dans Sage.\n" "Créez au moins un article manuellement dans Sage pour servir de modèle." ) # ======================================== # ÉTAPE 5 : COPIER UNITÉ + FAMILLE # ======================================== logger.info("[OBJETS] Copie Unite + Famille depuis modèle...") # Unite unite_trouvee = False try: unite_obj = getattr(article_modele, "Unite", None) if unite_obj: article.Unite = unite_obj logger.info( f" [OK] Objet Unite copié depuis {article_modele_ref}" ) unite_trouvee = True except Exception as e: logger.debug(f" Unite non copiable : {str(e)[:80]}") if not unite_trouvee: raise ValueError( "Impossible de copier l'unité de vente depuis le modèle" ) # Famille famille_trouvee = False famille_code_personnalise = article_data.get("famille") famille_obj = None if famille_code_personnalise: logger.info( f" [FAMILLE] Code personnalisé demandé : {famille_code_personnalise}" ) try: # Vérifier existence via SQL famille_existe_sql = False famille_code_exact = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT FA_CodeFamille, FA_Type FROM F_FAMILLE WHERE UPPER(FA_CodeFamille) = ? """, (famille_code_personnalise.upper(),), ) row = cursor.fetchone() if row: famille_code_exact = self._safe_strip( row.FA_CodeFamille ) famille_existe_sql = True logger.info( f" [SQL] Famille trouvée : {famille_code_exact}" ) else: raise ValueError( f"Famille '{famille_code_personnalise}' introuvable" ) except ValueError: raise except Exception as e_sql: logger.warning(f" [SQL] Erreur : {e_sql}") # Charger via COM if famille_existe_sql and famille_code_exact: factory_famille = self.cial.FactoryFamille try: index = 1 max_scan = 1000 while index <= max_scan: try: persist_test = factory_famille.List(index) if persist_test is None: break fam_test = win32com.client.CastTo( persist_test, "IBOFamille3" ) fam_test.Read() code_test = ( getattr(fam_test, "FA_CodeFamille", "") .strip() .upper() ) if code_test == famille_code_exact.upper(): famille_obj = fam_test famille_trouvee = True logger.info( f" [OK] Famille trouvée à l'index {index}" ) break index += 1 except Exception as e: if "Accès refusé" in str(e): break index += 1 if famille_obj: famille_obj.Read() article.Famille = famille_obj logger.info( f" [OK] Famille '{famille_code_personnalise}' assignée" ) else: raise ValueError( f"Famille '{famille_code_personnalise}' inaccessible via COM" ) except Exception as e: logger.warning(f" [COM] Erreur scanner : {e}") raise except ValueError: raise except Exception as e: logger.warning( f" [WARN] Famille personnalisée non chargée : {str(e)[:100]}" ) # Si pas de famille perso, copier depuis le modèle if not famille_trouvee: try: famille_obj = getattr(article_modele, "Famille", None) if famille_obj: article.Famille = famille_obj logger.info( f" [OK] Objet Famille copié depuis {article_modele_ref}" ) famille_trouvee = True except Exception as e: logger.debug(f" Famille non copiable : {str(e)[:80]}") # ======================================== # ÉTAPE 6 : CHAMPS OBLIGATOIRES # ======================================== logger.info("[CHAMPS] Copie champs obligatoires depuis modèle...") # Types et natures article.AR_Type = int(getattr(article_modele, "AR_Type", 0)) article.AR_Nature = int(getattr(article_modele, "AR_Nature", 0)) article.AR_Nomencl = int(getattr(article_modele, "AR_Nomencl", 0)) # Suivi stock (forcé à 2 = FIFO/LIFO) article.AR_SuiviStock = 2 logger.info(f" [OK] AR_SuiviStock=2 (FIFO/LIFO)") # ======================================== # ÉTAPE 7 : PRIX # ======================================== prix_vente = article_data.get("prix_vente") if prix_vente is not None: try: article.AR_PrixVen = float(prix_vente) logger.info(f" Prix vente : {prix_vente} EUR") except Exception as e: logger.warning(f" Prix vente erreur : {str(e)[:100]}") prix_achat = article_data.get("prix_achat") if prix_achat is not None: try: try: article.AR_PrixAch = float(prix_achat) logger.info( f" Prix achat (AR_PrixAch) : {prix_achat} EUR" ) except: article.AR_PrixAchat = float(prix_achat) logger.info( f" Prix achat (AR_PrixAchat) : {prix_achat} EUR" ) except Exception as e: logger.warning(f" Prix achat erreur : {str(e)[:100]}") # ======================================== # ÉTAPE 8 : CODE EAN # ======================================== code_ean = article_data.get("code_ean") if code_ean: article.AR_CodeBarre = str(code_ean) logger.info(f" Code EAN/Barre : {code_ean}") # ======================================== # ÉTAPE 9 : DESCRIPTION # ======================================== description = article_data.get("description") if description: try: article.AR_Commentaire = description logger.info(f" Description définie") except: pass # ======================================== # ÉTAPE 10 : ÉCRITURE ARTICLE # ======================================== logger.info("[ARTICLE] Écriture dans Sage...") try: article.Write() logger.info(" [OK] 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() échoué : {error_detail}") raise RuntimeError(f"Échec création article : {error_detail}") # ======================================== # ÉTAPE 11 : DÉFINIR LE STOCK DANS F_ARTSTOCK (CRITIQUE) # ======================================== stock_defini = False stock_erreur = None # Vérifier si on a des valeurs de stock à définir has_stock_values = stock_reel or stock_mini or stock_maxi if has_stock_values: logger.info( f"[STOCK] Définition stock dans F_ARTSTOCK (dépôt '{depot_a_utiliser['code']}')..." ) try: depot_obj = depot_a_utiliser["objet"] # Chercher FactoryArticleStock ou FactoryDepotStock factory_stock = None for factory_name in [ "FactoryArticleStock", "FactoryDepotStock", ]: try: factory_stock = getattr( depot_obj, factory_name, None ) if factory_stock: logger.info( f" Factory trouvée : {factory_name}" ) break except: continue if not factory_stock: raise RuntimeError( "Factory de stock introuvable sur le dépôt" ) # Créer l'entrée de stock dans F_ARTSTOCK stock_persist = factory_stock.Create() stock_obj = win32com.client.CastTo( stock_persist, "IBODepotStock3" ) stock_obj.SetDefault() # Référence article stock_obj.AR_Ref = reference # Stock réel if stock_reel: stock_obj.AS_QteSto = float(stock_reel) logger.info(f" AS_QteSto = {stock_reel}") # Stock minimum if stock_mini: try: stock_obj.AS_QteMini = float(stock_mini) logger.info(f" AS_QteMini = {stock_mini}") except Exception as e: logger.warning(f" AS_QteMini non défini : {e}") # Stock maximum if stock_maxi: try: stock_obj.AS_QteMaxi = float(stock_maxi) logger.info(f" AS_QteMaxi = {stock_maxi}") except Exception as e: logger.warning(f" AS_QteMaxi non défini : {e}") stock_obj.Write() stock_defini = True logger.info( f" [OK] Stock défini dans F_ARTSTOCK : réel={stock_reel}, min={stock_mini}, max={stock_maxi}" ) except Exception as e: stock_erreur = str(e) logger.error( f" [ERREUR] Stock non défini dans F_ARTSTOCK : {e}", exc_info=True, ) # ======================================== # ÉTAPE 12 : COMMIT # ======================================== if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info( "[COMMIT] Transaction committée - Article persiste dans Sage" ) except Exception as e: logger.warning(f"[COMMIT] Erreur commit : {e}") # ======================================== # ÉTAPE 13 : VÉRIFICATION & RELECTURE # ======================================== logger.info("[VERIF] Relecture article créé...") article_cree_persist = factory.ReadReference(reference) if not article_cree_persist: raise RuntimeError( "Article créé mais introuvable à la relecture" ) article_cree = win32com.client.CastTo( article_cree_persist, "IBOArticle3" ) article_cree.Read() # ======================================== # ÉTAPE 14 : VÉRIFIER LE STOCK DANS F_ARTSTOCK VIA SQL # ======================================== stocks_par_depot = [] stock_total = 0.0 try: with self._get_sql_connection() as conn: cursor = conn.cursor() # Vérifier si le stock a été créé dans F_ARTSTOCK cursor.execute( """ SELECT d.DE_Code, s.AS_QteSto, s.AS_QteMini, s.AS_QteMaxi FROM F_ARTSTOCK s LEFT JOIN F_DEPOT d ON s.DE_No = d.DE_No WHERE s.AR_Ref = ? """, (reference.upper(),), ) depot_rows = cursor.fetchall() for depot_row in depot_rows: if len(depot_row) >= 4: qte = float(depot_row[1]) if depot_row[1] else 0.0 stock_total += qte stocks_par_depot.append( { "depot_code": self._safe_strip( depot_row[0] ), "quantite": qte, "qte_mini": ( float(depot_row[2]) if depot_row[2] else 0.0 ), "qte_maxi": ( float(depot_row[3]) if depot_row[3] else 0.0 ), } ) logger.info( f"[VERIF] Stock dans F_ARTSTOCK : {stock_total} unités dans {len(stocks_par_depot)} dépôt(s)" ) except Exception as e: logger.warning( f"[VERIF] Impossible de vérifier le stock dans F_ARTSTOCK : {e}" ) logger.info( f"[OK] ARTICLE CREE : {reference} - Stock total : {stock_total}" ) # ======================================== # ÉTAPE 15 : EXTRACTION COMPLÈTE # ======================================== logger.info("[EXTRACTION] Extraction complète de l'article créé...") # Utiliser _extraire_article() pour avoir TOUS les champs resultat = self._extraire_article(article_cree) if not resultat: # Fallback si extraction échoue resultat = { "reference": reference, "designation": designation, } # ======================================== # ÉTAPE 16 : FORCER LES VALEURS DE STOCK DEPUIS F_ARTSTOCK # ======================================== # ✅ 1. STOCK (forcer les valeurs depuis F_ARTSTOCK) resultat["stock_reel"] = stock_total if stock_mini: resultat["stock_mini"] = float(stock_mini) if stock_maxi: resultat["stock_maxi"] = float(stock_maxi) # Stock disponible = stock réel (article neuf, pas de réservation) resultat["stock_disponible"] = stock_total resultat["stock_reserve"] = 0.0 resultat["stock_commande"] = 0.0 # ✅ 2. PRIX if prix_vente is not None: resultat["prix_vente"] = float(prix_vente) if prix_achat is not None: resultat["prix_achat"] = float(prix_achat) # ✅ 3. DESCRIPTION if description: resultat["description"] = description # ✅ 4. CODE EAN if code_ean: resultat["code_ean"] = str(code_ean) resultat["code_barre"] = str(code_ean) # ✅ 5. FAMILLE if famille_code_personnalise and famille_trouvee: resultat["famille_code"] = famille_code_personnalise try: if famille_obj: famille_obj.Read() resultat["famille_libelle"] = getattr( famille_obj, "FA_Intitule", "" ) except: pass # ✅ 6. INFOS DÉPÔTS if stocks_par_depot: resultat["stocks_par_depot"] = stocks_par_depot resultat["depot_principal"] = { "code": depot_a_utiliser["code"], "intitule": depot_a_utiliser["intitule"], } # ✅ 7. SUIVI DE STOCK resultat["suivi_stock_active"] = stock_defini # ✅ 8. AVERTISSEMENT SI STOCK NON DÉFINI if has_stock_values and not stock_defini and stock_erreur: resultat["avertissement"] = ( f"Stock demandé mais non défini dans F_ARTSTOCK : {stock_erreur}" ) logger.info( f"[EXTRACTION] ✅ Article extrait et enrichi avec {len(resultat)} champs" ) return resultat except ValueError: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except: pass raise except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except: pass logger.error(f"Erreur creation article : {e}", exc_info=True) raise RuntimeError(f"Erreur creation article : {str(e)}") except Exception as e: logger.error(f"Erreur globale : {e}", exc_info=True) raise def modifier_article(self, reference: str, article_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"[ARTICLE] === MODIFICATION {reference} ===") # ======================================== # ÉTAPE 1 : CHARGER L'ARTICLE EXISTANT # ======================================== factory_article = self.cial.FactoryArticle persist = factory_article.ReadReference(reference.upper()) if not persist: raise ValueError(f"Article {reference} introuvable") article = win32com.client.CastTo(persist, "IBOArticle3") article.Read() designation_actuelle = getattr(article, "AR_Design", "") logger.info(f"[ARTICLE] Trouvé : {reference} - {designation_actuelle}") # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS # ======================================== logger.info("[ARTICLE] Mise à jour des champs...") champs_modifies = [] # ======================================== # 🆕 FAMILLE (NOUVEAU - avec scanner List) # ======================================== if "famille" in article_data and article_data["famille"]: famille_code_demande = article_data["famille"].upper().strip() logger.info( f"[FAMILLE] Changement demandé : {famille_code_demande}" ) try: # ======================================== # VÉRIFIER EXISTENCE VIA SQL # ======================================== famille_existe_sql = False famille_code_exact = None famille_type = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT FA_CodeFamille, FA_Type FROM F_FAMILLE WHERE UPPER(FA_CodeFamille) = ? """, (famille_code_demande,), ) row = cursor.fetchone() if row: famille_code_exact = self._safe_strip( row.FA_CodeFamille ) famille_type = row.FA_Type if len(row) > 1 else 0 famille_existe_sql = True # Vérifier le type if famille_type == 1: raise ValueError( f"La famille '{famille_code_demande}' est de type 'Total' " f"et ne peut pas contenir d'articles. " f"Utilisez une famille de type Détail." ) logger.info( f" [SQL] Famille trouvée : {famille_code_exact} (type={famille_type})" ) else: raise ValueError( f"Famille '{famille_code_demande}' introuvable dans Sage" ) except ValueError: raise except Exception as e: logger.warning(f" [SQL] Erreur : {e}") raise ValueError(f"Impossible de vérifier la famille : {e}") # ======================================== # CHARGER VIA COM (SCANNER) # ======================================== if famille_existe_sql and famille_code_exact: logger.info(f" [COM] Recherche via scanner...") factory_famille = self.cial.FactoryFamille famille_obj = None # Scanner List() try: index = 1 max_scan = 1000 while index <= max_scan: try: persist_test = factory_famille.List(index) if persist_test is None: break fam_test = win32com.client.CastTo( persist_test, "IBOFamille3" ) fam_test.Read() code_test = ( getattr(fam_test, "FA_CodeFamille", "") .strip() .upper() ) if code_test == famille_code_exact.upper(): # TROUVÉ ! famille_obj = fam_test logger.info( f" [OK] Famille trouvée à l'index {index}" ) break index += 1 except Exception as e: if "Accès refusé" in str(e) or "Access" in str( e ): break index += 1 except Exception as e: logger.warning( f" [COM] Scanner échoué : {str(e)[:200]}" ) # Assigner la famille if famille_obj: famille_obj.Read() article.Famille = famille_obj champs_modifies.append(f"famille={famille_code_exact}") logger.info( f" [OK] Famille changée : {famille_code_exact}" ) else: raise ValueError( f"Famille '{famille_code_demande}' trouvée en SQL mais inaccessible via COM. " f"Essayez avec une autre famille." ) except ValueError: raise except Exception as e: logger.error(f" [ERREUR] Changement famille : {e}") raise ValueError(f"Impossible de changer la famille : {str(e)}") # ======================================== # DÉSIGNATION # ======================================== if "designation" in article_data: designation = str(article_data["designation"])[:69].strip() article.AR_Design = designation champs_modifies.append(f"designation") logger.info(f" [OK] Désignation : {designation}") # ======================================== # PRIX DE VENTE # ======================================== if "prix_vente" in article_data: try: prix_vente = float(article_data["prix_vente"]) article.AR_PrixVen = prix_vente champs_modifies.append("prix_vente") logger.info(f" [OK] Prix vente : {prix_vente} EUR") except Exception as e: logger.warning(f" [WARN] Prix vente : {e}") # ======================================== # PRIX D'ACHAT # ======================================== if "prix_achat" in article_data: try: prix_achat = float(article_data["prix_achat"]) # Double tentative (AR_PrixAch / AR_PrixAchat) try: article.AR_PrixAch = prix_achat champs_modifies.append("prix_achat") logger.info( f" [OK] Prix achat (AR_PrixAch) : {prix_achat} EUR" ) except: article.AR_PrixAchat = prix_achat champs_modifies.append("prix_achat") logger.info( f" [OK] Prix achat (AR_PrixAchat) : {prix_achat} EUR" ) except Exception as e: logger.warning(f" [WARN] Prix achat : {e}") # ======================================== # STOCK RÉEL (NIVEAU ARTICLE) # ======================================== if "stock_reel" in article_data: try: stock_reel = float(article_data["stock_reel"]) ancien_stock = float(getattr(article, "AR_Stock", 0.0)) article.AR_Stock = stock_reel champs_modifies.append("stock_reel") logger.info(f" [OK] Stock : {ancien_stock} -> {stock_reel}") if stock_reel > ancien_stock: logger.info( f" [+] Stock augmenté de {stock_reel - ancien_stock}" ) except Exception as e: logger.error(f" [ERREUR] Stock : {e}") raise ValueError(f"Impossible de modifier le stock: {e}") # ======================================== # STOCK MINI/MAXI (NIVEAU ARTICLE) # ======================================== if "stock_mini" in article_data: try: stock_mini = float(article_data["stock_mini"]) article.AR_StockMini = stock_mini champs_modifies.append("stock_mini") logger.info(f" [OK] Stock mini : {stock_mini}") except Exception as e: logger.warning(f" [WARN] Stock mini : {e}") if "stock_maxi" in article_data: try: stock_maxi = float(article_data["stock_maxi"]) article.AR_StockMaxi = stock_maxi champs_modifies.append("stock_maxi") logger.info(f" [OK] Stock maxi : {stock_maxi}") except Exception as e: logger.warning(f" [WARN] Stock maxi : {e}") # ======================================== # CODE EAN # ======================================== if "code_ean" in article_data: try: code_ean = str(article_data["code_ean"])[:13].strip() article.AR_CodeBarre = code_ean champs_modifies.append("code_ean") logger.info(f" [OK] Code EAN : {code_ean}") except Exception as e: logger.warning(f" [WARN] Code EAN : {e}") # ======================================== # DESCRIPTION # ======================================== if "description" in article_data: try: description = str(article_data["description"])[:255].strip() article.AR_Commentaire = description champs_modifies.append("description") logger.info(f" [OK] Description définie") except Exception as e: logger.warning(f" [WARN] Description : {e}") # ======================================== # VÉRIFICATION # ======================================== if not champs_modifies: logger.warning("[ARTICLE] Aucun champ à modifier") return self._extraire_article(article) logger.info( f"[ARTICLE] Champs à modifier : {', '.join(champs_modifies)}" ) # ======================================== # ÉCRITURE # ======================================== logger.info("[ARTICLE] Écriture des modifications...") try: article.Write() logger.info("[ARTICLE] 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"[ARTICLE] Erreur Write() : {error_detail}") raise RuntimeError(f"Échec modification : {error_detail}") # ======================================== # RELECTURE ET EXTRACTION # ======================================== article.Read() logger.info( f"[ARTICLE] MODIFIÉ : {reference} ({len(champs_modifies)} champs)" ) # Extraction complète resultat = self._extraire_article(article) if not resultat: # Fallback si extraction échoue resultat = { "reference": reference, "designation": getattr(article, "AR_Design", ""), } # Enrichir avec les valeurs qu'on vient de modifier if "prix_vente" in article_data: resultat["prix_vente"] = float(article_data["prix_vente"]) if "prix_achat" in article_data: resultat["prix_achat"] = float(article_data["prix_achat"]) if "stock_reel" in article_data: resultat["stock_reel"] = float(article_data["stock_reel"]) if "stock_mini" in article_data: resultat["stock_mini"] = float(article_data["stock_mini"]) if "stock_maxi" in article_data: resultat["stock_maxi"] = float(article_data["stock_maxi"]) if "code_ean" in article_data: resultat["code_ean"] = str(article_data["code_ean"]) resultat["code_barre"] = str(article_data["code_ean"]) if "description" in article_data: resultat["description"] = str(article_data["description"]) if "famille" in article_data: resultat["famille_code"] = ( famille_code_exact if "famille_code_exact" in locals() else "" ) return resultat except ValueError as e: logger.error(f"[ARTICLE] Erreur métier : {e}") raise except Exception as e: logger.error(f"[ARTICLE] 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}" except: pass raise RuntimeError(f"Erreur technique Sage : {error_message}") def creer_famille(self, famille_data: dict) -> dict: """ ✅ Crée une nouvelle famille d'articles dans Sage 100c **RESTRICTION : Seules les familles de type DÉTAIL peuvent être créées** Les familles de type Total doivent être créées manuellement dans Sage. Args: famille_data: { "code": str (obligatoire, max 18 car, ex: "ALIM"), "intitule": str (obligatoire, max 69 car, ex: "Produits alimentaires"), "type": int (IGNORÉ - toujours 0=Détail), "compte_achat": str (optionnel, ex: "607000"), "compte_vente": str (optionnel, ex: "707000") } Returns: dict: Famille créée avec tous ses attributs Raises: ValueError: Si la famille existe déjà ou données invalides RuntimeError: Si erreur technique Sage """ with self._com_context(), self._lock_com: try: logger.info("[FAMILLE] === CRÉATION FAMILLE (TYPE DÉTAIL) ===") # ======================================== # VALIDATION # ======================================== code = famille_data.get("code", "").upper().strip() if not code: raise ValueError("Le code famille est obligatoire") if len(code) > 18: raise ValueError( "Le code famille ne peut pas dépasser 18 caractères" ) intitule = famille_data.get("intitule", "").strip() if not intitule: raise ValueError("L'intitulé est obligatoire") if len(intitule) > 69: intitule = intitule[:69] logger.info(f"[FAMILLE] Code : {code}") logger.info(f"[FAMILLE] Intitulé : {intitule}") # ✅ NOUVEAU : Avertir si l'utilisateur demande un type Total type_demande = famille_data.get("type", 0) if type_demande == 1: logger.warning( "[FAMILLE] Type 'Total' demandé mais IGNORÉ - Création en type Détail uniquement" ) # ======================================== # VÉRIFIER SI EXISTE DÉJÀ # ======================================== factory_famille = self.cial.FactoryFamille try: # Scanner pour vérifier l'existence index = 1 while index <= 1000: try: persist_test = factory_famille.List(index) if persist_test is None: break fam_test = win32com.client.CastTo( persist_test, "IBOFamille3" ) fam_test.Read() code_existant = ( getattr(fam_test, "FA_CodeFamille", "").strip().upper() ) if code_existant == code: raise ValueError(f"La famille {code} existe déjà") index += 1 except ValueError: raise # Re-raise si c'est notre erreur except: index += 1 except ValueError: raise # ======================================== # CRÉER LA FAMILLE # ======================================== persist = factory_famille.Create() famille = win32com.client.CastTo(persist, "IBOFamille3") famille.SetDefault() # Champs obligatoires famille.FA_CodeFamille = code famille.FA_Intitule = intitule # ✅ CRITIQUE : FORCER Type = 0 (Détail) try: famille.FA_Type = 0 # ✅ Toujours Détail logger.info(f"[FAMILLE] Type : 0 (Détail)") except Exception as e: logger.warning(f"[FAMILLE] FA_Type non défini : {e}") # Comptes généraux (optionnels) compte_achat = famille_data.get("compte_achat") if compte_achat: try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte_achat) if persist_compte: compte_obj = win32com.client.CastTo( persist_compte, "IBOCompteG3" ) compte_obj.Read() famille.CompteGAchat = compte_obj logger.info(f"[FAMILLE] Compte achat : {compte_achat}") except Exception as e: logger.warning(f"[FAMILLE] Compte achat non défini : {e}") compte_vente = famille_data.get("compte_vente") if compte_vente: try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte_vente) if persist_compte: compte_obj = win32com.client.CastTo( persist_compte, "IBOCompteG3" ) compte_obj.Read() famille.CompteGVente = compte_obj logger.info(f"[FAMILLE] Compte vente : {compte_vente}") except Exception as e: logger.warning(f"[FAMILLE] Compte vente non défini : {e}") # ======================================== # ÉCRIRE DANS SAGE # ======================================== logger.info("[FAMILLE] Écriture dans Sage...") try: famille.Write() logger.info("[FAMILLE] 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"[FAMILLE] Erreur Write() : {error_detail}") raise RuntimeError(f"Échec création famille : {error_detail}") # ======================================== # RELIRE ET RETOURNER # ======================================== famille.Read() resultat = { "code": getattr(famille, "FA_CodeFamille", "").strip(), "intitule": getattr(famille, "FA_Intitule", "").strip(), "type": 0, # ✅ Toujours Détail "type_libelle": "Détail", } logger.info(f"[FAMILLE] FAMILLE CRÉÉE : {code} - {intitule} (Détail)") return resultat except ValueError as e: logger.error(f"[FAMILLE] Erreur métier : {e}") raise except Exception as e: logger.error(f"[FAMILLE] 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}" except: pass raise RuntimeError(f"Erreur technique Sage : {error_message}") def lister_toutes_familles( self, filtre: str = "", inclure_totaux: bool = False ) -> List[Dict]: try: with self._get_sql_connection() as conn: cursor = conn.cursor() logger.info("[SQL] Détection des colonnes de F_FAMILLE...") # Requête de test pour récupérer les métadonnées cursor.execute("SELECT TOP 1 * FROM F_FAMILLE") colonnes_disponibles = [column[0] for column in cursor.description] logger.info(f"[SQL] Colonnes trouvées : {len(colonnes_disponibles)}") colonnes_souhaitees = [ "FA_CodeFamille", "FA_Intitule", "FA_Type", "FA_UniteVen", "FA_Coef", "FA_Central", "FA_Nature", "CG_NumAch", "CG_NumVte", "FA_Stat", "FA_Raccourci", ] # Ne garder QUE les colonnes qui existent vraiment colonnes_a_lire = [ col for col in colonnes_souhaitees if col in colonnes_disponibles ] if not colonnes_a_lire: colonnes_a_lire = colonnes_disponibles logger.info(f"[SQL] Colonnes sélectionnées : {len(colonnes_a_lire)}") colonnes_str = ", ".join(colonnes_a_lire) query = f""" SELECT {colonnes_str} FROM F_FAMILLE WHERE 1=1 """ params = [] if "FA_Type" in colonnes_disponibles: if not inclure_totaux: query += " AND FA_Type = 0" # ✅ Seulement Détail logger.info("[SQL] Filtre : FA_Type = 0 (Détail uniquement)") else: logger.info("[SQL] Filtre : TOUS les types (Détail + Total)") # Filtre texte (si fourni) if filtre: conditions_filtre = [] if "FA_CodeFamille" in colonnes_a_lire: conditions_filtre.append("FA_CodeFamille LIKE ?") params.append(f"%{filtre}%") if "FA_Intitule" in colonnes_a_lire: conditions_filtre.append("FA_Intitule LIKE ?") params.append(f"%{filtre}%") if conditions_filtre: query += " AND (" + " OR ".join(conditions_filtre) + ")" # Tri if "FA_Intitule" in colonnes_a_lire: query += " ORDER BY FA_Intitule" elif "FA_CodeFamille" in colonnes_a_lire: query += " ORDER BY FA_CodeFamille" cursor.execute(query, params) rows = cursor.fetchall() familles = [] for row in rows: famille = {} # Remplir avec les colonnes disponibles for idx, colonne in enumerate(colonnes_a_lire): valeur = row[idx] if isinstance(valeur, str): valeur = valeur.strip() famille[colonne] = valeur # Alias if "FA_CodeFamille" in famille: famille["code"] = famille["FA_CodeFamille"] if "FA_Intitule" in famille: famille["intitule"] = famille["FA_Intitule"] # Type lisible if "FA_Type" in famille: type_val = famille["FA_Type"] famille["type"] = type_val famille["type_libelle"] = "Total" if type_val == 1 else "Détail" famille["est_total"] = type_val == 1 else: famille["type"] = 0 famille["type_libelle"] = "Détail" famille["est_total"] = False # Autres champs famille["unite_vente"] = str(famille.get("FA_UniteVen", "")) famille["coef"] = ( float(famille.get("FA_Coef", 0.0)) if famille.get("FA_Coef") is not None else 0.0 ) famille["compte_achat"] = famille.get("CG_NumAch", "") famille["compte_vente"] = famille.get("CG_NumVte", "") famille["est_statistique"] = ( (famille.get("FA_Stat") == 1) if "FA_Stat" in famille else False ) famille["est_centrale"] = ( (famille.get("FA_Central") == 1) if "FA_Central" in famille else False ) famille["nature"] = famille.get("FA_Nature", 0) familles.append(famille) type_msg = "DÉTAIL uniquement" if not inclure_totaux else "TOUS types" logger.info(f"SQL: {len(familles)} familles chargées ({type_msg})") return familles except Exception as e: logger.error(f"Erreur SQL familles: {e}", exc_info=True) raise RuntimeError(f"Erreur lecture familles: {str(e)}") def lire_famille(self, code: str) -> Dict: try: with self._com_context(), self._lock_com: logger.info(f"[FAMILLE] Lecture : {code}") code_recherche = code.upper().strip() famille_existe_sql = False famille_code_exact = None famille_type_sql = None famille_intitule_sql = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() # Détecter les colonnes disponibles cursor.execute("SELECT TOP 1 * FROM F_FAMILLE") colonnes_disponibles = [col[0] for col in cursor.description] # Construire la requête selon les colonnes disponibles colonnes_select = ["FA_CodeFamille", "FA_Intitule"] if "FA_Type" in colonnes_disponibles: colonnes_select.append("FA_Type") colonnes_str = ", ".join(colonnes_select) cursor.execute( f""" SELECT {colonnes_str} FROM F_FAMILLE WHERE UPPER(FA_CodeFamille) = ? """, (code_recherche,), ) row = cursor.fetchone() if row: famille_existe_sql = True famille_code_exact = self._safe_strip(row.FA_CodeFamille) famille_intitule_sql = self._safe_strip(row.FA_Intitule) # Type (si disponible) if "FA_Type" in colonnes_disponibles and len(row) > 2: famille_type_sql = row.FA_Type logger.info( f" [SQL] Trouvée : {famille_code_exact} - {famille_intitule_sql} (type={famille_type_sql})" ) else: raise ValueError(f"Famille '{code}' introuvable dans Sage") except ValueError: raise except Exception as e: logger.warning(f" [SQL] Erreur : {e}") # Continuer quand même avec COM if not famille_code_exact: famille_code_exact = code_recherche logger.info( f" [COM] Recherche de '{famille_code_exact}' via scanner..." ) factory_famille = self.cial.FactoryFamille famille_obj = None index_trouve = None try: index = 1 max_scan = 2000 # Scanner jusqu'à 2000 familles while index <= max_scan: try: persist_test = factory_famille.List(index) if persist_test is None: break fam_test = win32com.client.CastTo( persist_test, "IBOFamille3" ) fam_test.Read() code_test = ( getattr(fam_test, "FA_CodeFamille", "").strip().upper() ) if code_test == famille_code_exact: # TROUVÉE ! famille_obj = fam_test index_trouve = index logger.info(f" [OK] Famille trouvée à l'index {index}") break index += 1 except Exception as e: if "Accès refusé" in str(e) or "Access" in str(e): break index += 1 if not famille_obj: if famille_existe_sql: raise ValueError( f"Famille '{code}' trouvée en SQL mais inaccessible via COM. " f"Vérifiez les permissions." ) else: raise ValueError(f"Famille '{code}' introuvable") except ValueError: raise except Exception as e: logger.error(f" [COM] Erreur scanner : {e}") raise RuntimeError(f"Erreur chargement famille : {str(e)}") logger.info("[FAMILLE] Extraction des informations...") famille_obj.Read() # Champs de base resultat = { "code": getattr(famille_obj, "FA_CodeFamille", "").strip(), "intitule": getattr(famille_obj, "FA_Intitule", "").strip(), } # Type try: fa_type = getattr(famille_obj, "FA_Type", 0) resultat["type"] = fa_type resultat["type_libelle"] = "Total" if fa_type == 1 else "Détail" resultat["est_total"] = fa_type == 1 resultat["est_detail"] = fa_type == 0 # ⚠️ Avertissement si famille Total if fa_type == 1: resultat["avertissement"] = ( "Cette famille est de type 'Total' (agrégation comptable) " "et ne peut pas contenir d'articles directement." ) logger.warning( f" [TYPE] Famille Total détectée : {resultat['code']}" ) except: resultat["type"] = 0 resultat["type_libelle"] = "Détail" resultat["est_total"] = False resultat["est_detail"] = True # Unité de vente try: resultat["unite_vente"] = getattr( famille_obj, "FA_UniteVen", "" ).strip() except: resultat["unite_vente"] = "" # Coefficient try: coef = getattr(famille_obj, "FA_Coef", None) resultat["coef"] = float(coef) if coef is not None else 0.0 except: resultat["coef"] = 0.0 # Nature try: resultat["nature"] = getattr(famille_obj, "FA_Nature", 0) except: resultat["nature"] = 0 # Centrale d'achat try: central = getattr(famille_obj, "FA_Central", None) resultat["est_centrale"] = ( (central == 1) if central is not None else False ) except: resultat["est_centrale"] = False # Statistique try: stat = getattr(famille_obj, "FA_Stat", None) resultat["est_statistique"] = ( (stat == 1) if stat is not None else False ) except: resultat["est_statistique"] = False # Raccourci try: resultat["raccourci"] = getattr( famille_obj, "FA_Raccourci", "" ).strip() except: resultat["raccourci"] = "" # ======================================== # COMPTES GÉNÉRAUX # ======================================== # Compte achat try: compte_achat_obj = getattr(famille_obj, "CompteGAchat", None) if compte_achat_obj: compte_achat_obj.Read() resultat["compte_achat"] = getattr( compte_achat_obj, "CG_Num", "" ).strip() else: resultat["compte_achat"] = "" except: resultat["compte_achat"] = "" # Compte vente try: compte_vente_obj = getattr(famille_obj, "CompteGVente", None) if compte_vente_obj: compte_vente_obj.Read() resultat["compte_vente"] = getattr( compte_vente_obj, "CG_Num", "" ).strip() else: resultat["compte_vente"] = "" except: resultat["compte_vente"] = "" # Index de lecture resultat["index_com"] = index_trouve # Dates (si disponibles) try: date_creation = getattr(famille_obj, "cbCreation", None) resultat["date_creation"] = ( str(date_creation) if date_creation else "" ) except: resultat["date_creation"] = "" try: date_modif = getattr(famille_obj, "cbModification", None) resultat["date_modification"] = ( str(date_modif) if date_modif else "" ) except: resultat["date_modification"] = "" # Compter les articles de cette famille via SQL try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT COUNT(*) FROM F_ARTICLE WHERE FA_CodeFamille = ? """, (resultat["code"],), ) row = cursor.fetchone() if row: resultat["nb_articles"] = row[0] logger.info( f" [STAT] {resultat['nb_articles']} article(s) dans cette famille" ) except Exception as e: logger.warning(f" [STAT] Impossible de compter les articles : {e}") resultat["nb_articles"] = None logger.info( f"[FAMILLE] Lecture complète : {resultat['code']} - {resultat['intitule']}" ) return resultat except ValueError as e: logger.error(f"[FAMILLE] Erreur métier : {e}") raise except Exception as e: logger.error(f"[FAMILLE] 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}" except: pass raise RuntimeError(f"Erreur technique Sage : {error_message}") def creer_entree_stock(self, entree_data: Dict) -> Dict: try: with self._com_context(), self._lock_com: logger.info(f"[STOCK] === CRÉATION ENTRÉE STOCK (COM pur) ===") # Démarrer transaction transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("Transaction démarrée") except: pass try: # ======================================== # ÉTAPE 1 : CRÉER LE DOCUMENT D'ENTRÉE # ======================================== factory_doc = self.cial.FactoryDocumentStock persist_doc = factory_doc.CreateType(180) # 180 = Entrée doc = win32com.client.CastTo(persist_doc, "IBODocumentStock3") doc.SetDefault() date_mouv = entree_data.get("date_mouvement") if isinstance(date_mouv, date): doc.DO_Date = pywintypes.Time( datetime.combine(date_mouv, datetime.min.time()) ) else: doc.DO_Date = pywintypes.Time(datetime.now()) if entree_data.get("reference"): doc.DO_Ref = entree_data["reference"] doc.Write() logger.info(f"[STOCK] Document créé") # ======================================== # ÉTAPE 2 : PRÉPARER POUR LES STOCKS MINI/MAXI # ======================================== factory_article = self.cial.FactoryArticle factory_depot = self.cial.FactoryDepot stocks_mis_a_jour = [] depot_principal = None # Trouver un dépôt principal try: persist_depot = factory_depot.List(1) if persist_depot: depot_principal = win32com.client.CastTo( persist_depot, "IBODepot3" ) depot_principal.Read() logger.info( f"Dépôt principal: {getattr(depot_principal, 'DE_Code', '?')}" ) except Exception as e: logger.warning(f"Erreur chargement dépôt: {e}") # ======================================== # ÉTAPE 3 : TRAITER CHAQUE LIGNE (MOUVEMENT + STOCK) # ======================================== try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentStockLigne for idx, ligne_data in enumerate(entree_data["lignes"], 1): article_ref = ligne_data["article_ref"].upper() quantite = ligne_data["quantite"] stock_mini = ligne_data.get("stock_mini") stock_maxi = ligne_data.get("stock_maxi") logger.info(f"[STOCK] Ligne {idx}: {article_ref} x {quantite}") # A. CHARGER L'ARTICLE persist_article = factory_article.ReadReference(article_ref) if not persist_article: raise ValueError(f"Article {article_ref} introuvable") article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() # B. CRÉER LA LIGNE DE MOUVEMENT ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentStockLigne3" ) ligne_obj.SetDefault() # Lier l'article au mouvement try: ligne_obj.SetDefaultArticleReference( article_ref, float(quantite) ) except: try: ligne_obj.SetDefaultArticle( article_obj, float(quantite) ) except: raise ValueError( f"Impossible de lier l'article {article_ref}" ) # Prix prix = ligne_data.get("prix_unitaire") if prix: try: ligne_obj.DL_PrixUnitaire = float(prix) except: pass # Écrire la ligne ligne_obj.Write() # ======================================== # ÉTAPE 4 : GÉRER LES STOCKS MINI/MAXI (COM PUR) # ======================================== if stock_mini is not None or stock_maxi is not None: logger.info( f"[STOCK] Ajustement stock pour {article_ref}..." ) try: # MÉTHODE A : Via Article.FactoryArticleStock (LA BONNE MÉTHODE) logger.info( f" [COM] Méthode A : Article.FactoryArticleStock" ) # 1. Charger l'article COMPLET avec sa factory factory_article = self.cial.FactoryArticle persist_article_full = factory_article.ReadReference( article_ref ) article_full = win32com.client.CastTo( persist_article_full, "IBOArticle3" ) article_full.Read() # 2. Accéder à la FactoryArticleStock de l'article factory_article_stock = None try: factory_article_stock = ( article_full.FactoryArticleStock ) logger.info(" ✅ FactoryArticleStock trouvée") except AttributeError: logger.warning( " ❌ FactoryArticleStock non disponible" ) if factory_article_stock: # 3. Chercher si le stock existe déjà stock_trouve = None index_stock = 1 while index_stock <= 100: try: stock_persist = factory_article_stock.List( index_stock ) if stock_persist is None: break stock_obj = win32com.client.CastTo( stock_persist, "IBOArticleStock3" ) stock_obj.Read() # Vérifier le dépôt depot_stock = None try: depot_stock = getattr( stock_obj, "Depot", None ) if depot_stock: depot_stock.Read() depot_code = getattr( depot_stock, "DE_Code", "" ).strip() logger.debug( f" Dépôt {index_stock}: {depot_code}" ) # Si c'est le dépôt principal ou le premier trouvé if ( not stock_trouve or depot_code == getattr( depot_principal, "DE_Code", "", ) ): stock_trouve = stock_obj logger.info( f" Stock trouvé pour dépôt {depot_code}" ) except: pass index_stock += 1 except Exception as e: logger.debug( f" Erreur stock {index_stock}: {e}" ) index_stock += 1 # 4. Si pas trouvé, créer un nouveau stock if not stock_trouve: try: stock_persist = ( factory_article_stock.Create() ) stock_trouve = win32com.client.CastTo( stock_persist, "IBOArticleStock3" ) stock_trouve.SetDefault() # Lier au dépôt principal si disponible if depot_principal: try: stock_trouve.Depot = depot_principal logger.info( " Dépôt principal lié" ) except: pass logger.info(" Nouvel ArticleStock créé") except Exception as e: logger.error( f" ❌ Impossible de créer ArticleStock: {e}" ) raise # 5. METTRE À JOUR LES STOCKS MINI/MAXI if stock_trouve: # Sauvegarder l'état avant modification try: stock_trouve.Read() except: pass # STOCK MINI if stock_mini is not None: try: # Essayer différentes propriétés possibles for prop_name in [ "AS_QteMini", "AS_Mini", "AR_StockMini", "StockMini", ]: try: setattr( stock_trouve, prop_name, float(stock_mini), ) logger.info( f" ✅ Stock mini défini via {prop_name}: {stock_mini}" ) break except AttributeError: continue except Exception as e: logger.debug( f" {prop_name} échoué: {e}" ) continue except Exception as e: logger.warning( f" Stock mini non défini: {e}" ) # STOCK MAXI if stock_maxi is not None: try: # Essayer différentes propriétés possibles for prop_name in [ "AS_QteMaxi", "AS_Maxi", "AR_StockMaxi", "StockMaxi", ]: try: setattr( stock_trouve, prop_name, float(stock_maxi), ) logger.info( f" ✅ Stock maxi défini via {prop_name}: {stock_maxi}" ) break except AttributeError: continue except Exception as e: logger.debug( f" {prop_name} échoué: {e}" ) continue except Exception as e: logger.warning( f" Stock maxi non défini: {e}" ) # 6. SAUVEGARDER try: stock_trouve.Write() logger.info( f" ✅ ArticleStock sauvegardé" ) except Exception as e: logger.error( f" ❌ Erreur Write() ArticleStock: {e}" ) raise # MÉTHODE B : Alternative via DepotStock si A échoue if depot_principal and ( stock_mini is not None or stock_maxi is not None ): logger.info( f" [COM] Méthode B : Depot.FactoryDepotStock (alternative)" ) try: factory_depot_stock = None for factory_name in [ "FactoryDepotStock", "FactoryArticleStock", ]: try: factory_depot_stock = getattr( depot_principal, factory_name, None ) if factory_depot_stock: logger.info( f" Factory trouvée: {factory_name}" ) break except: continue if factory_depot_stock: # Chercher le stock existant stock_depot_trouve = None index_ds = 1 while index_ds <= 100: try: stock_ds_persist = ( factory_depot_stock.List( index_ds ) ) if stock_ds_persist is None: break stock_ds = win32com.client.CastTo( stock_ds_persist, "IBODepotStock3", ) stock_ds.Read() ar_ref_ds = ( getattr(stock_ds, "AR_Ref", "") .strip() .upper() ) if ar_ref_ds == article_ref: stock_depot_trouve = stock_ds break index_ds += 1 except: index_ds += 1 # Si pas trouvé, créer if not stock_depot_trouve: try: stock_ds_persist = ( factory_depot_stock.Create() ) stock_depot_trouve = ( win32com.client.CastTo( stock_ds_persist, "IBODepotStock3", ) ) stock_depot_trouve.SetDefault() stock_depot_trouve.AR_Ref = ( article_ref ) logger.info( " Nouveau DepotStock créé" ) except Exception as e: logger.error( f" ❌ Impossible de créer DepotStock: {e}" ) # Mettre à jour if stock_depot_trouve: if stock_mini is not None: try: stock_depot_trouve.AS_QteMini = float( stock_mini ) logger.info( f" ✅ DepotStock.AS_QteMini = {stock_mini}" ) except Exception as e: logger.warning( f" DepotStock mini échoué: {e}" ) if stock_maxi is not None: try: stock_depot_trouve.AS_QteMaxi = float( stock_maxi ) logger.info( f" ✅ DepotStock.AS_QteMaxi = {stock_maxi}" ) except Exception as e: logger.warning( f" DepotStock maxi échoué: {e}" ) try: stock_depot_trouve.Write() logger.info( " ✅ DepotStock sauvegardé" ) except Exception as e: logger.error( f" ❌ DepotStock Write() échoué: {e}" ) except Exception as e: logger.warning(f" Méthode B échouée: {e}") except Exception as e: logger.error( f"[STOCK] Erreur ajustement stock: {e}", exc_info=True, ) # Ne pas bloquer si l'ajustement échoue stocks_mis_a_jour.append( { "article_ref": article_ref, "quantite_ajoutee": quantite, "stock_mini_defini": stock_mini, "stock_maxi_defini": stock_maxi, } ) # ======================================== # ÉTAPE 5 : FINALISER LE DOCUMENT # ======================================== doc.Write() doc.Read() numero = getattr(doc, "DO_Piece", "") logger.info(f"[STOCK] ✅ Document finalisé: {numero}") # ======================================== # ÉTAPE 6 : VÉRIFICATION VIA COM # ======================================== logger.info(f"[STOCK] Vérification finale via COM...") for stock_info in stocks_mis_a_jour: article_ref = stock_info["article_ref"] try: # Recharger l'article pour voir les stocks persist_article = factory_article.ReadReference(article_ref) article_verif = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_verif.Read() # Lire les attributs de stock stock_total = 0.0 stock_mini_lu = 0.0 stock_maxi_lu = 0.0 # Essayer différents attributs for attr in ["AR_Stock", "AS_QteSto", "Stock"]: try: val = getattr(article_verif, attr, None) if val is not None: stock_total = float(val) break except: pass for attr in ["AR_StockMini", "AS_QteMini", "StockMini"]: try: val = getattr(article_verif, attr, None) if val is not None: stock_mini_lu = float(val) break except: pass for attr in ["AR_StockMaxi", "AS_QteMaxi", "StockMaxi"]: try: val = getattr(article_verif, attr, None) if val is not None: stock_maxi_lu = float(val) break except: pass logger.info( f"[VERIF] {article_ref}: " f"Total={stock_total}, " f"Mini={stock_mini_lu}, " f"Maxi={stock_maxi_lu}" ) stock_info["stock_total_verifie"] = stock_total stock_info["stock_mini_verifie"] = stock_mini_lu stock_info["stock_maxi_verifie"] = stock_maxi_lu except Exception as e: logger.warning( f"[VERIF] Erreur vérification {article_ref}: {e}" ) # Commit if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info(f"[STOCK] ✅ Transaction committée") except: logger.info(f"[STOCK] ✅ Changements sauvegardés") return { "article_ref": article_ref, "numero": numero, "type": 180, "type_libelle": "Entrée en stock", "date": str(getattr(doc, "DO_Date", "")), "nb_lignes": len(stocks_mis_a_jour), "stocks_mis_a_jour": stocks_mis_a_jour, } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() logger.info(f"[STOCK] ❌ Transaction annulée") except: pass logger.error(f"[STOCK] ERREUR : {e}", exc_info=True) raise ValueError(f"Erreur création entrée stock : {str(e)}") except Exception as e: logger.error(f"[STOCK] ERREUR GLOBALE : {e}", exc_info=True) raise ValueError(f"Erreur création entrée stock : {str(e)}") def lire_stock_article(self, reference: str, calcul_complet: bool = False) -> Dict: try: with self._com_context(), self._lock_com: logger.info(f"[STOCK] Lecture stock : {reference}") # Charger l'article factory_article = self.cial.FactoryArticle persist_article = factory_article.ReadReference(reference.upper()) if not persist_article: raise ValueError(f"Article {reference} introuvable") article = win32com.client.CastTo(persist_article, "IBOArticle3") article.Read() ar_suivi = getattr(article, "AR_SuiviStock", 0) ar_design = getattr(article, "AR_Design", reference) stock_info = { "article": reference.upper(), "designation": ar_design, "stock_total": 0.0, "suivi_stock": ar_suivi, "suivi_libelle": { 0: "Aucun suivi", 1: "CMUP (sans lot)", 2: "FIFO/LIFO (avec lot)", }.get(ar_suivi, f"Code {ar_suivi}"), "depots": [], "methode_lecture": None, } # ======================================== # MÉTHODE 1 : Via Depot.FactoryDepotStock (RAPIDE - 1-2 sec) # ======================================== logger.info("[STOCK] Tentative Méthode 1 : Depot.FactoryDepotStock...") try: factory_depot = self.cial.FactoryDepot index_depot = 1 stocks_trouves = [] # OPTIMISATION : Limiter à 20 dépôts max (au lieu de 100) while index_depot <= 20: try: persist_depot = factory_depot.List(index_depot) if persist_depot is None: break depot = win32com.client.CastTo(persist_depot, "IBODepot3") depot.Read() depot_code = "" depot_intitule = "" try: depot_code = getattr(depot, "DE_Code", "").strip() depot_intitule = getattr( depot, "DE_Intitule", f"Dépôt {depot_code}" ) except: pass if not depot_code: index_depot += 1 continue # Chercher FactoryDepotStock factory_depot_stock = None for factory_name in [ "FactoryDepotStock", "FactoryArticleStock", ]: try: factory_depot_stock = getattr( depot, factory_name, None ) if factory_depot_stock: break except: pass if factory_depot_stock: # OPTIMISATION : Limiter le scan à 1000 stocks par dépôt index_stock = 1 while index_stock <= 1000: try: stock_persist = factory_depot_stock.List( index_stock ) if stock_persist is None: break stock = win32com.client.CastTo( stock_persist, "IBODepotStock3" ) stock.Read() # Vérifier si c'est notre article article_ref_stock = "" # Essayer différents attributs for attr_ref in [ "AR_Ref", "AS_Article", "Article_Ref", ]: try: val = getattr(stock, attr_ref, None) if val: article_ref_stock = ( str(val).strip().upper() ) break except: pass # Si pas trouvé, essayer via l'objet Article if not article_ref_stock: try: article_obj = getattr( stock, "Article", None ) if article_obj: article_obj.Read() article_ref_stock = ( getattr( article_obj, "AR_Ref", "" ) .strip() .upper() ) except: pass if article_ref_stock == reference.upper(): # TROUVÉ ! quantite = 0.0 qte_mini = 0.0 qte_maxi = 0.0 # Essayer différents attributs de quantité for attr_qte in [ "AS_QteSto", "AS_Qte", "QteSto", "Quantite", ]: try: val = getattr(stock, attr_qte, None) if val is not None: quantite = float(val) break except: pass # Qte mini/maxi try: qte_mini = float( getattr(stock, "AS_QteMini", 0.0) ) except: pass try: qte_maxi = float( getattr(stock, "AS_QteMaxi", 0.0) ) except: pass stocks_trouves.append( { "code": depot_code, "intitule": depot_intitule, "quantite": quantite, "qte_mini": qte_mini, "qte_maxi": qte_maxi, } ) logger.info( f"[STOCK] Trouvé dépôt {depot_code} : {quantite} unités" ) break index_stock += 1 except Exception as e: if "Accès refusé" in str(e): break index_stock += 1 index_depot += 1 except Exception as e: if "Accès refusé" in str(e): break index_depot += 1 if stocks_trouves: stock_info["depots"] = stocks_trouves stock_info["stock_total"] = sum( d["quantite"] for d in stocks_trouves ) stock_info["methode_lecture"] = ( "Depot.FactoryDepotStock (RAPIDE)" ) logger.info( f"[STOCK] ✅ Méthode 1 réussie : {stock_info['stock_total']} unités" ) return stock_info except Exception as e: logger.warning(f"[STOCK] Méthode 1 échouée : {e}") # ======================================== # MÉTHODE 2 : Via attributs Article (RAPIDE - < 1 sec) # ======================================== logger.info("[STOCK] Tentative Méthode 2 : Attributs Article...") try: stock_trouve = False # Essayer différents attributs for attr_stock in ["AR_Stock", "AR_QteSto", "AR_QteStock"]: try: val = getattr(article, attr_stock, None) if val is not None: stock_info["stock_total"] = float(val) stock_info["methode_lecture"] = ( f"Article.{attr_stock} (RAPIDE)" ) stock_trouve = True logger.info( f"[STOCK] ✅ Méthode 2 réussie via {attr_stock}" ) break except: pass if stock_trouve: return stock_info except Exception as e: logger.warning(f"[STOCK] Méthode 2 échouée : {e}") # ======================================== # MÉTHODE 3 : Calcul depuis mouvements (LENT - DÉSACTIVÉ PAR DÉFAUT) # ======================================== if not calcul_complet: # Méthodes rapides ont échoué, mais calcul complet non demandé logger.warning( f"[STOCK] ⚠️ Méthodes rapides échouées pour {reference}" ) stock_info["methode_lecture"] = "ÉCHEC - Méthodes rapides échouées" stock_info["stock_total"] = 0.0 stock_info["note"] = ( "Les méthodes rapides de lecture de stock ont échoué. " "Options disponibles :\n" "1. Utiliser GET /sage/diagnostic/stock-rapide-test/{reference} pour un diagnostic détaillé\n" "2. Appeler lire_stock_article(reference, calcul_complet=True) pour un calcul depuis mouvements (LENT - 20+ min)\n" "3. Vérifier que l'article a bien un suivi de stock activé (AR_SuiviStock)" ) return stock_info # ⚠️ ATTENTION : Cette partie est ULTRA-LENTE (20+ minutes) logger.warning( "[STOCK] ⚠️ CALCUL DEPUIS MOUVEMENTS ACTIVÉ - PEUT PRENDRE 20+ MINUTES" ) # [Le reste du code de calcul depuis mouvements reste inchangé...] # ... (code existant pour la méthode 3) except ValueError: raise except Exception as e: logger.error(f"[STOCK] Erreur lecture : {e}", exc_info=True) raise ValueError(f"Erreur lecture stock : {str(e)}") def verifier_stock_apres_mouvement( self, article_ref: str, numero_mouvement: str ) -> Dict: try: with self._com_context(), self._lock_com: logger.info( f"[DEBUG] Vérification mouvement {numero_mouvement} pour {article_ref}" ) diagnostic = { "article_ref": article_ref.upper(), "numero_mouvement": numero_mouvement, "mouvement_trouve": False, "ar_ref_dans_ligne": None, "quantite_ligne": 0, "stock_actuel": 0, "problemes": [], } # ======================================== # 1. VÉRIFIER LE DOCUMENT # ======================================== factory = self.cial.FactoryDocumentStock persist = None index = 1 while index < 10000: try: persist_test = factory.List(index) if persist_test is None: break doc_test = win32com.client.CastTo( persist_test, "IBODocumentStock3" ) doc_test.Read() if getattr(doc_test, "DO_Piece", "") == numero_mouvement: persist = persist_test diagnostic["mouvement_trouve"] = True break index += 1 except: index += 1 if not persist: diagnostic["problemes"].append( f"Document {numero_mouvement} introuvable" ) return diagnostic doc = win32com.client.CastTo(persist, "IBODocumentStock3") doc.Read() # ======================================== # 2. VÉRIFIER LES LIGNES # ======================================== try: factory_lignes = getattr(doc, "FactoryDocumentLigne", None) if not factory_lignes: factory_lignes = getattr(doc, "FactoryDocumentStockLigne", None) if factory_lignes: idx = 1 while idx <= 100: try: ligne_p = factory_lignes.List(idx) if ligne_p is None: break ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() ar_ref_ligne = getattr(ligne, "AR_Ref", "").strip() if ar_ref_ligne == article_ref.upper(): diagnostic["ar_ref_dans_ligne"] = ar_ref_ligne diagnostic["quantite_ligne"] = float( getattr(ligne, "DL_Qte", 0) ) break idx += 1 except: idx += 1 except Exception as e: diagnostic["problemes"].append(f"Erreur lecture lignes : {e}") if not diagnostic["ar_ref_dans_ligne"]: diagnostic["problemes"].append( f"AR_Ref '{article_ref}' non trouvé dans les lignes du mouvement. " f"L'article n'a pas été correctement lié." ) # ======================================== # 3. LIRE LE STOCK ACTUEL # ======================================== try: stock_info = self.lire_stock_article(article_ref) diagnostic["stock_actuel"] = stock_info["stock_total"] except: diagnostic["problemes"].append("Impossible de lire le stock actuel") # ======================================== # 4. ANALYSE # ======================================== if diagnostic["ar_ref_dans_ligne"] and diagnostic["stock_actuel"] == 0: diagnostic["problemes"].append( "PROBLÈME : L'article est dans la ligne du mouvement, " "mais le stock n'a pas été mis à jour. Cela indique un problème " "avec la méthode SetDefaultArticle() ou la configuration Sage." ) return diagnostic except Exception as e: logger.error(f"[DEBUG] Erreur : {e}", exc_info=True) raise """ 📦 Lit le stock d'un article - VERSION CORRIGÉE ✅ CORRECTIONS : 1. Cherche d'abord via ArticleStock 2. Puis via DepotStock si disponible 3. Calcule le total même si aucun dépôt n'est trouvé """ try: with self._com_context(), self._lock_com: logger.info(f"[STOCK] Lecture stock article : {reference}") factory_article = self.cial.FactoryArticle persist = factory_article.ReadReference(reference.upper()) if not persist: raise ValueError(f"Article {reference} introuvable") article = win32com.client.CastTo(persist, "IBOArticle3") article.Read() # Infos de base ar_suivi = getattr(article, "AR_SuiviStock", 0) suivi_libelles = { 0: "Aucun", 1: "CMUP (sans lot)", 2: "FIFO/LIFO (avec lot)", } stock_info = { "article": getattr(article, "AR_Ref", "").strip(), "designation": getattr(article, "AR_Design", ""), "stock_total": 0.0, "suivi_stock": ar_suivi, "suivi_libelle": suivi_libelles.get(ar_suivi, "Inconnu"), "depots": [], } # ======================================== # MÉTHODE 1 : Via ArticleStock (global) # ======================================== stock_global_trouve = False try: # Chercher dans ArticleStock (collection sur l'article) if hasattr(article, "ArticleStock"): article_stocks = article.ArticleStock if article_stocks: try: nb_stocks = article_stocks.Count logger.info(f" ArticleStock.Count = {nb_stocks}") for i in range(1, nb_stocks + 1): try: stock_item = article_stocks.Item(i) qte = float( getattr(stock_item, "AS_QteSto", 0.0) ) stock_info["stock_total"] += qte depot_code = "?" try: depot_obj = getattr( stock_item, "Depot", None ) if depot_obj: depot_obj.Read() depot_code = getattr( depot_obj, "DE_Code", "?" ) except: pass stock_info["depots"].append( { "code": depot_code, "quantite": qte, "qte_mini": float( getattr( stock_item, "AS_QteMini", 0.0 ) ), "qte_maxi": float( getattr( stock_item, "AS_QteMaxi", 0.0 ) ), } ) stock_global_trouve = True except: continue except: pass except: pass # ======================================== # MÉTHODE 2 : Via FactoryDepotStock (si méthode 1 échoue) # ======================================== if not stock_global_trouve: logger.info( " ArticleStock non disponible, essai FactoryDepotStock..." ) try: factory_depot = self.cial.FactoryDepot # Scanner tous les dépôts index_depot = 1 while index_depot <= 100: try: persist_depot = factory_depot.List(index_depot) if persist_depot is None: break depot_obj = win32com.client.CastTo( persist_depot, "IBODepot3" ) depot_obj.Read() depot_code = getattr(depot_obj, "DE_Code", "").strip() # Chercher le stock dans ce dépôt try: factory_depot_stock = getattr( depot_obj, "FactoryDepotStock", None ) if factory_depot_stock: index_stock = 1 while index_stock <= 1000: try: stock_persist = ( factory_depot_stock.List( index_stock ) ) if stock_persist is None: break stock = win32com.client.CastTo( stock_persist, "IBODepotStock3" ) stock.Read() ar_ref_stock = getattr( stock, "AR_Ref", "" ).strip() if ar_ref_stock == reference.upper(): qte = float( getattr(stock, "AS_QteSto", 0.0) ) stock_info["stock_total"] += qte stock_info["depots"].append( { "code": depot_code, "quantite": qte, "qte_mini": float( getattr( stock, "AS_QteMini", 0.0, ) ), "qte_maxi": float( getattr( stock, "AS_QteMaxi", 0.0, ) ), } ) break index_stock += 1 except: index_stock += 1 except: pass index_depot += 1 except: index_depot += 1 except: pass # ======================================== # RÉSULTAT FINAL # ======================================== if not stock_info["depots"]: logger.warning(f"[STOCK] {reference} : Aucun stock trouvé") else: logger.info( f"[STOCK] {reference} : {stock_info['stock_total']} unités dans {len(stock_info['depots'])} dépôt(s)" ) return stock_info except Exception as e: logger.error(f"[STOCK] Erreur lecture : {e}", exc_info=True) raise def creer_sortie_stock(self, sortie_data: Dict) -> Dict: try: with self._com_context(), self._lock_com: logger.info(f"[STOCK] === CRÉATION SORTIE STOCK ===") logger.info(f"[STOCK] {len(sortie_data.get('lignes', []))} ligne(s)") try: self.cial.CptaApplication.BeginTrans() except: pass try: # ======================================== # ÉTAPE 1 : CRÉER LE DOCUMENT # ======================================== factory = self.cial.FactoryDocumentStock persist = factory.CreateType(181) # 181 = Sortie doc = win32com.client.CastTo(persist, "IBODocumentStock3") doc.SetDefault() # Date date_mouv = sortie_data.get("date_mouvement") if isinstance(date_mouv, date): doc.DO_Date = pywintypes.Time( datetime.combine(date_mouv, datetime.min.time()) ) else: doc.DO_Date = pywintypes.Time(datetime.now()) # Référence if sortie_data.get("reference"): doc.DO_Ref = sortie_data["reference"] doc.Write() logger.info( f"[STOCK] Document créé : {getattr(doc, 'DO_Piece', '?')}" ) # ======================================== # ÉTAPE 2 : FACTORY LIGNES # ======================================== try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentStockLigne factory_article = self.cial.FactoryArticle stocks_mis_a_jour = [] # ======================================== # ÉTAPE 3 : TRAITER CHAQUE LIGNE # ======================================== for idx, ligne_data in enumerate(sortie_data["lignes"], 1): article_ref = ligne_data["article_ref"].upper() quantite = ligne_data["quantite"] logger.info( f"[STOCK] ======== LIGNE {idx} : {article_ref} x {quantite} ========" ) # Charger l'article persist_article = factory_article.ReadReference(article_ref) if not persist_article: raise ValueError(f"Article {article_ref} introuvable") article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() ar_suivi = getattr(article_obj, "AR_SuiviStock", 0) ar_design = getattr(article_obj, "AR_Design", article_ref) logger.info(f"[STOCK] Article : {ar_design}") logger.info(f"[STOCK] AR_SuiviStock : {ar_suivi}") # ⚠️ VÉRIFIER LE STOCK DISPONIBLE stock_dispo = self.verifier_stock_suffisant( article_ref, quantite, None ) if not stock_dispo["suffisant"]: raise ValueError( f"Stock insuffisant pour {article_ref} : " f"disponible={stock_dispo['stock_disponible']}, " f"demandé={quantite}" ) logger.info( f"[STOCK] Stock disponible : {stock_dispo['stock_disponible']}" ) # Gérer le lot numero_lot = ligne_data.get("numero_lot") if ar_suivi == 1: # CMUP if numero_lot: logger.warning(f"[STOCK] CMUP : Suppression du lot") numero_lot = None elif ar_suivi == 2: # FIFO/LIFO if not numero_lot: import uuid numero_lot = f"AUTO-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6].upper()}" logger.info( f"[STOCK] FIFO/LIFO : Lot auto-généré '{numero_lot}'" ) # ======================================== # 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, "IBODocumentStockLigne3" ) ligne_obj.SetDefault() # ======================================== # LIAISON ARTICLE # ======================================== article_lie = False try: ligne_obj.SetDefaultArticleReference( article_ref, float(quantite) ) article_lie = True logger.info(f"[STOCK] ✅ SetDefaultArticleReference()") except: try: ligne_obj.SetDefaultArticle( article_obj, float(quantite) ) article_lie = True logger.info(f"[STOCK] ✅ SetDefaultArticle()") except: pass if not article_lie: raise ValueError( f"Impossible de lier l'article {article_ref}" ) # ======================================== # LOT (si FIFO/LIFO) # ======================================== if numero_lot and ar_suivi == 2: try: ligne_obj.SetDefaultLot(numero_lot) logger.info(f"[STOCK] ✅ Lot défini") except: try: ligne_obj.LS_NoSerie = numero_lot logger.info(f"[STOCK] ✅ Lot via LS_NoSerie") except: pass # Prix prix = ligne_data.get("prix_unitaire") if prix: try: ligne_obj.DL_PrixUnitaire = float(prix) except: pass # ======================================== # ÉCRIRE LA LIGNE # ======================================== ligne_obj.Write() logger.info(f"[STOCK] ✅ Write() réussi") # Vérification ligne_obj.Read() ref_verifiee = article_ref # Supposer OK si Write() réussi try: article_lie_obj = getattr(ligne_obj, "Article", None) if article_lie_obj: article_lie_obj.Read() ref_verifiee = ( getattr(article_lie_obj, "AR_Ref", "").strip() or article_ref ) except: pass logger.info(f"[STOCK] ✅ LIGNE {idx} COMPLÈTE") stocks_mis_a_jour.append( { "article_ref": article_ref, "quantite_retiree": quantite, "reference_verifiee": ref_verifiee, "stock_avant": stock_dispo["stock_disponible"], "stock_apres": stock_dispo["stock_apres"], "numero_lot": numero_lot if ar_suivi == 2 else None, } ) # ======================================== # FINALISER # ======================================== doc.Write() doc.Read() numero = getattr(doc, "DO_Piece", "") logger.info(f"[STOCK] ✅ Document finalisé : {numero}") # Commit try: self.cial.CptaApplication.CommitTrans() logger.info(f"[STOCK] ✅ Transaction committée") except: pass return { "numero": numero, "type": 1, "date": str(getattr(doc, "DO_Date", "")), "nb_lignes": len(stocks_mis_a_jour), "reference": sortie_data.get("reference"), "stocks_mis_a_jour": stocks_mis_a_jour, } except Exception as e: logger.error(f"[STOCK] ERREUR : {e}", exc_info=True) try: self.cial.CptaApplication.RollbackTrans() except: pass raise ValueError(f"Erreur création sortie stock : {str(e)}") except Exception as e: logger.error(f"[STOCK] ERREUR GLOBALE : {e}", exc_info=True) raise ValueError(f"Erreur création sortie stock : {str(e)}") def lire_mouvement_stock(self, numero: str) -> Dict: try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentStock # Chercher le document persist = None index = 1 logger.info(f"[MOUVEMENT] Recherche de {numero}...") while index < 10000: try: persist_test = factory.List(index) if persist_test is None: break doc_test = win32com.client.CastTo( persist_test, "IBODocumentStock3" ) doc_test.Read() if getattr(doc_test, "DO_Piece", "") == numero: persist = persist_test logger.info(f"[MOUVEMENT] Trouvé à l'index {index}") break index += 1 except: index += 1 if not persist: raise ValueError(f"Mouvement {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentStock3") doc.Read() # Infos du document do_type = getattr(doc, "DO_Type", -1) types_mouvements = { 180: "Entrée", 181: "Sortie", 182: "Transfert", 183: "Inventaire", } mouvement = { "numero": numero, "type": do_type, "type_libelle": types_mouvements.get(do_type, f"Type {do_type}"), "date": str(getattr(doc, "DO_Date", "")), "reference": getattr(doc, "DO_Ref", ""), "lignes": [], } # Lire les lignes try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentStockLigne", None) if factory_lignes: idx = 1 while idx <= 100: try: ligne_p = factory_lignes.List(idx) if ligne_p is None: break try: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) except: ligne = win32com.client.CastTo( ligne_p, "IBODocumentStockLigne3" ) ligne.Read() # Récupérer la référence article via l'objet Article article_ref = "" try: article_obj = getattr(ligne, "Article", None) if article_obj: article_obj.Read() article_ref = getattr( article_obj, "AR_Ref", "" ).strip() except: pass ligne_info = { "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) ), "montant_ht": float( getattr(ligne, "DL_MontantHT", 0.0) ), "numero_lot": getattr(ligne, "LS_NoSerie", ""), } mouvement["lignes"].append(ligne_info) idx += 1 except: break except Exception as e: logger.warning(f"[MOUVEMENT] Erreur lecture lignes : {e}") mouvement["nb_lignes"] = len(mouvement["lignes"]) logger.info( f"[MOUVEMENT] Lu : {numero} - {mouvement['nb_lignes']} ligne(s)" ) return mouvement except ValueError: raise except Exception as e: logger.error(f"[MOUVEMENT] Erreur : {e}", exc_info=True) raise ValueError(f"Erreur lecture mouvement : {str(e)}") def verifier_stock_suffisant(self, article_ref, quantite, depot=None): """Version thread-safe avec lock SQL""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ✅ LOCK pour éviter les race conditions cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") cursor.execute("BEGIN TRANSACTION") try: # Lire stock avec lock cursor.execute( """ SELECT SUM(AS_QteSto) FROM F_ARTSTOCK WITH (UPDLOCK, ROWLOCK) WHERE AR_Ref = ? """, (article_ref.upper(),), ) row = cursor.fetchone() stock_dispo = float(row[0]) if row and row[0] else 0.0 suffisant = stock_dispo >= quantite cursor.execute("COMMIT") return { "suffisant": suffisant, "stock_disponible": stock_dispo, "quantite_demandee": quantite, } except: cursor.execute("ROLLBACK") raise except Exception as e: logger.error(f"Erreur vérification stock: {e}") raise def lister_modeles_crystal(self) -> Dict: """ 📋 Liste les modèles en scannant le répertoire Sage ✅ FONCTIONNE MÊME SI FactoryEtat N'EXISTE PAS """ try: logger.info("[MODELES] Scan du répertoire des modèles...") # Chemin typique des modèles Sage 100c # Adapter selon votre installation chemins_possibles = [ r"C:\Users\Public\Documents\Sage\Entreprise 100c\fr-FR\Documents standards\Gestion commerciale\Ventes", ] # Essayer de détecter depuis la base Sage chemin_base = self.chemin_base if chemin_base: # Extraire le répertoire Sage import os dossier_sage = os.path.dirname(os.path.dirname(chemin_base)) chemins_possibles.insert(0, os.path.join(dossier_sage, "Modeles")) modeles_par_type = { "devis": [], "commandes": [], "livraisons": [], "factures": [], "avoirs": [], "autres": [], } # Scanner les répertoires import os import glob for chemin in chemins_possibles: if not os.path.exists(chemin): continue logger.info(f"[MODELES] Scan: {chemin}") # Chercher tous les fichiers .RPT et .BGC for pattern in ["*.RPT", "*.rpt", "*.BGC", "*.bgc"]: fichiers = glob.glob(os.path.join(chemin, pattern)) for fichier in fichiers: nom_fichier = os.path.basename(fichier) # Déterminer la catégorie categorie = "autres" nom_upper = nom_fichier.upper() if "DEVIS" in nom_upper or nom_upper.startswith("VT_DE"): categorie = "devis" elif ( "CMDE" in nom_upper or "COMMANDE" in nom_upper or nom_upper.startswith("VT_BC") ): categorie = "commandes" elif nom_upper.startswith("VT_BL") or "LIVRAISON" in nom_upper: categorie = "livraisons" elif "FACT" in nom_upper or nom_upper.startswith("VT_FA"): categorie = "factures" elif "AVOIR" in nom_upper or nom_upper.startswith("VT_AV"): categorie = "avoirs" modeles_par_type[categorie].append( { "fichier": nom_fichier, "nom": nom_fichier.replace(".RPT", "") .replace(".rpt", "") .replace(".BGC", "") .replace(".bgc", ""), "chemin_complet": fichier, } ) # Si on a trouvé des fichiers, pas besoin de continuer if any(len(v) > 0 for v in modeles_par_type.values()): break total = sum(len(v) for v in modeles_par_type.values()) logger.info(f"[MODELES] {total} modèles trouvés") return modeles_par_type except Exception as e: logger.error(f"[MODELES] Erreur: {e}", exc_info=True) raise RuntimeError(f"Erreur listage modèles: {str(e)}") def _detecter_methodes_impression(self, doc) -> dict: """🔍 Détecte les méthodes d'impression disponibles""" methodes = {} # Tester FactoryEtat try: factory_etat = self.cial.CptaApplication.FactoryEtat if factory_etat: methodes["FactoryEtat"] = True except: try: factory_etat = self.cial.FactoryEtat if factory_etat: methodes["FactoryEtat"] = True except: pass # Tester Imprimer() if hasattr(doc, "Imprimer"): methodes["Imprimer"] = True # Tester Print() if hasattr(doc, "Print"): methodes["Print"] = True # Tester ExportPDF() if hasattr(doc, "ExportPDF"): methodes["ExportPDF"] = True return methodes def generer_pdf_document( self, numero: str, type_doc: int, modele: str = None ) -> bytes: """ 📄 Génération PDF Sage 100c - UTILISE LES .BGC DIRECTEMENT Args: numero: Numéro document (ex: "FA00123") type_doc: Type Sage (0=devis, 60=facture, etc.) modele: Nom fichier .bgc (optionnel) Returns: bytes: Contenu PDF """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"[PDF] === GÉNÉRATION PDF AVEC .BGC ===") logger.info(f"[PDF] Document: {numero} (type={type_doc})") # ======================================== # 1. CHARGER LE DOCUMENT SAGE # ======================================== factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(type_doc, numero) if not persist: persist = self._find_document_in_list(numero, type_doc) if not persist: raise ValueError(f"Document {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() logger.info(f"[PDF] ✅ Document chargé") # ======================================== # 2. DÉTERMINER LE MODÈLE .BGC # ======================================== chemin_modele = self._determiner_modele(type_doc, modele) logger.info(f"[PDF] 📄 Modèle: {os.path.basename(chemin_modele)}") logger.info(f"[PDF] 📁 Chemin: {chemin_modele}") # ======================================== # 3. VÉRIFIER QUE LE FICHIER EXISTE # ======================================== import os if not os.path.exists(chemin_modele): raise ValueError(f"Modèle introuvable: {chemin_modele}") # ======================================== # 4. CRÉER FICHIER TEMPORAIRE # ======================================== import tempfile import time temp_dir = tempfile.gettempdir() pdf_path = os.path.join( temp_dir, f"sage_{numero}_{int(time.time())}.pdf" ) pdf_bytes = None # ======================================== # MÉTHODE 1 : Crystal Reports Runtime (PRIORITAIRE) # ======================================== logger.info("[PDF] 🔷 Méthode 1: Crystal Reports Runtime...") try: pdf_bytes = self._generer_pdf_crystal_runtime( numero, type_doc, chemin_modele, pdf_path ) if pdf_bytes: logger.info("[PDF] ✅ Méthode 1 réussie (Crystal Runtime)") except Exception as e: logger.warning(f"[PDF] Méthode 1 échouée: {e}") # ======================================== # MÉTHODE 2 : Crystal via DLL Sage # ======================================== if not pdf_bytes: logger.info("[PDF] 🔷 Méthode 2: Crystal via DLL Sage...") try: pdf_bytes = self._generer_pdf_crystal_sage_dll( numero, type_doc, chemin_modele, pdf_path ) if pdf_bytes: logger.info("[PDF] ✅ Méthode 2 réussie (DLL Sage)") except Exception as e: logger.warning(f"[PDF] Méthode 2 échouée: {e}") # ======================================== # MÉTHODE 3 : Sage Reports Viewer (si installé) # ======================================== if not pdf_bytes: logger.info("[PDF] 🔷 Méthode 3: Sage Reports Viewer...") try: pdf_bytes = self._generer_pdf_sage_viewer( numero, type_doc, chemin_modele, pdf_path ) if pdf_bytes: logger.info("[PDF] ✅ Méthode 3 réussie (Sage Viewer)") except Exception as e: logger.warning(f"[PDF] Méthode 3 échouée: {e}") # ======================================== # MÉTHODE 4 : Python reportlab (FALLBACK) # ======================================== if not pdf_bytes: logger.warning("[PDF] ⚠️ TOUTES LES MÉTHODES .BGC ONT ÉCHOUÉ") logger.info("[PDF] 🔷 Méthode 4: PDF Custom (fallback)...") try: pdf_bytes = self._generer_pdf_custom(doc, numero, type_doc) if pdf_bytes: logger.info("[PDF] ✅ Méthode 4 réussie (PDF custom)") except Exception as e: logger.error(f"[PDF] Méthode 4 échouée: {e}") # ======================================== # VALIDATION & NETTOYAGE # ======================================== try: if os.path.exists(pdf_path): os.remove(pdf_path) except: pass if not pdf_bytes: raise RuntimeError( "❌ ÉCHEC GÉNÉRATION PDF\n\n" "🔍 DIAGNOSTIC:\n" f"- Modèle .bgc trouvé: ✅ ({os.path.basename(chemin_modele)})\n" f"- Crystal Reports installé: ❌ NON DÉTECTÉ\n\n" "💡 SOLUTIONS:\n" "1. Installer SAP Crystal Reports Runtime (gratuit):\n" " https://www.sap.com/products/technology-platform/crystal-reports/trial.html\n" " Choisir: Crystal Reports Runtime (64-bit)\n\n" "2. OU installer depuis DVD Sage 100c:\n" " Composants/Crystal Reports Runtime\n\n" "3. Vérifier que le service 'Crystal Reports' est démarré:\n" " services.msc → SAP Crystal Reports Processing Server\n\n" "4. En attendant, utiliser /pdf-custom pour un PDF simple" ) if len(pdf_bytes) < 500: raise RuntimeError("PDF généré trop petit (probablement corrompu)") logger.info(f"[PDF] ✅✅✅ SUCCÈS: {len(pdf_bytes):,} octets") return pdf_bytes except ValueError as e: logger.error(f"[PDF] Erreur métier: {e}") raise except Exception as e: logger.error(f"[PDF] Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur PDF: {str(e)}") def _generer_pdf_crystal_runtime(self, numero, type_doc, chemin_modele, pdf_path): """🔷 Méthode 1: Crystal Reports Runtime API""" try: import os # Essayer différentes ProgID Crystal Reports prog_ids_crystal = [ "CrystalRuntime.Application.140", # Crystal Reports 2020 "CrystalRuntime.Application.13", # Crystal Reports 2016 "CrystalRuntime.Application.12", # Crystal Reports 2013 "CrystalRuntime.Application.11", # Crystal Reports 2011 "CrystalRuntime.Application", # Générique "CrystalDesignRunTime.Application", # Alternative ] crystal = None prog_id_utilisee = None for prog_id in prog_ids_crystal: try: crystal = win32com.client.Dispatch(prog_id) prog_id_utilisee = prog_id logger.info(f" ✅ Crystal trouvé: {prog_id}") break except Exception as e: logger.debug(f" {prog_id}: {e}") continue if not crystal: logger.info(" ❌ Aucune ProgID Crystal trouvée") return None # Ouvrir le rapport .bgc logger.info(f" 📂 Ouverture: {os.path.basename(chemin_modele)}") rapport = crystal.OpenReport(chemin_modele) # Configurer la connexion SQL logger.info(" 🔌 Configuration connexion SQL...") for table in rapport.Database.Tables: try: # Méthode 1: SetDataSource table.SetDataSource(self.sql_server, self.sql_database, "", "") except: try: # Méthode 2: ConnectionProperties table.ConnectionProperties.Item["Server Name"] = self.sql_server table.ConnectionProperties.Item["Database Name"] = ( self.sql_database ) table.ConnectionProperties.Item["Integrated Security"] = True except: pass # Appliquer le filtre Crystal Reports logger.info(f" 🔍 Filtre: DO_Piece = '{numero}'") rapport.RecordSelectionFormula = f"{{F_DOCENTETE.DO_Piece}} = '{numero}'" # Exporter en PDF logger.info(" 📄 Export PDF...") rapport.ExportOptions.DestinationType = 1 # crEDTDiskFile rapport.ExportOptions.FormatType = 31 # crEFTPortableDocFormat (PDF) rapport.ExportOptions.DiskFileName = pdf_path rapport.Export(False) # Attendre la création du fichier import time max_wait = 30 waited = 0 while not os.path.exists(pdf_path) and waited < max_wait: time.sleep(0.5) waited += 0.5 if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: with open(pdf_path, "rb") as f: return f.read() logger.warning(" ⚠️ Fichier PDF non créé") return None except Exception as e: logger.debug(f" Crystal Runtime: {e}") return None def _generer_pdf_crystal_sage_dll(self, numero, type_doc, chemin_modele, pdf_path): """🔷 Méthode 2: Utiliser les DLL Crystal de Sage directement""" try: import os import ctypes # Chercher les DLL Crystal dans le dossier Sage dossier_sage = os.path.dirname(os.path.dirname(self.chemin_base)) chemins_dll = [ os.path.join(dossier_sage, "CrystalReports", "crpe32.dll"), os.path.join(dossier_sage, "Crystal", "crpe32.dll"), r"C:\Program Files (x86)\SAP BusinessObjects\Crystal Reports for .NET Framework 4.0\Common\SAP BusinessObjects Enterprise XI 4.0\win64_x64\crpe32.dll", ] dll_trouvee = None for chemin_dll in chemins_dll: if os.path.exists(chemin_dll): dll_trouvee = chemin_dll break if not dll_trouvee: logger.info(" ❌ DLL Crystal Sage non trouvée") return None logger.info(f" ✅ DLL trouvée: {dll_trouvee}") # Charger la DLL crpe = ctypes.cdll.LoadLibrary(dll_trouvee) # Ouvrir le rapport (API C Crystal Reports) # Note: Ceci est une approche bas niveau, peut nécessiter des ajustements job_handle = crpe.PEOpenPrintJob(chemin_modele.encode()) if job_handle == 0: logger.warning(" ⚠️ Impossible d'ouvrir le rapport") return None # Définir les paramètres de connexion # ... (code simplifié, nécessiterait plus de configuration) # Exporter crpe.PEExportTo(job_handle, pdf_path.encode(), 31) # 31 = PDF # Fermer crpe.PEClosePrintJob(job_handle) import time time.sleep(2) if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: with open(pdf_path, "rb") as f: return f.read() return None except Exception as e: logger.debug(f" DLL Sage: {e}") return None def _generer_pdf_sage_viewer(self, numero, type_doc, chemin_modele, pdf_path): """🔷 Méthode 3: Sage Reports Viewer (si installé)""" try: import os # Chercher l'exécutable Sage Reports executables_possibles = [ r"C:\Program Files\Sage\Reports\SageReports.exe", r"C:\Program Files (x86)\Sage\Reports\SageReports.exe", os.path.join( os.path.dirname(os.path.dirname(self.chemin_base)), "Reports", "SageReports.exe", ), ] exe_trouve = None for exe in executables_possibles: if os.path.exists(exe): exe_trouve = exe break if not exe_trouve: logger.info(" ❌ SageReports.exe non trouvé") return None logger.info(f" ✅ SageReports trouvé: {exe_trouve}") # Lancer en ligne de commande avec paramètres import subprocess cmd = [ exe_trouve, "/report", chemin_modele, "/export", pdf_path, "/format", "PDF", "/filter", f"DO_Piece='{numero}'", "/silent", ] logger.info(" 🚀 Lancement SageReports...") result = subprocess.run(cmd, capture_output=True, timeout=30) import time time.sleep(2) if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: with open(pdf_path, "rb") as f: return f.read() logger.warning(" ⚠️ PDF non généré par SageReports") return None except Exception as e: logger.debug(f" Sage Viewer: {e}") return None def _generer_pdf_custom(self, doc, numero, type_doc): """🎨 Génère un PDF simple avec les données du document (FALLBACK)""" try: from reportlab.lib.pagesizes import A4 from reportlab.lib.units import cm from reportlab.pdfgen import canvas from reportlab.lib import colors from io import BytesIO buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 # En-tête pdf.setFont("Helvetica-Bold", 20) type_libelles = { 0: "DEVIS", 10: "BON DE COMMANDE", 30: "BON DE LIVRAISON", 60: "FACTURE", 50: "AVOIR", } type_libelle = type_libelles.get(type_doc, "DOCUMENT") pdf.drawString(2 * cm, height - 3 * cm, type_libelle) # Numéro pdf.setFont("Helvetica", 12) pdf.drawString(2 * cm, height - 4 * cm, f"Numéro: {numero}") # Date date_doc = getattr(doc, "DO_Date", "") pdf.drawString(2 * cm, height - 4.5 * cm, f"Date: {date_doc}") # Client try: client = getattr(doc, "Client", None) if client: client.Read() client_nom = getattr(client, "CT_Intitule", "") pdf.drawString(2 * cm, height - 5.5 * cm, f"Client: {client_nom}") except: pass # Ligne séparatrice pdf.line(2 * cm, height - 6 * cm, width - 2 * cm, height - 6 * cm) # Lignes du document y_pos = height - 7 * cm pdf.setFont("Helvetica-Bold", 10) pdf.drawString(2 * cm, y_pos, "Article") pdf.drawString(8 * cm, y_pos, "Qté") pdf.drawString(11 * cm, y_pos, "Prix U.") pdf.drawString(15 * cm, y_pos, "Total") y_pos -= 0.5 * cm pdf.line(2 * cm, y_pos, width - 2 * cm, y_pos) # Lire lignes pdf.setFont("Helvetica", 9) try: factory_lignes = getattr(doc, "FactoryDocumentLigne", None) if factory_lignes: idx = 1 while idx <= 50 and y_pos > 5 * cm: try: ligne_p = factory_lignes.List(idx) if ligne_p is None: break ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") ligne.Read() y_pos -= 0.7 * cm design = getattr(ligne, "DL_Design", "")[:40] qte = float(getattr(ligne, "DL_Qte", 0)) prix = float(getattr(ligne, "DL_PrixUnitaire", 0)) total = float(getattr(ligne, "DL_MontantHT", 0)) pdf.drawString(2 * cm, y_pos, design) pdf.drawString(8 * cm, y_pos, f"{qte:.2f}") pdf.drawString(11 * cm, y_pos, f"{prix:.2f}€") pdf.drawString(15 * cm, y_pos, f"{total:.2f}€") idx += 1 except: break except: pass # Totaux y_pos = 5 * cm pdf.line(2 * cm, y_pos, width - 2 * cm, y_pos) y_pos -= 0.7 * cm pdf.setFont("Helvetica-Bold", 11) total_ht = float(getattr(doc, "DO_TotalHT", 0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0)) pdf.drawString(13 * cm, y_pos, f"Total HT:") pdf.drawString(16 * cm, y_pos, f"{total_ht:.2f}€") y_pos -= 0.7 * cm pdf.drawString(13 * cm, y_pos, f"TVA:") pdf.drawString(16 * cm, y_pos, f"{(total_ttc - total_ht):.2f}€") y_pos -= 0.7 * cm pdf.setFont("Helvetica-Bold", 14) pdf.drawString(13 * cm, y_pos, f"Total TTC:") pdf.drawString(16 * cm, y_pos, f"{total_ttc:.2f}€") # Pied de page pdf.setFont("Helvetica", 8) pdf.drawString( 2 * cm, 2 * cm, "PDF généré par l'API Sage - Version simplifiée" ) pdf.drawString( 2 * cm, 1.5 * cm, "Pour un rendu Crystal Reports complet, installez SAP BusinessObjects", ) pdf.showPage() pdf.save() return buffer.getvalue() except Exception as e: logger.error(f"PDF custom: {e}") return None def _determiner_modele(self, type_doc: int, modele_demande: str = None) -> str: """ 🔍 Détermine le chemin du modèle Crystal Reports à utiliser Args: type_doc: Type Sage (0=devis, 60=facture, etc.) modele_demande: Nom fichier .bgc spécifique (optionnel) Returns: str: Chemin complet du modèle """ if modele_demande: # Modèle spécifié modeles_dispo = self.lister_modeles_crystal() for categorie, liste in modeles_dispo.items(): for m in liste: if m["fichier"].lower() == modele_demande.lower(): return m["chemin_complet"] raise ValueError(f"Modèle '{modele_demande}' introuvable") # Modèle par défaut selon type modeles = self.lister_modeles_crystal() mapping = { 0: "devis", 10: "commandes", 30: "livraisons", 60: "factures", 50: "avoirs", } categorie = mapping.get(type_doc) if not categorie or categorie not in modeles: raise ValueError(f"Aucun modèle disponible pour type {type_doc}") liste = modeles[categorie] if not liste: raise ValueError(f"Aucun modèle {categorie} trouvé") # Prioriser modèle "standard" (sans FlyDoc, email, etc.) modele_std = next( ( m for m in liste if "flydoc" not in m["fichier"].lower() and "email" not in m["fichier"].lower() ), liste[0], ) return modele_std["chemin_complet"] def diagnostiquer_impression_approfondi(self): """🔬 Diagnostic ultra-complet pour trouver les objets d'impression""" try: with self._com_context(), self._lock_com: logger.info("=" * 80) logger.info("DIAGNOSTIC IMPRESSION APPROFONDI") logger.info("=" * 80) objets_a_tester = [ ("self.cial", self.cial), ("CptaApplication", self.cial.CptaApplication), ] # Charger un document pour tester try: factory = self.cial.FactoryDocumentVente persist = factory.List(1) if persist: doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() objets_a_tester.append(("Document", doc)) except: pass for nom_objet, objet in objets_a_tester: logger.info(f"\n{'='*60}") logger.info(f"OBJET: {nom_objet}") logger.info(f"{'='*60}") # Chercher tous les attributs qui contiennent "print", "etat", "bilan", "crystal", "report" mots_cles = [ "print", "etat", "bilan", "crystal", "report", "pdf", "export", "impression", "imprimer", ] attributs_trouves = [] for attr in dir(objet): if attr.startswith("_"): continue attr_lower = attr.lower() # Vérifier si contient un des mots-clés if any(mot in attr_lower for mot in mots_cles): try: val = getattr(objet, attr) type_val = type(val).__name__ is_callable = callable(val) attributs_trouves.append( { "nom": attr, "type": type_val, "callable": is_callable, } ) logger.info( f" ✅ TROUVÉ: {attr} ({type_val}) {'[MÉTHODE]' if is_callable else '[PROPRIÉTÉ]'}" ) except Exception as e: logger.debug(f" Erreur {attr}: {e}") if not attributs_trouves: logger.warning( f" ❌ Aucun objet d'impression trouvé sur {nom_objet}" ) # Tester des noms de méthodes spécifiques logger.info(f"\n{'='*60}") logger.info("TESTS DIRECTS") logger.info(f"{'='*60}") methodes_a_tester = [ ("self.cial.BilanEtat", lambda: self.cial.BilanEtat), ("self.cial.Etat", lambda: self.cial.Etat), ( "self.cial.CptaApplication.BilanEtat", lambda: self.cial.CptaApplication.BilanEtat, ), ( "self.cial.CptaApplication.Etat", lambda: self.cial.CptaApplication.Etat, ), ("self.cial.FactoryEtat", lambda: self.cial.FactoryEtat), ( "self.cial.CptaApplication.FactoryEtat", lambda: self.cial.CptaApplication.FactoryEtat, ), ] for nom, getter in methodes_a_tester: try: obj = getter() logger.info(f" ✅ {nom} EXISTE : {type(obj).__name__}") except AttributeError as e: logger.info(f" ❌ {nom} N'EXISTE PAS : {e}") except Exception as e: logger.info(f" ⚠️ {nom} ERREUR : {e}") logger.info("=" * 80) return {"diagnostic": "terminé"} except Exception as e: logger.error(f"Erreur diagnostic: {e}", exc_info=True) raise def lister_objets_com_disponibles(self): """🔍 Liste tous les objets COM disponibles dans Sage""" try: with self._com_context(), self._lock_com: objets_trouves = {"cial": [], "cpta_application": [], "document": []} # 1. Objets sur self.cial for attr in dir(self.cial): if not attr.startswith("_"): try: obj = getattr(self.cial, attr) objets_trouves["cial"].append( { "nom": attr, "type": str(type(obj)), "callable": callable(obj), } ) except: pass # 2. Objets sur CptaApplication try: cpta = self.cial.CptaApplication for attr in dir(cpta): if not attr.startswith("_"): try: obj = getattr(cpta, attr) objets_trouves["cpta_application"].append( { "nom": attr, "type": str(type(obj)), "callable": callable(obj), } ) except: pass except: pass # 3. Objets sur un document try: factory = self.cial.FactoryDocumentVente persist = factory.List(1) if persist: doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() for attr in dir(doc): if not attr.startswith("_"): try: obj = getattr(doc, attr) objets_trouves["document"].append( { "nom": attr, "type": str(type(obj)), "callable": callable(obj), } ) except: pass except: pass return objets_trouves except Exception as e: logger.error(f"Erreur listage objets COM: {e}", exc_info=True) raise def explorer_methodes_impression(self): """Explore toutes les méthodes d'impression disponibles""" try: with self._com_context(), self._lock_com: # Charger un document de test factory = self.cial.FactoryDocumentVente persist = factory.List(1) if not persist: return {"error": "Aucun document trouvé"} doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() methods = {} # Tester différentes signatures de Print signatures_to_test = [ "Print", "PrintToFile", "Export", "ExportToPDF", "SaveAs", "GeneratePDF", ] for method_name in signatures_to_test: if hasattr(doc, method_name): try: # Essayer d'appeler pour voir les paramètres method = getattr(doc, method_name) methods[method_name] = { "exists": True, "callable": callable(method), } except: methods[method_name] = { "exists": True, "error": "Access error", } return methods except Exception as e: return {"error": str(e)} def generer_pdf_document_via_print(self, numero: str, type_doc: int) -> bytes: """Utilise la méthode Print() native des documents Sage""" try: with self._com_context(), self._lock_com: # Charger le document factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(type_doc, numero) if not persist: persist = self._find_document_in_list(numero, type_doc) if not persist: raise ValueError(f"Document {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() # Créer un fichier temporaire import tempfile import os temp_dir = tempfile.gettempdir() pdf_path = os.path.join(temp_dir, f"document_{numero}.pdf") # Utiliser Print() avec destination fichier PDF # Les codes de destination typiques dans Sage : # 0 = Imprimante par défaut # 1 = Aperçu # 2 = Fichier # 6 = PDF (dans certaines versions) try: # Tentative 1 : Print() avec paramètres doc.Print(Destination=6, FileName=pdf_path, Preview=False) except: # Tentative 2 : Print() simplifié try: doc.Print( pdf_path ) # Certaines versions acceptent juste le chemin except: # Tentative 3 : PrintToFile() try: doc.PrintToFile(pdf_path) except AttributeError: raise RuntimeError("Aucune méthode d'impression disponible") # Lire le fichier PDF import time max_wait = 10 waited = 0 while not os.path.exists(pdf_path) and waited < max_wait: time.sleep(0.5) waited += 0.5 if not os.path.exists(pdf_path): raise RuntimeError("Le fichier PDF n'a pas été généré") with open(pdf_path, "rb") as f: pdf_bytes = f.read() # Nettoyer try: os.remove(pdf_path) except: pass return pdf_bytes except Exception as e: logger.error(f"Erreur génération PDF via Print(): {e}") raise