diff --git a/main.py b/main.py index ae885fe..79d89d4 100644 --- a/main.py +++ b/main.py @@ -7,8 +7,10 @@ from enum import Enum import uvicorn import logging import win32com.client +import time from config import settings, validate_settings from sage_connector import SageConnector +import pyodbc # ===================================================== # LOGGING @@ -210,6 +212,85 @@ class PDFGenerationRequest(BaseModel): type_doc: int = Field(..., ge=0, le=60, description="Type de document Sage") +class ArticleCreateRequest(BaseModel): + reference: str = Field(..., description="Référence article (max 18 car)") + designation: str = Field(..., description="Désignation (max 69 car)") + famille: Optional[str] = Field(None, description="Code famille") + prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT") + prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT") + stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial") + stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum") + code_ean: Optional[str] = Field(None, description="Code-barres EAN") + unite_vente: Optional[str] = Field("UN", description="Unité de vente") + tva_code: Optional[str] = Field(None, description="Code TVA") + description: Optional[str] = Field(None, description="Description/Commentaire") + + +class ArticleUpdateGatewayRequest(BaseModel): + """Modèle pour modification article côté gateway""" + + reference: str + article_data: Dict + + +class MouvementStockLigneRequest(BaseModel): + """Ligne de mouvement de stock""" + + article_ref: str = Field(..., description="Référence de l'article") + quantite: float = Field(..., gt=0, description="Quantité (>0)") + depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')") + prix_unitaire: Optional[float] = Field( + None, ge=0, description="Prix unitaire (optionnel)" + ) + commentaire: Optional[str] = Field(None, description="Commentaire ligne") + + +class EntreeStockRequest(BaseModel): + """Création d'un bon d'entrée en stock""" + + date_entree: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigneRequest] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) + commentaire: Optional[str] = Field(None, description="Commentaire général") + + +class SortieStockRequest(BaseModel): + """Création d'un bon de sortie de stock""" + + date_sortie: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigneRequest] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) + commentaire: Optional[str] = Field(None, description="Commentaire général") + + +class FamilleCreate(BaseModel): + """Modèle pour créer une famille d'articles""" + + code: str = Field(..., description="Code famille (max 18 car)", max_length=18) + intitule: str = Field(..., description="Intitulé (max 69 car)", max_length=69) + type: int = Field(0, description="0=Détail, 1=Total") + compte_achat: Optional[str] = Field( + None, description="Compte général d'achat (ex: 607000)" + ) + compte_vente: Optional[str] = Field( + None, description="Compte général de vente (ex: 707000)" + ) + + # ===================================================== # SÉCURITÉ # ===================================================== @@ -306,9 +387,6 @@ def clients_list(req: FiltreRequest): @app.post("/sage/clients/update", dependencies=[Depends(verify_token)]) def modifier_client_endpoint(req: ClientUpdateGatewayRequest): - """ - ✏️ Modification d'un client dans Sage - """ try: resultat = sage.modifier_client(req.code, req.client_data) return {"success": True, "data": resultat} @@ -406,12 +484,6 @@ def creer_devis(req: DevisRequest): @app.post("/sage/devis/get", dependencies=[Depends(verify_token)]) def lire_devis(req: CodeRequest): - """ - 📄 Lecture d'un devis AVEC ses lignes (lecture Sage directe) - - ⚠️ Plus lent que /list car charge les lignes depuis Sage - 💡 Utiliser /list pour afficher une table rapide - """ try: # ✅ Lecture complète depuis Sage (avec lignes) devis = sage.lire_devis(req.code) @@ -431,12 +503,6 @@ def devis_list( statut: Optional[int] = Query(None, description="Filtrer par statut"), filtre: str = Query("", description="Filtre texte (numero, client)"), ): - """ - 📋 Liste rapide des devis depuis le CACHE (sans lignes) - - ⚡ ULTRA-RAPIDE: Utilise le cache mémoire au lieu de scanner Sage - 💡 Pour les détails avec lignes, utiliser GET /sage/devis/get - """ try: # ✅ Récupération depuis le cache (instantané) devis_list = sage.lister_tous_devis_cache(filtre) @@ -516,26 +582,6 @@ def transformer_document( type_source: int = Query(..., description="Type document source"), type_cible: int = Query(..., description="Type document cible"), ): - """ - 🔧 Transformation de document - - ✅ CORRECTION : Utilise les VRAIS types Sage Dataven - - Types valides : - - 0: Devis - - 10: Bon de commande - - 20: Préparation - - 30: Bon de livraison - - 40: Bon de retour - - 50: Bon d'avoir - - 60: Facture - - Transformations autorisées : - - Devis (0) → Commande (10) - - Commande (10) → Bon livraison (30) - - Commande (10) → Facture (60) - - Bon livraison (30) → Facture (60) - """ try: logger.info( f"🔄 Transformation demandée: {numero_source} " @@ -584,7 +630,6 @@ def transformer_document( @app.post("/sage/documents/champ-libre", dependencies=[Depends(verify_token)]) def maj_champ_libre(req: ChampLibreRequest): - """Mise à jour d'un champ libre""" try: success = sage.mettre_a_jour_champ_libre( req.doc_id, req.type_doc, req.nom_champ, req.valeur @@ -597,7 +642,6 @@ def maj_champ_libre(req: ChampLibreRequest): @app.post("/sage/documents/derniere-relance", dependencies=[Depends(verify_token)]) def maj_derniere_relance(doc_id: str, type_doc: int): - """📅 Met à jour le champ 'Dernière relance' d'un document""" try: success = sage.mettre_a_jour_derniere_relance(doc_id, type_doc) return {"success": success} @@ -630,21 +674,9 @@ def commandes_list( statut: Optional[int] = Query(None, description="Filtrer par statut"), filtre: str = Query("", description="Filtre texte"), ): - """ - 📋 Liste rapide des commandes depuis le CACHE (sans lignes) - - ⚡ ULTRA-RAPIDE: Utilise le cache mémoire - """ try: commandes = sage.lister_toutes_commandes_cache(filtre) - if statut is not None: - commandes = [c for c in commandes if c.get("statut") == statut] - - commandes = commandes[:limit] - - logger.info(f"✅ {len(commandes)} commandes retournées depuis le cache") - return {"success": True, "data": commandes} except Exception as e: @@ -658,12 +690,6 @@ def factures_list( statut: Optional[int] = Query(None, description="Filtrer par statut"), filtre: str = Query("", description="Filtre texte"), ): - """ - 📋 Liste rapide des factures depuis le CACHE (sans lignes) - - ⚡ ULTRA-RAPIDE: Utilise le cache mémoire - 💡 Pour les détails avec lignes, utiliser /sage/documents/get - """ try: factures = sage.lister_toutes_factures_cache(filtre) @@ -739,1633 +765,11 @@ def cache_info_get(): raise HTTPException(500, str(e)) -# Script à ajouter temporairement dans main.py pour diagnostiquer - - -@app.get("/sage/devis/{numero}/diagnostic", dependencies=[Depends(verify_token)]) -def diagnostiquer_devis(numero: str): - """ - ENDPOINT DE DIAGNOSTIC: Affiche TOUTES les infos d'un devis - - Permet de comprendre pourquoi un devis ne peut pas être transformé - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - factory = sage.cial.FactoryDocumentVente - - # Essayer ReadPiece - persist = factory.ReadPiece(0, numero) - - # Si échec, chercher dans List() - if not persist: - logger.info(f"[DIAG] ReadPiece echoue, recherche dans List()...") - index = 1 - while index < 10000: - try: - persist_test = factory.List(index) - if persist_test is None: - break - - doc_test = win32com.client.CastTo( - persist_test, "IBODocumentVente3" - ) - doc_test.Read() - - if ( - getattr(doc_test, "DO_Type", -1) == 0 - and getattr(doc_test, "DO_Piece", "") == numero - ): - persist = persist_test - logger.info(f"[DIAG] Trouve a l'index {index}") - break - - index += 1 - except: - index += 1 - - if not persist: - raise HTTPException(404, f"Devis {numero} introuvable") - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # EXTRACTION COMPLÈTE - diagnostic = { - "numero": getattr(doc, "DO_Piece", ""), - "type": getattr(doc, "DO_Type", -1), - "statut": getattr(doc, "DO_Statut", -1), - "statut_libelle": { - 0: "Brouillon", - 1: "Soumis", - 2: "Accepte", - 3: "Realise partiellement", - 4: "Realise totalement", - 5: "Transforme", - 6: "Annule", - }.get(getattr(doc, "DO_Statut", -1), "Inconnu"), - "date": str(getattr(doc, "DO_Date", "")), - "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), - "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), - "est_transformable": False, - "raison_blocage": None, - } - - # Client - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - diagnostic["client_code"] = getattr( - client_obj, "CT_Num", "" - ).strip() - diagnostic["client_intitule"] = getattr( - client_obj, "CT_Intitule", "" - ).strip() - except Exception as e: - diagnostic["erreur_client"] = str(e) - - # Lignes - try: - factory_lignes = doc.FactoryDocumentLigne - except: - factory_lignes = doc.FactoryDocumentVenteLigne - - lignes = [] - index = 1 - while index <= 100: - try: - ligne_p = factory_lignes.List(index) - if ligne_p is None: - break - - ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") - ligne.Read() - - article_ref = "" - try: - article_ref = getattr(ligne, "AR_Ref", "").strip() - if not article_ref: - article_obj = getattr(ligne, "Article", None) - if article_obj: - article_obj.Read() - article_ref = getattr(article_obj, "AR_Ref", "").strip() - except: - pass - - lignes.append( - { - "index": index, - "article": article_ref, - "designation": getattr(ligne, "DL_Design", ""), - "quantite": float(getattr(ligne, "DL_Qte", 0.0)), - "prix_unitaire": float( - getattr(ligne, "DL_PrixUnitaire", 0.0) - ), - "montant_ht": float(getattr(ligne, "DL_MontantHT", 0.0)), - } - ) - - index += 1 - except: - break - - diagnostic["nb_lignes"] = len(lignes) - diagnostic["lignes"] = lignes - - # ANALYSE TRANSFORMABILITÉ - statut = diagnostic["statut"] - - if statut == 5: - diagnostic["raison_blocage"] = "Document deja transforme (statut=5)" - elif statut == 6: - diagnostic["raison_blocage"] = "Document annule (statut=6)" - elif statut in [3, 4]: - diagnostic["raison_blocage"] = ( - f"Document deja realise partiellement ou totalement (statut={statut}). " - f"Une commande/BL/facture existe probablement deja." - ) - diagnostic["suggestion"] = ( - "Cherchez les documents lies a ce devis dans Sage. " - "Il a peut-etre deja ete transforme manuellement." - ) - elif statut == 0: - diagnostic["est_transformable"] = True - diagnostic["action_requise"] = ( - "Statut 'Brouillon'. Le systeme le passera automatiquement a 'Accepte' " - "avant transformation." - ) - elif statut == 2: - diagnostic["est_transformable"] = True - diagnostic["action_requise"] = ( - "Statut 'Accepte'. Transformation possible." - ) - else: - diagnostic["raison_blocage"] = f"Statut inconnu ou non gere: {statut}" - - # Champs libres (pour Universign, etc.) - champs_libres = {} - try: - for champ in ["UniversignID", "DerniereRelance", "DO_Ref"]: - try: - valeur = getattr(doc, f"DO_{champ}", None) - if valeur: - champs_libres[champ] = str(valeur) - except: - pass - except: - pass - - if champs_libres: - diagnostic["champs_libres"] = champs_libres - - logger.info( - f"[DIAG] Devis {numero}: statut={statut}, transformable={diagnostic['est_transformable']}" - ) - - return {"success": True, "diagnostic": diagnostic} - - except HTTPException: - raise - except Exception as e: - logger.error(f"[DIAG] Erreur diagnostic: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -@app.get("/sage/diagnostic/configuration", dependencies=[Depends(verify_token)]) -def diagnostic_configuration(): - """ - DIAGNOSTIC COMPLET de la configuration Sage - - Teste: - - Quelles méthodes COM sont disponibles - - Quels types de documents sont autorisés - - Quelles permissions l'utilisateur a - - Version de Sage - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - diagnostic = { - "connexion": "OK", - "chemin_base": sage.chemin_base, - "utilisateur": sage.utilisateur, - } - - # Version Sage - try: - version = getattr(sage.cial, "Version", "Inconnue") - diagnostic["version_sage"] = str(version) - except: - diagnostic["version_sage"] = "Non disponible" - - # Test des méthodes disponibles sur IBSCIALApplication3 - methodes_disponibles = [] - methodes_a_tester = [ - "CreateProcess_Document", - "FactoryDocumentVente", - "FactoryArticle", - "CptaApplication", - "BeginTrans", - "CommitTrans", - "RollbackTrans", - ] - - for methode in methodes_a_tester: - try: - if hasattr(sage.cial, methode): - methodes_disponibles.append(methode) - except: - pass - - diagnostic["methodes_cial_disponibles"] = methodes_disponibles - - # Test des types de documents autorisés - types_autorises = [] - types_bloques = [] - - for type_doc in range(6): # 0-5 - try: - # Essayer de créer un process (sans le valider) - process = sage.cial.CreateProcess_Document(type_doc) - if process: - types_autorises.append( - { - "type": type_doc, - "libelle": { - 0: "Devis", - 1: "Bon de livraison", - 2: "Bon de retour", - 3: "Commande", - 4: "Preparation", - 5: "Facture", - }[type_doc], - } - ) - # Ne pas valider, juste tester - del process - except Exception as e: - types_bloques.append( - { - "type": type_doc, - "libelle": { - 0: "Devis", - 1: "Bon de livraison", - 2: "Bon de retour", - 3: "Commande", - 4: "Preparation", - 5: "Facture", - }[type_doc], - "erreur": str(e)[:200], - } - ) - - diagnostic["types_documents_autorises"] = types_autorises - diagnostic["types_documents_bloques"] = types_bloques - - # Test TransformInto() sur un devis test - try: - factory = sage.cial.FactoryDocumentVente - - # Chercher n'importe quel devis - index = 1 - devis_test = None - - while index < 100: - try: - persist = factory.List(index) - if persist is None: - break - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - if getattr(doc, "DO_Type", -1) == 0: # Devis - devis_test = doc - break - - index += 1 - except: - index += 1 - - if devis_test: - # Tester si TransformInto existe - if hasattr(devis_test, "TransformInto"): - diagnostic["transforminto_disponible"] = True - diagnostic["transforminto_test"] = "Methode existe (non testee)" - else: - diagnostic["transforminto_disponible"] = False - diagnostic["transforminto_test"] = ( - "Methode TransformInto() inexistante" - ) - else: - diagnostic["transforminto_disponible"] = ( - "Impossible de tester (aucun devis trouve)" - ) - - except Exception as e: - diagnostic["transforminto_disponible"] = False - diagnostic["transforminto_erreur"] = str(e) - - # Modules Sage actifs - try: - # Tester l'accès aux différentes factories - modules = {} - - try: - sage.cial.FactoryDocumentVente - modules["Ventes"] = "OK" - except: - modules["Ventes"] = "INACCESSIBLE" - - try: - sage.cial.CptaApplication.FactoryClient - modules["Clients"] = "OK" - except: - modules["Clients"] = "INACCESSIBLE" - - try: - sage.cial.FactoryArticle - modules["Articles"] = "OK" - except: - modules["Articles"] = "INACCESSIBLE" - - diagnostic["modules_actifs"] = modules - except Exception as e: - diagnostic["modules_actifs_erreur"] = str(e) - - # Compter documents existants - try: - counts = {} - factory = sage.cial.FactoryDocumentVente - - for type_doc in range(6): - count = 0 - index = 1 - - while index < 1000: - 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: - count += 1 - - index += 1 - except: - index += 1 - break - - counts[ - { - 0: "Devis", - 1: "Bons_livraison", - 2: "Bons_retour", - 3: "Commandes", - 4: "Preparations", - 5: "Factures", - }[type_doc] - ] = count - - diagnostic["documents_existants"] = counts - except Exception as e: - diagnostic["documents_existants_erreur"] = str(e) - - logger.info("[DIAG] Configuration Sage analysee") - - return {"success": True, "diagnostic": diagnostic} - - except HTTPException: - raise - except Exception as e: - logger.error(f"[DIAG] Erreur diagnostic config: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -@app.get("/sage/diagnostic/types-reels", dependencies=[Depends(verify_token)]) -def decouvrir_types_reels(): - """ - DIAGNOSTIC CRITIQUE: Découvre les VRAIS types de documents Sage - - Au lieu de deviner les types (0-5), on va: - 1. Créer manuellement un document de chaque type dans Sage - 2. Les lister ici pour voir leurs vrais numéros de type - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - factory = sage.cial.FactoryDocumentVente - - # Parcourir TOUS les documents - documents_par_type = {} - index = 1 - max_docs = 500 # Limiter pour ne pas bloquer - - logger.info("[DIAG] Scan de tous les documents...") - - while index < max_docs: - try: - persist = factory.List(index) - if persist is None: - break - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # Récupérer le type ET le sous-type - type_doc = getattr(doc, "DO_Type", -1) - piece = getattr(doc, "DO_Piece", "") - statut = getattr(doc, "DO_Statut", -1) - - # Essayer de récupérer le domaine (vente/achat) - domaine = "Inconnu" - try: - domaine_val = getattr(doc, "DO_Domaine", -1) - domaine = {0: "Vente", 1: "Achat"}.get( - domaine_val, f"Code {domaine_val}" - ) - except: - pass - - # Récupérer la catégorie - categorie = "Inconnue" - try: - cat_val = getattr(doc, "DO_Categorie", -1) - categorie = str(cat_val) - except: - pass - - # Grouper par type - if type_doc not in documents_par_type: - documents_par_type[type_doc] = { - "count": 0, - "exemples": [], - "domaine": domaine, - "categorie": categorie, - } - - documents_par_type[type_doc]["count"] += 1 - - # Garder quelques exemples - if len(documents_par_type[type_doc]["exemples"]) < 3: - documents_par_type[type_doc]["exemples"].append( - { - "numero": piece, - "statut": statut, - "domaine": domaine, - "categorie": categorie, - } - ) - - index += 1 - - except Exception as e: - logger.debug(f"Erreur index {index}: {e}") - index += 1 - - # Formater le résultat - types_trouves = [] - - for type_num, infos in sorted(documents_par_type.items()): - types_trouves.append( - { - "type_code": type_num, - "nombre_documents": infos["count"], - "domaine": infos["domaine"], - "categorie": infos["categorie"], - "exemples": infos["exemples"], - "suggestion_libelle": _deviner_libelle_type( - type_num, infos["exemples"] - ), - } - ) - - logger.info( - f"[DIAG] {len(types_trouves)} types de documents distincts trouves" - ) - - return { - "success": True, - "types_documents_reels": types_trouves, - "instructions": ( - "Pour identifier les types corrects:\n" - "1. Creez manuellement dans Sage: 1 Bon de commande, 1 BL, 1 Facture\n" - "2. Appelez de nouveau cet endpoint\n" - "3. Les nouveaux types apparaitront avec leurs numeros corrects" - ), - "total_documents_scannes": index - 1, - } - - except Exception as e: - logger.error(f"[DIAG] Erreur decouverte types: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -def _deviner_libelle_type(type_num, exemples): - """Devine le libellé d'un type basé sur les numéros de pièce""" - if not exemples: - return "Type inconnu" - - # Analyser les préfixes des numéros - prefixes = [ex["numero"][:2] for ex in exemples if ex["numero"]] - prefix_commun = max(set(prefixes), key=prefixes.count) if prefixes else "" - - # Deviner selon le type_num et les préfixes - suggestions = { - 0: "Devis (DE)", - 1: "Bon de livraison (BL)", - 2: "Bon de retour (BR)", - 3: "Bon de commande (BC)", - 4: "Preparation de livraison (PL)", - 5: "Facture (FA)", - 6: "Facture d'avoir (AV)", - 7: "Bon d'avoir financier (BA)", - } - - libelle_base = suggestions.get(type_num, f"Type {type_num}") - - if prefix_commun: - libelle_base += f" - Detecte: prefix '{prefix_commun}'" - - return libelle_base - - -@app.post("/sage/test-creation-par-type", dependencies=[Depends(verify_token)]) -def tester_creation_par_type(type_doc: int = Query(..., ge=0, le=20)): - """ - TEST: Essaie de créer un document d'un type spécifique - - Permet de tester tous les types possibles (0-20) pour trouver - lesquels fonctionnent sur votre installation - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - logger.info(f"[TEST] Tentative creation type {type_doc}...") - - try: - # Essayer de créer un process - process = sage.cial.CreateProcess_Document(type_doc) - - if not process: - return { - "success": False, - "type": type_doc, - "resultat": "Process NULL retourne", - } - - # Si on arrive ici, le type est valide ! - doc = process.Document - - try: - doc = win32com.client.CastTo(doc, "IBODocumentVente3") - except: - pass - - # Récupérer les infos du document créé - type_reel = getattr(doc, "DO_Type", -1) - domaine = getattr(doc, "DO_Domaine", -1) - - # NE PAS VALIDER le document (pas de Write/Process) - # On veut juste savoir si la création est possible - - del process - del doc - - return { - "success": True, - "type_demande": type_doc, - "type_reel_doc": type_reel, - "domaine": {0: "Vente", 1: "Achat"}.get(domaine, domaine), - "resultat": "CREATION POSSIBLE", - "note": "Document non valide (test uniquement)", - } - - except Exception as e: - return { - "success": False, - "type": type_doc, - "erreur": str(e), - "resultat": "CREATION IMPOSSIBLE", - } - - except Exception as e: - logger.error(f"[TEST] Erreur test type {type_doc}: {e}") - raise HTTPException(500, str(e)) - - -@app.get("/sage/diagnostic/facture-requirements", dependencies=[Depends(verify_token)]) -def diagnostiquer_exigences_facture(): - """ - DIAGNOSTIC: Découvre les champs obligatoires pour créer une facture - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - # Créer un process facture de test - process = sage.cial.CreateProcess_Document(60) - doc_test = process.Document - - try: - doc_test = win32com.client.CastTo(doc_test, "IBODocumentVente3") - except: - pass - - # Tester tous les champs potentiellement obligatoires - champs_a_tester = [ - "DO_ModeRegl", - "DO_CondRegl", - "DO_CodeJournal", - "DO_Souche", - "DO_TypeCalcul", - "DO_CodeTaxe1", - "CT_Num", - "DO_Date", - "DO_Statut", - ] - - resultats = {} - - for champ in champs_a_tester: - try: - valeur = getattr(doc_test, champ, None) - resultats[champ] = { - "valeur_defaut": str(valeur) if valeur is not None else "None", - "accessible": True, - } - except Exception as e: - resultats[champ] = { - "valeur_defaut": "N/A", - "accessible": False, - "erreur": str(e)[:100], - } - - # Ne pas valider le document de test - del process - del doc_test - - return { - "success": True, - "champs_facture": resultats, - "conseil": "Les champs avec valeur_defaut=None ou 0 sont souvent obligatoires", - } - - except Exception as e: - logger.error(f"[DIAG] Erreur: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -@app.post("/sage/test/creer-facture-vide", dependencies=[Depends(verify_token)]) -def tester_creation_facture_vide(): - """ - 🧪 TEST: Crée une facture vide pour identifier les champs obligatoires - - Ce test permet de découvrir EXACTEMENT quels champs Sage exige - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - logger.info("[TEST] Creation facture test...") - - # 1. Créer le process - process = sage.cial.CreateProcess_Document(60) - doc = process.Document - - try: - doc = win32com.client.CastTo(doc, "IBODocumentVente3") - except: - pass - - # 2. Définir UNIQUEMENT les champs absolument critiques - import pywintypes - - # Date (obligatoire) - doc.DO_Date = pywintypes.Time(datetime.now()) - - # Client (obligatoire) - Utiliser le premier client disponible - factory_client = sage.cial.CptaApplication.FactoryClient - persist_client = factory_client.List(1) - - if persist_client: - client = sage._cast_client(persist_client) - if client: - doc.SetDefaultClient(client) - client_code = getattr(client, "CT_Num", "?") - logger.info(f"[TEST] Client test: {client_code}") - - # 3. Écrire sans Process() pour voir les valeurs par défaut - doc.Write() - doc.Read() - - # 4. Analyser tous les champs - champs_analyse = {} - - for attr in dir(doc): - if attr.startswith("DO_") or attr.startswith("CT_"): - try: - valeur = getattr(doc, attr, None) - if valeur is not None: - champs_analyse[attr] = { - "valeur": str(valeur), - "type": type(valeur).__name__, - } - except: - pass - - logger.info(f"[TEST] {len(champs_analyse)} champs analyses") - - # 5. Tester Process() pour voir l'erreur exacte - erreur_process = None - try: - process.Process() - logger.info("[TEST] Process() reussi (inattendu!)") - except Exception as e: - erreur_process = str(e) - logger.info(f"[TEST] Process() echoue comme prevu: {e}") - - # Ne pas commit - c'est juste un test - try: - sage.cial.CptaApplication.RollbackTrans() - except: - pass - - return { - "success": True, - "champs_definis": champs_analyse, - "erreur_process": erreur_process, - "conseil": "Les champs manquants dans l'erreur sont probablement obligatoires", - } - - except Exception as e: - logger.error(f"[TEST] Erreur: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -@app.get("/sage/config/parametres-facture", dependencies=[Depends(verify_token)]) -def verifier_parametres_facture(): - """ - 🔍 Vérifie les paramètres Sage pour la création de factures - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - parametres = {} - - # Paramètres société - try: - param_societe = sage.cial.CptaApplication.ParametreSociete - - parametres["societe"] = { - "journal_vente_defaut": getattr( - param_societe, "P_CodeJournalVte", "N/A" - ), - "mode_reglement_defaut": getattr( - param_societe, "P_ModeRegl", "N/A" - ), - "souche_facture": getattr(param_societe, "P_SoucheFacture", "N/A"), - } - except Exception as e: - parametres["erreur_societe"] = str(e) - - # Tester un client existant - try: - factory_client = sage.cial.CptaApplication.FactoryClient - persist = factory_client.List(1) - - if persist: - client = sage._cast_client(persist) - if client: - parametres["exemple_client"] = { - "code": getattr(client, "CT_Num", "?"), - "mode_reglement": getattr(client, "CT_ModeRegl", "N/A"), - "conditions_reglement": getattr( - client, "CT_CondRegl", "N/A" - ), - } - except Exception as e: - parametres["erreur_client"] = str(e) - - # Journaux disponibles - try: - factory_journal = sage.cial.CptaApplication.FactoryJournal - journaux = [] - - index = 1 - while index <= 20: # Max 20 journaux - try: - persist_journal = factory_journal.List(index) - if persist_journal is None: - break - - # Cast en journal - journal = win32com.client.CastTo(persist_journal, "IBOJournal3") - journal.Read() - - journaux.append( - { - "code": getattr(journal, "JO_Num", "?"), - "intitule": getattr(journal, "JO_Intitule", "?"), - "type": getattr(journal, "JO_Type", "?"), - } - ) - - index += 1 - except: - index += 1 - break - - parametres["journaux_disponibles"] = journaux - - except Exception as e: - parametres["erreur_journaux"] = str(e) - - return { - "success": True, - "parametres": parametres, - "conseil": "Utilisez ces valeurs pour remplir les champs obligatoires des factures", - } - - except Exception as e: - logger.error(f"Erreur verification config: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -@app.get("/sage/diagnostic/statuts-globaux", dependencies=[Depends(verify_token)]) -def diagnostiquer_statuts_globaux(): - """ - 📊 MATRICE COMPLÈTE DES STATUTS SAGE - - Retourne pour CHAQUE type de document : - - Tous les statuts possibles avec leurs descriptions - - Les statuts requis pour transformation - - Les changements de statuts après transformation - - Les restrictions de changement de statut - - Cette route analyse la base Sage pour découvrir les règles réelles - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - factory = sage.cial.FactoryDocumentVente - - # Définition des types de documents - types_documents = { - 0: "Devis", - 10: "Bon de commande", - 20: "Préparation", - 30: "Bon de livraison", - 40: "Bon de retour", - 50: "Bon d'avoir", - 60: "Facture", - } - - # Descriptions standard des statuts Sage - descriptions_statuts = { - 0: "Brouillon", - 1: "Soumis/En attente", - 2: "Accepté/Validé", - 3: "Réalisé partiellement", - 4: "Réalisé totalement", - 5: "Transformé", - 6: "Annulé", - } - - matrice_complete = {} - - logger.info( - "[DIAG] 🔍 Analyse des statuts pour tous les types de documents..." - ) - - # Pour chaque type de document - for type_doc, libelle_type in types_documents.items(): - logger.info(f"[DIAG] Analyse type {type_doc} ({libelle_type})...") - - analyse_type = { - "type": type_doc, - "libelle": libelle_type, - "statuts_observes": {}, - "exemples_par_statut": {}, - "nb_documents_total": 0, - } - - # Scanner tous les documents de ce type - index = 1 - max_scan = 1000 - - while index < max_scan: - try: - persist = factory.List(index) - if persist is None: - break - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - doc_type = getattr(doc, "DO_Type", -1) - - # Filtrer sur le type qu'on analyse - if doc_type != type_doc: - index += 1 - continue - - analyse_type["nb_documents_total"] += 1 - - # Récupérer le statut - statut = getattr(doc, "DO_Statut", -1) - - # Compter les statuts observés - if statut not in analyse_type["statuts_observes"]: - analyse_type["statuts_observes"][statut] = { - "count": 0, - "description": descriptions_statuts.get( - statut, f"Statut {statut}" - ), - "exemples": [], - } - - analyse_type["statuts_observes"][statut]["count"] += 1 - - # Garder quelques exemples - if ( - len(analyse_type["statuts_observes"][statut]["exemples"]) - < 3 - ): - numero = getattr(doc, "DO_Piece", "") - date = str(getattr(doc, "DO_Date", "")) - - analyse_type["statuts_observes"][statut]["exemples"].append( - { - "numero": numero, - "date": date, - "total_ttc": float( - getattr(doc, "DO_TotalTTC", 0.0) - ), - } - ) - - index += 1 - - except Exception as e: - index += 1 - continue - - # Trier les statuts par nombre d'occurrences - analyse_type["statuts_par_frequence"] = sorted( - [ - { - "statut": s, - "description": info["description"], - "count": info["count"], - "pourcentage": ( - round( - info["count"] - / analyse_type["nb_documents_total"] - * 100, - 1, - ) - if analyse_type["nb_documents_total"] > 0 - else 0 - ), - } - for s, info in analyse_type["statuts_observes"].items() - ], - key=lambda x: x["count"], - reverse=True, - ) - - matrice_complete[type_doc] = analyse_type - - logger.info( - f"[DIAG] ✅ Type {type_doc}: {analyse_type['nb_documents_total']} docs, " - f"{len(analyse_type['statuts_observes'])} statuts différents" - ) - - # RÈGLES DE TRANSFORMATION - regles_transformation = { - "transformations_valides": [ - { - "source_type": 0, - "source_libelle": "Devis", - "cible_type": 10, - "cible_libelle": "Bon de commande", - "statut_source_requis": [2], - "statut_source_requis_description": ["Accepté/Validé"], - "statut_source_apres": 5, - "statut_source_apres_description": "Transformé", - "statut_cible_initial": 2, - "statut_cible_initial_description": "Accepté/Validé", - }, - { - "source_type": 10, - "source_libelle": "Bon de commande", - "cible_type": 30, - "cible_libelle": "Bon de livraison", - "statut_source_requis": [2], - "statut_source_requis_description": ["Accepté/Validé"], - "statut_source_apres": 5, - "statut_source_apres_description": "Transformé", - "statut_cible_initial": 2, - "statut_cible_initial_description": "Accepté/Validé", - }, - { - "source_type": 10, - "source_libelle": "Bon de commande", - "cible_type": 60, - "cible_libelle": "Facture", - "statut_source_requis": [2], - "statut_source_requis_description": ["Accepté/Validé"], - "statut_source_apres": 5, - "statut_source_apres_description": "Transformé", - "statut_cible_initial": 2, - "statut_cible_initial_description": "Accepté/Validé", - }, - { - "source_type": 30, - "source_libelle": "Bon de livraison", - "cible_type": 60, - "cible_libelle": "Facture", - "statut_source_requis": [2], - "statut_source_requis_description": ["Accepté/Validé"], - "statut_source_apres": 5, - "statut_source_apres_description": "Transformé", - "statut_cible_initial": 2, - "statut_cible_initial_description": "Accepté/Validé", - }, - { - "source_type": 0, - "source_libelle": "Devis", - "cible_type": 60, - "cible_libelle": "Facture", - "statut_source_requis": [2], - "statut_source_requis_description": ["Accepté/Validé"], - "statut_source_apres": 5, - "statut_source_apres_description": "Transformé", - "statut_cible_initial": 2, - "statut_cible_initial_description": "Accepté/Validé", - }, - ], - "statuts_bloquants_pour_transformation": [ - { - "statut": 5, - "description": "Transformé", - "raison": "Le document a déjà été transformé", - }, - { - "statut": 6, - "description": "Annulé", - "raison": "Le document est annulé", - }, - { - "statut": 3, - "description": "Réalisé partiellement", - "raison": "Un document cible existe probablement déjà (transformation partielle effectuée)", - }, - { - "statut": 4, - "description": "Réalisé totalement", - "raison": "Le document a été entièrement réalisé (transformation déjà effectuée)", - }, - ], - "changements_statut_autorises": { - "0_Brouillon": { - "vers": [2, 6], - "descriptions": ["Accepté/Validé", "Annulé"], - "note": "Un brouillon peut être accepté ou annulé", - }, - "2_Accepte": { - "vers": [5, 6], - "descriptions": ["Transformé", "Annulé"], - "note": "Un document accepté peut être transformé ou annulé", - }, - "5_Transforme": { - "vers": [], - "descriptions": [], - "note": "Un document transformé ne peut plus changer de statut", - }, - "6_Annule": { - "vers": [], - "descriptions": [], - "note": "Un document annulé ne peut plus changer de statut", - }, - }, - } - - return { - "success": True, - "matrice_statuts_par_type": matrice_complete, - "regles_transformation": regles_transformation, - "legende_statuts": descriptions_statuts, - "types_documents": types_documents, - "date_analyse": datetime.now().isoformat(), - "note": "Cette matrice est construite à partir des documents réels dans votre base Sage", - } - - except Exception as e: - logger.error(f"[DIAG] Erreur diagnostic global: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -@app.get( - "/sage/diagnostic/statuts-permis/{numero}", dependencies=[Depends(verify_token)] -) -def diagnostiquer_statuts_permis(numero: str): - """ - 🔍 DIAGNOSTIC CRITIQUE: Découvre TOUS les statuts possibles pour un document - - Teste tous les statuts de 0 à 10 pour identifier lesquels sont valides - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - factory = sage.cial.FactoryDocumentVente - - # Chercher le document (tous types confondus) - persist = None - type_doc_trouve = None - - # Essayer ReadPiece pour différents types - for type_test in range(7): # 0-6 - try: - persist_test = factory.ReadPiece(type_test, numero) - if persist_test: - persist = persist_test - type_doc_trouve = type_test - logger.info( - f"[DIAG] Document {numero} trouvé avec ReadPiece(type={type_test})" - ) - break - except: - continue - - # Si pas trouvé, chercher dans List() - if not persist: - index = 1 - while index < 10000: - try: - persist_test = factory.List(index) - if persist_test is None: - break - - doc_test = win32com.client.CastTo( - persist_test, "IBODocumentVente3" - ) - doc_test.Read() - - if getattr(doc_test, "DO_Piece", "") == numero: - persist = persist_test - type_doc_trouve = getattr(doc_test, "DO_Type", -1) - logger.info( - f"[DIAG] Document {numero} trouvé dans List() à l'index {index}" - ) - break - - index += 1 - except: - index += 1 - - if not persist: - raise HTTPException(404, f"Document {numero} introuvable") - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # Infos du document - statut_actuel = getattr(doc, "DO_Statut", -1) - type_actuel = getattr(doc, "DO_Type", -1) - - diagnostic = { - "numero": numero, - "type_document": type_actuel, - "type_libelle": { - 0: "Devis", - 10: "Bon de commande", - 20: "Préparation", - 30: "Bon de livraison", - 40: "Bon de retour", - 50: "Bon d'avoir", - 60: "Facture", - }.get(type_actuel, f"Type {type_actuel}"), - "statut_actuel": statut_actuel, - "statut_actuel_libelle": { - 0: "Brouillon", - 1: "Soumis/En attente", - 2: "Accepté/Validé", - 3: "Réalisé partiellement", - 4: "Réalisé totalement", - 5: "Transformé", - 6: "Annulé", - }.get(statut_actuel, f"Statut {statut_actuel}"), - "tests_statuts": [], - } - - # Tester tous les statuts de 0 à 10 - logger.info(f"[DIAG] Test des statuts pour {numero}...") - - for statut_test in range(11): - resultat_test = { - "statut": statut_test, - "libelle": { - 0: "Brouillon", - 1: "Soumis/En attente", - 2: "Accepté/Validé", - 3: "Réalisé partiellement", - 4: "Réalisé totalement", - 5: "Transformé", - 6: "Annulé", - 7: "Statut 7", - 8: "Statut 8", - 9: "Statut 9", - 10: "Statut 10", - }.get(statut_test, f"Statut {statut_test}"), - "autorise": False, - "erreur": None, - "est_statut_actuel": (statut_test == statut_actuel), - } - - # Si c'est le statut actuel, on sait qu'il est valide - if statut_test == statut_actuel: - resultat_test["autorise"] = True - resultat_test["note"] = "Statut actuel du document" - else: - # Tester le changement de statut - try: - # Relire le document - doc.Read() - - # Essayer de changer le statut - doc.DO_Statut = statut_test - - # Essayer d'écrire - doc.Write() - - # Si on arrive ici, le statut est valide ! - resultat_test["autorise"] = True - resultat_test["note"] = "Changement de statut réussi" - - logger.info(f"[DIAG] ✅ Statut {statut_test} AUTORISÉ") - - # Restaurer le statut d'origine immédiatement - doc.Read() - doc.DO_Statut = statut_actuel - doc.Write() - - except Exception as e: - erreur_str = str(e) - resultat_test["autorise"] = False - resultat_test["erreur"] = erreur_str - - logger.debug( - f"[DIAG] ❌ Statut {statut_test} REFUSÉ: {erreur_str[:100]}" - ) - - # Restaurer en cas d'erreur - try: - doc.Read() - except: - pass - - diagnostic["tests_statuts"].append(resultat_test) - - # Résumé - statuts_autorises = [ - t["statut"] for t in diagnostic["tests_statuts"] if t["autorise"] - ] - statuts_refuses = [ - t["statut"] for t in diagnostic["tests_statuts"] if not t["autorise"] - ] - - diagnostic["resume"] = { - "nb_statuts_autorises": len(statuts_autorises), - "statuts_autorises": statuts_autorises, - "statuts_autorises_libelles": [ - t["libelle"] for t in diagnostic["tests_statuts"] if t["autorise"] - ], - "nb_statuts_refuses": len(statuts_refuses), - "statuts_refuses": statuts_refuses, - } - - # Recommandations - recommendations = [] - - if 2 in statuts_autorises and statut_actuel == 0: - recommendations.append( - "✅ Vous pouvez passer ce document de 'Brouillon' (0) à 'Accepté' (2)" - ) - - if 5 in statuts_autorises: - recommendations.append( - "✅ Le statut 'Transformé' (5) est disponible - utilisé après transformation" - ) - - if 6 in statuts_autorises: - recommendations.append("✅ Vous pouvez annuler ce document (statut 6)") - - if not any(s in statuts_autorises for s in [2, 3, 4]): - recommendations.append( - "⚠️ Aucun statut de validation (2/3/4) n'est disponible - " - "le document a peut-être déjà été traité" - ) - - diagnostic["recommendations"] = recommendations - - logger.info( - f"[DIAG] Statuts autorisés pour {numero}: " - f"{statuts_autorises} / Refusés: {statuts_refuses}" - ) - - return {"success": True, "diagnostic": diagnostic} - - except HTTPException: - raise - except Exception as e: - logger.error(f"[DIAG] Erreur diagnostic statuts: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -@app.get( - "/sage/diagnostic/erreur-transformation/{numero}", - dependencies=[Depends(verify_token)], -) -def diagnostiquer_erreur_transformation( - numero: str, type_source: int = Query(...), type_cible: int = Query(...) -): - """ - 🔍 DIAGNOSTIC AVANCÉ: Analyse pourquoi une transformation échoue - - Vérifie: - - Statut du document source - - Statuts autorisés - - Lignes du document - - Client associé - - Champs obligatoires manquants - """ - try: - if not sage or not sage.cial: - raise HTTPException(503, "Service Sage indisponible") - - with sage._com_context(), sage._lock_com: - factory = sage.cial.FactoryDocumentVente - - # Lire le document source - persist = factory.ReadPiece(type_source, numero) - - if not persist: - persist = sage._find_document_in_list(numero, type_source) - - if not persist: - raise HTTPException( - 404, f"Document {numero} (type {type_source}) introuvable" - ) - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - diagnostic = { - "numero": numero, - "type_source": type_source, - "type_cible": type_cible, - "problemes_detectes": [], - "avertissements": [], - "suggestions": [], - } - - # 1. Vérifier le statut - statut_actuel = getattr(doc, "DO_Statut", -1) - diagnostic["statut_actuel"] = statut_actuel - - if statut_actuel == 5: - diagnostic["problemes_detectes"].append( - { - "severite": "BLOQUANT", - "champ": "DO_Statut", - "valeur": 5, - "message": "Document déjà transformé (statut=5)", - } - ) - - elif statut_actuel == 6: - diagnostic["problemes_detectes"].append( - { - "severite": "BLOQUANT", - "champ": "DO_Statut", - "valeur": 6, - "message": "Document annulé (statut=6)", - } - ) - - elif statut_actuel in [3, 4]: - diagnostic["avertissements"].append( - { - "severite": "ATTENTION", - "champ": "DO_Statut", - "valeur": statut_actuel, - "message": f"Document déjà réalisé (statut={statut_actuel}). " - f"Un document cible existe peut-être déjà.", - } - ) - - elif statut_actuel == 0: - diagnostic["suggestions"].append( - "Le document est en 'Brouillon' (statut=0). " - "Le système le passera automatiquement à 'Accepté' (statut=2) avant transformation." - ) - - # 2. Vérifier le client - client_code = "" - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - except: - pass - - if not client_code: - diagnostic["problemes_detectes"].append( - { - "severite": "BLOQUANT", - "champ": "CT_Num", - "valeur": None, - "message": "Aucun client associé au document", - } - ) - else: - diagnostic["client_code"] = client_code - - # 3. Vérifier les lignes - try: - factory_lignes = getattr(doc, "FactoryDocumentLigne", None) or getattr( - doc, "FactoryDocumentVenteLigne", None - ) - - nb_lignes = 0 - lignes_problemes = [] - - if factory_lignes: - index = 1 - while index <= 100: - try: - ligne_p = factory_lignes.List(index) - if ligne_p is None: - break - - ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") - ligne.Read() - - nb_lignes += 1 - - # Vérifier article - article_ref = "" - try: - article_ref = getattr(ligne, "AR_Ref", "").strip() - if not article_ref: - article_obj = getattr(ligne, "Article", None) - if article_obj: - article_obj.Read() - article_ref = getattr( - article_obj, "AR_Ref", "" - ).strip() - except: - pass - - if not article_ref: - lignes_problemes.append( - { - "ligne": index, - "probleme": "Aucune référence article", - } - ) - - # Vérifier prix - prix = float(getattr(ligne, "DL_PrixUnitaire", 0.0)) - if prix == 0: - lignes_problemes.append( - {"ligne": index, "probleme": "Prix unitaire = 0"} - ) - - index += 1 - except: - break - - diagnostic["nb_lignes"] = nb_lignes - - if nb_lignes == 0: - diagnostic["problemes_detectes"].append( - { - "severite": "BLOQUANT", - "champ": "Lignes", - "valeur": 0, - "message": "Document vide (aucune ligne)", - } - ) - - if lignes_problemes: - diagnostic["avertissements"].append( - { - "severite": "ATTENTION", - "champ": "Lignes", - "message": f"{len(lignes_problemes)} ligne(s) avec des problèmes", - "details": lignes_problemes, - } - ) - - except Exception as e: - diagnostic["avertissements"].append( - { - "severite": "ERREUR", - "champ": "Lignes", - "message": f"Impossible de lire les lignes: {e}", - } - ) - - # 4. Vérifier les totaux - total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) - total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) - - diagnostic["totaux"] = {"total_ht": total_ht, "total_ttc": total_ttc} - - if total_ht == 0 and total_ttc == 0: - diagnostic["avertissements"].append( - { - "severite": "ATTENTION", - "champ": "Totaux", - "message": "Tous les totaux sont à 0", - } - ) - - # 5. Vérifier si la transformation est autorisée - transformations_valides = {(0, 10), (10, 30), (10, 60), (30, 60), (0, 60)} - - if (type_source, type_cible) not in transformations_valides: - diagnostic["problemes_detectes"].append( - { - "severite": "BLOQUANT", - "champ": "Transformation", - "message": f"Transformation {type_source} → {type_cible} non autorisée. " - f"Transformations valides: {transformations_valides}", - } - ) - - # Résumé - nb_bloquants = sum( - 1 - for p in diagnostic["problemes_detectes"] - if p.get("severite") == "BLOQUANT" - ) - nb_avertissements = len(diagnostic["avertissements"]) - - diagnostic["resume"] = { - "peut_transformer": nb_bloquants == 0, - "nb_problemes_bloquants": nb_bloquants, - "nb_avertissements": nb_avertissements, - } - - if nb_bloquants == 0: - diagnostic["suggestions"].append( - "✅ Aucun problème bloquant détecté. La transformation devrait fonctionner." - ) - else: - diagnostic["suggestions"].append( - f"❌ {nb_bloquants} problème(s) bloquant(s) doivent être résolus avant la transformation." - ) - - return {"success": True, "diagnostic": diagnostic} - - except HTTPException: - raise - except Exception as e: - logger.error(f"[DIAG] Erreur diagnostic transformation: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - # ===================================================== # ENDPOINTS - PROSPECTS # ===================================================== @app.post("/sage/prospects/list", dependencies=[Depends(verify_token)]) def prospects_list(req: FiltreRequest): - """📋 Liste tous les prospects (CT_Type=0 AND CT_Prospect=1)""" try: prospects = sage.lister_tous_prospects(req.filtre) return {"success": True, "data": prospects} @@ -2376,7 +780,6 @@ def prospects_list(req: FiltreRequest): @app.post("/sage/prospects/get", dependencies=[Depends(verify_token)]) def prospect_get(req: CodeRequest): - """📄 Lecture d'un prospect par code""" try: prospect = sage.lire_prospect(req.code) if not prospect: @@ -2394,12 +797,6 @@ def prospect_get(req: CodeRequest): # ===================================================== @app.post("/sage/fournisseurs/list", dependencies=[Depends(verify_token)]) def fournisseurs_list(req: FiltreRequest): - """ - ⚡ Liste rapide des fournisseurs depuis le CACHE - - ✅ Utilise le cache mémoire pour une réponse instantanée - 🔄 Cache actualisé automatiquement toutes les 15 minutes - """ try: # ✅ Utiliser le cache au lieu de la lecture directe fournisseurs = sage.lister_tous_fournisseurs_cache(req.filtre) @@ -2415,11 +812,6 @@ def fournisseurs_list(req: FiltreRequest): @app.post("/sage/fournisseurs/create", dependencies=[Depends(verify_token)]) def create_fournisseur_endpoint(req: FournisseurCreateRequest): - """ - ➕ Création d'un fournisseur dans Sage - - ✅ Utilise FactoryFournisseur.Create() directement - """ try: # Appel au connecteur Sage resultat = sage.creer_fournisseur(req.dict()) @@ -2441,9 +833,7 @@ def create_fournisseur_endpoint(req: FournisseurCreateRequest): @app.post("/sage/fournisseurs/update", dependencies=[Depends(verify_token)]) def modifier_fournisseur_endpoint(req: FournisseurUpdateGatewayRequest): - """ - ✏️ Modification d'un fournisseur dans Sage - """ + try: resultat = sage.modifier_fournisseur(req.code, req.fournisseur_data) return {"success": True, "data": resultat} @@ -2477,24 +867,50 @@ def fournisseur_get(req: CodeRequest): # ENDPOINTS - AVOIRS # ===================================================== @app.post("/sage/avoirs/list", dependencies=[Depends(verify_token)]) -def avoirs_list(limit: int = 100, statut: Optional[int] = None): - """📋 Liste tous les avoirs (DO_Domaine=0 AND DO_Type=5)""" +def avoirs_list( + limit: int = Query(100, description="Nombre max d'avoirs"), + statut: Optional[int] = Query(None, description="Filtrer par statut"), + filtre: str = Query("", description="Filtre texte"), +): try: - avoirs = sage.lister_avoirs(limit=limit, statut=statut) + # ✅ Récupération depuis le cache (instantané) + avoirs = sage.lister_tous_avoirs_cache(filtre) + + # Filtrer par statut si demandé + if statut is not None: + avoirs = [a for a in avoirs if a.get("statut") == statut] + + # Limiter le nombre de résultats + avoirs = avoirs[:limit] + + logger.info(f"✅ {len(avoirs)} avoirs retournés depuis le cache") + return {"success": True, "data": avoirs} + except Exception as e: - logger.error(f"Erreur liste avoirs: {e}") + logger.error(f"❌ Erreur liste avoirs: {e}", exc_info=True) raise HTTPException(500, str(e)) @app.post("/sage/avoirs/get", dependencies=[Depends(verify_token)]) def avoir_get(req: CodeRequest): - """📄 Lecture d'un avoir avec ses lignes""" try: + # ✅ Essayer le cache d'abord + avoir = sage.lire_avoir_cache(req.code) + + if avoir: + logger.info(f"✅ Avoir {req.code} retourné depuis le cache") + return {"success": True, "data": avoir, "source": "cache"} + + # ❌ Pas dans le cache → Lecture directe depuis Sage + logger.info(f"⚠️ Avoir {req.code} absent du cache, lecture depuis Sage...") avoir = sage.lire_avoir(req.code) + if not avoir: raise HTTPException(404, f"Avoir {req.code} non trouvé") - return {"success": True, "data": avoir} + + return {"success": True, "data": avoir, "source": "sage"} + except HTTPException: raise except Exception as e: @@ -2506,24 +922,50 @@ def avoir_get(req: CodeRequest): # ENDPOINTS - LIVRAISONS # ===================================================== @app.post("/sage/livraisons/list", dependencies=[Depends(verify_token)]) -def livraisons_list(limit: int = 100, statut: Optional[int] = None): - """📋 Liste tous les bons de livraison (DO_Domaine=0 AND DO_Type=30)""" +def livraisons_list( + limit: int = Query(100, description="Nombre max de livraisons"), + statut: Optional[int] = Query(None, description="Filtrer par statut"), + filtre: str = Query("", description="Filtre texte"), +): try: - livraisons = sage.lister_livraisons(limit=limit, statut=statut) + # ✅ Récupération depuis le cache (instantané) + livraisons = sage.lister_toutes_livraisons_cache(filtre) + + # Filtrer par statut si demandé + if statut is not None: + livraisons = [l for l in livraisons if l.get("statut") == statut] + + # Limiter le nombre de résultats + livraisons = livraisons[:limit] + + logger.info(f"✅ {len(livraisons)} livraisons retournées depuis le cache") + return {"success": True, "data": livraisons} + except Exception as e: - logger.error(f"Erreur liste livraisons: {e}") + logger.error(f"❌ Erreur liste livraisons: {e}", exc_info=True) raise HTTPException(500, str(e)) @app.post("/sage/livraisons/get", dependencies=[Depends(verify_token)]) def livraison_get(req: CodeRequest): - """📄 Lecture d'une livraison avec ses lignes""" try: + # ✅ Essayer le cache d'abord + livraison = sage.lire_livraison_cache(req.code) + + if livraison: + logger.info(f"✅ Livraison {req.code} retournée depuis le cache") + return {"success": True, "data": livraison, "source": "cache"} + + # ❌ Pas dans le cache → Lecture directe depuis Sage + logger.info(f"⚠️ Livraison {req.code} absente du cache, lecture depuis Sage...") livraison = sage.lire_livraison(req.code) + if not livraison: raise HTTPException(404, f"Livraison {req.code} non trouvée") - return {"success": True, "data": livraison} + + return {"success": True, "data": livraison, "source": "sage"} + except HTTPException: raise except Exception as e: @@ -2533,14 +975,6 @@ def livraison_get(req: CodeRequest): @app.post("/sage/devis/update", dependencies=[Depends(verify_token)]) def modifier_devis_endpoint(req: DevisUpdateGatewayRequest): - """ - ✏️ Modification d'un devis dans Sage - - Permet de modifier: - - La date du devis - - Les lignes (remplace toutes les lignes) - - Le statut - """ try: resultat = sage.modifier_devis(req.numero, req.devis_data) return {"success": True, "data": resultat} @@ -2560,9 +994,6 @@ def modifier_devis_endpoint(req: DevisUpdateGatewayRequest): @app.post("/sage/commandes/create", dependencies=[Depends(verify_token)]) def creer_commande_endpoint(req: CommandeCreateRequest): - """ - ➕ Création d'une commande (Bon de commande) dans Sage - """ try: # Transformer en format attendu par sage_connector commande_data = { @@ -2585,15 +1016,6 @@ def creer_commande_endpoint(req: CommandeCreateRequest): @app.post("/sage/commandes/update", dependencies=[Depends(verify_token)]) def modifier_commande_endpoint(req: CommandeUpdateGatewayRequest): - """ - ✏️ Modification d'une commande dans Sage - - Permet de modifier: - - La date de la commande - - Les lignes (remplace toutes les lignes) - - Le statut - - La référence externe - """ try: resultat = sage.modifier_commande(req.numero, req.commande_data) return {"success": True, "data": resultat} @@ -2608,9 +1030,6 @@ def modifier_commande_endpoint(req: CommandeUpdateGatewayRequest): @app.post("/sage/livraisons/create", dependencies=[Depends(verify_token)]) def creer_livraison_endpoint(req: LivraisonCreateGatewayRequest): - """ - ➕ Création d'une livraison (Bon de livraison) dans Sage - """ try: # Vérifier que le client existe client = sage.lire_client(req.client_id) @@ -2638,9 +1057,6 @@ def creer_livraison_endpoint(req: LivraisonCreateGatewayRequest): @app.post("/sage/livraisons/update", dependencies=[Depends(verify_token)]) def modifier_livraison_endpoint(req: LivraisonUpdateGatewayRequest): - """ - ✏️ Modification d'une livraison dans Sage - """ try: resultat = sage.modifier_livraison(req.numero, req.livraison_data) return {"success": True, "data": resultat} @@ -2655,9 +1071,6 @@ def modifier_livraison_endpoint(req: LivraisonUpdateGatewayRequest): @app.post("/sage/avoirs/create", dependencies=[Depends(verify_token)]) def creer_avoir_endpoint(req: AvoirCreateGatewayRequest): - """ - ➕ Création d'un avoir (Bon d'avoir) dans Sage - """ try: # Vérifier que le client existe client = sage.lire_client(req.client_id) @@ -2702,12 +1115,6 @@ def modifier_avoir_endpoint(req: AvoirUpdateGatewayRequest): @app.post("/sage/factures/create", dependencies=[Depends(verify_token)]) def creer_facture_endpoint(req: FactureCreateGatewayRequest): - """ - ➕ Création d'une facture dans Sage - - ⚠️ NOTE: Les factures peuvent avoir des champs obligatoires supplémentaires - selon la configuration Sage (DO_CodeJournal, DO_Souche, etc.) - """ try: # Vérifier que le client existe client = sage.lire_client(req.client_id) @@ -2735,11 +1142,6 @@ def creer_facture_endpoint(req: FactureCreateGatewayRequest): @app.post("/sage/factures/update", dependencies=[Depends(verify_token)]) def modifier_facture_endpoint(req: FactureUpdateGatewayRequest): - """ - ✏️ Modification d'une facture dans Sage - - ⚠️ ATTENTION: Les factures comptabilisées peuvent être verrouillées - """ try: resultat = sage.modifier_facture(req.numero, req.facture_data) return {"success": True, "data": resultat} @@ -2752,37 +1154,162 @@ def modifier_facture_endpoint(req: FactureUpdateGatewayRequest): raise HTTPException(500, str(e)) +@app.post("/sage/articles/create", dependencies=[Depends(verify_token)]) +def create_article_endpoint(req: ArticleCreateRequest): + try: + resultat = sage.creer_article(req.dict()) + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"Erreur métier création article: {e}") + raise HTTPException(400, str(e)) + + except Exception as e: + logger.error(f"Erreur technique création article: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/articles/update", dependencies=[Depends(verify_token)]) +def modifier_article_endpoint(req: ArticleUpdateGatewayRequest): + try: + resultat = sage.modifier_article(req.reference, req.article_data) + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"Erreur métier modification article: {e}") + raise HTTPException(404, str(e)) + + except Exception as e: + logger.error(f"Erreur technique modification article: {e}") + raise HTTPException(500, str(e)) + + +@app.post( + "/sage/familles/create", + response_model=dict, +) +async def creer_famille(famille: FamilleCreate): + """Crée une famille d'articles dans Sage 100c""" + try: + resultat = sage.creer_famille(famille.dict()) + return { + "success": True, + "message": f"Famille {resultat['code']} créée avec succès", + "data": resultat, + } + + except ValueError as e: + logger.warning(f"Erreur métier création famille : {e}") + raise HTTPException(status_code=400, detail=str(e)) + + except Exception as e: + logger.error(f"Erreur création famille : {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Erreur serveur : {str(e)}") + + +# ======================================== +# ROUTE GET : Lister toutes les familles +# ======================================== + + +@app.get( + "/sage/familles", + response_model=dict, +) +async def lister_familles(filtre: str = ""): + try: + familles = sage.lister_toutes_familles(filtre=filtre) + + return { + "success": True, + "count": len(familles), + "filtre": filtre if filtre else None, + "data": familles, + "meta": { + "methode": "SQL direct (F_FAMILLE)", + "temps_reponse": "< 1 seconde", + }, + } + + except Exception as e: + logger.error(f"Erreur listage familles : {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Erreur serveur : {str(e)}") + + +# ======================================== +# ROUTE GET : Lire UNE famille par son code +# ======================================== + + +@app.get( + "/sage/familles/{code}", + response_model=dict, +) +async def lire_famille(code: str): + try: + familles = sage.lister_toutes_familles() + + famille = next((f for f in familles if f["code"].upper() == code.upper()), None) + + if not famille: + raise HTTPException(status_code=404, detail=f"Famille {code} introuvable") + + return {"success": True, "data": famille} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture famille {code} : {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Erreur serveur : {str(e)}") + + +# ======================================== +# ROUTE GET : Statistiques sur les familles +# ======================================== + + +@app.get("/sage/familles/stats", response_model=dict) +async def stats_familles(): + try: + familles = sage.lister_toutes_familles() + + # Calculer les stats + nb_total = len(familles) + nb_detail = sum(1 for f in familles if f["type"] == 0) + nb_total_type = sum(1 for f in familles if f["type"] == 1) + nb_statistiques = sum(1 for f in familles if f["est_statistique"]) + + # Top 10 familles par intitulé (alphabétique) + top_familles = sorted(familles, key=lambda f: f["intitule"])[:10] + + return { + "success": True, + "stats": { + "total": nb_total, + "detail": nb_detail, + "total_type": nb_total_type, + "statistiques": nb_statistiques, + "pourcentage_detail": ( + round((nb_detail / nb_total * 100), 2) if nb_total > 0 else 0 + ), + }, + "top_10": [ + { + "code": f["code"], + "intitule": f["intitule"], + "type_libelle": f["type_libelle"], + } + for f in top_familles + ], + } + + except Exception as e: + logger.error(f"Erreur stats familles : {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Erreur serveur : {str(e)}") + + @app.post("/sage/documents/generate-pdf", dependencies=[Depends(verify_token)]) def generer_pdf_document(req: PDFGenerationRequest): - """ - 📄 Génération PDF d'un document (endpoint généralisé) - - **Supporte tous les types de documents Sage:** - - Devis (0) - - Bons de commande (10) - - Bons de livraison (30) - - Factures (60) - - Avoirs (50) - - **Process:** - 1. Charge le document depuis Sage - 2. Génère le PDF via l'état Sage correspondant - 3. Retourne le PDF en base64 - - Args: - req: Requête contenant doc_id et type_doc - - Returns: - { - "success": true, - "data": { - "pdf_base64": "JVBERi0xLjQK...", - "taille_octets": 12345, - "type_doc": 0, - "numero": "DE00001" - } - } - """ try: logger.info(f"📄 Génération PDF: {req.doc_id} (type={req.type_doc})") @@ -2816,6 +1343,237 @@ def generer_pdf_document(req: PDFGenerationRequest): raise HTTPException(500, str(e)) +@app.get("/sage/depots/list", dependencies=[Depends(verify_token)]) +def lister_depots(): + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + depots = [] + + try: + factory_depot = sage.cial.FactoryDepot + + index = 1 + while index <= 100: # Max 100 dépôts + try: + persist = factory_depot.List(index) + + if persist is None: + logger.info(f" ℹ️ Fin de liste à l'index {index} (None)") + break + + depot = win32com.client.CastTo(persist, "IBODepot3") + depot.Read() + + # ✅ Lire les attributs identifiés + code = "" + numero = 0 + intitule = "" + contact = "" + exclu = False + + try: + code = getattr(depot, "DE_Code", "").strip() + except: + pass + + try: + numero = int(getattr(depot, "Compteur", 0)) + except: + # Fallback : convertir DE_Code en int + try: + numero = int(code) + except: + numero = 0 + + try: + intitule = getattr(depot, "DE_Intitule", "") + except: + pass + + try: + contact = getattr(depot, "DE_Contact", "") + except: + pass + + try: + exclu = getattr(depot, "DE_Exclure", False) + except: + pass + + # Validation : un dépôt doit avoir au moins un code + if not code: + logger.warning(f" ⚠️ Dépôt à l'index {index} sans code") + index += 1 + continue + + # Récupérer adresse (objet COM complexe) + adresse_complete = "" + try: + adresse_obj = getattr(depot, "Adresse", None) + if adresse_obj: + try: + adresse = getattr(adresse_obj, "Adresse", "") + cp = getattr(adresse_obj, "CodePostal", "") + ville = getattr(adresse_obj, "Ville", "") + adresse_complete = f"{adresse} {cp} {ville}".strip() + except: + pass + except: + pass + + # Déterminer si principal (premier non exclu = principal) + principal = False + if not exclu and len(depots) == 0: + principal = True + + depot_info = { + "code": code, # ⭐ "01", "02" + "numero": numero, # ⭐ 1, 2 (depuis Compteur) + "intitule": intitule, + "adresse": adresse_complete, + "contact": contact, + "exclu": exclu, + "principal": principal, + "index_sage": index, + } + + depots.append(depot_info) + + logger.info( + f" ✅ Dépôt {index}: code='{code}', compteur={numero}, intitulé='{intitule}'" + ) + + index += 1 + + except Exception as e: + # ⚠️ CORRECTION : "Accès refusé" = fin de liste dans cette version Sage + error_msg = str(e) + if "Accès refusé" in error_msg or "-1073741819" in error_msg: + logger.info( + f" ℹ️ Fin de liste à l'index {index} (Accès refusé)" + ) + break + else: + logger.error(f"❌ Erreur inattendue index {index}: {e}") + index += 1 + continue + + logger.info(f"✅ {len(depots)} dépôt(s) trouvé(s)") + + if not depots: + return { + "success": False, + "depots": [], + "message": "Aucun dépôt trouvé dans Sage", + } + + return { + "success": True, + "depots": depots, + "nb_depots": len(depots), + "version_sage": { + "identifiant_code": "DE_Code (string)", + "identifiant_numero": "Compteur (int)", + "fin_liste": "Erreur 'Accès refusé' au lieu de None", + }, + "conseil": f"Utilisez le 'code' (ex: '{depots[0]['code']}') lors de la création d'articles avec stock", + } + + except Exception as e: + logger.error(f"❌ Erreur lecture dépôts: {e}", exc_info=True) + raise HTTPException(500, f"Erreur lecture dépôts: {str(e)}") + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/sage/stock/entree", dependencies=[Depends(verify_token)]) +def creer_entree_stock(req: EntreeStockRequest): + try: + logger.info( + f"📦 [ENTREE STOCK] Création bon d'entrée : {len(req.lignes)} ligne(s)" + ) + + # Préparer les données pour le connecteur + entree_data = { + "date_mouvement": req.date_entree or date.today(), + "reference": req.reference, + "depot_code": req.depot_code, + "lignes": [ligne.dict() for ligne in req.lignes], + "commentaire": req.commentaire, + } + + # Appel au connecteur + resultat = sage.creer_entree_stock(entree_data) + + logger.info(f"✅ [ENTREE STOCK] Créé : {resultat.get('numero')}") + + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"⚠️ Erreur métier entrée stock : {e}") + raise HTTPException(400, str(e)) + + except Exception as e: + logger.error(f"❌ Erreur technique entrée stock : {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/sage/stock/sortie", dependencies=[Depends(verify_token)]) +def creer_sortie_stock(req: SortieStockRequest): + try: + logger.info( + f"📤 [SORTIE STOCK] Création bon de sortie : {len(req.lignes)} ligne(s)" + ) + + # Préparer les données pour le connecteur + sortie_data = { + "date_mouvement": req.date_sortie or date.today(), + "reference": req.reference, + "depot_code": req.depot_code, + "lignes": [ligne.dict() for ligne in req.lignes], + "commentaire": req.commentaire, + } + + # Appel au connecteur + resultat = sage.creer_sortie_stock(sortie_data) + + logger.info(f"✅ [SORTIE STOCK] Créé : {resultat.get('numero')}") + + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"⚠️ Erreur métier sortie stock : {e}") + raise HTTPException(400, str(e)) + + except Exception as e: + logger.error(f"❌ Erreur technique sortie stock : {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get("/sage/stock/mouvement/{numero}", dependencies=[Depends(verify_token)]) +def lire_mouvement_stock(numero: str): + try: + mouvement = sage.lire_mouvement_stock(numero) + + if not mouvement: + raise HTTPException(404, f"Mouvement de stock {numero} non trouvé") + + return {"success": True, "data": mouvement} + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur lecture mouvement : {e}") + raise HTTPException(500, str(e)) + + # ===================================================== # LANCEMENT # ===================================================== diff --git a/requirements.txt b/requirements.txt index 0e52fa2..3772d49 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ pydantic pydantic-settings python-multipart python-dotenv -pywin32 \ No newline at end of file +pywin32 +pyodbc \ No newline at end of file diff --git a/sage_connector.py b/sage_connector.py index f6fcf33..bfc59e9 100644 --- a/sage_connector.py +++ b/sage_connector.py @@ -5,72 +5,29 @@ from typing import Dict, List, Optional import threading import time import logging -from contextlib import contextmanager from config import settings, validate_settings +import pyodbc +from contextlib import contextmanager logger = logging.getLogger(__name__) class SageConnector: - """ - Connecteur Sage 100c avec gestion COM threading correcte - - CHANGEMENTS PRODUCTION: - - Initialisation COM par thread (CoInitialize/CoUninitialize) - - Lock robuste pour thread-safety - - Gestion d'erreurs exhaustive - - Logging structuré - - Retry automatique sur erreurs COM - """ - def __init__(self, chemin_base, utilisateur="", mot_de_passe=""): self.chemin_base = chemin_base self.utilisateur = utilisateur self.mot_de_passe = mot_de_passe self.cial = None - # ✅ Caches existants - self._cache_clients: List[Dict] = [] - self._cache_articles: List[Dict] = [] - self._cache_clients_dict: Dict[str, Dict] = {} - self._cache_articles_dict: Dict[str, Dict] = {} - - # ✅ NOUVEAUX CACHES - self._cache_devis: List[Dict] = [] - self._cache_commandes: List[Dict] = [] - self._cache_factures: List[Dict] = [] - self._cache_fournisseurs: List[Dict] = [] - - self._cache_devis_dict: Dict[str, Dict] = {} - self._cache_commandes_dict: Dict[str, Dict] = {} - self._cache_factures_dict: Dict[str, Dict] = {} - self._cache_fournisseurs_dict: Dict[str, Dict] = {} - - # ✅ Métadonnées cache existantes - self._cache_clients_last_update: Optional[datetime] = None - self._cache_articles_last_update: Optional[datetime] = None - - # ✅ NOUVELLES MÉTADONNÉES - self._cache_devis_last_update: Optional[datetime] = None - self._cache_commandes_last_update: Optional[datetime] = None - self._cache_factures_last_update: Optional[datetime] = None - self._cache_fournisseurs_last_update: Optional[datetime] = None - - self._cache_ttl_minutes = 15 - - # Thread d'actualisation - self._refresh_thread: Optional[threading.Thread] = None - self._stop_refresh = threading.Event() - - # ✅ Locks existants - self._lock_clients = threading.RLock() - self._lock_articles = threading.RLock() - - # ✅ NOUVEAUX LOCKS - self._lock_devis = threading.RLock() - self._lock_commandes = threading.RLock() - self._lock_factures = threading.RLock() - self._lock_fournisseurs = threading.RLock() + 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() @@ -83,12 +40,6 @@ class SageConnector: @contextmanager def _com_context(self): - """ - Context manager pour initialiser COM dans chaque thread - - CRITIQUE: FastAPI utilise un pool de threads. - Chaque thread doit initialiser COM avant d'utiliser les objets Sage. - """ # Vérifier si COM est déjà initialisé pour ce thread if not hasattr(self._thread_local, "com_initialized"): try: @@ -107,6 +58,28 @@ class SageConnector: # 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"): @@ -124,8 +97,11 @@ class SageConnector: # ========================================================================= def connecter(self): - """Connexion initiale à Sage""" + """Connexion initiale à Sage - VERSION HYBRIDE""" try: + # ======================================== + # CONNEXION COM (pour écritures) + # ======================================== with self._com_context(): self.cial = win32com.client.gencache.EnsureDispatch( "Objets100c.Cial.Stream" @@ -135,19 +111,20 @@ class SageConnector: self.cial.Loggable.UserPwd = self.mot_de_passe self.cial.Open() - logger.info(f"✅ Connexion Sage réussie: {self.chemin_base}") + logger.info(f"✅ Connexion COM Sage réussie: {self.chemin_base}") - # ✅ Chargement initial des caches - logger.info("📦 Chargement initial des caches...") - self._refresh_cache_clients() - self._refresh_cache_articles() - self._refresh_cache_devis() - self._refresh_cache_commandes() - self._refresh_cache_factures() - self._refresh_cache_fournisseurs() - - # Démarrage du thread d'actualisation - self._start_refresh_thread() + # ======================================== + # 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 @@ -157,11 +134,6 @@ class SageConnector: def deconnecter(self): """Déconnexion propre""" - self._stop_refresh.set() - - if self._refresh_thread: - self._refresh_thread.join(timeout=5) - if self.cial: try: with self._com_context(): @@ -170,918 +142,112 @@ class SageConnector: except: pass - # ========================================================================= - # SYSTÈME DE CACHE - # ========================================================================= - - def _start_refresh_thread(self): - """Démarre le thread d'actualisation automatique""" - - def refresh_loop(): - pythoncom.CoInitialize() - - try: - while not self._stop_refresh.is_set(): - time.sleep(60) - - # ✅ Caches existants - # Clients - if self._cache_clients_last_update: - age = datetime.now() - self._cache_clients_last_update - if age.total_seconds() > self._cache_ttl_minutes * 60: - self._refresh_cache_clients() - - # Articles - if self._cache_articles_last_update: - age = datetime.now() - self._cache_articles_last_update - if age.total_seconds() > self._cache_ttl_minutes * 60: - self._refresh_cache_articles() - - # ✅ NOUVEAUX CACHES - # Devis - if self._cache_devis_last_update: - age = datetime.now() - self._cache_devis_last_update - if age.total_seconds() > self._cache_ttl_minutes * 60: - self._refresh_cache_devis() - - # Commandes - if self._cache_commandes_last_update: - age = datetime.now() - self._cache_commandes_last_update - if age.total_seconds() > self._cache_ttl_minutes * 60: - self._refresh_cache_commandes() - - # Factures - if self._cache_factures_last_update: - age = datetime.now() - self._cache_factures_last_update - if age.total_seconds() > self._cache_ttl_minutes * 60: - self._refresh_cache_factures() - - # Fournisseurs - if self._cache_fournisseurs_last_update: - age = datetime.now() - self._cache_fournisseurs_last_update - if age.total_seconds() > self._cache_ttl_minutes * 60: - self._refresh_cache_fournisseurs() - - finally: - pythoncom.CoUninitialize() - - self._refresh_thread = threading.Thread( - target=refresh_loop, daemon=True, name="SageCacheRefresh" - ) - self._refresh_thread.start() - - def _refresh_cache_clients(self): - """ - Actualise le cache des clients - Charge TOUS les tiers (CT_Type=0 ET CT_Type=1) - """ - if not self.cial: - return - - clients = [] - clients_dict = {} - - try: - with self._com_context(), self._lock_com: - factory = self.cial.CptaApplication.FactoryClient - index = 1 - erreurs_consecutives = 0 - max_erreurs = 50 - - logger.info("🔄 Actualisation cache clients et prospects...") - - while index < 10000 and erreurs_consecutives < max_erreurs: - try: - persist = factory.List(index) - if persist is None: - break - - obj = self._cast_client(persist) - if obj: - data = self._extraire_client(obj) - - # ✅ INCLURE TOUS LES TYPES (clients, prospects) - clients.append(data) - clients_dict[data["numero"]] = data - erreurs_consecutives = 0 - - index += 1 - - except Exception as e: - erreurs_consecutives += 1 - index += 1 - if erreurs_consecutives >= max_erreurs: - logger.warning( - f"Arrêt refresh clients après {max_erreurs} erreurs" - ) - break - - with self._lock_clients: - self._cache_clients = clients - self._cache_clients_dict = clients_dict - self._cache_clients_last_update = datetime.now() - - # 📊 Statistiques détaillées - nb_clients = sum( - 1 - for c in clients - if c.get("type") == 0 and not c.get("est_prospect") - ) - nb_prospects = sum( - 1 for c in clients if c.get("type") == 0 and c.get("est_prospect") - ) - - logger.info( - f"✅ Cache actualisé: {len(clients)} tiers " - f"({nb_clients} clients, {nb_prospects} prospects)" - ) - - except Exception as e: - logger.error(f"❌ Erreur refresh clients: {e}", exc_info=True) - - def _refresh_cache_articles(self): - """Actualise le cache des articles""" - if not self.cial: - return - - articles = [] - articles_dict = {} - - try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryArticle - index = 1 - erreurs_consecutives = 0 - max_erreurs = 50 - - while index < 10000 and erreurs_consecutives < max_erreurs: - try: - persist = factory.List(index) - if persist is None: - break - - obj = self._cast_article(persist) - if obj: - data = self._extraire_article(obj) - articles.append(data) - articles_dict[data["reference"]] = data - erreurs_consecutives = 0 - - index += 1 - - except Exception as e: - erreurs_consecutives += 1 - index += 1 - if erreurs_consecutives >= max_erreurs: - logger.warning( - f"Arrêt refresh articles après {max_erreurs} erreurs" - ) - break - - with self._lock_articles: - self._cache_articles = articles - self._cache_articles_dict = articles_dict - self._cache_articles_last_update = datetime.now() - - logger.info(f" Cache articles actualisé: {len(articles)} articles") - - except Exception as e: - logger.error(f" Erreur refresh articles: {e}", exc_info=True) - - def _refresh_cache_devis(self): - """Actualise le cache des devis AVEC leurs lignes""" - if not self.cial: - return - - devis_list = [] - devis_dict = {} - - try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente - index = 1 - erreurs_consecutives = 0 - max_erreurs = 50 - - logger.info("🔄 Actualisation cache devis (avec lignes)...") - - while index < 10000 and erreurs_consecutives < max_erreurs: - try: - persist = factory.List(index) - if persist is None: - break - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # Filtrer uniquement les devis (type 0) - if getattr(doc, "DO_Type", -1) != 0: - index += 1 - continue - - numero = getattr(doc, "DO_Piece", "") - if not numero: - index += 1 - continue - - # Charger client - client_code = "" - client_intitule = "" - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - client_intitule = getattr( - client_obj, "CT_Intitule", "" - ).strip() - except: - pass - - # ✅ CHARGER LES LIGNES - lignes = [] - try: - factory_lignes = getattr(doc, "FactoryDocumentLigne", None) - if not factory_lignes: - factory_lignes = getattr( - doc, "FactoryDocumentVenteLigne", None - ) - - if factory_lignes: - ligne_index = 1 - while ligne_index <= 100: - try: - ligne_persist = factory_lignes.List(ligne_index) - if ligne_persist is None: - break - - ligne = win32com.client.CastTo( - ligne_persist, "IBODocumentLigne3" - ) - ligne.Read() - - # Charger référence article - article_ref = "" - try: - article_ref = getattr( - ligne, "AR_Ref", "" - ).strip() - if not article_ref: - article_obj = getattr( - ligne, "Article", None - ) - if article_obj: - article_obj.Read() - article_ref = getattr( - article_obj, "AR_Ref", "" - ).strip() - except: - pass - - lignes.append( - { - "article": article_ref, - "designation": getattr( - ligne, "DL_Design", "" - ), - "quantite": float( - getattr(ligne, "DL_Qte", 0.0) - ), - "prix_unitaire": float( - getattr( - ligne, "DL_PrixUnitaire", 0.0 - ) - ), - "montant_ht": float( - getattr(ligne, "DL_MontantHT", 0.0) - ), - } - ) - - ligne_index += 1 - except: - break - except Exception as e: - logger.debug( - f"Erreur chargement lignes devis {numero}: {e}" - ) - - data = { - "numero": numero, - "date": str(getattr(doc, "DO_Date", "")), - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), - "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), - "statut": getattr(doc, "DO_Statut", 0), - "lignes": lignes, # ✅ LIGNES INCLUSES - } - - devis_list.append(data) - devis_dict[numero] = data - erreurs_consecutives = 0 - index += 1 - - except: - erreurs_consecutives += 1 - index += 1 - if erreurs_consecutives >= max_erreurs: - break - - with self._lock_devis: - self._cache_devis = devis_list - self._cache_devis_dict = devis_dict - self._cache_devis_last_update = datetime.now() - - logger.info( - f"✅ Cache devis actualisé: {len(devis_list)} devis (avec lignes)" - ) - - except Exception as e: - logger.error(f"❌ Erreur refresh devis: {e}", exc_info=True) - - def _refresh_cache_commandes(self): - """Actualise le cache des commandes AVEC leurs lignes""" - if not self.cial: - return - - commandes_list = [] - commandes_dict = {} - - try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente - index = 1 - erreurs_consecutives = 0 - max_erreurs = 50 - - logger.info("🔄 Actualisation cache commandes (avec lignes)...") - - while index < 10000 and erreurs_consecutives < max_erreurs: - try: - persist = factory.List(index) - if persist is None: - break - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # Filtrer uniquement les commandes (type 10) - if ( - getattr(doc, "DO_Type", -1) - != settings.SAGE_TYPE_BON_COMMANDE - ): - index += 1 - continue - - numero = getattr(doc, "DO_Piece", "") - if not numero: - index += 1 - continue - - # Charger client - client_code = "" - client_intitule = "" - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - client_intitule = getattr( - client_obj, "CT_Intitule", "" - ).strip() - except: - pass - - # ✅ CHARGER LES LIGNES - lignes = [] - try: - factory_lignes = getattr(doc, "FactoryDocumentLigne", None) - if not factory_lignes: - factory_lignes = getattr( - doc, "FactoryDocumentVenteLigne", None - ) - - if factory_lignes: - ligne_index = 1 - while ligne_index <= 100: - try: - ligne_persist = factory_lignes.List(ligne_index) - if ligne_persist is None: - break - - ligne = win32com.client.CastTo( - ligne_persist, "IBODocumentLigne3" - ) - ligne.Read() - - # Charger référence article - article_ref = "" - try: - article_ref = getattr( - ligne, "AR_Ref", "" - ).strip() - if not article_ref: - article_obj = getattr( - ligne, "Article", None - ) - if article_obj: - article_obj.Read() - article_ref = getattr( - article_obj, "AR_Ref", "" - ).strip() - except: - pass - - lignes.append( - { - "article": article_ref, - "designation": getattr( - ligne, "DL_Design", "" - ), - "quantite": float( - getattr(ligne, "DL_Qte", 0.0) - ), - "prix_unitaire": float( - getattr( - ligne, "DL_PrixUnitaire", 0.0 - ) - ), - "montant_ht": float( - getattr(ligne, "DL_MontantHT", 0.0) - ), - } - ) - - ligne_index += 1 - except: - break - except Exception as e: - logger.debug( - f"Erreur chargement lignes commande {numero}: {e}" - ) - - data = { - "numero": numero, - "reference": getattr(doc, "DO_Ref", ""), - "date": str(getattr(doc, "DO_Date", "")), - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), - "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), - "statut": getattr(doc, "DO_Statut", 0), - "lignes": lignes, # ✅ LIGNES INCLUSES - } - - commandes_list.append(data) - commandes_dict[numero] = data - erreurs_consecutives = 0 - index += 1 - - except: - erreurs_consecutives += 1 - index += 1 - if erreurs_consecutives >= max_erreurs: - break - - with self._lock_commandes: - self._cache_commandes = commandes_list - self._cache_commandes_dict = commandes_dict - self._cache_commandes_last_update = datetime.now() - - logger.info( - f"✅ Cache commandes actualisé: {len(commandes_list)} commandes (avec lignes)" - ) - - except Exception as e: - logger.error(f"❌ Erreur refresh commandes: {e}", exc_info=True) - - def _refresh_cache_factures(self): - """Actualise le cache des factures AVEC leurs lignes""" - if not self.cial: - return - - factures_list = [] - factures_dict = {} - - try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente - index = 1 - erreurs_consecutives = 0 - max_erreurs = 50 - - logger.info("🔄 Actualisation cache factures (avec lignes)...") - - while index < 10000 and erreurs_consecutives < max_erreurs: - try: - persist = factory.List(index) - if persist is None: - break - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # Filtrer uniquement les factures (type 60) - if getattr(doc, "DO_Type", -1) != settings.SAGE_TYPE_FACTURE: - index += 1 - continue - - numero = getattr(doc, "DO_Piece", "") - if not numero: - index += 1 - continue - - # Charger client - client_code = "" - client_intitule = "" - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - client_intitule = getattr( - client_obj, "CT_Intitule", "" - ).strip() - except: - pass - - # ✅ CHARGER LES LIGNES - lignes = [] - try: - factory_lignes = getattr(doc, "FactoryDocumentLigne", None) - if not factory_lignes: - factory_lignes = getattr( - doc, "FactoryDocumentVenteLigne", None - ) - - if factory_lignes: - ligne_index = 1 - while ligne_index <= 100: - try: - ligne_persist = factory_lignes.List(ligne_index) - if ligne_persist is None: - break - - ligne = win32com.client.CastTo( - ligne_persist, "IBODocumentLigne3" - ) - ligne.Read() - - # Charger référence article - article_ref = "" - try: - article_ref = getattr( - ligne, "AR_Ref", "" - ).strip() - if not article_ref: - article_obj = getattr( - ligne, "Article", None - ) - if article_obj: - article_obj.Read() - article_ref = getattr( - article_obj, "AR_Ref", "" - ).strip() - except: - pass - - lignes.append( - { - "article": article_ref, - "designation": getattr( - ligne, "DL_Design", "" - ), - "quantite": float( - getattr(ligne, "DL_Qte", 0.0) - ), - "prix_unitaire": float( - getattr( - ligne, "DL_PrixUnitaire", 0.0 - ) - ), - "montant_ht": float( - getattr(ligne, "DL_MontantHT", 0.0) - ), - } - ) - - ligne_index += 1 - except: - break - except Exception as e: - logger.debug( - f"Erreur chargement lignes facture {numero}: {e}" - ) - - data = { - "numero": numero, - "reference": getattr(doc, "DO_Ref", ""), - "date": str(getattr(doc, "DO_Date", "")), - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), - "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), - "statut": getattr(doc, "DO_Statut", 0), - "lignes": lignes, # ✅ LIGNES INCLUSES - } - - factures_list.append(data) - factures_dict[numero] = data - erreurs_consecutives = 0 - index += 1 - - except: - erreurs_consecutives += 1 - index += 1 - if erreurs_consecutives >= max_erreurs: - break - - with self._lock_factures: - self._cache_factures = factures_list - self._cache_factures_dict = factures_dict - self._cache_factures_last_update = datetime.now() - - logger.info( - f"✅ Cache factures actualisé: {len(factures_list)} factures (avec lignes)" - ) - - except Exception as e: - logger.error(f"❌ Erreur refresh factures: {e}", exc_info=True) - - def _refresh_cache_fournisseurs(self): - """ - Actualise le cache des fournisseurs - ✅ CORRECTION : Utilise maintenant la logique complète de lecture - """ - if not self.cial: - return - - fournisseurs_list = [] - fournisseurs_dict = {} - - try: - with self._com_context(), self._lock_com: - logger.info("🔄 Actualisation cache fournisseurs...") - - factory = self.cial.CptaApplication.FactoryFournisseur - index = 1 - max_iterations = 10000 - erreurs_consecutives = 0 - max_erreurs = 50 - - while index < max_iterations and erreurs_consecutives < max_erreurs: - try: - persist = factory.List(index) - - if persist is None: - logger.debug(f"Fin de liste à l'index {index}") - break - - # Cast - fourn = self._cast_client(persist) - - if fourn: - # ✅ EXTRACTION DIRECTE (même logique que lister_tous_fournisseurs) - try: - numero = getattr(fourn, "CT_Num", "").strip() - if not numero: - logger.debug(f"Index {index}: CT_Num vide, skip") - erreurs_consecutives += 1 - index += 1 - continue - - intitule = getattr(fourn, "CT_Intitule", "").strip() - - # Construction objet minimal - data = { - "numero": numero, - "intitule": intitule, - "type": 1, # Fournisseur - "est_fournisseur": True, - } - - # Champs optionnels (avec gestion d'erreur) - try: - adresse_obj = getattr(fourn, "Adresse", None) - if adresse_obj: - data["adresse"] = getattr( - adresse_obj, "Adresse", "" - ).strip() - data["code_postal"] = getattr( - adresse_obj, "CodePostal", "" - ).strip() - data["ville"] = getattr( - adresse_obj, "Ville", "" - ).strip() - except: - data["adresse"] = "" - data["code_postal"] = "" - data["ville"] = "" - - try: - telecom_obj = getattr(fourn, "Telecom", None) - if telecom_obj: - data["telephone"] = getattr( - telecom_obj, "Telephone", "" - ).strip() - data["email"] = getattr( - telecom_obj, "EMail", "" - ).strip() - except: - data["telephone"] = "" - data["email"] = "" - - fournisseurs_list.append(data) - fournisseurs_dict[numero] = data - erreurs_consecutives = 0 - - except Exception as e: - logger.debug(f"⚠️ Erreur extraction index {index}: {e}") - erreurs_consecutives += 1 - else: - erreurs_consecutives += 1 - - index += 1 - - except Exception as e: - logger.debug(f"⚠️ Erreur index {index}: {e}") - erreurs_consecutives += 1 - index += 1 - - if erreurs_consecutives >= max_erreurs: - logger.warning( - f"⚠️ Arrêt après {max_erreurs} erreurs consécutives" - ) - break - - with self._lock_fournisseurs: - self._cache_fournisseurs = fournisseurs_list - self._cache_fournisseurs_dict = fournisseurs_dict - self._cache_fournisseurs_last_update = datetime.now() - - logger.info( - f"✅ Cache fournisseurs actualisé: {len(fournisseurs_list)} fournisseurs" - ) - - except Exception as e: - logger.error(f"❌ Erreur refresh fournisseurs: {e}", exc_info=True) - def lister_tous_fournisseurs(self, filtre=""): - """ - ✅ CORRECTION FINALE : Liste fournisseurs SANS passer par _extraire_client() - - BYPASS TOTAL de _extraire_client() car : - - Les objets fournisseurs n'ont pas les mêmes champs que les clients - - _extraire_client() plante sur "CT_Qualite" (n'existe pas sur fournisseurs) - - Le diagnostic fournisseurs-analyse-complete fonctionne SANS _extraire_client() - - → On fait EXACTEMENT comme le diagnostic qui marche - """ - if not self.cial: - logger.error("❌ self.cial est None") - return [] - - fournisseurs = [] - try: - with self._com_context(), self._lock_com: - logger.info( - f"🔍 Lecture fournisseurs via FactoryFournisseur (filtre='{filtre}')" - ) + with self._get_sql_connection() as conn: + cursor = conn.cursor() - factory = self.cial.CptaApplication.FactoryFournisseur - index = 1 - max_iterations = 10000 - erreurs_consecutives = 0 - max_erreurs = 50 + 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 + """ - filtre_lower = filtre.lower() if filtre else "" + params = [] - while index < max_iterations and erreurs_consecutives < max_erreurs: - try: - persist = factory.List(index) + if filtre: + query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)" + params.extend([f"%{filtre}%", f"%{filtre}%"]) - if persist is None: - logger.debug(f"Fin de liste à l'index {index}") - break + query += " ORDER BY CT_Intitule" - # Cast - fourn = self._cast_client(persist) + cursor.execute(query, params) + rows = cursor.fetchall() - if fourn: - # ✅✅✅ EXTRACTION DIRECTE (pas de _extraire_client) ✅✅✅ - try: - numero = getattr(fourn, "CT_Num", "").strip() - intitule = getattr(fourn, "CT_Intitule", "").strip() + 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), + } + ) - if not numero: - logger.debug(f"Index {index}: CT_Num vide, skip") - erreurs_consecutives += 1 - index += 1 - continue - - # Construction objet minimal - data = { - "numero": numero, - "intitule": intitule, - "type": 1, # Fournisseur - "est_fournisseur": True, - } - - # Champs optionnels (avec gestion d'erreur) - try: - adresse_obj = getattr(fourn, "Adresse", None) - if adresse_obj: - data["adresse"] = getattr( - adresse_obj, "Adresse", "" - ).strip() - data["code_postal"] = getattr( - adresse_obj, "CodePostal", "" - ).strip() - data["ville"] = getattr( - adresse_obj, "Ville", "" - ).strip() - except: - data["adresse"] = "" - data["code_postal"] = "" - data["ville"] = "" - - try: - telecom_obj = getattr(fourn, "Telecom", None) - if telecom_obj: - data["telephone"] = getattr( - telecom_obj, "Telephone", "" - ).strip() - data["email"] = getattr( - telecom_obj, "EMail", "" - ).strip() - except: - data["telephone"] = "" - data["email"] = "" - - # Filtrer si nécessaire - if ( - not filtre_lower - or filtre_lower in numero.lower() - or filtre_lower in intitule.lower() - ): - fournisseurs.append(data) - logger.debug( - f"✅ Fournisseur ajouté: {numero} - {intitule}" - ) - - erreurs_consecutives = 0 - - except Exception as e: - logger.debug(f"⚠️ Erreur extraction index {index}: {e}") - erreurs_consecutives += 1 - else: - erreurs_consecutives += 1 - - index += 1 - - except Exception as e: - logger.debug(f"⚠️ Erreur index {index}: {e}") - erreurs_consecutives += 1 - index += 1 - - if erreurs_consecutives >= max_erreurs: - logger.warning( - f"⚠️ Arrêt après {max_erreurs} erreurs consécutives" - ) - break - - logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés") + logger.info(f"✅ SQL: {len(fournisseurs)} fournisseurs") return fournisseurs except Exception as e: - logger.error(f"❌ Erreur liste fournisseurs: {e}", exc_info=True) + 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: - """ - ✅ Crée un nouveau fournisseur dans Sage 100c via FactoryFournisseur - - IMPORTANT: Utilise FactoryFournisseur.Create() et NON FactoryClient.Create() - car les fournisseurs sont gérés séparément dans Sage. - - Args: - fournisseur_data: Dictionnaire contenant: - - intitule (obligatoire): Raison sociale - - compte_collectif (défaut: "401000"): Compte général - - num (optionnel): Code fournisseur personnalisé - - adresse, code_postal, ville, pays - - email, telephone - - siret, tva_intra - - Returns: - Dict contenant le fournisseur créé avec son numéro définitif - - Raises: - ValueError: Si le fournisseur existe déjà ou données invalides - RuntimeError: Si erreur technique Sage - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -1391,18 +557,6 @@ class SageConnector: raise RuntimeError(f"Erreur technique Sage: {error_message}") def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: - """ - ✏️ Modification d'un fournisseur existant dans Sage 100c - - IMPORTANT: Utilise FactoryFournisseur.ReadNumero() pour charger le fournisseur - - Args: - code: Code du fournisseur à modifier - fournisseur_data: Dictionnaire avec les champs à mettre à jour - - Returns: - Fournisseur modifié - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -1609,312 +763,723 @@ class SageConnector: raise RuntimeError(f"Erreur technique Sage: {error_message}") - def lire_fournisseur(self, code): - """ - ✅ NOUVEAU : Lecture d'un fournisseur par code - - Utilise FactoryFournisseur.ReadNumero() directement - """ - if not self.cial: - return None - + def lister_tous_clients(self, filtre=""): try: - with self._com_context(), self._lock_com: - factory = self.cial.CptaApplication.FactoryFournisseur - persist = factory.ReadNumero(code) + with self._get_sql_connection() as conn: + cursor = conn.cursor() - if not persist: - logger.warning(f"Fournisseur {code} introuvable") - return None + 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 + """ - fourn = self._cast_client(persist) + params = [] - if not fourn: - return None + if filtre: + query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)" + params.extend([f"%{filtre}%", f"%{filtre}%"]) - # Extraction directe (même logique que lister_tous_fournisseurs) - numero = getattr(fourn, "CT_Num", "").strip() - intitule = getattr(fourn, "CT_Intitule", "").strip() + query += " ORDER BY CT_Intitule" - data = { - "numero": numero, - "intitule": intitule, - "type": 1, - "est_fournisseur": True, - } + cursor.execute(query, params) + rows = cursor.fetchall() - # Adresse - try: - adresse_obj = getattr(fourn, "Adresse", None) - if adresse_obj: - data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() - data["code_postal"] = getattr( - adresse_obj, "CodePostal", "" - ).strip() - data["ville"] = getattr(adresse_obj, "Ville", "").strip() - except: - data["adresse"] = "" - data["code_postal"] = "" - data["ville"] = "" + 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), + } + ) - # Télécom - try: - telecom_obj = getattr(fourn, "Telecom", None) - if telecom_obj: - data["telephone"] = getattr( - telecom_obj, "Telephone", "" - ).strip() - data["email"] = getattr(telecom_obj, "EMail", "").strip() - except: - data["telephone"] = "" - data["email"] = "" - - logger.info(f"✅ Fournisseur {code} lu: {intitule}") - return data + logger.info(f"✅ SQL: {len(clients)} clients") + return clients except Exception as e: - logger.error(f"❌ Erreur lecture fournisseur {code}: {e}") - return None - - def lister_tous_clients(self, filtre=""): - """Retourne les clients depuis le cache (instantané)""" - with self._lock_clients: - if not filtre: - return self._cache_clients.copy() - - filtre_lower = filtre.lower() - return [ - c - for c in self._cache_clients - if filtre_lower in c["numero"].lower() - or filtre_lower in c["intitule"].lower() - ] + logger.error(f"❌ Erreur SQL clients: {e}") + raise RuntimeError(f"Erreur lecture clients: {str(e)}") def lire_client(self, code_client): - """Retourne un client depuis le cache (instantané)""" - with self._lock_clients: - return self._cache_clients_dict.get(code_client) + try: + with self._get_sql_connection() as conn: + cursor = conn.cursor() - def lister_tous_articles(self, filtre=""): - """Retourne les articles depuis le cache (instantané)""" - with self._lock_articles: - if not filtre: - return self._cache_articles.copy() + # ✅ 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(),), + ) - filtre_lower = filtre.lower() - return [ - a - for a in self._cache_articles - if filtre_lower in a["reference"].lower() - or filtre_lower in a["designation"].lower() - ] + 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 (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, + # Valeurs par défaut + "stock_reel": 0.0, + "stock_mini": 0.0, + "stock_maxi": 0.0, + } + + articles.append(article) + + # ======================================== + # ÉTAPE 2 : ENRICHIR AVEC LE STOCK (si demandé) + # ======================================== + if avec_stock and articles: + logger.info( + f"📦 Enrichissement stock pour {len(articles)} articles..." + ) + + # Essayer différentes tables de stock (selon version Sage) + tables_stock = ["F_ARTSTOCK", "F_DEPOTSTOCK", "F_STOCK"] + table_utilisee = None + + for table in tables_stock: + try: + # Test si la table existe + cursor.execute(f"SELECT TOP 1 * FROM {table}") + table_utilisee = table + logger.info(f" ✅ Table de stock détectée : {table}") + break + except: + continue + + if table_utilisee: + # Construire un mapping référence -> stock + stock_map = {} + + try: + # ✅ CORRECTION : Requête adaptée selon la table avec les BONS noms de colonnes + if table_utilisee == "F_ARTSTOCK": + stock_query = """ + 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 + FROM F_ARTSTOCK + GROUP BY AR_Ref + """ + elif table_utilisee == "F_DEPOTSTOCK": + stock_query = """ + SELECT + AR_Ref, + SUM(ISNULL(DS_QteSto, 0)) as Stock_Total, + MIN(ISNULL(DS_QteMini, 0)) as Stock_Mini, + MAX(ISNULL(DS_QteMaxi, 0)) as Stock_Maxi + FROM F_DEPOTSTOCK + GROUP BY AR_Ref + """ + else: # F_STOCK ou autre + stock_query = f""" + SELECT + AR_Ref, + SUM(ISNULL(Quantite, 0)) as Stock_Total, + 0 as Stock_Mini, + 0 as Stock_Maxi + FROM {table_utilisee} + GROUP BY AR_Ref + """ + + cursor.execute(stock_query) + stock_rows = cursor.fetchall() + + 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 + ), + } + + logger.info( + f" ✅ Stocks chargés pour {len(stock_map)} articles" + ) + + # Enrichir les articles + for article in articles: + if article["reference"] in stock_map: + article.update(stock_map[article["reference"]]) + + except Exception as e: + logger.warning( + f" ⚠️ Erreur lecture stocks depuis {table_utilisee}: {e}" + ) + else: + logger.warning(" ⚠️ Aucune table de stock trouvée") + + 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}") + raise RuntimeError(f"Erreur lecture articles: {str(e)}") def lire_article(self, reference): - """Retourne un article depuis le cache (instantané)""" - with self._lock_articles: - return self._cache_articles_dict.get(reference) + try: + with self._get_sql_connection() as conn: + cursor = conn.cursor() + + # ✅ MÊME REQUÊTE que lister_tous_articles (colonnes existantes uniquement) + 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]), # Même valeur que code_ean + "type_article": row[8] if row[8] is not None else 0, + # Valeurs par défaut pour le stock + "stock_reel": 0.0, + "stock_mini": 0.0, + "stock_maxi": 0.0, + "stock_reserve": 0.0, + "stock_commande": 0.0, + "stock_disponible": 0.0, + } + + # ✅ Enrichir avec les stocks (MÊME logique que lister_tous_articles) + 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 + FROM F_ARTSTOCK + WHERE AR_Ref = ? + """, + (reference.upper(),), + ) + + stock_row = cursor.fetchone() + + if stock_row: + 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 + ) + article["stock_disponible"] = article["stock_reel"] # Simplifié + + except Exception as e: + logger.warning( + f"⚠️ Impossible de lire le stock pour {reference}: {e}" + ) + + return article + + except Exception as e: + logger.error(f"❌ Erreur SQL article {reference}: {e}") + return None + + def _lire_document_sql(self, numero: str, type_doc: int): + try: + with self._get_sql_connection() as conn: + cursor = conn.cursor() + + # ======================================== + # VÉRIFIER SI DO_Domaine EXISTE + # ======================================== + do_domaine_existe = False + + try: + cursor.execute( + "SELECT TOP 1 DO_Domaine FROM F_DOCENTETE WHERE DO_Type = ?", + (type_doc,), + ) + row_test = cursor.fetchone() + if row_test is not None: + do_domaine_existe = True + except: + do_domaine_existe = False + + # ======================================== + # LIRE L'ENTÊTE (avec filtre DO_Domaine si disponible) + # ======================================== + if do_domaine_existe: + query = """ + SELECT + DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC, + DO_Statut, DO_Tiers + FROM F_DOCENTETE + WHERE DO_Piece = ? AND DO_Type = ? AND DO_Domaine = 0 + """ + else: + query = """ + SELECT + DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC, + DO_Statut, DO_Tiers + FROM F_DOCENTETE + WHERE DO_Piece = ? AND DO_Type = ? + """ + + cursor.execute(query, (numero, type_doc)) + row = cursor.fetchone() + + if not row: + # ✅ Si DO_Domaine n'existe pas, vérifier le préfixe + if not do_domaine_existe: + prefixes_vente = { + 0: ["DE"], + 10: ["BC"], + 30: ["BL"], + 50: ["AV", "AR"], + 60: ["FA", "FC"], + } + + prefixes_acceptes = prefixes_vente.get(type_doc, []) + est_vente = any( + numero.upper().startswith(p) for p in prefixes_acceptes + ) + + if not est_vente: + logger.warning( + f"Document {numero} semble être un document d'achat (préfixe non reconnu)" + ) + + return None + + # Charger le client + client_code = self._safe_strip(row[6]) if row[6] else "" + client_intitule = "" + + if client_code: + cursor.execute( + """ + SELECT CT_Intitule + FROM F_COMPTET + WHERE CT_Num = ? + """, + (client_code,), + ) + + client_row = cursor.fetchone() + if client_row: + client_intitule = self._safe_strip(client_row[0]) + + # ======================================== + # LIRE LES LIGNES + # ======================================== + cursor.execute( + """ + SELECT + AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT, + DL_Remise01REM_Valeur, DL_Remise01REM_Type + FROM F_DOCLIGNE + WHERE DO_Piece = ? AND DO_Type = ? + ORDER BY DL_Ligne + """, + (numero, type_doc), + ) + + lignes = [] + for ligne_row in cursor.fetchall(): + ligne = { + "article_code": self._safe_strip(ligne_row[0]), + "designation": self._safe_strip(ligne_row[1]), + "quantite": float(ligne_row[2]) if ligne_row[2] else 0.0, + "prix_unitaire_ht": ( + float(ligne_row[3]) if ligne_row[3] else 0.0 + ), + "montant_ligne_ht": ( + float(ligne_row[4]) if ligne_row[4] else 0.0 + ), + } + + # Remise (si présente) + if ligne_row[5]: + ligne["remise_pourcentage"] = float(ligne_row[5]) + ligne["remise_type"] = int(ligne_row[6]) if ligne_row[6] else 0 + else: + ligne["remise_pourcentage"] = 0.0 + ligne["remise_type"] = 0 + + lignes.append(ligne) + + return { + "numero": self._safe_strip(row[0]), + "reference": self._safe_strip(row[2]), + "date": str(row[1]) if row[1] else "", + "client_code": client_code, + "client_intitule": client_intitule, + "total_ht": float(row[3]) if row[3] else 0.0, + "total_ttc": float(row[4]) if row[4] else 0.0, + "statut": row[5] if row[5] is not None else 0, + "lignes": lignes, + "nb_lignes": len(lignes), + } + + except Exception as e: + logger.error(f"❌ Erreur SQL lecture document {numero}: {e}") + return None + + def _lister_documents_avec_lignes_sql( + self, type_doc: int, filtre: str = "", limit: int = None + ): + try: + with self._get_sql_connection() as conn: + cursor = conn.cursor() + + # ======================================== + # ÉTAPE 0 : DIAGNOSTIC - Vérifier si DO_Domaine existe + # ======================================== + do_domaine_existe = False + + try: + cursor.execute( + """ + SELECT TOP 1 DO_Domaine + FROM F_DOCENTETE + WHERE DO_Type = ? + """, + (type_doc,), + ) + + row = cursor.fetchone() + if row is not None: + do_domaine_existe = True + logger.info( + f"[SQL] Colonne DO_Domaine détectée (valeur exemple: {row[0]})" + ) + except Exception as e: + logger.info(f"[SQL] Colonne DO_Domaine non disponible: {e}") + do_domaine_existe = False + + # ======================================== + # ÉTAPE 1 : CONSTRUIRE LA REQUÊTE SELON DISPONIBILITÉ DO_Domaine + # ======================================== + if do_domaine_existe: + # Version avec filtre DO_Domaine + query = """ + SELECT + d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, + d.DO_Statut, d.DO_Tiers, c.CT_Intitule + FROM F_DOCENTETE d + LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num + WHERE d.DO_Type = ? + AND d.DO_Domaine = 0 + """ + logger.info(f"[SQL] Requête AVEC filtre DO_Domaine = 0") + else: + # Version SANS filtre DO_Domaine (utilise heuristique) + query = """ + SELECT + d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, + d.DO_Statut, d.DO_Tiers, c.CT_Intitule + FROM F_DOCENTETE d + LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num + WHERE d.DO_Type = ? + """ + logger.warning( + f"[SQL] Requête SANS filtre DO_Domaine (heuristique sur préfixe)" + ) + + params = [type_doc] + + if filtre: + query += " AND (d.DO_Piece LIKE ? OR c.CT_Intitule LIKE ?)" + params.extend([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] {len(entetes)} documents bruts récupérés (type={type_doc})" + ) + + documents = [] + + # ======================================== + # ÉTAPE 2 : FILTRER PAR HEURISTIQUE SI DO_Domaine N'EXISTE PAS + # ======================================== + for entete in entetes: + numero = self._safe_strip(entete.DO_Piece) + + # Si DO_Domaine n'existe pas, filtrer par préfixe du numéro + if not do_domaine_existe: + # Heuristique : + # - Vente (clients) : BC, BL, FA, AV, DE + # - Achat (fournisseurs) : DA, RA, FAF, etc. + + prefixes_vente = { + 0: ["DE"], # Devis + 10: ["BC"], # Bon de commande + 30: ["BL"], # Bon de livraison + 50: ["AV", "AR"], # Avoir + 60: ["FA", "FC"], # Facture + } + + prefixes_acceptes = prefixes_vente.get(type_doc, []) + + if prefixes_acceptes: + # Vérifier si le numéro commence par un préfixe valide + est_vente = any( + numero.upper().startswith(p) for p in prefixes_acceptes + ) + + if not est_vente: + logger.debug( + f"[SQL] Document {numero} exclu (préfixe achat)" + ) + continue + + # Créer l'objet document de base + doc = { + "numero": numero, + "reference": self._safe_strip(entete.DO_Ref), + "date": str(entete.DO_Date) if entete.DO_Date else "", + "client_code": self._safe_strip(entete.DO_Tiers), + "client_intitule": self._safe_strip(entete.CT_Intitule), + "total_ht": ( + float(entete.DO_TotalHT) if entete.DO_TotalHT else 0.0 + ), + "total_ttc": ( + float(entete.DO_TotalTTC) if entete.DO_TotalTTC else 0.0 + ), + "statut": ( + entete.DO_Statut if entete.DO_Statut is not None else 0 + ), + "lignes": [], + } + + # ======================================== + # CHARGER LES LIGNES + # ======================================== + try: + cursor.execute( + """ + SELECT + AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT, + DL_Remise01REM_Valeur, DL_Remise01REM_Type + FROM F_DOCLIGNE + WHERE DO_Piece = ? AND DO_Type = ? + ORDER BY DL_Ligne + """, + (numero, type_doc), + ) + + lignes_rows = cursor.fetchall() + + for ligne_row in lignes_rows: + ligne = { + "article_code": self._safe_strip(ligne_row.AR_Ref), + "designation": self._safe_strip(ligne_row.DL_Design), + "quantite": ( + float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0 + ), + "prix_unitaire_ht": ( + float(ligne_row.DL_PrixUnitaire) + if ligne_row.DL_PrixUnitaire + else 0.0 + ), + "montant_ligne_ht": ( + float(ligne_row.DL_MontantHT) + if ligne_row.DL_MontantHT + else 0.0 + ), + } + + # Remise (si présente) + if ligne_row.DL_Remise01REM_Valeur: + ligne["remise_pourcentage"] = float( + ligne_row.DL_Remise01REM_Valeur + ) + ligne["remise_type"] = ( + int(ligne_row.DL_Remise01REM_Type) + if ligne_row.DL_Remise01REM_Type + else 0 + ) + else: + ligne["remise_pourcentage"] = 0.0 + ligne["remise_type"] = 0 + + doc["lignes"].append(ligne) + + except Exception as e: + logger.warning(f"Erreur chargement lignes pour {numero}: {e}") + + # Ajouter le nombre de lignes + doc["nb_lignes"] = len(doc["lignes"]) + + documents.append(doc) + + methode = "DO_Domaine" if do_domaine_existe else "heuristique prefixe" + logger.info( + f"SQL: {len(documents)} documents (type={type_doc}, filtre={methode})" + ) + return documents + + except Exception as e: + logger.error(f"Erreur SQL listage documents avec lignes: {e}") + return [] - # --- DEVIS (CACHE) --- def lister_tous_devis_cache(self, filtre=""): - """Retourne les devis depuis le cache (instantané)""" - with self._lock_devis: - if not filtre: - return self._cache_devis.copy() - filtre_lower = filtre.lower() - return [ - d - for d in self._cache_devis - if filtre_lower in d["numero"].lower() - or filtre_lower in d.get("client_intitule", "").lower() - ] + return self._lister_documents_avec_lignes_sql(type_doc=0, filtre=filtre) def lire_devis_cache(self, numero): - """Retourne un devis depuis le cache (instantané) - SANS lignes""" - with self._lock_devis: - return self._cache_devis_dict.get(numero) + return self._lire_document_sql(numero, type_doc=0) - # --- COMMANDES (CACHE) --- def lister_toutes_commandes_cache(self, filtre=""): - """Retourne les commandes depuis le cache (instantané)""" - with self._lock_commandes: - if not filtre: - return self._cache_commandes.copy() - filtre_lower = filtre.lower() - return [ - c - for c in self._cache_commandes - if filtre_lower in c["numero"].lower() - or filtre_lower in c.get("client_intitule", "").lower() - ] + return self._lister_documents_avec_lignes_sql(type_doc=1, filtre=filtre) def lire_commande_cache(self, numero): - """Retourne une commande depuis le cache (instantané) - SANS lignes""" - with self._lock_commandes: - return self._cache_commandes_dict.get(numero) + return self._lire_document_sql(numero, type_doc=1) - # --- FACTURES (CACHE) --- def lister_toutes_factures_cache(self, filtre=""): - """Retourne les factures depuis le cache (instantané)""" - with self._lock_factures: - if not filtre: - return self._cache_factures.copy() - filtre_lower = filtre.lower() - return [ - f - for f in self._cache_factures - if filtre_lower in f["numero"].lower() - or filtre_lower in f.get("client_intitule", "").lower() - ] + return self._lister_documents_avec_lignes_sql(type_doc=6, filtre=filtre) def lire_facture_cache(self, numero): - """Retourne une facture depuis le cache (instantané) - SANS lignes""" - with self._lock_factures: - return self._cache_factures_dict.get(numero) + return self._lire_document_sql(numero, type_doc=6) - # --- FOURNISSEURS (CACHE) --- def lister_tous_fournisseurs_cache(self, filtre=""): - """Retourne les fournisseurs depuis le cache (instantané)""" - with self._lock_fournisseurs: - if not filtre: - return self._cache_fournisseurs.copy() - filtre_lower = filtre.lower() - return [ - f - for f in self._cache_fournisseurs - if filtre_lower in f["numero"].lower() - or filtre_lower in f["intitule"].lower() - ] + return self.lister_tous_fournisseurs() def lire_fournisseur_cache(self, code): - """Retourne un fournisseur depuis le cache (instantané)""" - with self._lock_fournisseurs: - return self._cache_fournisseurs_dict.get(code) + return self.lire_fournisseur() - # --- UTILITAIRES CACHE --- - def forcer_actualisation_cache(self): - """Force l'actualisation immédiate du cache (endpoint admin)""" - # ... (existant) + def lister_toutes_livraisons_cache(self, filtre=""): + return self._lister_documents_avec_lignes_sql(type_doc=3, filtre=filtre) - def get_cache_info(self): - """Retourne les infos du cache (endpoint monitoring)""" - # ... (existant, déjà mis à jour avec les nouveaux caches) + def lire_livraison_cache(self, numero): + return self._lire_document_sql(numero, type_doc=3) - def forcer_actualisation_cache(self): - """Force l'actualisation immédiate du cache (endpoint admin)""" - logger.info("🔄 Actualisation forcée de TOUS les caches...") + def lister_tous_avoirs_cache(self, filtre=""): + return self._lister_documents_avec_lignes_sql(type_doc=5, filtre=filtre) - self._refresh_cache_clients() - self._refresh_cache_articles() - self._refresh_cache_devis() - self._refresh_cache_commandes() - self._refresh_cache_factures() - self._refresh_cache_fournisseurs() - - logger.info("✅ Tous les caches actualisés") - - def get_cache_info(self): - """Retourne les infos du cache (endpoint monitoring)""" - with self._lock_clients: - info = { - "clients": { - "count": len(self._cache_clients), - "last_update": ( - self._cache_clients_last_update.isoformat() - if self._cache_clients_last_update - else None - ), - "age_minutes": ( - ( - datetime.now() - self._cache_clients_last_update - ).total_seconds() - / 60 - if self._cache_clients_last_update - else None - ), - }, - "articles": { - "count": len(self._cache_articles), - "last_update": ( - self._cache_articles_last_update.isoformat() - if self._cache_articles_last_update - else None - ), - "age_minutes": ( - ( - datetime.now() - self._cache_articles_last_update - ).total_seconds() - / 60 - if self._cache_articles_last_update - else None - ), - }, - # ✅ NOUVEAUX CACHES - "devis": { - "count": len(self._cache_devis), - "last_update": ( - self._cache_devis_last_update.isoformat() - if self._cache_devis_last_update - else None - ), - "age_minutes": ( - (datetime.now() - self._cache_devis_last_update).total_seconds() - / 60 - if self._cache_devis_last_update - else None - ), - }, - "commandes": { - "count": len(self._cache_commandes), - "last_update": ( - self._cache_commandes_last_update.isoformat() - if self._cache_commandes_last_update - else None - ), - "age_minutes": ( - ( - datetime.now() - self._cache_commandes_last_update - ).total_seconds() - / 60 - if self._cache_commandes_last_update - else None - ), - }, - "factures": { - "count": len(self._cache_factures), - "last_update": ( - self._cache_factures_last_update.isoformat() - if self._cache_factures_last_update - else None - ), - "age_minutes": ( - ( - datetime.now() - self._cache_factures_last_update - ).total_seconds() - / 60 - if self._cache_factures_last_update - else None - ), - }, - "fournisseurs": { - "count": len(self._cache_fournisseurs), - "last_update": ( - self._cache_fournisseurs_last_update.isoformat() - if self._cache_fournisseurs_last_update - else None - ), - "age_minutes": ( - ( - datetime.now() - self._cache_fournisseurs_last_update - ).total_seconds() - / 60 - if self._cache_fournisseurs_last_update - else None - ), - }, - } - - info["ttl_minutes"] = self._cache_ttl_minutes - return info + def lire_avoir_cache(self, numero): + return self._lire_document_sql(numero, type_doc=5) # ========================================================================= # CAST HELPERS @@ -1937,16 +1502,7 @@ class SageConnector: except: return None - # ========================================================================= - # EXTRACTION - # ========================================================================= - def _extraire_client(self, client_obj): - """ - ✅ CORRECTION : Extraction ULTRA-ROBUSTE pour clients ET fournisseurs - - Gère tous les cas où des champs peuvent être manquants - """ try: # === 1. CHAMPS OBLIGATOIRES === try: @@ -1966,78 +1522,318 @@ class SageConnector: logger.debug(f"⚠️ Erreur CT_Intitule sur {numero}: {e}") intitule = "" - # === 2. CONSTRUCTION OBJET MINIMAL === + # === 2. CONSTRUCTION OBJET DE BASE === data = { "numero": numero, "intitule": intitule, } - # === 3. CHAMPS OPTIONNELS (avec try-except individuels) === - - # Type + # === 3. TYPE DE TIERS (CLIENT/PROSPECT/FOURNISSEUR) === + # CT_Qualite : 0=Client, 1=Fournisseur, 2=Client+Fournisseur, 3=Salarié, 4=Prospect try: - data["type"] = getattr(client_obj, "CT_Type", 0) - except: - data["type"] = 0 + 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] - # Qualité - try: - qualite = getattr(client_obj, "CT_Qualite", None) - data["qualite"] = qualite - data["est_fournisseur"] = ( - qualite in [2, 3] if qualite is not None else False - ) except: - data["qualite"] = None + data["qualite"] = "CLI" data["est_fournisseur"] = False - # Prospect + # CT_Prospect : 0=Non, 1=Oui try: data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1 except: data["est_prospect"] = False - # === 4. ADRESSE (non critique) === + # 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: - adresse = getattr(client_obj, "Adresse", None) - if adresse: + 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, "Adresse", "").strip() + data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() except: data["adresse"] = "" try: - data["code_postal"] = getattr(adresse, "CodePostal", "").strip() + 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, "Ville", "").strip() + 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"] = "" - # === 5. TELECOM (non critique) === + # === 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 @@ -2046,34 +1842,675 @@ class SageConnector: return None def _extraire_article(self, article_obj): - return { - "reference": getattr(article_obj, "AR_Ref", ""), - "designation": getattr(article_obj, "AR_Design", ""), - "prix_vente": getattr(article_obj, "AR_PrixVen", 0.0), - "prix_achat": getattr(article_obj, "AR_PrixAch", 0.0), - "stock_reel": getattr(article_obj, "AR_Stock", 0.0), - "stock_mini": getattr(article_obj, "AR_StockMini", 0.0), - } + try: + data = { + # === IDENTIFICATION === + "reference": getattr(article_obj, "AR_Ref", "").strip(), + "designation": getattr(article_obj, "AR_Design", "").strip(), + } - # ========================================================================= - # CRÉATION DEVIS (US-A1) - VERSION TRANSACTIONNELLE - # ========================================================================= + # === 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 creer_devis_enrichi(self, devis_data: dict, forcer_brouillon: bool = False): - """ - Création de devis OPTIMISÉE - Version hybride - - Args: - devis_data: Données du devis - forcer_brouillon: Si True, crée en statut 0 (Brouillon) - Si False, laisse Sage décider (généralement statut 2) - - ✅ AVANTAGES: - - Rapide comme l'ancienne version - - Possibilité de forcer en brouillon si nécessaire - - Pas d'attentes inutiles - - Relecture simplifiée - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -2383,405 +2820,105 @@ class SageConnector: logger.error(f"❌ ERREUR CRÉATION DEVIS: {e}", exc_info=True) raise RuntimeError(f"Échec création devis: {str(e)}") - # ========================================================================= - # LECTURE DEVIS - # ========================================================================= - def lire_devis(self, numero_devis): - """ - Lecture d'un devis (y compris brouillon) - ✅ ENRICHI: Inclut maintenant a_deja_ete_transforme - ❌ N'utilise JAMAIS List() - uniquement ReadPiece - """ - if not self.cial: - return None - try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente + # Lire le devis via SQL + devis = self._lire_document_sql(numero_devis, type_doc=0) - # ✅ UNIQUEMENT ReadPiece - try: - persist = factory.ReadPiece(0, numero_devis) - if persist: - logger.info(f"✅ Devis {numero_devis} trouvé via ReadPiece") - else: - logger.warning( - f"❌ Devis {numero_devis} introuvable via ReadPiece" - ) - return None - except Exception as e: - logger.error(f"❌ ReadPiece échoué pour {numero_devis}: {e}") - return None + if not devis: + return None - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # ✅ Charger client - client_code = "" - client_intitule = "" - - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - client_intitule = getattr(client_obj, "CT_Intitule", "").strip() - except Exception as e: - logger.debug(f"Erreur chargement client: {e}") - - devis = { - "numero": getattr(doc, "DO_Piece", ""), - "date": str(getattr(doc, "DO_Date", "")), - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), - "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), - "statut": getattr(doc, "DO_Statut", 0), - "lignes": [], - } - - # ✅✅ NOUVEAU: Vérifier si déjà transformé - try: - verif = self.verifier_si_deja_transforme(numero_devis, 0) - devis["a_deja_ete_transforme"] = verif.get("deja_transforme", False) - devis["documents_cibles"] = verif.get("documents_cibles", []) - - logger.info( - f"📊 Devis {numero_devis}: " - f"transformé={devis['a_deja_ete_transforme']}, " - f"nb_docs_cibles={len(devis['documents_cibles'])}" - ) - except Exception as e: - logger.warning(f"⚠️ Erreur vérification transformation: {e}") - devis["a_deja_ete_transforme"] = False - devis["documents_cibles"] = [] - - # Lecture des lignes - try: - factory_lignes = doc.FactoryDocumentLigne - except: - factory_lignes = doc.FactoryDocumentVenteLigne - - index = 1 - while True: - try: - ligne_persist = factory_lignes.List(index) - if ligne_persist is None: - break - - ligne = win32com.client.CastTo( - ligne_persist, "IBODocumentLigne3" - ) - ligne.Read() - - # Charger article - article_ref = "" - try: - article_ref = getattr(ligne, "AR_Ref", "").strip() - if not article_ref: - article_obj = getattr(ligne, "Article", None) - if article_obj: - article_obj.Read() - article_ref = getattr( - article_obj, "AR_Ref", "" - ).strip() - except Exception as e: - logger.debug( - f"Erreur chargement article ligne {index}: {e}" - ) - - devis["lignes"].append( - { - "article": article_ref, - "designation": getattr(ligne, "DL_Design", ""), - "quantite": float(getattr(ligne, "DL_Qte", 0.0)), - "prix_unitaire": float( - getattr(ligne, "DL_PrixUnitaire", 0.0) - ), - "montant_ht": float( - getattr(ligne, "DL_MontantHT", 0.0) - ), - } - ) - - index += 1 - except Exception as e: - logger.debug(f"Erreur lecture ligne {index}: {e}") - break - - logger.info( - f"✅ Devis {numero_devis} lu: {len(devis['lignes'])} lignes, " - f"{devis['total_ttc']:.2f}€, statut={devis['statut']}, " - f"transformé={devis['a_deja_ete_transforme']}" + # ✅ Vérifier si transformé (via SQL pour DO_Ref) + try: + verification = self.verifier_si_deja_transforme_sql(numero_devis, 0) + devis["a_deja_ete_transforme"] = verification.get( + "deja_transforme", False ) - return devis + devis["documents_cibles"] = verification.get("documents_cibles", []) + except Exception as e: + logger.warning(f"⚠️ Erreur vérification transformation: {e}") + devis["a_deja_ete_transforme"] = False + devis["documents_cibles"] = [] + + logger.info( + f"✅ SQL: Devis {numero_devis} lu ({len(devis['lignes'])} lignes)" + ) + return devis except Exception as e: - logger.error(f"❌ Erreur lecture devis {numero_devis}: {e}", exc_info=True) + logger.error(f"❌ Erreur SQL lecture devis {numero_devis}: {e}") return None def lire_document(self, numero, type_doc): - """ - Lecture générique document - ✅ AJOUT: Retourne maintenant DO_Ref - """ + return self._lire_document_sql(numero, type_doc) + + def verifier_si_deja_transforme_sql( + self, numero_source: str, type_source: int + ) -> Dict: try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente - persist = factory.ReadPiece(type_doc, numero) + with self._get_sql_connection() as conn: + cursor = conn.cursor() - if not persist: - return None + # Types cibles selon le type source + types_cibles_map = { + 0: [10, 60], # Devis → Commande ou Facture + 10: [30, 60], # Commande → BL ou Facture + 30: [60], # BL → Facture + } - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() + types_cibles = types_cibles_map.get(type_source, []) - # Charger client via .Client - client_code = "" - client_intitule = "" + if not types_cibles: + return {"deja_transforme": False, "documents_cibles": []} - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - client_intitule = getattr(client_obj, "CT_Intitule", "").strip() - except Exception as e: - logger.debug(f"Erreur chargement client: {e}") + # Construire la clause WHERE avec OR pour chaque type + placeholders = ",".join(["?"] * len(types_cibles)) - # Lire lignes - lignes = [] - try: - factory_lignes = doc.FactoryDocumentLigne - except: - factory_lignes = doc.FactoryDocumentVenteLigne + query = f""" + SELECT DO_Piece, DO_Type, DO_Date, DO_Ref, DO_TotalTTC, DO_Statut + FROM F_DOCENTETE + WHERE DO_Ref LIKE ? + AND DO_Type IN ({placeholders}) + ORDER BY DO_Date DESC + """ - index = 1 - while True: - try: - ligne_p = factory_lignes.List(index) - if ligne_p is None: - break + params = [f"%{numero_source}%"] + types_cibles - ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") - ligne.Read() + cursor.execute(query, params) + rows = cursor.fetchall() - # Charger article via .Article - article_ref = "" - try: - article_ref = getattr(ligne, "AR_Ref", "").strip() - if not article_ref: - article_obj = getattr(ligne, "Article", None) - if article_obj: - article_obj.Read() - article_ref = getattr( - article_obj, "AR_Ref", "" - ).strip() - except: - pass + documents_cibles = [] - lignes.append( + for row in rows: + # Vérifier que DO_Ref correspond exactement (éviter les faux positifs) + ref_origine = self._safe_strip(row.DO_Ref) + + if numero_source in ref_origine or ref_origine == numero_source: + type_libelle = { + 10: "Bon de commande", + 30: "Bon de livraison", + 60: "Facture", + }.get(row.DO_Type, f"Type {row.DO_Type}") + + documents_cibles.append( { - "article": article_ref, - "designation": getattr(ligne, "DL_Design", ""), - "quantite": getattr(ligne, "DL_Qte", 0.0), - "prix_unitaire": getattr(ligne, "DL_PrixUnitaire", 0.0), - "montant_ht": getattr(ligne, "DL_MontantHT", 0.0), + "numero": self._safe_strip(row.DO_Piece), + "type": row.DO_Type, + "type_libelle": type_libelle, + "date": str(row.DO_Date) if row.DO_Date else "", + "reference": ref_origine, + "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 + ), + "methode_detection": "sql_do_ref", } ) - index += 1 - except: - break - - return { - "numero": getattr(doc, "DO_Piece", ""), - "reference": getattr(doc, "DO_Ref", ""), # ✅ AJOUT - "date": str(getattr(doc, "DO_Date", "")), - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": getattr(doc, "DO_TotalHT", 0.0), - "total_ttc": getattr(doc, "DO_TotalTTC", 0.0), - "statut": getattr(doc, "DO_Statut", 0), - "lignes": lignes, - } - except Exception as e: - logger.error(f"❌ Erreur lecture document: {e}") - return None - - def verifier_si_deja_transforme(self, numero_source: str, type_source: int) -> Dict: - """ - 🔍 Vérifie si un document a déjà été transformé - - ✅ ULTRA-OPTIMISÉ: Utilise ReadPiece avec DO_Ref au lieu de scanner List() - - Performance: - - Ancienne méthode: 30+ secondes (scan de 10000+ documents) - - Nouvelle méthode: < 1 seconde (lectures directes ciblées) - - Stratégie: - 1. Construire les numéros potentiels basés sur les conventions Sage - 2. Tester directement avec ReadPiece - 3. Limite stricte de 50 documents à scanner en dernier recours - - Returns: - { - "deja_transforme": bool, - "documents_cibles": [ - {"numero": "BC00001", "type": 10, "date": "..."} - ] - } - """ - if not self.cial: - return {"deja_transforme": False, "documents_cibles": []} - - try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente - documents_cibles = [] - - logger.info(f"🔍 Vérification transformations pour {numero_source}...") - - # ======================================== - # MÉTHODE 1: DEVINER LES NUMÉROS CIBLES PAR CONVENTION - # ======================================== - # Extraire le numéro de base (ex: "00001" depuis "DE00001") - import re - - match = re.search(r"(\d+)$", numero_source) - - if match: - numero_base = match.group(1) - - # Mapper les préfixes selon les types - prefixes_par_type = { - 10: ["BC", "CMD"], # Bon de commande - 30: ["BL", "LIV"], # Bon de livraison - 60: ["FA", "FACT"], # Facture - } - - # Types cibles possibles selon le type source - types_cibles_possibles = { - 0: [10, 60], # Devis → Commande ou Facture - 10: [30, 60], # Commande → BL ou Facture - 30: [60], # BL → Facture - } - - types_a_tester = types_cibles_possibles.get(type_source, []) - - # Tester chaque combinaison type/préfixe - for type_cible in types_a_tester: - for prefix in prefixes_par_type.get(type_cible, []): - numero_potentiel = f"{prefix}{numero_base}" - - try: - persist = factory.ReadPiece( - type_cible, numero_potentiel - ) - - if persist: - doc = win32com.client.CastTo( - persist, "IBODocumentVente3" - ) - doc.Read() - - # Vérifier que DO_Ref correspond bien - ref_origine = getattr(doc, "DO_Ref", "").strip() - - if ( - numero_source in ref_origine - or ref_origine == numero_source - ): - documents_cibles.append( - { - "numero": getattr(doc, "DO_Piece", ""), - "type": type_cible, - "type_libelle": self._get_type_libelle( - type_cible - ), - "date": str( - getattr(doc, "DO_Date", "") - ), - "reference": ref_origine, - "total_ttc": float( - getattr(doc, "DO_TotalTTC", 0.0) - ), - "statut": getattr(doc, "DO_Statut", -1), - "methode_detection": "convention_nommage", - } - ) - - logger.info( - f"✅ Trouvé via convention: {numero_potentiel} " - f"(DO_Ref={ref_origine})" - ) - except: - # Ce numéro n'existe pas, continuer - continue - - # ======================================== - # MÉTHODE 2: SCAN ULTRA-LIMITÉ (max 50 documents) - # ======================================== - # Seulement si rien trouvé ET que c'est critique - if not documents_cibles: - logger.info(f"🔍 Scan limité (max 50 documents)...") - - index = 1 - max_scan = 100 # ⚡ LIMITE STRICTE à 50 au lieu de 500 - - while index < max_scan: - try: - persist = factory.List(index) - if persist is None: - break - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # Vérifier DO_Ref - ref_origine = getattr(doc, "DO_Ref", "").strip() - - if ( - numero_source in ref_origine - or ref_origine == numero_source - ): - doc_type = getattr(doc, "DO_Type", -1) - - documents_cibles.append( - { - "numero": getattr(doc, "DO_Piece", ""), - "type": doc_type, - "type_libelle": self._get_type_libelle( - doc_type - ), - "date": str(getattr(doc, "DO_Date", "")), - "reference": ref_origine, - "total_ttc": float( - getattr(doc, "DO_TotalTTC", 0.0) - ), - "statut": getattr(doc, "DO_Statut", -1), - "methode_detection": "scan_limite", - } - ) - - logger.info( - f"✅ Trouvé via scan: {getattr(doc, 'DO_Piece', '')} " - f"à l'index {index}" - ) - - index += 1 - - except Exception as e: - index += 1 - continue - - # ======================================== - # RÉSULTAT - # ======================================== logger.info( - f"📊 Résultat vérification {numero_source}: " - f"{len(documents_cibles)} transformation(s) trouvée(s)" + f"✅ SQL: Vérification {numero_source} → {len(documents_cibles)} transformation(s)" ) return { @@ -2791,7 +2928,7 @@ class SageConnector: } except Exception as e: - logger.error(f"❌ Erreur vérification transformation: {e}") + logger.error(f"❌ Erreur SQL vérification transformation: {e}") return {"deja_transforme": False, "documents_cibles": []} def _get_type_libelle(self, type_doc: int) -> str: @@ -2807,14 +2944,9 @@ class SageConnector: } return types.get(type_doc, f"Type {type_doc}") - def transformer_document(self, numero_source, type_source, type_cible): - """ - 🔧 Transformation de document - VERSION FUSIONNÉE FINALE - - ✅ Copie DO_Ref du source vers la cible (du nouveau) - ✅ Ne modifie JAMAIS le statut du document source - ✅ Préserve toutes les lignes correctement (de l'ancien) - """ + def transformer_document( + self, numero_source, type_source, type_cible, ignorer_controle_stock=True + ): if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -2822,52 +2954,122 @@ class SageConnector: type_cible = int(type_cible) logger.info( - f"[TRANSFORM] Demande : {numero_source} " - f"(type {type_source}) -> type {type_cible}" + f"[TRANSFORM] {numero_source} (type {type_source}) → type {type_cible}" ) - # Matrice de transformations - transformations_autorisees = { - (0, 10): "Devis -> Commande", - (10, 30): "Commande -> Bon de livraison", - (10, 60): "Commande -> Facture", - (30, 60): "Bon de livraison -> Facture", - (0, 60): "Devis -> Facture", + transformations_valides = { + (0, 10), # Devis → Commande + (0, 60), # Devis → Facture + (10, 30), # Commande → Bon de livraison + (10, 60), # Commande → Facture + (30, 60), # Bon de livraison → Facture } - if (type_source, type_cible) not in transformations_autorisees: + if (type_source, type_cible) not in transformations_valides: raise ValueError( f"Transformation non autorisée: {type_source} -> {type_cible}" ) # ======================================== - # VÉRIFICATION AUTOMATIQUE DES DOUBLONS + # FONCTION UTILITAIRE # ======================================== - logger.info("[TRANSFORM] Vérification des doublons via DO_Ref...") + def lire_erreurs_sage(obj, nom_obj=""): + """Lit toutes les erreurs d'un objet Sage COM""" + 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 + 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 = "" + + for attr in ["Description", "Descr", "Message", "Text"]: + try: + val = getattr(err, attr, None) + if val: + description = str(val) + break + except: + pass + + for attr in ["Field", "FieldName", "Champ", "Property"]: + try: + val = getattr(err, attr, None) + if val: + field = str(val) + break + except: + pass + + 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 + + # Vérification doublons + logger.info("[TRANSFORM] Vérification des doublons via DO_Ref...") verification = self.verifier_si_deja_transforme(numero_source, type_source) if verification["deja_transforme"]: docs_existants = verification["documents_cibles"] - docs_meme_type = [d for d in docs_existants if d["type"] == type_cible] if docs_meme_type: nums = [d["numero"] for d in docs_meme_type] - error_msg = ( f"❌ Le document {numero_source} a déjà été transformé " f"en {self._get_type_libelle(type_cible)}. " f"Document(s) existant(s) : {', '.join(nums)}" ) - logger.error(f"[TRANSFORM] {error_msg}") raise ValueError(error_msg) - else: - logger.warning( - f"[TRANSFORM] ⚠️ Le document {numero_source} a déjà été transformé " - f"{len(docs_existants)} fois vers d'autres types" - ) try: with self._com_context(), self._lock_com: @@ -2890,43 +3092,99 @@ class SageConnector: doc_source.Read() statut_actuel = getattr(doc_source, "DO_Statut", 0) - type_reel = getattr(doc_source, "DO_Type", -1) - logger.info( - f"[TRANSFORM] Source: type={type_reel}, statut={statut_actuel}" + f"[TRANSFORM] Source: type={type_source}, statut={statut_actuel}" ) # ======================================== - # ÉTAPE 2 : EXTRAIRE LES DONNÉES SOURCE + # ÉTAPE 2 : EXTRAIRE DONNÉES SOURCE # ======================================== logger.info("[TRANSFORM] Extraction données source...") # Client client_code = "" - client_obj = None + client_obj_source = None try: - client_obj = getattr(doc_source, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() + client_obj_source = getattr(doc_source, "Client", None) + if client_obj_source: + client_obj_source.Read() + client_code = getattr(client_obj_source, "CT_Num", "").strip() except Exception as e: - logger.error(f"Erreur lecture client: {e}") - raise ValueError(f"Impossible de lire le client du document source") + logger.error(f"Erreur lecture client source: {e}") + raise ValueError("Impossible de lire le client du document source") if not client_code: raise ValueError("Client introuvable dans document source") logger.info(f"[TRANSFORM] Client: {client_code}") - # Date + # Date et référence date_source = getattr(doc_source, "DO_Date", None) - - # ✅ NOUVEAU: Référence externe (DO_Ref) - UTILISER LE NUMÉRO SOURCE reference_pour_cible = numero_source logger.info(f"[TRANSFORM] Référence à copier: {reference_pour_cible}") - # Lignes + # Champs à copier + champs_source = {} + champs_a_copier = [ + "DO_Souche", + "DO_Regime", + "DO_CodeJournal", + "DO_Coord01", + "DO_TypeCalcul", + "DO_Devise", + "DO_Cours", + "DO_Period", + "DO_Expedit", + "DO_NbFacture", + "DO_BLFact", + "DO_TxEsworte", + "DO_Reliquat", + "DO_Imprim", + "DO_Ventile", + "DO_Motif", + ] + + for champ in champs_a_copier: + try: + val = getattr(doc_source, champ, None) + if val is not None: + champs_source[champ] = val + except: + pass + + # Infos règlement client + client_mode_regl = None + client_cond_regl = None + + if client_obj_source: + try: + client_mode_regl = getattr( + client_obj_source, "CT_ModeRegl", None + ) + if client_mode_regl: + logger.info( + f"[TRANSFORM] Mode règlement client: {client_mode_regl}" + ) + except: + pass + + try: + client_cond_regl = getattr( + client_obj_source, "CT_CondRegl", None + ) + if client_cond_regl: + logger.info( + f"[TRANSFORM] Conditions règlement client: {client_cond_regl}" + ) + except: + pass + + # ======================================== + # ÉTAPE 3 : EXTRACTION LIGNES + # ======================================== lignes_source = [] + factory_article = self.cial.FactoryArticle + try: factory_lignes_source = getattr( doc_source, "FactoryDocumentLigne", None @@ -2949,7 +3207,7 @@ class SageConnector: ) ligne.Read() - # Récupérer référence article + # Référence article article_ref = "" try: article_ref = getattr(ligne, "AR_Ref", "").strip() @@ -2963,6 +3221,42 @@ class SageConnector: except: pass + # Prix unitaire + prix_unitaire = float( + getattr(ligne, "DL_PrixUnitaire", 0.0) + ) + + # Si prix = 0, récupérer depuis l'article + if prix_unitaire == 0 and article_ref: + try: + persist_article = factory_article.ReadReference( + article_ref + ) + if persist_article: + article_obj_price = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) + article_obj_price.Read() + prix_unitaire = float( + getattr( + article_obj_price, "AR_PrixVen", 0.0 + ) + ) + logger.info( + f" Prix récupéré depuis article {article_ref}: {prix_unitaire}€" + ) + except: + pass + + # ✅ NOUVEAU : Récupérer le DL_MvtStock de la ligne source + mvt_stock_source = 0 + try: + mvt_stock_source = int( + getattr(ligne, "DL_MvtStock", 0) + ) + except: + pass + lignes_source.append( { "article_ref": article_ref, @@ -2970,15 +3264,17 @@ class SageConnector: "quantite": float( getattr(ligne, "DL_Qte", 0.0) ), - "prix_unitaire": float( - getattr(ligne, "DL_PrixUnitaire", 0.0) - ), + "prix_unitaire": prix_unitaire, "remise": float( getattr(ligne, "DL_Remise01REM_Valeur", 0.0) ), "type_remise": int( getattr(ligne, "DL_Remise01REM_Type", 0) ), + "montant_ht": float( + getattr(ligne, "DL_MontantHT", 0.0) + ), + "mvt_stock": mvt_stock_source, # ✅ Conservé ! } ) @@ -2999,8 +3295,13 @@ class SageConnector: if nb_lignes == 0: raise ValueError("Document source vide (aucune ligne)") + total_attendu_ht = sum(l["montant_ht"] for l in lignes_source) + logger.info( + f"[TRANSFORM] Total HT attendu (calculé): {total_attendu_ht}€" + ) + # ======================================== - # ÉTAPE 3 : TRANSACTION + # ÉTAPE 4 : TRANSACTION # ======================================== transaction_active = False try: @@ -3012,7 +3313,7 @@ class SageConnector: try: # ======================================== - # ÉTAPE 4 : CRÉER LE DOCUMENT CIBLE + # ÉTAPE 5 : CRÉER DOCUMENT CIBLE # ======================================== logger.info(f"[TRANSFORM] Création document type {type_cible}...") @@ -3033,7 +3334,7 @@ class SageConnector: logger.info("[TRANSFORM] Document cible créé") # ======================================== - # ÉTAPE 5 : DÉFINIR LA DATE + # ÉTAPE 6 : DÉFINIR LA DATE # ======================================== import pywintypes @@ -3048,7 +3349,7 @@ class SageConnector: doc_cible.DO_Date = pywintypes.Time(datetime.now()) # ======================================== - # ÉTAPE 6 : ASSOCIER LE CLIENT + # ÉTAPE 7 : ASSOCIER LE CLIENT # ======================================== logger.info(f"[TRANSFORM] Association client {client_code}...") @@ -3063,17 +3364,18 @@ class SageConnector: raise ValueError(f"Impossible de charger client {client_code}") try: - doc_cible.SetClient(client_obj_cible) - logger.info( - f"[TRANSFORM] SetClient() appelé pour {client_code}" - ) - except Exception as e: - logger.warning( - f"[TRANSFORM] SetClient() échoue: {e}, tentative SetDefaultClient()" - ) doc_cible.SetDefaultClient(client_obj_cible) + logger.info("[TRANSFORM] SetDefaultClient() appelé") + except Exception as e: + try: + doc_cible.Client = client_obj_cible + logger.info( + "[TRANSFORM] Client assigné via propriété .Client" + ) + except Exception as e2: + raise ValueError(f"Impossible d'associer le client: {e2}") - # ✅ FUSION: Définir DO_Ref AVANT le premier Write() + # DO_Ref AVANT 1er Write try: doc_cible.DO_Ref = reference_pour_cible logger.info( @@ -3082,41 +3384,79 @@ class SageConnector: except Exception as e: logger.warning(f"Impossible de définir DO_Ref: {e}") - doc_cible.Write() - - # Vérifier que le client est bien attaché - doc_cible.Read() - client_verifie = getattr(doc_cible, "CT_Num", None) - - if not client_verifie: + # ======================================== + # ÉTAPE 7.5 : COPIER CHAMPS + # ======================================== + for champ, valeur in champs_source.items(): try: - client_test = getattr(doc_cible, "Client", None) - if client_test: - client_test.Read() - client_verifie = getattr(client_test, "CT_Num", None) - except: - pass - - if not client_verifie: - raise ValueError(f"Echec association client {client_code}") - - logger.info( - f"[TRANSFORM] Client {client_code} associé (CT_Num={client_verifie})" - ) - - client_obj_sauvegarde = client_obj_cible + setattr(doc_cible, champ, valeur) + logger.debug(f"[TRANSFORM] {champ} copié: {valeur}") + except Exception as e: + logger.debug(f"[TRANSFORM] {champ} non copié: {e}") # ======================================== - # ÉTAPE 7 : COPIER LES LIGNES + # ÉTAPE 7.6 : CHAMPS SPÉCIFIQUES FACTURES + # ======================================== + if type_cible == 60: + logger.info("[TRANSFORM] Configuration champs factures...") + + # DO_Souche + try: + souche = champs_source.get("DO_Souche", 0) + doc_cible.DO_Souche = souche + logger.debug(f" ✅ DO_Souche: {souche}") + except Exception as e: + logger.debug(f" ⚠️ DO_Souche: {e}") + + # DO_Regime + try: + regime = champs_source.get("DO_Regime", 0) + doc_cible.DO_Regime = regime + logger.debug(f" ✅ DO_Regime: {regime}") + except Exception as e: + logger.debug(f" ⚠️ DO_Regime: {e}") + + # DO_Transaction + try: + doc_cible.DO_Transaction = 11 + logger.debug(f" ✅ DO_Transaction: 11") + except Exception as e: + logger.debug(f" ⚠️ DO_Transaction: {e}") + + # Mode règlement + if client_mode_regl: + try: + doc_cible.DO_ModeRegl = client_mode_regl + logger.info(f" ✅ DO_ModeRegl: {client_mode_regl}") + except Exception as e: + logger.debug(f" ⚠️ DO_ModeRegl: {e}") + + # Conditions règlement + if client_cond_regl: + try: + doc_cible.DO_CondRegl = client_cond_regl + logger.info(f" ✅ DO_CondRegl: {client_cond_regl}") + except Exception as e: + logger.debug(f" ⚠️ DO_CondRegl: {e}") + + # 1er Write + doc_cible.Write() + logger.info("[TRANSFORM] Document initialisé (1er Write)") + + # ======================================== + # ÉTAPE 8 : COPIER LIGNES (AVEC GESTION STOCK) # ======================================== logger.info(f"[TRANSFORM] Copie de {nb_lignes} lignes...") + if ignorer_controle_stock: + logger.info("[TRANSFORM] ⚠️ Contrôle de stock DÉSACTIVÉ") + try: factory_lignes_cible = doc_cible.FactoryDocumentLigne except: factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne - factory_article = self.cial.FactoryArticle + lignes_creees = 0 for idx, ligne_data in enumerate(lignes_source, 1): logger.debug(f"[TRANSFORM] Ligne {idx}/{nb_lignes}") @@ -3151,20 +3491,58 @@ class SageConnector: ) quantite = ligne_data["quantite"] + prix = ligne_data["prix_unitaire"] + # ✅ CRITIQUE : Associer l'article AVANT de définir les flags stock try: ligne_obj.SetDefaultArticleReference(article_ref, quantite) - except: + logger.debug(f" SetDefaultArticleReference OK") + except Exception as e1: + logger.debug(f" SetDefaultArticleReference échoué: {e1}") try: ligne_obj.SetDefaultArticle(article_obj, quantite) - except: + logger.debug(f" SetDefaultArticle OK") + except Exception as e2: + logger.debug(f" SetDefaultArticle échoué: {e2}") ligne_obj.DL_Design = ligne_data["designation"] ligne_obj.DL_Qte = quantite + logger.debug(f" Configuration manuelle") - prix = ligne_data["prix_unitaire"] + # ✅ APRÈS association : Désactiver le contrôle de stock + if ignorer_controle_stock: + # Méthode 1 : Copier DL_MvtStock depuis la source + try: + mvt_stock_source = ligne_data.get("mvt_stock", 0) + ligne_obj.DL_MvtStock = mvt_stock_source + logger.debug( + f" ✅ DL_MvtStock = {mvt_stock_source} (copié depuis source)" + ) + except Exception as e: + logger.debug(f" ⚠️ DL_MvtStock: {e}") + + # Méthode 2 : DL_NoStock = 1 + try: + ligne_obj.DL_NoStock = 1 + logger.debug(f" ✅ DL_NoStock = 1") + except Exception as e: + logger.debug(f" ⚠️ DL_NoStock: {e}") + + # ✅ MÉTHODE 3 CRITIQUE : DL_NonLivre = quantité + # Indique que la quantité n'est PAS livrée, donc pas de sortie de stock + try: + ligne_obj.DL_NonLivre = quantite + logger.debug( + f" ✅ DL_NonLivre = {quantite} (évite sortie stock)" + ) + except Exception as e: + logger.debug(f" ⚠️ DL_NonLivre: {e}") + + # Prix unitaire if prix > 0: ligne_obj.DL_PrixUnitaire = float(prix) + logger.debug(f" Prix forcé: {prix}€") + # Remise remise = ligne_data["remise"] if remise > 0: try: @@ -3172,111 +3550,103 @@ class SageConnector: ligne_obj.DL_Remise01REM_Type = ligne_data[ "type_remise" ] + logger.debug(f" Remise: {remise}%") except: pass - ligne_obj.Write() - # ✅ FUSION: Log détaillé de la ligne écrite - logger.debug( - f"[TRANSFORM] Ligne {idx} écrite: {article_ref} x{quantite} @ {prix}€" - ) - - logger.info(f"[TRANSFORM] {nb_lignes} lignes copiées") - - # ======================================== - # ÉTAPE 8 : COMPLÉTER LES CHAMPS OBLIGATOIRES - # ======================================== - if type_cible == 60: # Facture - logger.info( - "[TRANSFORM] Complétion champs obligatoires facture..." - ) - + # Écrire la ligne try: - journal = ( - getattr(doc_source, "DO_CodeJournal", None) or "VTE" - ) - if hasattr(doc_cible, "DO_CodeJournal"): - doc_cible.DO_CodeJournal = journal + ligne_obj.Write() + lignes_creees += 1 + logger.debug(f" ✅ Ligne {idx} écrite") except Exception as e: - logger.warning(f"Code journal: {e}") + logger.error(f" ❌ Erreur écriture ligne {idx}: {e}") - try: - souche = getattr(doc_source, "DO_Souche", 0) - if hasattr(doc_cible, "DO_Souche"): - doc_cible.DO_Souche = souche - except: - pass + # Lire erreurs + erreurs_ligne = lire_erreurs_sage(ligne_obj, f"Ligne_{idx}") + for err in erreurs_ligne: + logger.error( + f" {err['field']}: {err['description']}" + ) - try: - regime = getattr(doc_source, "DO_Regime", None) - if regime is not None and hasattr(doc_cible, "DO_Regime"): - doc_cible.DO_Regime = regime - except: - pass + continue + + logger.info(f"[TRANSFORM] {lignes_creees} lignes créées") + + if lignes_creees == 0: + raise ValueError("Aucune ligne n'a pu être créée") # ======================================== - # ÉTAPE 9 : RÉASSOCIER LE CLIENT + # ÉTAPE 9 : WRITE FINAL # ======================================== - logger.info("[TRANSFORM] Réassociation client avant validation...") - - try: - doc_cible.SetClient(client_obj_sauvegarde) - except: - doc_cible.SetDefaultClient(client_obj_sauvegarde) - - logger.info("[TRANSFORM] Écriture document finale...") + logger.info("[TRANSFORM] Write() final avant Process()...") doc_cible.Write() # ======================================== - # ÉTAPE 10 : VALIDER LE DOCUMENT + # ÉTAPE 10 : PROCESS() # ======================================== - logger.info("[TRANSFORM] Validation document cible...") - - doc_cible.Read() - - client_final = getattr(doc_cible, "CT_Num", None) - - if not client_final: - try: - client_obj_test = getattr(doc_cible, "Client", None) - if client_obj_test: - client_obj_test.Read() - client_final = getattr(client_obj_test, "CT_Num", None) - except: - pass - - if not client_final: - logger.warning( - "Client perdu ! Tentative réassociation urgence..." - ) - try: - doc_cible.SetClient(client_obj_sauvegarde) - except: - doc_cible.SetDefaultClient(client_obj_sauvegarde) - - doc_cible.Write() - doc_cible.Read() - - client_final = getattr(doc_cible, "CT_Num", None) - - if not client_final: - raise ValueError(f"Client {client_code} impossible à associer") - - logger.info(f"[TRANSFORM] ✅ Client confirmé: {client_final}") + logger.info("[TRANSFORM] Appel Process()...") try: - logger.info("[TRANSFORM] Appel Process()...") process.Process() - logger.info("[TRANSFORM] Document cible validé avec succès") + logger.info("[TRANSFORM] Process() réussi !") except Exception as e: logger.error(f"[TRANSFORM] ERREUR Process(): {e}") - raise + + toutes_erreurs = [] + + # Erreurs du process + erreurs_process = lire_erreurs_sage(process, "Process") + if erreurs_process: + logger.error( + f"[TRANSFORM] {len(erreurs_process)} erreur(s) process:" + ) + for err in erreurs_process: + logger.error( + f" {err['field']}: {err['description']} (code: {err['number']})" + ) + toutes_erreurs.append( + f"{err['field']}: {err['description']}" + ) + + # Erreurs du document + erreurs_doc = lire_erreurs_sage(doc_cible, "Document") + if erreurs_doc: + logger.error( + f"[TRANSFORM] {len(erreurs_doc)} erreur(s) document:" + ) + for err in erreurs_doc: + logger.error( + f" {err['field']}: {err['description']} (code: {err['number']})" + ) + toutes_erreurs.append( + f"{err['field']}: {err['description']}" + ) + + # Construire message + if toutes_erreurs: + error_msg = ( + f"Process() échoué: {' | '.join(toutes_erreurs)}" + ) + else: + error_msg = f"Process() échoué: {str(e)}" + + # Conseil stock + if "stock" in error_msg.lower() or "2881" in error_msg: + error_msg += " | CONSEIL: Vérifiez le stock ou créez le document manuellement dans Sage." + + logger.error(f"[TRANSFORM] {error_msg}") + raise RuntimeError(error_msg) # ======================================== - # ÉTAPE 11 : RÉCUPÉRER LE NUMÉRO + # ÉTAPE 11 : RÉCUPÉRER NUMÉRO # ======================================== numero_cible = None + total_ht_final = 0.0 + total_ttc_final = 0.0 + + # DocumentResult try: doc_result = process.DocumentResult if doc_result: @@ -3285,19 +3655,45 @@ class SageConnector: ) doc_result.Read() numero_cible = getattr(doc_result, "DO_Piece", "") - except: - pass + total_ht_final = float( + getattr(doc_result, "DO_TotalHT", 0.0) + ) + total_ttc_final = float( + getattr(doc_result, "DO_TotalTTC", 0.0) + ) + logger.info( + f"[TRANSFORM] DocumentResult: {numero_cible}, {total_ht_final}€ HT" + ) + except Exception as e: + logger.debug(f"[TRANSFORM] DocumentResult non disponible: {e}") + + # Relire doc_cible + if not numero_cible: + try: + doc_cible.Read() + numero_cible = getattr(doc_cible, "DO_Piece", "") + total_ht_final = float( + getattr(doc_cible, "DO_TotalHT", 0.0) + ) + total_ttc_final = float( + getattr(doc_cible, "DO_TotalTTC", 0.0) + ) + logger.info( + f"[TRANSFORM] doc_cible relu: {numero_cible}, {total_ht_final}€ HT" + ) + except: + pass if not numero_cible: - numero_cible = getattr(doc_cible, "DO_Piece", "") - - if not numero_cible: - raise RuntimeError("Numéro document cible vide") + raise RuntimeError("Numéro document cible vide après Process()") logger.info(f"[TRANSFORM] Document cible créé: {numero_cible}") + logger.info( + f"[TRANSFORM] Totaux: {total_ht_final}€ HT / {total_ttc_final}€ TTC" + ) # ======================================== - # ÉTAPE 12 : COMMIT (STATUT SOURCE INCHANGÉ) + # ÉTAPE 12 : COMMIT # ======================================== if transaction_active: try: @@ -3306,22 +3702,20 @@ class SageConnector: except: pass - # Attente indexation time.sleep(1) - # ✅ LE DOCUMENT SOURCE GARDE SON STATUT ACTUEL - # ✅ FUSION: Message final clair logger.info( f"[TRANSFORM] ✅ SUCCÈS: {numero_source} ({type_source}) -> " - f"{numero_cible} ({type_cible}) - {nb_lignes} lignes - " - f"Référence: {reference_pour_cible} - Statut source inchangé" + f"{numero_cible} ({type_cible}) - {lignes_creees} lignes" ) return { "success": True, "document_source": numero_source, "document_cible": numero_cible, - "nb_lignes": nb_lignes, + "nb_lignes": lignes_creees, + "total_ht": total_ht_final, + "total_ttc": total_ttc_final, } except Exception as e: @@ -3369,10 +3763,6 @@ class SageConnector: logger.error(f"[TRANSFORM] Erreur recherche document: {e}") return None - # ========================================================================= - # CHAMPS LIBRES (US-A3) - # ========================================================================= - def mettre_a_jour_champ_libre(self, doc_id, type_doc, nom_champ, valeur): """Mise à jour champ libre pour Universign ID""" try: @@ -3413,17 +3803,7 @@ class SageConnector: return None - # ========================================================================= - # US-A6 - LECTURE CONTACTS - # ========================================================================= - def lire_contact_principal_client(self, code_client): - """ - NOUVEAU: Lecture contact principal d'un client - - Pour US-A6: relance devis via Universign - Récupère l'email du contact principal pour l'envoi - """ if not self.cial: return None @@ -3472,542 +3852,219 @@ class SageConnector: logger.error(f"Erreur lecture contact client {code_client}: {e}") return None - # ========================================================================= - # US-A7 - MAJ CHAMP DERNIERE RELANCE - # ========================================================================= - def mettre_a_jour_derniere_relance(self, doc_id, type_doc): - """ - NOUVEAU: Met à jour le champ libre "Dernière relance" - - Pour US-A7: relance facture en un clic - """ date_relance = datetime.now().strftime("%Y-%m-%d %H:%M:%S") return self.mettre_a_jour_champ_libre( doc_id, type_doc, "DerniereRelance", date_relance ) - # ========================================================================= - # PROSPECTS (CT_Type = 0 AND CT_Prospect = 1) - # ========================================================================= def lister_tous_prospects(self, filtre=""): - """Liste tous les prospects depuis le cache""" - with self._lock_clients: - if not filtre: - return [ - c - for c in self._cache_clients - if c.get("type") == 0 and c.get("est_prospect") - ] - - filtre_lower = filtre.lower() - return [ - c - for c in self._cache_clients - if c.get("type") == 0 - and c.get("est_prospect") - and ( - filtre_lower in c["numero"].lower() - or filtre_lower in c["intitule"].lower() - ) - ] - - def lire_prospect(self, code_prospect): - """Retourne un prospect depuis le cache""" - with self._lock_clients: - prospect = self._cache_clients_dict.get(code_prospect) - if prospect and prospect.get("type") == 0 and prospect.get("est_prospect"): - return prospect - return None - - # ========================================================================= - # EXTRACTION CLIENTS (Mise à jour pour inclure prospects) - # ========================================================================= - def _extraire_client(self, client_obj): - """MISE À JOUR : Extraction avec détection prospect""" - data = { - "numero": getattr(client_obj, "CT_Num", ""), - "intitule": getattr(client_obj, "CT_Intitule", ""), - "type": getattr( - client_obj, "CT_Type", 0 - ), # 0=Client/Prospect, 1=Fournisseur - "est_prospect": getattr(client_obj, "CT_Prospect", 0) == 1, # ✅ NOUVEAU - } - try: - adresse = getattr(client_obj, "Adresse", None) - if adresse: - data["adresse"] = getattr(adresse, "Adresse", "") - data["code_postal"] = getattr(adresse, "CodePostal", "") - data["ville"] = getattr(adresse, "Ville", "") - except: - pass + with self._get_sql_connection() as conn: + cursor = conn.cursor() - try: - telecom = getattr(client_obj, "Telecom", None) - if telecom: - data["telephone"] = getattr(telecom, "Telephone", "") - data["email"] = getattr(telecom, "EMail", "") - except: - pass + 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 + """ - return data + params = [] - # ========================================================================= - # AVOIRS (DO_Domaine = 0 AND DO_Type = 5) - # ========================================================================= - def lister_avoirs(self, limit=100, statut=None): - """Liste tous les avoirs de vente""" - if not self.cial: + 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 [] - avoirs = [] - + def lire_prospect(self, code_prospect): try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente - index = 1 - max_iterations = limit * 3 - erreurs_consecutives = 0 - max_erreurs = 50 + with self._get_sql_connection() as conn: + cursor = conn.cursor() - while ( - len(avoirs) < limit - and index < max_iterations - and erreurs_consecutives < max_erreurs - ): - try: - persist = factory.List(index) - if persist is None: - break + 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(),), + ) - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() + row = cursor.fetchone() - # ✅ Filtrer : DO_Domaine = 0 (Vente) AND DO_Type = 5 (Avoir) - doc_type = getattr(doc, "DO_Type", -1) - doc_domaine = getattr(doc, "DO_Domaine", -1) + if not row: + return None - if doc_domaine != 0 or doc_type != settings.SAGE_TYPE_BON_AVOIR: - index += 1 - continue + 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), + } - doc_statut = getattr(doc, "DO_Statut", 0) + except Exception as e: + logger.error(f"❌ Erreur SQL prospect {code_prospect}: {e}") + return None - # Filtre statut optionnel - if statut is not None and doc_statut != statut: - index += 1 - continue + def lister_avoirs(self, limit=100, statut=None): + try: + with self._get_sql_connection() as conn: + cursor = conn.cursor() - # Charger client - client_code = "" - client_intitule = "" + 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 + """ - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - client_intitule = getattr( - client_obj, "CT_Intitule", "" - ).strip() - except: - pass + params = [] - avoirs.append( - { - "numero": getattr(doc, "DO_Piece", ""), - "reference": getattr(doc, "DO_Ref", ""), - "date": str(getattr(doc, "DO_Date", "")), - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), - "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), - "statut": doc_statut, - } - ) + if statut is not None: + query += " AND d.DO_Statut = ?" + params.append(statut) - erreurs_consecutives = 0 - index += 1 + query += " ORDER BY d.DO_Date DESC" - except: - erreurs_consecutives += 1 - index += 1 + cursor.execute(query, params) + rows = cursor.fetchall() - if erreurs_consecutives >= max_erreurs: - break + 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, + } + ) - logger.info(f"✅ {len(avoirs)} avoirs retournés") return avoirs except Exception as e: - logger.error(f"❌ Erreur liste avoirs: {e}", exc_info=True) + logger.error(f"❌ Erreur SQL avoirs: {e}") return [] def lire_avoir(self, numero): - """Lecture d'un avoir avec ses lignes""" - if not self.cial: - return None + return self._lire_document_sql(numero, type_doc=50) - try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente - - # Essayer ReadPiece - persist = factory.ReadPiece(settings.SAGE_TYPE_BON_AVOIR, numero) - - if not persist: - # Chercher dans List() - index = 1 - while index < 10000: - try: - persist_test = factory.List(index) - if persist_test is None: - break - - doc_test = win32com.client.CastTo( - persist_test, "IBODocumentVente3" - ) - doc_test.Read() - - if ( - getattr(doc_test, "DO_Type", -1) - == settings.SAGE_TYPE_BON_AVOIR - and getattr(doc_test, "DO_Piece", "") == numero - ): - persist = persist_test - break - - index += 1 - except: - index += 1 - - if not persist: - return None - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # Charger client - client_code = "" - client_intitule = "" - - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - client_intitule = getattr(client_obj, "CT_Intitule", "").strip() - except: - pass - - avoir = { - "numero": getattr(doc, "DO_Piece", ""), - "reference": getattr(doc, "DO_Ref", ""), - "date": str(getattr(doc, "DO_Date", "")), - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), - "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), - "statut": getattr(doc, "DO_Statut", 0), - "lignes": [], - } - - # Charger lignes - try: - factory_lignes = getattr( - doc, "FactoryDocumentLigne", None - ) or getattr(doc, "FactoryDocumentVenteLigne", None) - - if factory_lignes: - index = 1 - while index <= 100: - try: - ligne_persist = factory_lignes.List(index) - if ligne_persist is None: - break - - ligne = win32com.client.CastTo( - ligne_persist, "IBODocumentLigne3" - ) - ligne.Read() - - article_ref = "" - try: - article_ref = getattr(ligne, "AR_Ref", "").strip() - if not article_ref: - article_obj = getattr(ligne, "Article", None) - if article_obj: - article_obj.Read() - article_ref = getattr( - article_obj, "AR_Ref", "" - ).strip() - except: - pass - - avoir["lignes"].append( - { - "article": article_ref, - "designation": getattr(ligne, "DL_Design", ""), - "quantite": float( - getattr(ligne, "DL_Qte", 0.0) - ), - "prix_unitaire": float( - getattr(ligne, "DL_PrixUnitaire", 0.0) - ), - "montant_ht": float( - getattr(ligne, "DL_MontantHT", 0.0) - ), - } - ) - - index += 1 - except: - break - except: - pass - - logger.info(f"✅ Avoir {numero} lu: {len(avoir['lignes'])} lignes") - return avoir - - except Exception as e: - logger.error(f"❌ Erreur lecture avoir {numero}: {e}") - return None - - # ========================================================================= - # LIVRAISONS (DO_Domaine = 0 AND DO_Type = 3) - # ========================================================================= def lister_livraisons(self, limit=100, statut=None): - """Liste tous les bons de livraison""" - if not self.cial: - return [] - - livraisons = [] - + """📖 Liste les livraisons via SQL (méthode legacy)""" try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente - index = 1 - max_iterations = limit * 3 - erreurs_consecutives = 0 - max_erreurs = 50 + with self._get_sql_connection() as conn: + cursor = conn.cursor() - while ( - len(livraisons) < limit - and index < max_iterations - and erreurs_consecutives < max_erreurs - ): - try: - persist = factory.List(index) - if persist is None: - break + 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 + """ - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() + params = [] - # ✅ Filtrer : DO_Domaine = 0 (Vente) AND DO_Type = 30 (Livraison) - doc_type = getattr(doc, "DO_Type", -1) - doc_domaine = getattr(doc, "DO_Domaine", -1) + if statut is not None: + query += " AND d.DO_Statut = ?" + params.append(statut) - if ( - doc_domaine != 0 - or doc_type != settings.SAGE_TYPE_BON_LIVRAISON - ): - index += 1 - continue + query += " ORDER BY d.DO_Date DESC" - doc_statut = getattr(doc, "DO_Statut", 0) + cursor.execute(query, params) + rows = cursor.fetchall() - if statut is not None and doc_statut != statut: - index += 1 - continue + 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, + } + ) - # Charger client - client_code = "" - client_intitule = "" - - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - client_intitule = getattr( - client_obj, "CT_Intitule", "" - ).strip() - except: - pass - - livraisons.append( - { - "numero": getattr(doc, "DO_Piece", ""), - "reference": getattr(doc, "DO_Ref", ""), - "date": str(getattr(doc, "DO_Date", "")), - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), - "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), - "statut": doc_statut, - } - ) - - erreurs_consecutives = 0 - index += 1 - - except: - erreurs_consecutives += 1 - index += 1 - - if erreurs_consecutives >= max_erreurs: - break - - logger.info(f"✅ {len(livraisons)} livraisons retournées") return livraisons except Exception as e: - logger.error(f"❌ Erreur liste livraisons: {e}", exc_info=True) + logger.error(f"❌ Erreur SQL livraisons: {e}") return [] def lire_livraison(self, numero): - """Lecture d'une livraison avec ses lignes""" - if not self.cial: - return None - - try: - with self._com_context(), self._lock_com: - factory = self.cial.FactoryDocumentVente - - # Essayer ReadPiece - persist = factory.ReadPiece(settings.SAGE_TYPE_BON_LIVRAISON, numero) - - if not persist: - # Chercher dans List() - index = 1 - while index < 10000: - try: - persist_test = factory.List(index) - if persist_test is None: - break - - doc_test = win32com.client.CastTo( - persist_test, "IBODocumentVente3" - ) - doc_test.Read() - - if ( - getattr(doc_test, "DO_Type", -1) - == settings.SAGE_TYPE_BON_LIVRAISON - and getattr(doc_test, "DO_Piece", "") == numero - ): - persist = persist_test - break - - index += 1 - except: - index += 1 - - if not persist: - return None - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - # Charger client - client_code = "" - client_intitule = "" - - try: - client_obj = getattr(doc, "Client", None) - if client_obj: - client_obj.Read() - client_code = getattr(client_obj, "CT_Num", "").strip() - client_intitule = getattr(client_obj, "CT_Intitule", "").strip() - except: - pass - - livraison = { - "numero": getattr(doc, "DO_Piece", ""), - "reference": getattr(doc, "DO_Ref", ""), - "date": str(getattr(doc, "DO_Date", "")), - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), - "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), - "statut": getattr(doc, "DO_Statut", 0), - "lignes": [], - } - - # Charger lignes - try: - factory_lignes = getattr( - doc, "FactoryDocumentLigne", None - ) or getattr(doc, "FactoryDocumentVenteLigne", None) - - if factory_lignes: - index = 1 - while index <= 100: - try: - ligne_persist = factory_lignes.List(index) - if ligne_persist is None: - break - - ligne = win32com.client.CastTo( - ligne_persist, "IBODocumentLigne3" - ) - ligne.Read() - - article_ref = "" - try: - article_ref = getattr(ligne, "AR_Ref", "").strip() - if not article_ref: - article_obj = getattr(ligne, "Article", None) - if article_obj: - article_obj.Read() - article_ref = getattr( - article_obj, "AR_Ref", "" - ).strip() - except: - pass - - livraison["lignes"].append( - { - "article": article_ref, - "designation": getattr(ligne, "DL_Design", ""), - "quantite": float( - getattr(ligne, "DL_Qte", 0.0) - ), - "prix_unitaire": float( - getattr(ligne, "DL_PrixUnitaire", 0.0) - ), - "montant_ht": float( - getattr(ligne, "DL_MontantHT", 0.0) - ), - } - ) - - index += 1 - except: - break - except: - pass - - logger.info( - f"✅ Livraison {numero} lue: {len(livraison['lignes'])} lignes" - ) - return livraison - - except Exception as e: - logger.error(f"❌ Erreur lecture livraison {numero}: {e}") - return None - - # ========================================================================= - # CREATION CLIENT (US-A8 ?) - # ========================================================================= + """📖 Lit UNE livraison via SQL (avec lignes)""" + return self._lire_document_sql(numero, type_doc=30) def creer_client(self, client_data: Dict) -> Dict: - """ - Crée un nouveau client dans Sage 100c via l'API COM. - ✅ VERSION CORRIGÉE : CT_Type supprimé (n'existe pas dans cette version) - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -4351,7 +4408,6 @@ class SageConnector: # ======================================== # ÉTAPE 8 : REFRESH CACHE # ======================================== - self._refresh_cache_clients() return { "numero": num_final, @@ -4387,16 +4443,6 @@ class SageConnector: raise RuntimeError(f"Erreur technique Sage: {error_message}") def modifier_client(self, code: str, client_data: Dict) -> Dict: - """ - ✏️ Modification d'un client existant dans Sage 100c - - Args: - code: Code du client à modifier - client_data: Dictionnaire avec les champs à mettre à jour - - Returns: - Client modifié - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -4540,7 +4586,6 @@ class SageConnector: ) # Refresh cache - self._refresh_cache_clients() return self._extraire_client(client) @@ -4563,11 +4608,6 @@ class SageConnector: raise RuntimeError(f"Erreur technique Sage: {error_message}") def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: - """ - ✏️ Modification d'un devis - VERSION FINALE OPTIMISÉE - - ✅ Même stratégie intelligente que modifier_commande - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -4855,14 +4895,6 @@ class SageConnector: raise RuntimeError(f"Erreur technique Sage: {str(e)}") def creer_commande_enrichi(self, commande_data: dict) -> Dict: - """ - ➕ Création d'une commande (type 10 = Bon de commande) - - ✅ CORRECTION: Gestion identique aux devis - - Prix automatique depuis Sage si non fourni - - Prix = 0 toléré (articles de service, etc.) - - Remise optionnelle - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -5143,19 +5175,7 @@ class SageConnector: logger.error(f"❌ Erreur création commande: {e}", exc_info=True) raise RuntimeError(f"Échec création commande: {str(e)}") - # ============================================================================ - # CORRECTIF CRITIQUE : Modification devis/commandes - # ============================================================================ - def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: - """ - ✏️ Modification commande - VERSION SIMPLIFIÉE - - 🔧 STRATÉGIE REMPLACEMENT LIGNES: - - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - - Utilise .Remove() pour la suppression - - Simple, robuste, prévisible - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -5550,11 +5570,6 @@ class SageConnector: raise RuntimeError(f"Erreur Sage: {error_message}") def creer_livraison_enrichi(self, livraison_data: dict) -> Dict: - """ - ➕ Création d'une livraison (type 30 = Bon de livraison) - - ✅ Gestion identique aux commandes/devis - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -5817,13 +5832,6 @@ class SageConnector: raise RuntimeError(f"Échec création livraison: {str(e)}") def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: - """ - ✏️ Modification d'une livraison existante - - 🔧 STRATÉGIE REMPLACEMENT LIGNES: - - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - - Utilise .Remove() pour la suppression - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -6080,11 +6088,6 @@ class SageConnector: raise RuntimeError(f"Erreur Sage: {str(e)}") def creer_avoir_enrichi(self, avoir_data: dict) -> Dict: - """ - ➕ Création d'un avoir (type 50 = Bon d'avoir) - - ✅ Gestion identique aux commandes/devis/livraisons - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -6343,13 +6346,6 @@ class SageConnector: raise RuntimeError(f"Échec création avoir: {str(e)}") def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: - """ - ✏️ Modification d'un avoir existant - - 🔧 STRATÉGIE REMPLACEMENT LIGNES: - - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - - Utilise .Remove() pour la suppression - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -6603,14 +6599,6 @@ class SageConnector: raise RuntimeError(f"Erreur Sage: {str(e)}") def creer_facture_enrichi(self, facture_data: dict) -> Dict: - """ - ➕ Création d'une facture (type 60 = Facture) - - ⚠️ ATTENTION: Les factures ont souvent des champs obligatoires supplémentaires - selon la configuration Sage (DO_CodeJournal, DO_Souche, DO_Regime, etc.) - - ✅ Gestion identique aux autres documents + champs spécifiques factures - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -6932,15 +6920,6 @@ class SageConnector: raise RuntimeError(f"Échec création facture: {str(e)}") def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: - """ - ✏️ Modification d'une facture existante - - ⚠️ ATTENTION: Les factures comptabilisées peuvent être verrouillées par Sage - - 🔧 STRATÉGIE REMPLACEMENT LIGNES: - - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - - Utilise .Remove() pour la suppression - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -7215,28 +7194,6 @@ class SageConnector: raise RuntimeError(f"Erreur Sage: {str(e)}") def generer_pdf_document(self, numero: str, type_doc: int) -> bytes: - """ - 📄 Génère le PDF d'un document via les états Sage - - **Utilise les états Sage Crystal Reports pour générer les PDF** - - Args: - numero: Numéro du document (ex: "DE00001", "FA00001") - type_doc: Type de document Sage (0-60) - - Returns: - bytes: Contenu du PDF (binaire brut) - - Process: - 1. Charge le document depuis Sage - 2. Identifie l'état Crystal Reports approprié - 3. Génère le PDF via l'état - 4. Retourne le binaire - - Raises: - ValueError: Si le document n'existe pas - RuntimeError: Si erreur Sage lors de la génération - """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -7368,3 +7325,3331 @@ class SageConnector: except Exception as e: logger.error(f"❌ Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") + + 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] + + logger.info(f"[ARTICLE] Référence : {reference}") + logger.info(f"[ARTICLE] Désignation : {designation}") + + # ======================================== + # É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 (RAPIDE) + # ======================================== + 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 UNITE + FAMILLE (OBLIGATOIRES !) + # ======================================== + logger.info("[OBJETS] Copie Unite + Famille depuis modèle...") + + # Unite (obligatoire) + 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 (OBLIGATOIRE) - VERSION OPTIMISÉE SQL + SCANNER + 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: + # ======================================== + # 🚀 ÉTAPE 1 : VÉRIFIER EXISTENCE VIA SQL (ULTRA-RAPIDE) + # ======================================== + 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_personnalise.upper(),), + ) + + 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_personnalise}' est de type 'Total' " + f"(agrégation comptable) et ne peut pas contenir d'articles.\n\n" + f"Utilisez plutôt une sous-famille de détail." + ) + + logger.info( + f" [SQL] Famille trouvée : {famille_code_exact} (type={famille_type})" + ) + else: + logger.warning( + f" [SQL] Famille '{famille_code_personnalise}' introuvable" + ) + + except Exception as e_sql: + logger.warning(f" [SQL] Erreur : {e_sql}") + + # ======================================== + # 🚀 ÉTAPE 2 : SI EXISTE EN SQL, CHARGER VIA COM (SCANNER) + # ======================================== + if famille_existe_sql and famille_code_exact: + logger.info( + f" [COM] Recherche de '{famille_code_exact}' via scanner..." + ) + + factory_famille = self.cial.FactoryFamille + + # ✅ Scanner List() (compatible Sage v12 - IBOFamilleFactory2) + try: + index = 1 + max_scan = 1000 + + while index <= max_scan: + try: + persist_test = factory_famille.List(index) + if persist_test is None: + break + + # Cast et lecture + fam_test = win32com.client.CastTo( + persist_test, "IBOFamille3" + ) + fam_test.Read() + + # Comparer les codes + code_test = ( + getattr(fam_test, "FA_CodeFamille", "") + .strip() + .upper() + ) + + if code_test == famille_code_exact.upper(): + # ✅ TROUVÉ ! + 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 + ) or "Access" in str(e): + break + index += 1 + + if not famille_trouvee: + logger.warning( + f" [COM] Famille '{famille_code_exact}' non trouvée après scan de {index-1} familles" + ) + + except Exception as e: + logger.warning( + f" [COM] Scanner échoué : {str(e)[:200]}" + ) + + # ✅ ASSIGNER LA FAMILLE SI TROUVÉE + if famille_obj: + # ✅ CRITIQUE : Re-lire juste avant assignation + famille_obj.Read() + article.Famille = famille_obj + logger.info( + f" [OK] Famille '{famille_code_personnalise}' assignée" + ) + else: + # ❌ FAMILLE INTROUVABLE VIA COM + logger.error( + f" [ERREUR] Famille '{famille_code_personnalise}' trouvée en SQL mais inaccessible via COM" + ) + + # Lister les familles disponibles + familles_disponibles = [] + try: + with self._get_sql_connection() as conn: + cursor = conn.cursor() + + cursor.execute( + """ + SELECT TOP 30 FA_CodeFamille, FA_Intitule + FROM F_FAMILLE + WHERE FA_Type = 0 + ORDER BY FA_CodeFamille + """ + ) + + rows = cursor.fetchall() + + for row in rows: + code = self._safe_strip( + row.FA_CodeFamille + ) + intitule = self._safe_strip( + row.FA_Intitule + ) + if code: + familles_disponibles.append( + f"{code} - {intitule}" + ) + except: + pass + + msg_erreur = f"Famille '{famille_code_personnalise}' trouvée en SQL mais inaccessible via COM." + + if familles_disponibles: + msg_erreur += ( + f"\n\nFamilles de DÉTAIL disponibles :\n" + + "\n".join(familles_disponibles) + ) + + msg_erreur += "\n\nSolution : Essayez avec ZDIVERS ou créez une nouvelle famille" + + raise ValueError(msg_erreur) + + else: + # Famille pas trouvée en SQL + familles_disponibles = [] + try: + with self._get_sql_connection() as conn: + cursor = conn.cursor() + + cursor.execute( + """ + SELECT TOP 30 FA_CodeFamille, FA_Intitule + FROM F_FAMILLE + WHERE FA_Type = 0 + ORDER BY FA_CodeFamille + """ + ) + + rows = cursor.fetchall() + + for row in rows: + code = self._safe_strip(row.FA_CodeFamille) + intitule = self._safe_strip(row.FA_Intitule) + if code: + familles_disponibles.append( + f"{code} - {intitule}" + ) + except: + pass + + msg_erreur = f"Famille '{famille_code_personnalise}' introuvable dans Sage." + + if familles_disponibles: + msg_erreur += ( + f"\n\nFamilles disponibles :\n" + + "\n".join(familles_disponibles) + ) + else: + msg_erreur += "\n\nAucune famille trouvée. Créez d'abord des familles dans Sage." + + raise ValueError(msg_erreur) + + except ValueError: + raise # Re-raise si c'est notre erreur de validation + except Exception as e: + logger.warning( + f" [WARN] Famille personnalisée non chargée : {str(e)[:100]}" + ) + + # Si pas de famille perso OU si échec, 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]}") + + if not famille_trouvee: + logger.warning( + " ⚠️ Aucune famille assignée - risque d'erreur cohérence" + ) + # ======================================== + # ÉTAPE 6 : CHAMPS OBLIGATOIRES (ORDRE CRITIQUE) + # ======================================== + 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)") + + # Champs standards + article.AR_Coef = float(getattr(article_modele, "AR_Coef", 2.0)) + article.AR_Garantie = int( + getattr(article_modele, "AR_Garantie", 12) + ) + article.AR_UnitePoids = int( + getattr(article_modele, "AR_UnitePoids", 3) + ) + article.AR_Sommeil = False + + # Champs de gestion + article.AR_Cycle = int(getattr(article_modele, "AR_Cycle", 1)) + article.AR_Delai = int(getattr(article_modele, "AR_Delai", 0)) + article.AR_DelaiFabrication = int( + getattr(article_modele, "AR_DelaiFabrication", 0) + ) + article.AR_Criticite = int( + getattr(article_modele, "AR_Criticite", 0) + ) + + # Options booléennes + article.AR_HorsStat = bool( + getattr(article_modele, "AR_HorsStat", False) + ) + article.AR_Escompte = bool( + getattr(article_modele, "AR_Escompte", False) + ) + article.AR_PrixTTC = bool( + getattr(article_modele, "AR_PrixTTC", False) + ) + article.AR_VteDebit = bool( + getattr(article_modele, "AR_VteDebit", False) + ) + article.AR_NotImp = bool( + getattr(article_modele, "AR_NotImp", False) + ) + article.AR_Exclure = bool( + getattr(article_modele, "AR_Exclure", False) + ) + + logger.info(f" [OK] Tous les champs obligatoires copiés") + + # ======================================== + # É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: + # ⚠️ CORRECTION : Tester les deux noms possibles + 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-barres principal) + # ======================================== + 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 : STOCK MINI/MAXI (NIVEAU ARTICLE) + # ======================================== + stock_mini = article_data.get("stock_mini") + stock_maxi = article_data.get("stock_maxi") + + # ✅ NOUVEAU : Définir au niveau ARTICLE (global) + if stock_mini is not None: + try: + article.AR_StockMini = float(stock_mini) + logger.info(f" AR_StockMini (article) : {stock_mini}") + except Exception as e: + logger.warning(f" AR_StockMini erreur : {e}") + + if stock_maxi is not None: + try: + article.AR_StockMaxi = float(stock_maxi) + logger.info(f" AR_StockMaxi (article) : {stock_maxi}") + except Exception as e: + logger.warning(f" AR_StockMaxi erreur : {e}") + + # ======================================== + # ÉTAPE 11 : É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 12 : DÉFINIR LE STOCK (NIVEAU DÉPÔT) + # ======================================== + stock_reel = article_data.get("stock_reel") + stock_defini = False + stock_erreur = None + + if stock_reel and stock_reel > 0: + logger.info( + f"[STOCK] Définition stock : {stock_reel} unités sur dépôt '{depot_a_utiliser['code']}'" + ) + + try: + depot_obj = depot_a_utiliser["objet"] + + factory_depot_stock = None + for factory_name in [ + "FactoryDepotStock", + "FactoryArticleStock", + ]: + try: + factory_depot_stock = getattr( + depot_obj, factory_name, None + ) + if factory_depot_stock: + logger.info( + f" Factory trouvée : {factory_name}" + ) + break + except: + continue + + if not factory_depot_stock: + raise RuntimeError( + "FactoryDepotStock introuvable sur le dépôt" + ) + + stock_persist = factory_depot_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 + stock_obj.AS_QteSto = float(stock_reel) + logger.info(f" AS_QteSto = {stock_reel}") + + # ✅ Stock minimum (niveau dépôt) + if stock_mini is not None: + try: + stock_obj.AS_QteMin = float(stock_mini) + logger.info(f" AS_QteMin (dépôt) = {stock_mini}") + except Exception as e: + logger.warning(f" AS_QteMin non défini : {e}") + + # ✅ Stock maximum (niveau dépôt) + if stock_maxi is not None: + try: + stock_obj.AS_QteMax = float(stock_maxi) + logger.info(f" AS_QteMax (dépôt) = {stock_maxi}") + except Exception as e: + logger.warning(f" AS_QteMax non défini : {e}") + + stock_obj.Write() + + stock_defini = True + logger.info( + f" [OK] Stock défini : {stock_reel} unités (min={stock_mini}, max={stock_maxi})" + ) + + except Exception as e: + stock_erreur = str(e) + logger.error( + f" [ERREUR] Stock non défini : {e}", exc_info=True + ) + + # Gérer stock_mini/maxi SANS stock_reel + elif stock_mini is not None or stock_maxi is not None: + logger.info( + f"[STOCK] Définition stock_mini/maxi sans stock_reel..." + ) + + try: + depot_obj = depot_a_utiliser["objet"] + + factory_depot_stock = None + for factory_name in [ + "FactoryDepotStock", + "FactoryArticleStock", + ]: + try: + factory_depot_stock = getattr( + depot_obj, factory_name, None + ) + if factory_depot_stock: + break + except: + pass + + if factory_depot_stock: + stock_persist = factory_depot_stock.Create() + stock_obj = win32com.client.CastTo( + stock_persist, "IBODepotStock3" + ) + stock_obj.SetDefault() + + stock_obj.AR_Ref = reference + stock_obj.AS_QteSto = 0.0 # Stock réel à 0 + + if stock_mini is not None: + try: + stock_obj.AS_QteMin = float(stock_mini) + logger.info(f" AS_QteMin = {stock_mini}") + except: + pass + + if stock_maxi is not None: + try: + stock_obj.AS_QteMax = float(stock_maxi) + logger.info(f" AS_QteMax = {stock_maxi}") + except: + pass + + stock_obj.Write() + stock_defini = True + logger.info( + f" [OK] Stock min/max défini sans stock réel" + ) + + except Exception as e: + logger.warning(f" [WARN] Stock min/max non défini : {e}") + + # ======================================== + # ÉTAPE 13 : COMMIT (CRITIQUE POUR PERSISTANCE) + # ======================================== + 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 14 : 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 15 : LIRE LES STOCKS PAR DÉPÔT + # ======================================== + stocks_par_depot = [] + stock_total = 0.0 + + for depot_info in depots_disponibles: + try: + depot_obj = depot_info["objet"] + factory_depot_stock = getattr( + depot_obj, "FactoryDepotStock", None + ) + + if factory_depot_stock: + index = 1 + while index <= 1000: + try: + stock_persist = factory_depot_stock.List(index) + if stock_persist is None: + break + + stock = win32com.client.CastTo( + stock_persist, "IBODepotStock3" + ) + stock.Read() + + article_ref_stock = getattr( + stock, "AR_Ref", "" + ).strip() + if article_ref_stock == reference: + qte = float( + getattr(stock, "AS_QteSto", 0.0) + ) + stock_total += qte + + stocks_par_depot.append( + { + "depot_code": depot_info["code"], + "depot_intitule": depot_info[ + "intitule" + ], + "quantite": qte, + "qte_mini": float( + getattr(stock, "AS_QteMin", 0.0) + ), + "qte_maxi": float( + getattr(stock, "AS_QteMax", 0.0) + ), + } + ) + break + + index += 1 + except Exception as e: + if "Acces refuse" in str(e): + break + index += 1 + except: + pass + + logger.info( + f"[OK] ARTICLE CREE : {reference} - Stock total : {stock_total}" + ) + + # ======================================== + # ÉTAPE 16 : EXTRACTION COMPLÈTE + ENRICHISSEMENT + # ======================================== + 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 _extraire_article échoue + resultat = { + "reference": reference, + "designation": designation, + } + + # ======================================== + # ENRICHIR AVEC LES VALEURS QU'ON A DÉFINIES + # ======================================== + # ⚠️ IMPORTANT : Certaines valeurs ne sont pas encore relues correctement + # juste après le Write(), donc on force les valeurs qu'on a définies + + # ✅ 1. PRIX (forcer les valeurs définies) + if prix_vente is not None: + resultat["prix_vente"] = float(prix_vente) + + if prix_achat is not None: + resultat["prix_achat"] = float(prix_achat) + + # ✅ 2. STOCK (utiliser le calcul depuis les dépôts, plus fiable) + resultat["stock_reel"] = stock_total + + if stock_mini is not None: + resultat["stock_mini"] = float(stock_mini) + + if stock_maxi is not None: + 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 + + # ✅ 3. DESCRIPTION (forcer la valeur définie) + if description: + resultat["description"] = description + + # ✅ 4. CODE EAN (forcer la valeur définie) + if code_ean: + resultat["code_ean"] = str(code_ean) + resultat["code_barre"] = str( + code_ean + ) # Identique (code-barres principal) + + # ✅ 5. FAMILLE (si personnalisée, forcer le code et libellé) + if famille_code_personnalise and famille_trouvee: + resultat["famille_code"] = famille_code_personnalise + # Essayer de récupérer le libellé + try: + if famille_obj: + famille_obj.Read() + resultat["famille_libelle"] = getattr( + famille_obj, "FA_Intitule", "" + ) + except: + pass + + # ✅ 6. DATES (forcer date actuelle pour cohérence) + from datetime import datetime + + date_maintenant = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + resultat["date_creation"] = date_maintenant + resultat["date_modification"] = date_maintenant + + # ✅ 7. INFOS DÉPÔTS + resultat["stocks_par_depot"] = stocks_par_depot + resultat["depot_principal"] = { + "code": depot_a_utiliser["code"], + "intitule": depot_a_utiliser["intitule"], + } + + # ✅ 8. SUIVI DE STOCK + resultat["suivi_stock_active"] = stock_defini + + # ✅ 9. AVERTISSEMENT SI STOCK NON DÉFINI + if stock_reel and not stock_defini: + resultat["avertissement"] = ( + f"Stock demandé ({stock_reel}) mais non défini : {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() + + # ======================================== + # ÉTAPE 1 : DÉTECTER LES COLONNES DISPONIBLES + # ======================================== + 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)}") + + # ======================================== + # ÉTAPE 2 : DÉFINIR LES COLONNES PRIORITAIRES + # ======================================== + 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)}") + + # ======================================== + # ÉTAPE 3 : CONSTRUIRE LA REQUÊTE AVEC FILTRE TYPE + # ======================================== + colonnes_str = ", ".join(colonnes_a_lire) + + query = f""" + SELECT {colonnes_str} + FROM F_FAMILLE + WHERE 1=1 + """ + + params = [] + + # ✅ CRITIQUE : Filtrer par type (défaut = seulement Détail) + 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" + + # ======================================== + # ÉTAPE 4 : EXÉCUTER LA REQUÊTE + # ======================================== + cursor.execute(query, params) + rows = cursor.fetchall() + + # ======================================== + # ÉTAPE 5 : CONSTRUCTION DE LA LISTE + # ======================================== + 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 + + # ======================================== + # CHAMPS CALCULÉS & ALIAS + # ======================================== + + # 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"] = 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() + + # ======================================== + # ÉTAPE 1 : VÉRIFICATION SQL (RAPIDE) + # ======================================== + 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 + + # ======================================== + # ÉTAPE 2 : CHARGEMENT VIA COM (SCANNER) + # ======================================== + 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)}") + + # ======================================== + # ÉTAPE 3 : EXTRACTION COMPLÈTE + # ======================================== + 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"] = "" + + # ======================================== + # INFORMATIONS TECHNIQUES + # ======================================== + # 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"] = "" + + # ======================================== + # STATISTIQUES (NOMBRE D'ARTICLES) + # ======================================== + # 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 ===") + logger.info(f"[STOCK] {len(entree_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(180) # 180 = Entrée + doc = win32com.client.CastTo(persist, "IBODocumentStock3") + doc.SetDefault() + + # Date + import pywintypes + + 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()) + + # Référence + if entree_data.get("reference"): + doc.DO_Ref = entree_data["reference"] + + doc.Write() + logger.info( + f"[STOCK] Document créé : {getattr(doc, 'DO_Piece', '?')}" + ) + + # ======================================== + # ÉTAPE 2 : FACTORY LIGNES + # ======================================== + try: + factory_lignes = doc.FactoryDocumentLigne + logger.info(f"[STOCK] Factory lignes : FactoryDocumentLigne") + except: + factory_lignes = doc.FactoryDocumentStockLigne + logger.info( + f"[STOCK] Factory lignes : FactoryDocumentStockLigne" + ) + + factory_article = self.cial.FactoryArticle + stocks_mis_a_jour = [] + + # ======================================== + # ÉTAPE 3 : TRAITER CHAQUE LIGNE + # ======================================== + for idx, ligne_data in enumerate(entree_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} ({'CMUP' if ar_suivi == 1 else 'FIFO/LIFO' if ar_suivi == 2 else 'Aucun'})" + ) + + # Gérer le lot selon le mode de suivi + 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}'" + ) + 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() + + # Cast selon le type disponible + try: + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) + logger.info(f"[STOCK] Cast : IBODocumentLigne3") + except: + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentStockLigne3" + ) + logger.info(f"[STOCK] Cast : IBODocumentStockLigne3") + + ligne_obj.SetDefault() + + # ======================================== + # LIAISON ARTICLE + QUANTITÉ + # ======================================== + article_lie = False + methode_utilisee = None + + # MÉTHODE 1 : SetDefaultArticleReference() + try: + logger.info( + f"[STOCK] SetDefaultArticleReference('{article_ref}', {quantite})..." + ) + ligne_obj.SetDefaultArticleReference( + article_ref, float(quantite) + ) + article_lie = True + methode_utilisee = "SetDefaultArticleReference" + logger.info( + f"[STOCK] ✅ Article lié via SetDefaultArticleReference()" + ) + except Exception as e1: + logger.warning( + f"[STOCK] SetDefaultArticleReference échoué : {str(e1)[:150]}" + ) + + # MÉTHODE 2 : SetDefaultArticle() + if not article_lie: + try: + logger.info( + f"[STOCK] SetDefaultArticle(article_obj, {quantite})..." + ) + ligne_obj.SetDefaultArticle( + article_obj, float(quantite) + ) + article_lie = True + methode_utilisee = "SetDefaultArticle" + logger.info( + f"[STOCK] ✅ Article lié via SetDefaultArticle()" + ) + except Exception as e2: + logger.warning( + f"[STOCK] SetDefaultArticle échoué : {str(e2)[:150]}" + ) + + if not article_lie: + raise ValueError( + f"Impossible de lier l'article {article_ref}" + ) + + # ======================================== + # DÉFINIR LE LOT (si FIFO/LIFO) + # ======================================== + if numero_lot and ar_suivi == 2: + logger.info(f"[STOCK] Définition du lot '{numero_lot}'...") + + try: + # MÉTHODE 1 : SetDefaultLot() + ligne_obj.SetDefaultLot(numero_lot) + logger.info( + f"[STOCK] ✅ Lot défini via SetDefaultLot()" + ) + except Exception as e_lot1: + logger.warning( + f"[STOCK] SetDefaultLot échoué : {str(e_lot1)[:150]}" + ) + + # MÉTHODE 2 : Attribut LS_NoSerie + try: + ligne_obj.LS_NoSerie = numero_lot + logger.info(f"[STOCK] ✅ Lot défini via LS_NoSerie") + except Exception as e_lot2: + logger.warning( + f"[STOCK] LS_NoSerie échoué : {str(e_lot2)[:150]}" + ) + + # ======================================== + # PRIX UNITAIRE + # ======================================== + prix = ligne_data.get("prix_unitaire") + if prix: + try: + ligne_obj.DL_PrixUnitaire = float(prix) + logger.info(f"[STOCK] Prix unitaire : {prix}") + except: + pass + + # ======================================== + # ÉCRIRE LA LIGNE + # ======================================== + logger.info(f"[STOCK] Appel Write()...") + ligne_obj.Write() + logger.info(f"[STOCK] ✅ Write() réussi") + + # ======================================== + # VÉRIFICATION (via Article.AR_Ref, pas AR_Ref direct) + # ======================================== + logger.info(f"[STOCK] Vérification...") + ligne_obj.Read() + + ref_verifiee = None + + # Vérifier via l'objet Article (objet COM) + 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() + if ref_verifiee: + logger.info( + f"[STOCK] ✅ Référence vérifiée via Article.AR_Ref : {ref_verifiee}" + ) + except Exception as e_verif: + logger.warning( + f"[STOCK] Impossible de vérifier via Article : {e_verif}" + ) + + # Si pas de vérification possible, considérer comme OK si Write() a réussi + if not ref_verifiee: + logger.warning( + f"[STOCK] ⚠️ Impossible de vérifier la référence, mais Write() a réussi" + ) + ref_verifiee = article_ref # Supposer que c'est OK + + logger.info(f"[STOCK] ✅ LIGNE {idx} COMPLÈTE") + + stocks_mis_a_jour.append( + { + "article_ref": article_ref, + "quantite_ajoutee": quantite, + "methode_liaison": methode_utilisee, + "reference_verifiee": ref_verifiee, + "numero_lot": numero_lot if ar_suivi == 2 else None, + } + ) + + # ======================================== + # FINALISER LE DOCUMENT + # ======================================== + logger.info(f"[STOCK] Write() document final...") + 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: + logger.info(f"[STOCK] ✅ Changements sauvegardés") + + return { + "numero": numero, + "type": 0, + "date": str(getattr(doc, "DO_Date", "")), + "nb_lignes": len(stocks_mis_a_jour), + "reference": entree_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() + logger.info(f"[STOCK] Rollback effectué") + except: + pass + 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_QteMin", 0.0) + ) + except: + pass + + try: + qte_maxi = float( + getattr(stock, "AS_QteMax", 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_QteMin", 0.0 + ) + ), + "qte_maxi": float( + getattr( + stock_item, "AS_QteMax", 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_QteMin", + 0.0, + ) + ), + "qte_maxi": float( + getattr( + stock, + "AS_QteMax", + 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 + import pywintypes + + 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_demandee, depot_code=None): + try: + stock_info = self.lire_stock_article(article_ref) + + if depot_code: + # Vérifier dans un dépôt spécifique + depot_trouve = next( + (d for d in stock_info["depots"] if d["code"] == depot_code), None + ) + + if not depot_trouve: + return { + "suffisant": False, + "stock_disponible": 0.0, + "quantite_demandee": quantite_demandee, + "stock_apres": -quantite_demandee, + "erreur": f"Article non présent dans le dépôt {depot_code}", + } + + stock_dispo = depot_trouve["quantite"] + else: + # Vérifier sur le stock total + stock_dispo = stock_info["stock_total"] + + suffisant = stock_dispo >= quantite_demandee + stock_apres = stock_dispo - quantite_demandee + + return { + "suffisant": suffisant, + "stock_disponible": stock_dispo, + "quantite_demandee": quantite_demandee, + "stock_apres": stock_apres, + "depot": depot_code or "TOUS", + } + + except Exception as e: + logger.error(f"Erreur vérification stock : {e}") + raise