diff --git a/main.py b/main.py index cb29621..ae885fe 100644 --- a/main.py +++ b/main.py @@ -89,11 +89,14 @@ class ClientCreateRequest(BaseModel): siret: Optional[str] = None tva_intra: Optional[str] = None + class ClientUpdateGatewayRequest(BaseModel): """Modèle pour modification client côté gateway""" + code: str client_data: Dict - + + class FournisseurCreateRequest(BaseModel): intitule: str = Field(..., description="Raison sociale du fournisseur") compte_collectif: str = Field("401000", description="Compte général rattaché") @@ -124,17 +127,21 @@ class FournisseurCreateRequest(BaseModel): class FournisseurUpdateGatewayRequest(BaseModel): """Modèle pour modification fournisseur côté gateway""" + code: str fournisseur_data: Dict - + + class DevisUpdateGatewayRequest(BaseModel): """Modèle pour modification devis côté gateway""" + numero: str devis_data: Dict class CommandeCreateRequest(BaseModel): """Création d'une commande""" + client_id: str date_commande: Optional[date] = None reference: Optional[str] = None @@ -143,11 +150,14 @@ class CommandeCreateRequest(BaseModel): class CommandeUpdateGatewayRequest(BaseModel): """Modèle pour modification commande côté gateway""" + numero: str commande_data: Dict - + + class LivraisonCreateGatewayRequest(BaseModel): """Création d'une livraison côté gateway""" + client_id: str date_livraison: Optional[date] = None lignes: List[Dict] @@ -156,11 +166,14 @@ class LivraisonCreateGatewayRequest(BaseModel): class LivraisonUpdateGatewayRequest(BaseModel): """Modèle pour modification livraison côté gateway""" + numero: str livraison_data: Dict + class AvoirCreateGatewayRequest(BaseModel): """Création d'un avoir côté gateway""" + client_id: str date_avoir: Optional[date] = None lignes: List[Dict] @@ -169,11 +182,14 @@ class AvoirCreateGatewayRequest(BaseModel): class AvoirUpdateGatewayRequest(BaseModel): """Modèle pour modification avoir côté gateway""" + numero: str avoir_data: Dict + class FactureCreateGatewayRequest(BaseModel): """Création d'une facture côté gateway""" + client_id: str date_facture: Optional[date] = None lignes: List[Dict] @@ -182,14 +198,18 @@ class FactureCreateGatewayRequest(BaseModel): class FactureUpdateGatewayRequest(BaseModel): """Modèle pour modification facture côté gateway""" + numero: str facture_data: Dict + class PDFGenerationRequest(BaseModel): """Modèle pour génération PDF""" + doc_id: str = Field(..., description="Numéro du document") type_doc: int = Field(..., ge=0, le=60, description="Type de document Sage") + # ===================================================== # SÉCURITÉ # ===================================================== @@ -283,6 +303,7 @@ def clients_list(req: FiltreRequest): logger.error(f"Erreur liste clients: {e}") raise HTTPException(500, str(e)) + @app.post("/sage/clients/update", dependencies=[Depends(verify_token)]) def modifier_client_endpoint(req: ClientUpdateGatewayRequest): """ @@ -291,7 +312,7 @@ def modifier_client_endpoint(req: ClientUpdateGatewayRequest): try: resultat = sage.modifier_client(req.code, req.client_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier modification client: {e}") raise HTTPException(404, str(e)) @@ -299,6 +320,7 @@ def modifier_client_endpoint(req: ClientUpdateGatewayRequest): logger.error(f"Erreur technique modification client: {e}") raise HTTPException(500, str(e)) + @app.post("/sage/clients/get", dependencies=[Depends(verify_token)]) def client_get(req: CodeRequest): """Lecture d'un client par code""" @@ -330,7 +352,8 @@ def create_client_endpoint(req: ClientCreateRequest): logger.error(f"Erreur technique création client: {e}") # Erreur technique (ex: COM) -> 500 Internal Server Error raise HTTPException(500, str(e)) - + + # ===================================================== # ENDPOINTS - ARTICLES # ===================================================== @@ -385,7 +408,7 @@ def creer_devis(req: DevisRequest): 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 """ @@ -406,33 +429,34 @@ def lire_devis(req: CodeRequest): def devis_list( limit: int = Query(100, description="Nombre max de devis"), statut: Optional[int] = Query(None, description="Filtrer par statut"), - filtre: str = Query("", description="Filtre texte (numero, client)") + 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) - + # Filtrer par statut si demandé if statut is not None: devis_list = [d for d in devis_list if d.get("statut") == statut] - + # Limiter le nombre de résultats devis_list = devis_list[:limit] - + logger.info(f"✅ {len(devis_list)} devis retournés depuis le cache") - + return {"success": True, "data": devis_list} - + except Exception as e: logger.error(f"❌ Erreur liste devis: {e}", exc_info=True) raise HTTPException(500, str(e)) + @app.post("/sage/devis/statut", dependencies=[Depends(verify_token)]) def changer_statut_devis_endpoint(numero: str, nouveau_statut: int): """Change le statut d'un devis""" @@ -604,57 +628,59 @@ def contact_read(req: CodeRequest): def commandes_list( limit: int = Query(100, description="Nombre max de commandes"), statut: Optional[int] = Query(None, description="Filtrer par statut"), - filtre: str = Query("", description="Filtre texte") + 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: logger.error(f"❌ Erreur liste commandes: {e}", exc_info=True) raise HTTPException(500, str(e)) + @app.post("/sage/factures/list", dependencies=[Depends(verify_token)]) def factures_list( limit: int = Query(100, description="Nombre max de factures"), statut: Optional[int] = Query(None, description="Filtrer par statut"), - filtre: str = Query("", description="Filtre texte") + 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) - + if statut is not None: factures = [f for f in factures if f.get("statut") == statut] - + factures = factures[:limit] - + logger.info(f"✅ {len(factures)} factures retournées depuis le cache") - + return {"success": True, "data": factures} - + except Exception as e: logger.error(f"❌ Erreur liste factures: {e}", exc_info=True) raise HTTPException(500, str(e)) - + + @app.post("/sage/client/remise-max", dependencies=[Depends(verify_token)]) def lire_remise_max_client(code: str): """Récupère la remise max autorisée pour un client""" @@ -2370,50 +2396,49 @@ def prospect_get(req: CodeRequest): 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) - + logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés depuis le cache") - + return {"success": True, "data": fournisseurs} - + except Exception as e: logger.error(f"❌ Erreur liste fournisseurs: {e}", exc_info=True) raise HTTPException(500, str(e)) + @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()) - + logger.info(f"✅ Fournisseur créé: {resultat.get('numero')}") - - return { - "success": True, - "data": resultat - } - + + return {"success": True, "data": resultat} + except ValueError as e: # Erreur métier (ex: doublon) logger.warning(f"⚠️ Erreur métier création fournisseur: {e}") raise HTTPException(400, str(e)) - + except Exception as e: # Erreur technique (ex: COM) logger.error(f"❌ Erreur technique création fournisseur: {e}") raise HTTPException(500, str(e)) - + + @app.post("/sage/fournisseurs/update", dependencies=[Depends(verify_token)]) def modifier_fournisseur_endpoint(req: FournisseurUpdateGatewayRequest): """ @@ -2422,7 +2447,7 @@ def modifier_fournisseur_endpoint(req: FournisseurUpdateGatewayRequest): try: resultat = sage.modifier_fournisseur(req.code, req.fournisseur_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier modification fournisseur: {e}") raise HTTPException(404, str(e)) @@ -2430,6 +2455,7 @@ def modifier_fournisseur_endpoint(req: FournisseurUpdateGatewayRequest): logger.error(f"Erreur technique modification fournisseur: {e}") raise HTTPException(500, str(e)) + @app.post("/sage/fournisseurs/get", dependencies=[Depends(verify_token)]) def fournisseur_get(req: CodeRequest): """ @@ -2504,11 +2530,12 @@ def livraison_get(req: CodeRequest): logger.error(f"Erreur lecture livraison: {e}") raise HTTPException(500, str(e)) + @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) @@ -2517,7 +2544,7 @@ def modifier_devis_endpoint(req: DevisUpdateGatewayRequest): try: resultat = sage.modifier_devis(req.numero, req.devis_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier modification devis: {e}") raise HTTPException(404, str(e)) @@ -2530,6 +2557,7 @@ def modifier_devis_endpoint(req: DevisUpdateGatewayRequest): # ENDPOINTS - CRÉATION ET MODIFICATION COMMANDES # ===================================================== + @app.post("/sage/commandes/create", dependencies=[Depends(verify_token)]) def creer_commande_endpoint(req: CommandeCreateRequest): """ @@ -2543,10 +2571,10 @@ def creer_commande_endpoint(req: CommandeCreateRequest): "reference": req.reference, "lignes": req.lignes, } - + resultat = sage.creer_commande_enrichi(commande_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier création commande: {e}") raise HTTPException(400, str(e)) @@ -2559,7 +2587,7 @@ def creer_commande_endpoint(req: CommandeCreateRequest): 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) @@ -2569,7 +2597,7 @@ def modifier_commande_endpoint(req: CommandeUpdateGatewayRequest): try: resultat = sage.modifier_commande(req.numero, req.commande_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier modification commande: {e}") raise HTTPException(404, str(e)) @@ -2588,7 +2616,7 @@ def creer_livraison_endpoint(req: LivraisonCreateGatewayRequest): client = sage.lire_client(req.client_id) if not client: raise HTTPException(404, f"Client {req.client_id} introuvable") - + # Préparer les données pour le connecteur livraison_data = { "client": {"code": req.client_id, "intitule": ""}, @@ -2596,10 +2624,10 @@ def creer_livraison_endpoint(req: LivraisonCreateGatewayRequest): "reference": req.reference, "lignes": req.lignes, } - + resultat = sage.creer_livraison_enrichi(livraison_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier création livraison: {e}") raise HTTPException(400, str(e)) @@ -2616,7 +2644,7 @@ def modifier_livraison_endpoint(req: LivraisonUpdateGatewayRequest): try: resultat = sage.modifier_livraison(req.numero, req.livraison_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier modification livraison: {e}") raise HTTPException(404, str(e)) @@ -2624,6 +2652,7 @@ def modifier_livraison_endpoint(req: LivraisonUpdateGatewayRequest): logger.error(f"Erreur technique modification livraison: {e}") raise HTTPException(500, str(e)) + @app.post("/sage/avoirs/create", dependencies=[Depends(verify_token)]) def creer_avoir_endpoint(req: AvoirCreateGatewayRequest): """ @@ -2634,7 +2663,7 @@ def creer_avoir_endpoint(req: AvoirCreateGatewayRequest): client = sage.lire_client(req.client_id) if not client: raise HTTPException(404, f"Client {req.client_id} introuvable") - + # Préparer les données pour le connecteur avoir_data = { "client": {"code": req.client_id, "intitule": ""}, @@ -2642,10 +2671,10 @@ def creer_avoir_endpoint(req: AvoirCreateGatewayRequest): "reference": req.reference, "lignes": req.lignes, } - + resultat = sage.creer_avoir_enrichi(avoir_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier création avoir: {e}") raise HTTPException(400, str(e)) @@ -2662,19 +2691,20 @@ def modifier_avoir_endpoint(req: AvoirUpdateGatewayRequest): try: resultat = sage.modifier_avoir(req.numero, req.avoir_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier modification avoir: {e}") raise HTTPException(404, str(e)) except Exception as e: logger.error(f"Erreur technique modification avoir: {e}") raise HTTPException(500, str(e)) - + + @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.) """ @@ -2683,7 +2713,7 @@ def creer_facture_endpoint(req: FactureCreateGatewayRequest): client = sage.lire_client(req.client_id) if not client: raise HTTPException(404, f"Client {req.client_id} introuvable") - + # Préparer les données pour le connecteur facture_data = { "client": {"code": req.client_id, "intitule": ""}, @@ -2691,10 +2721,10 @@ def creer_facture_endpoint(req: FactureCreateGatewayRequest): "reference": req.reference, "lignes": req.lignes, } - + resultat = sage.creer_facture_enrichi(facture_data) return {"success": True, "data": resultat} - + except ValueError as e: logger.warning(f"Erreur métier création facture: {e}") raise HTTPException(400, str(e)) @@ -2707,41 +2737,41 @@ def creer_facture_endpoint(req: FactureCreateGatewayRequest): 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} - + except ValueError as e: logger.warning(f"Erreur métier modification facture: {e}") raise HTTPException(404, str(e)) except Exception as e: logger.error(f"Erreur technique modification facture: {e}") raise HTTPException(500, 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, @@ -2755,35 +2785,37 @@ def generer_pdf_document(req: PDFGenerationRequest): """ try: logger.info(f"📄 Génération PDF: {req.doc_id} (type={req.type_doc})") - + # Appel au connecteur Sage pdf_bytes = sage.generer_pdf_document(req.doc_id, req.type_doc) - + if not pdf_bytes: raise HTTPException(500, "PDF vide généré") - + # Encoder en base64 pour le transport JSON import base64 - pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') - + + pdf_base64 = base64.b64encode(pdf_bytes).decode("utf-8") + logger.info(f"✅ PDF généré: {len(pdf_bytes)} octets") - + return { "success": True, "data": { "pdf_base64": pdf_base64, "taille_octets": len(pdf_bytes), "type_doc": req.type_doc, - "numero": req.doc_id - } + "numero": req.doc_id, + }, } - + except HTTPException: raise except Exception as e: logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) raise HTTPException(500, str(e)) - + + # ===================================================== # LANCEMENT # ===================================================== diff --git a/sage_connector.py b/sage_connector.py index fe960ec..022cf0f 100644 --- a/sage_connector.py +++ b/sage_connector.py @@ -102,7 +102,9 @@ class SageConnector: """Connexion initiale à Sage""" try: with self._com_context(): - self.cial = win32com.client.gencache.EnsureDispatch("Objets100c.Cial.Stream") + self.cial = win32com.client.gencache.EnsureDispatch( + "Objets100c.Cial.Stream" + ) self.cial.Name = self.chemin_base self.cial.Loggable.UserName = self.utilisateur self.cial.Loggable.UserPwd = self.mot_de_passe @@ -145,29 +147,29 @@ class SageConnector: def _start_refresh_thread(self): """Démarre le thread d'actualisation automatique""" - + def refresh_loop(): pythoncom.CoInitialize() - + try: while not self._stop_refresh.is_set(): time.sleep(60) - + # Clients if self._cache_clients_last_update: age = datetime.now() - self._cache_clients_last_update if age.total_seconds() > self._cache_ttl_minutes * 60: self._refresh_cache_clients() - + # Articles if self._cache_articles_last_update: age = datetime.now() - self._cache_articles_last_update if age.total_seconds() > self._cache_ttl_minutes * 60: self._refresh_cache_articles() - + finally: pythoncom.CoUninitialize() - + self._refresh_thread = threading.Thread( target=refresh_loop, daemon=True, name="SageCacheRefresh" ) @@ -202,7 +204,7 @@ class SageConnector: 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 @@ -225,8 +227,14 @@ class SageConnector: 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")) + 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 " @@ -235,7 +243,7 @@ class SageConnector: 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: @@ -284,117 +292,135 @@ class SageConnector: except Exception as e: logger.error(f" Erreur refresh articles: {e}", exc_info=True) - + def lister_tous_fournisseurs(self, filtre=""): """ ✅ CORRECTION FINALE : Liste fournisseurs SANS passer par _extraire_client() - + BYPASS TOTAL de _extraire_client() car : - Les objets fournisseurs n'ont pas les mêmes champs que les clients - _extraire_client() plante sur "CT_Qualite" (n'existe pas sur fournisseurs) - Le diagnostic fournisseurs-analyse-complete fonctionne SANS _extraire_client() - + → On fait EXACTEMENT comme le diagnostic qui marche """ if not self.cial: logger.error("❌ self.cial est None") return [] - + fournisseurs = [] - + try: with self._com_context(), self._lock_com: - logger.info(f"🔍 Lecture fournisseurs via FactoryFournisseur (filtre='{filtre}')") - + logger.info( + f"🔍 Lecture fournisseurs via FactoryFournisseur (filtre='{filtre}')" + ) + factory = self.cial.CptaApplication.FactoryFournisseur index = 1 max_iterations = 10000 erreurs_consecutives = 0 max_erreurs = 50 - + filtre_lower = filtre.lower() if filtre else "" - + while index < max_iterations and erreurs_consecutives < max_erreurs: try: persist = factory.List(index) - + if persist is None: logger.debug(f"Fin de liste à l'index {index}") break - + # Cast fourn = self._cast_client(persist) - + if fourn: # ✅✅✅ EXTRACTION DIRECTE (pas de _extraire_client) ✅✅✅ try: numero = getattr(fourn, "CT_Num", "").strip() intitule = getattr(fourn, "CT_Intitule", "").strip() - + if not numero: logger.debug(f"Index {index}: CT_Num vide, skip") erreurs_consecutives += 1 index += 1 continue - + # Construction objet minimal data = { "numero": numero, "intitule": intitule, "type": 1, # Fournisseur - "est_fournisseur": True + "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() + 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() + 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(): + 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}") - + 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") + logger.warning( + f"⚠️ Arrêt après {max_erreurs} erreurs consécutives" + ) break - + logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés") return fournisseurs - + except Exception as e: logger.error(f"❌ Erreur liste fournisseurs: {e}", exc_info=True) return [] @@ -402,10 +428,10 @@ class SageConnector: 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 @@ -414,70 +440,76 @@ class SageConnector: - adresse, code_postal, ville, pays - email, telephone - siret, tva_intra - + Returns: Dict contenant le fournisseur créé avec son numéro définitif - + Raises: ValueError: Si le fournisseur existe déjà ou données invalides RuntimeError: Si erreur technique Sage """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - + try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 0 : VALIDATION & NETTOYAGE # ======================================== logger.info("🔍 === VALIDATION DONNÉES FOURNISSEUR ===") - + if not fournisseur_data.get("intitule"): raise ValueError("Le champ 'intitule' est obligatoire") - + # Nettoyage et troncature (longueurs max Sage) intitule = str(fournisseur_data["intitule"])[:69].strip() - num_prop = str(fournisseur_data.get("num", "")).upper()[:17].strip() if fournisseur_data.get("num") else "" - compte = str(fournisseur_data.get("compte_collectif", "401000"))[:13].strip() - + num_prop = ( + str(fournisseur_data.get("num", "")).upper()[:17].strip() + if fournisseur_data.get("num") + else "" + ) + compte = str(fournisseur_data.get("compte_collectif", "401000"))[ + :13 + ].strip() + adresse = str(fournisseur_data.get("adresse", ""))[:35].strip() code_postal = str(fournisseur_data.get("code_postal", ""))[:9].strip() ville = str(fournisseur_data.get("ville", ""))[:35].strip() pays = str(fournisseur_data.get("pays", ""))[:35].strip() - + telephone = str(fournisseur_data.get("telephone", ""))[:21].strip() email = str(fournisseur_data.get("email", ""))[:69].strip() - + siret = str(fournisseur_data.get("siret", ""))[:14].strip() tva_intra = str(fournisseur_data.get("tva_intra", ""))[:25].strip() - + logger.info(f" intitule: '{intitule}' (len={len(intitule)})") logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})") logger.info(f" compte: '{compte}' (len={len(compte)})") - + # ======================================== # ÉTAPE 1 : CRÉATION OBJET FOURNISSEUR # ======================================== # 🔑 CRITIQUE: Utiliser FactoryFournisseur, PAS FactoryClient ! factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur - + persist = factory_fournisseur.Create() fournisseur = win32com.client.CastTo(persist, "IBOFournisseur3") - + # 🔑 CRITIQUE : Initialiser l'objet fournisseur.SetDefault() - + logger.info("✅ Objet fournisseur créé et initialisé") - + # ======================================== # ÉTAPE 2 : CHAMPS OBLIGATOIRES # ======================================== logger.info("📝 Définition des champs obligatoires...") - + # 1. Intitulé (OBLIGATOIRE) fournisseur.CT_Intitule = intitule logger.debug(f" ✅ CT_Intitule: '{intitule}'") - + # 2. Type = Fournisseur (1) # ⚠️ NOTE: Sur certaines versions Sage, CT_Type n'existe pas # et le type est automatiquement défini par la factory utilisée @@ -486,31 +518,35 @@ class SageConnector: logger.debug(" ✅ CT_Type: 1 (Fournisseur)") except: logger.debug(" ⚠️ CT_Type non défini (géré par FactoryFournisseur)") - + # 3. Qualité (pour versions récentes Sage) try: fournisseur.CT_Qualite = "FOU" logger.debug(" ✅ CT_Qualite: 'FOU'") except: logger.debug(" ⚠️ CT_Qualite non défini (pas critique)") - + # 4. Compte général principal (OBLIGATOIRE) try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte) - + if persist_compte: - compte_obj = win32com.client.CastTo(persist_compte, "IBOCompteG3") + compte_obj = win32com.client.CastTo( + persist_compte, "IBOCompteG3" + ) compte_obj.Read() - + # Assigner l'objet CompteG fournisseur.CompteGPrinc = compte_obj logger.debug(f" ✅ CompteGPrinc: objet '{compte}' assigné") else: - logger.warning(f" ⚠️ Compte {compte} introuvable - utilisation défaut") + logger.warning( + f" ⚠️ Compte {compte} introuvable - utilisation défaut" + ) except Exception as e: logger.warning(f" ⚠️ Erreur CompteGPrinc: {e}") - + # 5. Numéro fournisseur (OBLIGATOIRE - générer si vide) if num_prop: fournisseur.CT_Num = num_prop @@ -519,7 +555,7 @@ class SageConnector: # 🔑 CRITIQUE : Générer le numéro automatiquement try: # Méthode 1 : SetDefaultNumPiece (si disponible) - if hasattr(fournisseur, 'SetDefaultNumPiece'): + if hasattr(fournisseur, "SetDefaultNumPiece"): fournisseur.SetDefaultNumPiece() num_genere = getattr(fournisseur, "CT_Num", "") logger.debug(f" ✅ CT_Num auto-généré: '{num_genere}'") @@ -528,39 +564,44 @@ class SageConnector: num_genere = factory_fournisseur.GetNextNumero() if num_genere: fournisseur.CT_Num = num_genere - logger.debug(f" ✅ CT_Num auto (GetNextNumero): '{num_genere}'") + logger.debug( + f" ✅ CT_Num auto (GetNextNumero): '{num_genere}'" + ) else: # Méthode 3 : Fallback - timestamp import time + num_genere = f"FOUR{int(time.time()) % 1000000}" fournisseur.CT_Num = num_genere logger.warning(f" ⚠️ CT_Num fallback: '{num_genere}'") except Exception as e: logger.error(f" ❌ Impossible de générer CT_Num: {e}") - raise ValueError("Impossible de générer le numéro fournisseur automatiquement") - + raise ValueError( + "Impossible de générer le numéro fournisseur automatiquement" + ) + # 6. Catégories (valeurs par défaut) try: - if hasattr(fournisseur, 'N_CatTarif'): + if hasattr(fournisseur, "N_CatTarif"): fournisseur.N_CatTarif = 1 - if hasattr(fournisseur, 'N_CatCompta'): + if hasattr(fournisseur, "N_CatCompta"): fournisseur.N_CatCompta = 1 - if hasattr(fournisseur, 'N_Period'): + if hasattr(fournisseur, "N_Period"): fournisseur.N_Period = 1 logger.debug(" ✅ Catégories (N_*) initialisées") except Exception as e: logger.warning(f" ⚠️ Catégories: {e}") - + # ======================================== # ÉTAPE 3 : CHAMPS OPTIONNELS # ======================================== logger.info("📝 Définition champs optionnels...") - + # Adresse (objet IAdresse) if any([adresse, code_postal, ville, pays]): try: adresse_obj = fournisseur.Adresse - + if adresse: adresse_obj.Adresse = adresse if code_postal: @@ -569,25 +610,25 @@ class SageConnector: adresse_obj.Ville = ville if pays: adresse_obj.Pays = pays - + logger.debug(" ✅ Adresse définie") except Exception as e: logger.warning(f" ⚠️ Adresse: {e}") - + # Télécom (objet ITelecom) if telephone or email: try: telecom_obj = fournisseur.Telecom - + if telephone: telecom_obj.Telephone = telephone if email: telecom_obj.EMail = email - + logger.debug(" ✅ Télécom défini") except Exception as e: logger.warning(f" ⚠️ Télécom: {e}") - + # Identifiants fiscaux if siret: try: @@ -595,63 +636,68 @@ class SageConnector: logger.debug(f" ✅ SIRET: '{siret}'") except Exception as e: logger.warning(f" ⚠️ SIRET: {e}") - + if tva_intra: try: fournisseur.CT_Identifiant = tva_intra logger.debug(f" ✅ TVA intra: '{tva_intra}'") except Exception as e: logger.warning(f" ⚠️ TVA: {e}") - + # Options par défaut try: - if hasattr(fournisseur, 'CT_Lettrage'): + if hasattr(fournisseur, "CT_Lettrage"): fournisseur.CT_Lettrage = True - if hasattr(fournisseur, 'CT_Sommeil'): + if hasattr(fournisseur, "CT_Sommeil"): fournisseur.CT_Sommeil = False logger.debug(" ✅ Options par défaut définies") except Exception as e: logger.debug(f" ⚠️ Options: {e}") - + # ======================================== # ÉTAPE 4 : VÉRIFICATION PRÉ-WRITE # ======================================== logger.info("🔍 === DIAGNOSTIC PRÉ-WRITE ===") - + num_avant_write = getattr(fournisseur, "CT_Num", "") if not num_avant_write: logger.error("❌ CRITIQUE: CT_Num toujours vide !") raise ValueError("Le numéro fournisseur (CT_Num) est obligatoire") - + logger.info(f"✅ CT_Num confirmé: '{num_avant_write}'") - + # ======================================== # ÉTAPE 5 : ÉCRITURE EN BASE # ======================================== logger.info("💾 Écriture du fournisseur dans Sage...") - + try: fournisseur.Write() logger.info("✅ Write() réussi !") - + except Exception as e: error_detail = str(e) - + # Récupérer l'erreur Sage détaillée try: sage_error = self.cial.CptaApplication.LastError if sage_error: - error_detail = f"{sage_error.Description} (Code: {sage_error.Number})" + error_detail = ( + f"{sage_error.Description} (Code: {sage_error.Number})" + ) logger.error(f"❌ Erreur Sage: {error_detail}") except: pass - + # Analyser l'erreur - if "doublon" in error_detail.lower() or "existe" in error_detail.lower(): + if ( + "doublon" in error_detail.lower() + or "existe" in error_detail.lower() + ): raise ValueError(f"Ce fournisseur existe déjà : {error_detail}") - + raise RuntimeError(f"Échec Write(): {error_detail}") - + # ======================================== # ÉTAPE 6 : RELECTURE & FINALISATION # ======================================== @@ -659,14 +705,14 @@ class SageConnector: fournisseur.Read() except Exception as e: logger.warning(f"⚠️ Impossible de relire: {e}") - + num_final = getattr(fournisseur, "CT_Num", "") - + if not num_final: raise RuntimeError("CT_Num vide après Write()") - + logger.info(f"✅✅✅ FOURNISSEUR CRÉÉ: {num_final} - {intitule} ✅✅✅") - + # ======================================== # ÉTAPE 7 : CONSTRUCTION RÉPONSE # ======================================== @@ -683,22 +729,22 @@ class SageConnector: "email": email or None, "telephone": telephone or None, "siret": siret or None, - "tva_intra": tva_intra or None + "tva_intra": tva_intra or None, } - + # ⚠️ PAS DE REFRESH CACHE ICI # Car lister_tous_fournisseurs() utilise FactoryFournisseur.List() # qui lit directement depuis Sage (pas de cache) - + return resultat - + except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise - + except Exception as e: logger.error(f"❌ Erreur création fournisseur: {e}", exc_info=True) - + error_message = str(e) if self.cial: try: @@ -707,103 +753,108 @@ class SageConnector: error_message = f"Erreur Sage: {err.Description}" except: pass - + raise RuntimeError(f"Erreur technique Sage: {error_message}") def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: """ ✏️ Modification d'un fournisseur existant dans Sage 100c - + IMPORTANT: Utilise FactoryFournisseur.ReadNumero() pour charger le fournisseur - + Args: code: Code du fournisseur à modifier fournisseur_data: Dictionnaire avec les champs à mettre à jour - + Returns: Fournisseur modifié """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - + try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : CHARGER LE FOURNISSEUR EXISTANT # ======================================== logger.info(f"🔍 Recherche fournisseur {code}...") - + factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur persist = factory_fournisseur.ReadNumero(code) - + if not persist: raise ValueError(f"Fournisseur {code} introuvable") - + fournisseur = self._cast_client(persist) # ✅ Réutiliser _cast_client if not fournisseur: raise ValueError(f"Impossible de charger le fournisseur {code}") - - logger.info(f"✅ Fournisseur {code} trouvé: {getattr(fournisseur, 'CT_Intitule', '')}") - + + logger.info( + f"✅ Fournisseur {code} trouvé: {getattr(fournisseur, 'CT_Intitule', '')}" + ) + # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS # ======================================== logger.info("📝 Mise à jour des champs...") - + champs_modifies = [] - + # Intitulé if "intitule" in fournisseur_data: intitule = str(fournisseur_data["intitule"])[:69].strip() fournisseur.CT_Intitule = intitule champs_modifies.append(f"intitule='{intitule}'") - + # Adresse - if any(k in fournisseur_data for k in ["adresse", "code_postal", "ville", "pays"]): + if any( + k in fournisseur_data + for k in ["adresse", "code_postal", "ville", "pays"] + ): try: adresse_obj = fournisseur.Adresse - + if "adresse" in fournisseur_data: adresse = str(fournisseur_data["adresse"])[:35].strip() adresse_obj.Adresse = adresse champs_modifies.append("adresse") - + if "code_postal" in fournisseur_data: cp = str(fournisseur_data["code_postal"])[:9].strip() adresse_obj.CodePostal = cp champs_modifies.append("code_postal") - + if "ville" in fournisseur_data: ville = str(fournisseur_data["ville"])[:35].strip() adresse_obj.Ville = ville champs_modifies.append("ville") - + if "pays" in fournisseur_data: pays = str(fournisseur_data["pays"])[:35].strip() adresse_obj.Pays = pays champs_modifies.append("pays") - + except Exception as e: logger.warning(f"⚠️ Erreur mise à jour adresse: {e}") - + # Télécom if "email" in fournisseur_data or "telephone" in fournisseur_data: try: telecom_obj = fournisseur.Telecom - + if "email" in fournisseur_data: email = str(fournisseur_data["email"])[:69].strip() telecom_obj.EMail = email champs_modifies.append("email") - + if "telephone" in fournisseur_data: tel = str(fournisseur_data["telephone"])[:21].strip() telecom_obj.Telephone = tel champs_modifies.append("telephone") - + except Exception as e: logger.warning(f"⚠️ Erreur mise à jour télécom: {e}") - + # SIRET if "siret" in fournisseur_data: try: @@ -812,7 +863,7 @@ class SageConnector: champs_modifies.append("siret") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour SIRET: {e}") - + # TVA Intracommunautaire if "tva_intra" in fournisseur_data: try: @@ -821,7 +872,7 @@ class SageConnector: champs_modifies.append("tva_intra") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour TVA: {e}") - + if not champs_modifies: logger.warning("⚠️ Aucun champ à modifier") # Retourner les données actuelles via extraction directe @@ -829,82 +880,90 @@ class SageConnector: "numero": getattr(fournisseur, "CT_Num", "").strip(), "intitule": getattr(fournisseur, "CT_Intitule", "").strip(), "type": 1, - "est_fournisseur": True + "est_fournisseur": True, } - + logger.info(f"📝 Champs à modifier: {', '.join(champs_modifies)}") - + # ======================================== # ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS # ======================================== logger.info("💾 Écriture des modifications...") - + try: fournisseur.Write() logger.info("✅ Write() réussi !") - + except Exception as e: error_detail = str(e) - + try: sage_error = self.cial.CptaApplication.LastError if sage_error: - error_detail = f"{sage_error.Description} (Code: {sage_error.Number})" + error_detail = ( + f"{sage_error.Description} (Code: {sage_error.Number})" + ) except: pass - + logger.error(f"❌ Erreur Write(): {error_detail}") raise RuntimeError(f"Échec modification: {error_detail}") - + # ======================================== # ÉTAPE 4 : RELIRE ET RETOURNER # ======================================== fournisseur.Read() - - logger.info(f"✅✅✅ FOURNISSEUR MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅") - + + logger.info( + f"✅✅✅ FOURNISSEUR MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅" + ) + # Extraction directe (comme lire_fournisseur) numero = getattr(fournisseur, "CT_Num", "").strip() intitule = getattr(fournisseur, "CT_Intitule", "").strip() - + data = { "numero": numero, "intitule": intitule, "type": 1, - "est_fournisseur": True + "est_fournisseur": True, } - + # Adresse try: adresse_obj = getattr(fournisseur, "Adresse", None) if adresse_obj: data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() - data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip() + data["code_postal"] = getattr( + adresse_obj, "CodePostal", "" + ).strip() data["ville"] = getattr(adresse_obj, "Ville", "").strip() except: data["adresse"] = "" data["code_postal"] = "" data["ville"] = "" - + # Télécom try: telecom_obj = getattr(fournisseur, "Telecom", None) if telecom_obj: - data["telephone"] = getattr(telecom_obj, "Telephone", "").strip() + data["telephone"] = getattr( + telecom_obj, "Telephone", "" + ).strip() data["email"] = getattr(telecom_obj, "EMail", "").strip() except: data["telephone"] = "" data["email"] = "" - + return data - + except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise - + except Exception as e: logger.error(f"❌ Erreur modification fournisseur: {e}", exc_info=True) - + error_message = str(e) if self.cial: try: @@ -913,68 +972,72 @@ class SageConnector: error_message = f"Erreur Sage: {err.Description}" except: pass - + raise RuntimeError(f"Erreur technique Sage: {error_message}") - + def lire_fournisseur(self, code): """ ✅ NOUVEAU : Lecture d'un fournisseur par code - + Utilise FactoryFournisseur.ReadNumero() directement """ if not self.cial: return None - + try: with self._com_context(), self._lock_com: factory = self.cial.CptaApplication.FactoryFournisseur persist = factory.ReadNumero(code) - + if not persist: logger.warning(f"Fournisseur {code} introuvable") return None - + fourn = self._cast_client(persist) - + if not fourn: return None - + # Extraction directe (même logique que lister_tous_fournisseurs) numero = getattr(fourn, "CT_Num", "").strip() intitule = getattr(fourn, "CT_Intitule", "").strip() - + data = { "numero": numero, "intitule": intitule, "type": 1, - "est_fournisseur": True + "est_fournisseur": True, } - + # Adresse try: adresse_obj = getattr(fourn, "Adresse", None) if adresse_obj: data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() - data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip() + data["code_postal"] = getattr( + adresse_obj, "CodePostal", "" + ).strip() data["ville"] = getattr(adresse_obj, "Ville", "").strip() except: data["adresse"] = "" data["code_postal"] = "" data["ville"] = "" - + # Télécom try: telecom_obj = getattr(fourn, "Telecom", None) if telecom_obj: - data["telephone"] = getattr(telecom_obj, "Telephone", "").strip() + data["telephone"] = getattr( + telecom_obj, "Telephone", "" + ).strip() data["email"] = getattr(telecom_obj, "EMail", "").strip() except: data["telephone"] = "" data["email"] = "" - + logger.info(f"✅ Fournisseur {code} lu: {intitule}") return data - + except Exception as e: logger.error(f"❌ Erreur lecture fournisseur {code}: {e}") return None @@ -1027,9 +1090,8 @@ class SageConnector: self._refresh_cache_clients() self._refresh_cache_articles() logger.info("✅ Cache actualisé") - - logger.info("Cache actualisé") + logger.info("Cache actualisé") def get_cache_info(self): """Retourne les infos du cache (endpoint monitoring)""" @@ -1043,7 +1105,10 @@ class SageConnector: else None ), "age_minutes": ( - (datetime.now() - self._cache_clients_last_update).total_seconds() / 60 + ( + datetime.now() - self._cache_clients_last_update + ).total_seconds() + / 60 if self._cache_clients_last_update else None ), @@ -1056,16 +1121,19 @@ class SageConnector: else None ), "age_minutes": ( - (datetime.now() - self._cache_articles_last_update).total_seconds() / 60 + ( + datetime.now() - self._cache_articles_last_update + ).total_seconds() + / 60 if self._cache_articles_last_update else None ), - } + }, } - + info["ttl_minutes"] = self._cache_ttl_minutes return info - + # ========================================================================= # CAST HELPERS # ========================================================================= @@ -1094,7 +1162,7 @@ class SageConnector: 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: @@ -1107,7 +1175,7 @@ class SageConnector: except Exception as e: logger.debug(f"❌ Erreur lecture CT_Num: {e}") return None - + try: intitule = getattr(client_obj, "CT_Intitule", "").strip() if not intitule: @@ -1115,36 +1183,38 @@ class SageConnector: except Exception as e: logger.debug(f"⚠️ Erreur CT_Intitule sur {numero}: {e}") intitule = "" - + # === 2. CONSTRUCTION OBJET MINIMAL === data = { "numero": numero, "intitule": intitule, } - + # === 3. CHAMPS OPTIONNELS (avec try-except individuels) === - + # Type try: data["type"] = getattr(client_obj, "CT_Type", 0) except: data["type"] = 0 - + # Qualité try: qualite = getattr(client_obj, "CT_Qualite", None) data["qualite"] = qualite - data["est_fournisseur"] = qualite in [2, 3] if qualite is not None else False + data["est_fournisseur"] = ( + qualite in [2, 3] if qualite is not None else False + ) except: data["qualite"] = None data["est_fournisseur"] = False - + # Prospect try: data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1 except: data["est_prospect"] = False - + # === 4. ADRESSE (non critique) === try: adresse = getattr(client_obj, "Adresse", None) @@ -1153,12 +1223,12 @@ class SageConnector: data["adresse"] = getattr(adresse, "Adresse", "").strip() except: data["adresse"] = "" - + try: data["code_postal"] = getattr(adresse, "CodePostal", "").strip() except: data["code_postal"] = "" - + try: data["ville"] = getattr(adresse, "Ville", "").strip() except: @@ -1168,7 +1238,7 @@ class SageConnector: data["adresse"] = "" data["code_postal"] = "" data["ville"] = "" - + # === 5. TELECOM (non critique) === try: telecom = getattr(client_obj, "Telecom", None) @@ -1177,7 +1247,7 @@ class SageConnector: data["telephone"] = getattr(telecom, "Telephone", "").strip() except: data["telephone"] = "" - + try: data["email"] = getattr(telecom, "EMail", "").strip() except: @@ -1186,12 +1256,12 @@ class SageConnector: logger.debug(f"⚠️ Erreur telecom sur {numero}: {e}") data["telephone"] = "" data["email"] = "" - + return data - + except Exception as e: logger.error(f"❌ ERREUR GLOBALE _extraire_client: {e}", exc_info=True) - return None + return None def _extraire_article(self, article_obj): return { @@ -1202,6 +1272,7 @@ class SageConnector: "stock_reel": getattr(article_obj, "AR_Stock", 0.0), "stock_mini": getattr(article_obj, "AR_StockMini", 0.0), } + # ========================================================================= # CRÉATION DEVIS (US-A1) - VERSION TRANSACTIONNELLE # ========================================================================= @@ -1209,12 +1280,12 @@ class SageConnector: 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 @@ -1287,12 +1358,12 @@ class SageConnector: ) doc.SetDefaultClient(client_obj) - + # ✅ STATUT: Définir SEULEMENT si brouillon demandé doc.DO_Statut = 2 logger.info("📊 Statut forcé: 0 (Brouillon)") # Sinon, laisser Sage décider (généralement 2 = Accepté) - + doc.Write() logger.info(f"👤 Client {devis_data['client']['code']} associé") @@ -1357,7 +1428,8 @@ class SageConnector: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ( - designation_sage or ligne_data.get("designation", "") + designation_sage + or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite @@ -1389,7 +1461,7 @@ class SageConnector: # ===== VALIDATION ===== doc.Write() - + # ✅ PROCESS() uniquement si pas en brouillon if not forcer_brouillon: logger.info("🔄 Lancement Process()...") @@ -1404,7 +1476,7 @@ class SageConnector: # ===== RÉCUPÉRATION NUMÉRO ===== numero_devis = None - + # Méthode 1: DocumentResult try: doc_result = process.DocumentResult @@ -1468,7 +1540,8 @@ class SageConnector: if ( getattr(doc_test, "DO_Type", -1) == 0 - and getattr(doc_test, "DO_Piece", "") == numero_devis + and getattr(doc_test, "DO_Piece", "") + == numero_devis ): persist_reread = persist_test logger.info(f"✅ Document trouvé à l'index {index}") @@ -1527,8 +1600,7 @@ class SageConnector: except Exception as e: logger.error(f"❌ ERREUR CRÉATION DEVIS: {e}", exc_info=True) raise RuntimeError(f"Échec création devis: {str(e)}") - - + # ========================================================================= # LECTURE DEVIS # ========================================================================= @@ -1545,14 +1617,16 @@ class SageConnector: try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentVente - + # ✅ UNIQUEMENT ReadPiece try: persist = factory.ReadPiece(0, numero_devis) if persist: logger.info(f"✅ Devis {numero_devis} trouvé via ReadPiece") else: - logger.warning(f"❌ Devis {numero_devis} introuvable via ReadPiece") + logger.warning( + f"❌ Devis {numero_devis} introuvable via ReadPiece" + ) return None except Exception as e: logger.error(f"❌ ReadPiece échoué pour {numero_devis}: {e}") @@ -1590,7 +1664,7 @@ class SageConnector: 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']}, " @@ -1614,7 +1688,9 @@ class SageConnector: if ligne_persist is None: break - ligne = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) ligne.Read() # Charger article @@ -1625,17 +1701,27 @@ class SageConnector: article_obj = getattr(ligne, "Article", None) if article_obj: article_obj.Read() - article_ref = getattr(article_obj, "AR_Ref", "").strip() + article_ref = getattr( + article_obj, "AR_Ref", "" + ).strip() except Exception as e: - logger.debug(f"Erreur chargement article ligne {index}: {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)), - }) + 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: @@ -1652,7 +1738,7 @@ class SageConnector: except Exception as e: logger.error(f"❌ Erreur lecture devis {numero_devis}: {e}", exc_info=True) return None - + def lire_document(self, numero, type_doc): """ Lecture générique document @@ -1707,7 +1793,9 @@ class SageConnector: article_obj = getattr(ligne, "Article", None) if article_obj: article_obj.Read() - article_ref = getattr(article_obj, "AR_Ref", "").strip() + article_ref = getattr( + article_obj, "AR_Ref", "" + ).strip() except: pass @@ -1743,18 +1831,18 @@ class SageConnector: 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, @@ -1765,67 +1853,83 @@ class SageConnector: """ 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) - + + 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 + 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 + 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) - + persist = factory.ReadPiece( + type_cible, numero_potentiel + ) + if persist: - doc = win32com.client.CastTo(persist, "IBODocumentVente3") + 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" - }) - + + 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})" @@ -1833,54 +1937,63 @@ class SageConnector: 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: + + 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" - }) - + + 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 # ======================================== @@ -1888,17 +2001,16 @@ class SageConnector: f"📊 Résultat vérification {numero_source}: " f"{len(documents_cibles)} transformation(s) trouvée(s)" ) - + return { "deja_transforme": len(documents_cibles) > 0, "nb_transformations": len(documents_cibles), - "documents_cibles": documents_cibles + "documents_cibles": documents_cibles, } - + except Exception as e: logger.error(f"❌ Erreur vérification transformation: {e}") return {"deja_transforme": False, "documents_cibles": []} - def _get_type_libelle(self, type_doc: int) -> str: """Retourne le libellé d'un type de document""" @@ -1909,15 +2021,14 @@ class SageConnector: 30: "Bon de livraison", 40: "Bon de retour", 50: "Bon d'avoir", - 60: "Facture" + 60: "Facture", } return types.get(type_doc, f"Type {type_doc}") - def transformer_document(self, numero_source, type_source, type_cible): """ 🔧 Transformation de document - VERSION FUSIONNÉE FINALE - + ✅ Copie DO_Ref du source vers la cible (du nouveau) ✅ Ne modifie JAMAIS le statut du document source ✅ Préserve toutes les lignes correctement (de l'ancien) @@ -1946,31 +2057,28 @@ class SageConnector: raise ValueError( f"Transformation non autorisée: {type_source} -> {type_cible}" ) - + # ======================================== # VÉRIFICATION AUTOMATIQUE DES DOUBLONS # ======================================== logger.info("[TRANSFORM] Vérification des doublons via DO_Ref...") - + verification = self.verifier_si_deja_transforme(numero_source, type_source) - + if verification["deja_transforme"]: docs_existants = verification["documents_cibles"] - - docs_meme_type = [ - d for d in docs_existants - if d["type"] == type_cible - ] - + + 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: @@ -2030,7 +2138,7 @@ class SageConnector: # Date date_source = getattr(doc_source, "DO_Date", None) - + # ✅ NOUVEAU: Référence externe (DO_Ref) - UTILISER LE NUMÉRO SOURCE reference_pour_cible = numero_source logger.info(f"[TRANSFORM] Référence à copier: {reference_pour_cible}") @@ -2186,7 +2294,9 @@ class SageConnector: # ✅ FUSION: Définir DO_Ref AVANT le premier Write() try: doc_cible.DO_Ref = reference_pour_cible - logger.info(f"[TRANSFORM] DO_Ref défini: {reference_pour_cible}") + logger.info( + f"[TRANSFORM] DO_Ref défini: {reference_pour_cible}" + ) except Exception as e: logger.warning(f"Impossible de définir DO_Ref: {e}") @@ -2206,11 +2316,11 @@ class SageConnector: pass if not client_verifie: - raise ValueError( - f"Echec association client {client_code}" - ) + raise ValueError(f"Echec association client {client_code}") - logger.info(f"[TRANSFORM] Client {client_code} associé (CT_Num={client_verifie})") + logger.info( + f"[TRANSFORM] Client {client_code} associé (CT_Num={client_verifie})" + ) client_obj_sauvegarde = client_obj_cible @@ -2285,7 +2395,9 @@ class SageConnector: ligne_obj.Write() # ✅ FUSION: Log détaillé de la ligne écrite - logger.debug(f"[TRANSFORM] Ligne {idx} écrite: {article_ref} x{quantite} @ {prix}€") + logger.debug( + f"[TRANSFORM] Ligne {idx} écrite: {article_ref} x{quantite} @ {prix}€" + ) logger.info(f"[TRANSFORM] {nb_lignes} lignes copiées") @@ -2298,7 +2410,9 @@ class SageConnector: ) try: - journal = getattr(doc_source, "DO_CodeJournal", None) or "VTE" + journal = ( + getattr(doc_source, "DO_CodeJournal", None) or "VTE" + ) if hasattr(doc_cible, "DO_CodeJournal"): doc_cible.DO_CodeJournal = journal except Exception as e: @@ -2350,7 +2464,9 @@ class SageConnector: pass if not client_final: - logger.warning("Client perdu ! Tentative réassociation urgence...") + logger.warning( + "Client perdu ! Tentative réassociation urgence..." + ) try: doc_cible.SetClient(client_obj_sauvegarde) except: @@ -2362,9 +2478,7 @@ class SageConnector: client_final = getattr(doc_cible, "CT_Num", None) if not client_final: - raise ValueError( - f"Client {client_code} impossible à associer" - ) + raise ValueError(f"Client {client_code} impossible à associer") logger.info(f"[TRANSFORM] ✅ Client confirmé: {client_final}") @@ -2440,8 +2554,7 @@ class SageConnector: except Exception as e: logger.error(f"[TRANSFORM] ❌ ERREUR: {e}", exc_info=True) raise RuntimeError(f"Echec transformation: {str(e)}") - - + def _find_document_in_list(self, numero, type_doc): """Cherche un document dans List() si ReadPiece échoue""" try: @@ -3122,79 +3235,87 @@ class SageConnector: # ÉTAPE 0 : VALIDATION & NETTOYAGE # ======================================== logger.info("🔍 === VALIDATION DES DONNÉES ===") - + if not client_data.get("intitule"): raise ValueError("Le champ 'intitule' est obligatoire") - + # Nettoyage et troncature intitule = str(client_data["intitule"])[:69].strip() - num_prop = str(client_data.get("num", "")).upper()[:17].strip() if client_data.get("num") else "" + num_prop = ( + str(client_data.get("num", "")).upper()[:17].strip() + if client_data.get("num") + else "" + ) compte = str(client_data.get("compte_collectif", "411000"))[:13].strip() - + adresse = str(client_data.get("adresse", ""))[:35].strip() code_postal = str(client_data.get("code_postal", ""))[:9].strip() ville = str(client_data.get("ville", ""))[:35].strip() pays = str(client_data.get("pays", ""))[:35].strip() - + telephone = str(client_data.get("telephone", ""))[:21].strip() email = str(client_data.get("email", ""))[:69].strip() - + siret = str(client_data.get("siret", ""))[:14].strip() tva_intra = str(client_data.get("tva_intra", ""))[:25].strip() - + logger.info(f" intitule: '{intitule}' (len={len(intitule)})") logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})") logger.info(f" compte: '{compte}' (len={len(compte)})") - + # ======================================== # ÉTAPE 1 : CRÉATION OBJET CLIENT # ======================================== factory_client = self.cial.CptaApplication.FactoryClient - + persist = factory_client.Create() client = win32com.client.CastTo(persist, "IBOClient3") - + # 🔑 CRITIQUE : Initialiser l'objet client.SetDefault() - + logger.info("✅ Objet client créé et initialisé") - + # ======================================== # ÉTAPE 2 : CHAMPS OBLIGATOIRES (dans l'ordre !) # ======================================== logger.info("📝 Définition des champs obligatoires...") - + # 1. Intitulé (OBLIGATOIRE) client.CT_Intitule = intitule logger.debug(f" ✅ CT_Intitule: '{intitule}'") - + # ❌ CT_Type SUPPRIMÉ (n'existe pas dans cette version) # client.CT_Type = 0 # <-- LIGNE SUPPRIMÉE - + # 2. Qualité (important pour filtrage Client/Fournisseur) try: client.CT_Qualite = "CLI" logger.debug(" ✅ CT_Qualite: 'CLI'") except: logger.debug(" ⚠️ CT_Qualite non défini (pas critique)") - + # 3. Compte général principal (OBLIGATOIRE) try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte) - + if persist_compte: - compte_obj = win32com.client.CastTo(persist_compte, "IBOCompteG3") + compte_obj = win32com.client.CastTo( + persist_compte, "IBOCompteG3" + ) compte_obj.Read() - + # Assigner l'objet CompteG client.CompteGPrinc = compte_obj logger.debug(f" ✅ CompteGPrinc: objet '{compte}' assigné") else: - logger.warning(f" ⚠️ Compte {compte} introuvable - utilisation du compte par défaut") + logger.warning( + f" ⚠️ Compte {compte} introuvable - utilisation du compte par défaut" + ) except Exception as e: logger.warning(f" ⚠️ Erreur CompteGPrinc: {e}") - + # 4. Numéro client (OBLIGATOIRE - générer si vide) if num_prop: client.CT_Num = num_prop @@ -3203,64 +3324,73 @@ class SageConnector: # 🔑 CRITIQUE : Générer le numéro automatiquement try: # Méthode 1 : Utiliser SetDefaultNumPiece (si disponible) - if hasattr(client, 'SetDefaultNumPiece'): + if hasattr(client, "SetDefaultNumPiece"): client.SetDefaultNumPiece() num_genere = getattr(client, "CT_Num", "") - logger.debug(f" ✅ CT_Num auto-généré (SetDefaultNumPiece): '{num_genere}'") + logger.debug( + f" ✅ CT_Num auto-généré (SetDefaultNumPiece): '{num_genere}'" + ) else: # Méthode 2 : Lire le prochain numéro depuis la souche factory_client = self.cial.CptaApplication.FactoryClient num_genere = factory_client.GetNextNumero() if num_genere: client.CT_Num = num_genere - logger.debug(f" ✅ CT_Num auto-généré (GetNextNumero): '{num_genere}'") + logger.debug( + f" ✅ CT_Num auto-généré (GetNextNumero): '{num_genere}'" + ) else: # Méthode 3 : Fallback - utiliser un timestamp import time + num_genere = f"CLI{int(time.time()) % 1000000}" client.CT_Num = num_genere - logger.warning(f" ⚠️ CT_Num fallback temporaire: '{num_genere}'") + logger.warning( + f" ⚠️ CT_Num fallback temporaire: '{num_genere}'" + ) except Exception as e: logger.error(f" ❌ Impossible de générer CT_Num: {e}") - raise ValueError("Impossible de générer le numéro client automatiquement. Veuillez fournir un numéro manuellement.") - + raise ValueError( + "Impossible de générer le numéro client automatiquement. Veuillez fournir un numéro manuellement." + ) + # 5. Catégories tarifaires (valeurs par défaut) try: # Catégorie tarifaire (obligatoire) - if hasattr(client, 'N_CatTarif'): + if hasattr(client, "N_CatTarif"): client.N_CatTarif = 1 - + # Catégorie comptable (obligatoire) - if hasattr(client, 'N_CatCompta'): + if hasattr(client, "N_CatCompta"): client.N_CatCompta = 1 - + # Autres catégories - if hasattr(client, 'N_Period'): + if hasattr(client, "N_Period"): client.N_Period = 1 - - if hasattr(client, 'N_Expedition'): + + if hasattr(client, "N_Expedition"): client.N_Expedition = 1 - - if hasattr(client, 'N_Condition'): + + if hasattr(client, "N_Condition"): client.N_Condition = 1 - - if hasattr(client, 'N_Risque'): + + if hasattr(client, "N_Risque"): client.N_Risque = 1 - + logger.debug(" ✅ Catégories (N_*) initialisées") except Exception as e: logger.warning(f" ⚠️ Catégories: {e}") - + # ======================================== # ÉTAPE 3 : CHAMPS OPTIONNELS MAIS RECOMMANDÉS # ======================================== logger.info("📝 Définition champs optionnels...") - + # Adresse (objet IAdresse) if any([adresse, code_postal, ville, pays]): try: adresse_obj = client.Adresse - + if adresse: adresse_obj.Adresse = adresse if code_postal: @@ -3269,25 +3399,25 @@ class SageConnector: adresse_obj.Ville = ville if pays: adresse_obj.Pays = pays - + logger.debug(" ✅ Adresse définie") except Exception as e: logger.warning(f" ⚠️ Adresse: {e}") - + # Télécom (objet ITelecom) if telephone or email: try: telecom_obj = client.Telecom - + if telephone: telecom_obj.Telephone = telephone if email: telecom_obj.EMail = email - + logger.debug(" ✅ Télécom défini") except Exception as e: logger.warning(f" ⚠️ Télécom: {e}") - + # Identifiants fiscaux if siret: try: @@ -3295,41 +3425,41 @@ class SageConnector: logger.debug(f" ✅ SIRET: '{siret}'") except Exception as e: logger.warning(f" ⚠️ SIRET: {e}") - + if tva_intra: try: client.CT_Identifiant = tva_intra logger.debug(f" ✅ TVA intracommunautaire: '{tva_intra}'") except Exception as e: logger.warning(f" ⚠️ TVA: {e}") - + # Autres champs utiles (valeurs par défaut intelligentes) try: # Type de facturation (1 = facture normale) - if hasattr(client, 'CT_Facture'): + if hasattr(client, "CT_Facture"): client.CT_Facture = 1 - + # Lettrage automatique activé - if hasattr(client, 'CT_Lettrage'): + if hasattr(client, "CT_Lettrage"): client.CT_Lettrage = True - + # Pas de prospect - if hasattr(client, 'CT_Prospect'): + if hasattr(client, "CT_Prospect"): client.CT_Prospect = False - + # Client actif (pas en sommeil) - if hasattr(client, 'CT_Sommeil'): + if hasattr(client, "CT_Sommeil"): client.CT_Sommeil = False - + logger.debug(" ✅ Options par défaut définies") except Exception as e: logger.debug(f" ⚠️ Options: {e}") - + # ======================================== # ÉTAPE 4 : DIAGNOSTIC PRÉ-WRITE (pour debug) # ======================================== logger.info("🔍 === DIAGNOSTIC PRÉ-WRITE ===") - + champs_critiques = [ ("CT_Intitule", "str"), ("CT_Num", "str"), @@ -3337,77 +3467,90 @@ class SageConnector: ("N_CatTarif", "int"), ("N_CatCompta", "int"), ] - + for champ, type_attendu in champs_critiques: try: val = getattr(client, champ, None) - + if type_attendu == "object": status = "✅ Objet défini" if val else "❌ NULL" else: if type_attendu == "str": - status = f"✅ '{val}' (len={len(val)})" if val else "❌ Vide" + status = ( + f"✅ '{val}' (len={len(val)})" if val else "❌ Vide" + ) else: status = f"✅ {val}" - + logger.info(f" {champ}: {status}") except Exception as e: logger.error(f" {champ}: ❌ Erreur - {e}") - + # ======================================== # ÉTAPE 5 : VÉRIFICATION FINALE CT_Num # ======================================== num_avant_write = getattr(client, "CT_Num", "") if not num_avant_write: - logger.error("❌ CRITIQUE: CT_Num toujours vide après toutes les tentatives !") + logger.error( + "❌ CRITIQUE: CT_Num toujours vide après toutes les tentatives !" + ) raise ValueError( "Le numéro client (CT_Num) est obligatoire mais n'a pas pu être généré. " "Veuillez fournir un numéro manuellement via le paramètre 'num'." ) - + logger.info(f"✅ CT_Num confirmé avant Write(): '{num_avant_write}'") - + # ======================================== # ÉTAPE 6 : ÉCRITURE EN BASE # ======================================== logger.info("💾 Écriture du client dans Sage...") - + try: client.Write() logger.info("✅ Write() réussi !") - + except Exception as e: error_detail = str(e) - + # Récupérer l'erreur Sage détaillée try: sage_error = self.cial.CptaApplication.LastError if sage_error: - error_detail = f"{sage_error.Description} (Code: {sage_error.Number})" + error_detail = ( + f"{sage_error.Description} (Code: {sage_error.Number})" + ) logger.error(f"❌ Erreur Sage: {error_detail}") except: pass - + # Analyser l'erreur spécifique if "longueur invalide" in error_detail.lower(): logger.error("❌ ERREUR 'longueur invalide' - Dump des champs:") - + for attr in dir(client): if attr.startswith("CT_") or attr.startswith("N_"): try: val = getattr(client, attr, None) if isinstance(val, str): - logger.error(f" {attr}: '{val}' (len={len(val)})") + logger.error( + f" {attr}: '{val}' (len={len(val)})" + ) elif val is not None and not callable(val): - logger.error(f" {attr}: {val} (type={type(val).__name__})") + logger.error( + f" {attr}: {val} (type={type(val).__name__})" + ) except: pass - - if "doublon" in error_detail.lower() or "existe" in error_detail.lower(): + + if ( + "doublon" in error_detail.lower() + or "existe" in error_detail.lower() + ): raise ValueError(f"Ce client existe déjà : {error_detail}") - + raise RuntimeError(f"Échec Write(): {error_detail}") - + # ======================================== # ÉTAPE 7 : RELECTURE & FINALISATION # ======================================== @@ -3415,19 +3558,19 @@ class SageConnector: client.Read() except Exception as e: logger.warning(f"⚠️ Impossible de relire: {e}") - + num_final = getattr(client, "CT_Num", "") - + if not num_final: raise RuntimeError("CT_Num vide après Write()") - + logger.info(f"✅✅✅ CLIENT CRÉÉ: {num_final} - {intitule} ✅✅✅") - + # ======================================== # ÉTAPE 8 : REFRESH CACHE # ======================================== self._refresh_cache_clients() - + return { "numero": num_final, "intitule": intitule, @@ -3440,16 +3583,16 @@ class SageConnector: "email": email or None, "telephone": telephone or None, "siret": siret or None, - "tva_intra": tva_intra or None + "tva_intra": tva_intra or None, } - + except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise - + except Exception as e: logger.error(f"❌ Erreur création client: {e}", exc_info=True) - + error_message = str(e) if self.cial: try: @@ -3458,101 +3601,105 @@ class SageConnector: error_message = f"Erreur Sage: {err.Description}" except: pass - + raise RuntimeError(f"Erreur technique Sage: {error_message}") - - + def modifier_client(self, code: str, client_data: Dict) -> Dict: """ ✏️ Modification d'un client existant dans Sage 100c - + Args: code: Code du client à modifier client_data: Dictionnaire avec les champs à mettre à jour - + Returns: Client modifié """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - + try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : CHARGER LE CLIENT EXISTANT # ======================================== logger.info(f"🔍 Recherche client {code}...") - + factory_client = self.cial.CptaApplication.FactoryClient persist = factory_client.ReadNumero(code) - + if not persist: raise ValueError(f"Client {code} introuvable") - + client = win32com.client.CastTo(persist, "IBOClient3") client.Read() - - logger.info(f"✅ Client {code} trouvé: {getattr(client, 'CT_Intitule', '')}") - + + logger.info( + f"✅ Client {code} trouvé: {getattr(client, 'CT_Intitule', '')}" + ) + # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS # ======================================== logger.info("📝 Mise à jour des champs...") - + champs_modifies = [] - + # Intitulé if "intitule" in client_data: intitule = str(client_data["intitule"])[:69].strip() client.CT_Intitule = intitule champs_modifies.append(f"intitule='{intitule}'") - + # Adresse - if any(k in client_data for k in ["adresse", "code_postal", "ville", "pays"]): + if any( + k in client_data + for k in ["adresse", "code_postal", "ville", "pays"] + ): try: adresse_obj = client.Adresse - + if "adresse" in client_data: adresse = str(client_data["adresse"])[:35].strip() adresse_obj.Adresse = adresse champs_modifies.append("adresse") - + if "code_postal" in client_data: cp = str(client_data["code_postal"])[:9].strip() adresse_obj.CodePostal = cp champs_modifies.append("code_postal") - + if "ville" in client_data: ville = str(client_data["ville"])[:35].strip() adresse_obj.Ville = ville champs_modifies.append("ville") - + if "pays" in client_data: pays = str(client_data["pays"])[:35].strip() adresse_obj.Pays = pays champs_modifies.append("pays") - + except Exception as e: logger.warning(f"⚠️ Erreur mise à jour adresse: {e}") - + # Télécom if "email" in client_data or "telephone" in client_data: try: telecom_obj = client.Telecom - + if "email" in client_data: email = str(client_data["email"])[:69].strip() telecom_obj.EMail = email champs_modifies.append("email") - + if "telephone" in client_data: tel = str(client_data["telephone"])[:21].strip() telecom_obj.Telephone = tel champs_modifies.append("telephone") - + except Exception as e: logger.warning(f"⚠️ Erreur mise à jour télécom: {e}") - + # SIRET if "siret" in client_data: try: @@ -3561,7 +3708,7 @@ class SageConnector: champs_modifies.append("siret") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour SIRET: {e}") - + # TVA Intracommunautaire if "tva_intra" in client_data: try: @@ -3570,54 +3717,58 @@ class SageConnector: champs_modifies.append("tva_intra") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour TVA: {e}") - + if not champs_modifies: logger.warning("⚠️ Aucun champ à modifier") return self._extraire_client(client) - + logger.info(f"📝 Champs à modifier: {', '.join(champs_modifies)}") - + # ======================================== # ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS # ======================================== logger.info("💾 Écriture des modifications...") - + try: client.Write() logger.info("✅ Write() réussi !") - + except Exception as e: error_detail = str(e) - + try: sage_error = self.cial.CptaApplication.LastError if sage_error: - error_detail = f"{sage_error.Description} (Code: {sage_error.Number})" + error_detail = ( + f"{sage_error.Description} (Code: {sage_error.Number})" + ) except: pass - + logger.error(f"❌ Erreur Write(): {error_detail}") raise RuntimeError(f"Échec modification: {error_detail}") - + # ======================================== # ÉTAPE 4 : RELIRE ET RETOURNER # ======================================== client.Read() - - logger.info(f"✅✅✅ CLIENT MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅") - + + logger.info( + f"✅✅✅ CLIENT MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅" + ) + # Refresh cache self._refresh_cache_clients() - + return self._extraire_client(client) - + except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise - + except Exception as e: logger.error(f"❌ Erreur modification client: {e}", exc_info=True) - + error_message = str(e) if self.cial: try: @@ -3626,27 +3777,26 @@ class SageConnector: error_message = f"Erreur Sage: {err.Description}" except: pass - + raise RuntimeError(f"Erreur technique Sage: {error_message}") - def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: """ ✏️ Modification d'un devis - VERSION FINALE OPTIMISÉE - + ✅ Même stratégie intelligente que modifier_commande """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - + try: with self._com_context(), self._lock_com: # ÉTAPE 1 : CHARGER LE DEVIS logger.info(f"🔍 Recherche devis {numero}...") - + factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(0, numero) - + if not persist: index = 1 while index < 10000: @@ -3654,75 +3804,84 @@ class SageConnector: persist_test = factory.List(index) if persist_test is None: break - - doc_test = win32com.client.CastTo(persist_test, "IBODocumentVente3") + + 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): + + if ( + getattr(doc_test, "DO_Type", -1) == 0 + and getattr(doc_test, "DO_Piece", "") == numero + ): persist = persist_test break - + index += 1 except: index += 1 - + if not persist: raise ValueError(f"Devis {numero} introuvable") - + doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() - + logger.info(f"✅ Devis {numero} trouvé") - + # Vérifier transformation verification = self.verifier_si_deja_transforme(numero, 0) - + if verification["deja_transforme"]: docs_cibles = verification["documents_cibles"] nums = [d["numero"] for d in docs_cibles] raise ValueError( f"❌ Devis {numero} déjà transformé en {len(docs_cibles)} document(s): {', '.join(nums)}" ) - + statut_actuel = getattr(doc, "DO_Statut", 0) if statut_actuel == 5: raise ValueError(f"Devis {numero} déjà transformé (statut=5)") - + # ÉTAPE 2 : CHAMPS SIMPLES champs_modifies = [] - + if "date_devis" in devis_data: import pywintypes + date_str = devis_data["date_devis"] - date_obj = datetime.fromisoformat(date_str) if isinstance(date_str, str) else date_str + date_obj = ( + datetime.fromisoformat(date_str) + if isinstance(date_str, str) + else date_str + ) doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f"📅 Date: {date_obj.date()}") - + if "statut" in devis_data: nouveau_statut = devis_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f"📊 Statut: {statut_actuel} → {nouveau_statut}") - + if champs_modifies: doc.Write() - + # ÉTAPE 3 : MODIFICATION INTELLIGENTE DES LIGNES if "lignes" in devis_data and devis_data["lignes"] is not None: logger.info(f"🔄 Modification intelligente des lignes...") - + nouvelles_lignes = devis_data["lignes"] nb_nouvelles = len(nouvelles_lignes) - + try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne - + factory_article = self.cial.FactoryArticle - + # Compter existantes nb_existantes = 0 index = 1 @@ -3735,107 +3894,141 @@ class SageConnector: index += 1 except: break - - logger.info(f"📊 {nb_existantes} existantes → {nb_nouvelles} nouvelles") - + + logger.info( + f"📊 {nb_existantes} existantes → {nb_nouvelles} nouvelles" + ) + # MODIFIER EXISTANTES nb_a_modifier = min(nb_existantes, nb_nouvelles) - + for idx in range(1, nb_a_modifier + 1): ligne_data = nouvelles_lignes[idx - 1] - + ligne_p = factory_lignes.List(idx) ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") ligne.Read() - - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"Article {ligne_data['article_code']} introuvable") - - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + raise ValueError( + f"Article {ligne_data['article_code']} introuvable" + ) + + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() - + try: ligne.WriteDefault() except: pass - + quantite = float(ligne_data["quantite"]) - + try: - ligne.SetDefaultArticleReference(ligne_data["article_code"], quantite) + ligne.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) except: try: ligne.SetDefaultArticle(article_obj, quantite) except: ligne.DL_Design = ligne_data.get("designation", "") ligne.DL_Qte = quantite - + if ligne_data.get("prix_unitaire_ht"): - ligne.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) - + ligne.DL_PrixUnitaire = float( + ligne_data["prix_unitaire_ht"] + ) + if ligne_data.get("remise_pourcentage", 0) > 0: try: - ligne.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) + ligne.DL_Remise01REM_Valeur = float( + ligne_data["remise_pourcentage"] + ) ligne.DL_Remise01REM_Type = 0 except: pass - + ligne.Write() logger.debug(f" ✅ Ligne {idx} modifiée") - + # AJOUTER MANQUANTES if nb_nouvelles > nb_existantes: for idx in range(nb_existantes, nb_nouvelles): ligne_data = nouvelles_lignes[idx] - - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"Article {ligne_data['article_code']} introuvable") - - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + raise ValueError( + f"Article {ligne_data['article_code']} introuvable" + ) + + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() - + ligne_persist = factory_lignes.Create() - + try: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) except: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") - + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) + quantite = float(ligne_data["quantite"]) - + try: - ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: - ligne_obj.DL_Design = ligne_data.get("designation", "") + ligne_obj.DL_Design = ligne_data.get( + "designation", "" + ) ligne_obj.DL_Qte = quantite - + if ligne_data.get("prix_unitaire_ht"): - ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) - + ligne_obj.DL_PrixUnitaire = float( + ligne_data["prix_unitaire_ht"] + ) + if ligne_data.get("remise_pourcentage", 0) > 0: try: - ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) + ligne_obj.DL_Remise01REM_Valeur = float( + ligne_data["remise_pourcentage"] + ) ligne_obj.DL_Remise01REM_Type = 0 except: pass - + ligne_obj.Write() logger.debug(f" ✅ Ligne {idx + 1} ajoutée") - + # SUPPRIMER EN TROP elif nb_nouvelles < nb_existantes: for idx in range(nb_existantes, nb_nouvelles, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: - ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne = win32com.client.CastTo( + ligne_p, "IBODocumentLigne3" + ) ligne.Read() - + try: ligne.Remove() except AttributeError: @@ -3844,36 +4037,37 @@ class SageConnector: pass except: pass - + champs_modifies.append("lignes") - + # VALIDATION logger.info("💾 Validation finale...") doc.Write() - + import time + time.sleep(1) - + doc.Read() - + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) - + logger.info(f"✅✅✅ DEVIS MODIFIÉ: {numero} ✅✅✅") logger.info(f"💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") - + return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, - "statut": getattr(doc, "DO_Statut", 0) + "statut": getattr(doc, "DO_Statut", 0), } - + except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise - + except Exception as e: logger.error(f"❌ Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur technique Sage: {str(e)}") @@ -3881,7 +4075,7 @@ class SageConnector: 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.) @@ -3889,9 +4083,11 @@ class SageConnector: """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - - logger.info(f"🚀 Début création commande pour client {commande_data['client']['code']}") - + + logger.info( + f"🚀 Début création commande pour client {commande_data['client']['code']}" + ) + try: with self._com_context(), self._lock_com: transaction_active = False @@ -3901,47 +4097,57 @@ class SageConnector: logger.debug("✅ Transaction Sage démarrée") except: pass - + try: # Création document COMMANDE (type 10) - process = self.cial.CreateProcess_Document(settings.SAGE_TYPE_BON_COMMANDE) + process = self.cial.CreateProcess_Document( + settings.SAGE_TYPE_BON_COMMANDE + ) doc = process.Document - + try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass - + logger.info("📄 Document commande créé") - + # Date import pywintypes - + if isinstance(commande_data["date_commande"], str): - date_obj = datetime.fromisoformat(commande_data["date_commande"]) + date_obj = datetime.fromisoformat( + commande_data["date_commande"] + ) elif isinstance(commande_data["date_commande"], date): - date_obj = datetime.combine(commande_data["date_commande"], datetime.min.time()) + date_obj = datetime.combine( + commande_data["date_commande"], datetime.min.time() + ) else: date_obj = datetime.now() - + doc.DO_Date = pywintypes.Time(date_obj) logger.info(f"📅 Date définie: {date_obj.date()}") - + # Client (CRITIQUE) factory_client = self.cial.CptaApplication.FactoryClient - persist_client = factory_client.ReadNumero(commande_data["client"]["code"]) - + persist_client = factory_client.ReadNumero( + commande_data["client"]["code"] + ) + if not persist_client: - raise ValueError(f"Client {commande_data['client']['code']} introuvable") - + raise ValueError( + f"Client {commande_data['client']['code']} introuvable" + ) + client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError(f"Impossible de charger le client") - + doc.SetDefaultClient(client_obj) doc.Write() logger.info(f"👤 Client {commande_data['client']['code']} associé") - + # Référence externe (optionnelle) if commande_data.get("reference"): try: @@ -3949,27 +4155,35 @@ class SageConnector: logger.info(f"📖 Référence: {commande_data['reference']}") except: pass - + # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne - + factory_article = self.cial.FactoryArticle - + logger.info(f"📦 Ajout de {len(commande_data['lignes'])} lignes...") - + for idx, ligne_data in enumerate(commande_data["lignes"], 1): - logger.info(f"--- Ligne {idx}: {ligne_data['article_code']} ---") + logger.info( + f"--- Ligne {idx}: {ligne_data['article_code']} ---" + ) # 📍 ÉTAPE 1: Charger l'article RÉEL depuis Sage - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"❌ Article {ligne_data['article_code']} introuvable dans Sage") + raise ValueError( + f"❌ Article {ligne_data['article_code']} introuvable dans Sage" + ) - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() # 💰 ÉTAPE 2: Récupérer le prix de vente RÉEL @@ -3979,30 +4193,45 @@ class SageConnector: # ✅ TOLÉRER prix = 0 (articles de service, etc.) if prix_sage == 0: - logger.warning(f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)") + logger.warning( + f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" + ) # 📍 ÉTAPE 3: Créer la ligne ligne_persist = factory_lignes.Create() try: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) except: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) # ✅ SetDefaultArticleReference quantite = float(ligne_data["quantite"]) try: - ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) - logger.info(f"✅ Article associé via SetDefaultArticleReference") + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) + logger.info( + f"✅ Article associé via SetDefaultArticleReference" + ) except Exception as e: - logger.warning(f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet") + logger.warning( + f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" + ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") - ligne_obj.DL_Design = designation_sage or ligne_data.get("designation", "") + ligne_obj.DL_Design = ( + designation_sage + or ligne_data.get("designation", "") + ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") @@ -4036,8 +4265,12 @@ class SageConnector: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = 0 - montant_apres_remise = montant_ligne * (1 - remise / 100) - logger.info(f"🎁 Remise {remise}% → {montant_apres_remise}€") + montant_apres_remise = montant_ligne * ( + 1 - remise / 100 + ) + logger.info( + f"🎁 Remise {remise}% → {montant_apres_remise}€" + ) except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") @@ -4048,51 +4281,65 @@ class SageConnector: # 🔍 VÉRIFICATION try: ligne_obj.Read() - prix_enregistre = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) - montant_enregistre = float(getattr(ligne_obj, "DL_MontantHT", 0.0)) - logger.info(f"🔍 Vérif: Prix={prix_enregistre}€, Montant HT={montant_enregistre}€") + prix_enregistre = float( + getattr(ligne_obj, "DL_PrixUnitaire", 0.0) + ) + montant_enregistre = float( + getattr(ligne_obj, "DL_MontantHT", 0.0) + ) + logger.info( + f"🔍 Vérif: Prix={prix_enregistre}€, Montant HT={montant_enregistre}€" + ) except Exception as e: logger.warning(f"⚠️ Impossible de vérifier: {e}") - + # Validation doc.Write() process.Process() - + if transaction_active: self.cial.CptaApplication.CommitTrans() - + # Récupération numéro time.sleep(2) - + numero_commande = None try: doc_result = process.DocumentResult if doc_result: - doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3") + doc_result = win32com.client.CastTo( + doc_result, "IBODocumentVente3" + ) doc_result.Read() numero_commande = getattr(doc_result, "DO_Piece", "") except: pass - + if not numero_commande: numero_commande = getattr(doc, "DO_Piece", "") - + # Relecture factory_doc = self.cial.FactoryDocumentVente - persist_reread = factory_doc.ReadPiece(settings.SAGE_TYPE_BON_COMMANDE, numero_commande) - + persist_reread = factory_doc.ReadPiece( + settings.SAGE_TYPE_BON_COMMANDE, numero_commande + ) + if persist_reread: - doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3") + doc_final = win32com.client.CastTo( + persist_reread, "IBODocumentVente3" + ) doc_final.Read() - + total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) else: total_ht = 0.0 total_ttc = 0.0 - - logger.info(f"✅✅✅ COMMANDE CRÉÉE: {numero_commande} - {total_ttc}€ TTC ✅✅✅") - + + logger.info( + f"✅✅✅ COMMANDE CRÉÉE: {numero_commande} - {total_ttc}€ TTC ✅✅✅" + ) + return { "numero_commande": numero_commande, "total_ht": total_ht, @@ -4101,7 +4348,7 @@ class SageConnector: "client_code": commande_data["client"]["code"], "date_commande": str(date_obj.date()), } - + except Exception as e: if transaction_active: try: @@ -4109,21 +4356,19 @@ class SageConnector: except: pass raise - + except Exception as e: logger.error(f"❌ Erreur création commande: {e}", exc_info=True) raise RuntimeError(f"Échec création commande: {str(e)}") - # ============================================================================ # CORRECTIF CRITIQUE : Modification devis/commandes # ============================================================================ - def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: """ ✏️ Modification commande - VERSION SIMPLIFIÉE - + 🔧 STRATÉGIE REMPLACEMENT LIGNES: - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - Utilise .Remove() pour la suppression @@ -4131,19 +4376,19 @@ class SageConnector: """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - + try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION COMMANDE {numero} ===") - + # ======================================== # ÉTAPE 1 : CHARGER LE DOCUMENT # ======================================== logger.info("📂 Chargement document...") - + factory = self.cial.FactoryDocumentVente persist = None - + # Chercher le document for type_test in [10, 3, settings.SAGE_TYPE_BON_COMMANDE]: try: @@ -4154,18 +4399,18 @@ class SageConnector: break except: continue - + if not persist: raise ValueError(f"❌ Commande {numero} INTROUVABLE") - + doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() - + statut_actuel = getattr(doc, "DO_Statut", 0) type_reel = getattr(doc, "DO_Type", -1) - + logger.info(f" 📊 Type={type_reel}, Statut={statut_actuel}") - + # ======================================== # ÉTAPE 2 : VÉRIFIER CLIENT INITIAL # ======================================== @@ -4180,14 +4425,16 @@ class SageConnector: logger.error(" ❌ Objet Client NULL à l'état initial !") except Exception as e: logger.error(f" ❌ Erreur lecture client initial: {e}") - + if not client_code_initial: raise ValueError("❌ Client introuvable dans le document") - + # Compter les lignes initiales nb_lignes_initial = 0 try: - factory_lignes = getattr(doc, "FactoryDocumentLigne", None) or getattr(doc, "FactoryDocumentVenteLigne", None) + factory_lignes = getattr( + doc, "FactoryDocumentLigne", None + ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: @@ -4198,37 +4445,39 @@ class SageConnector: index += 1 except: break - + logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") - + # ======================================== # ÉTAPE 3 : DÉTERMINER LES MODIFICATIONS # ======================================== champs_modifies = [] - + modif_date = "date_commande" in commande_data modif_statut = "statut" in commande_data modif_ref = "reference" in commande_data - modif_lignes = "lignes" in commande_data and commande_data["lignes"] is not None - + modif_lignes = ( + "lignes" in commande_data and commande_data["lignes"] is not None + ) + logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") - + # ======================================== # ÉTAPE 4 : TEST WRITE() BASIQUE # ======================================== logger.info("🧪 Test Write() basique (sans modification)...") - + try: doc.Write() logger.info(" ✅ Write() basique OK") doc.Read() - + # Vérifier que le client est toujours là client_obj = getattr(doc, "Client", None) if client_obj: @@ -4237,61 +4486,68 @@ class SageConnector: if client_apres == client_code_initial: logger.info(f" ✅ Client préservé: {client_apres}") else: - logger.error(f" ❌ Client a changé: {client_code_initial} → {client_apres}") + logger.error( + f" ❌ Client a changé: {client_code_initial} → {client_apres}" + ) else: logger.error(" ❌ Client devenu NULL après Write() basique") - + except Exception as e: logger.error(f" ❌ Write() basique ÉCHOUE: {e}") logger.error(f" ❌ ABANDON: Le document est VERROUILLÉ") - raise ValueError(f"Document verrouillé, impossible de modifier: {e}") - + raise ValueError( + f"Document verrouillé, impossible de modifier: {e}" + ) + # ======================================== # ÉTAPE 5 : MODIFICATIONS SIMPLES (pas de lignes) # ======================================== if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") - + if modif_date: logger.info(" 📅 Modification date...") import pywintypes + date_str = commande_data["date_commande"] - + if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str - + doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") - + if modif_statut: logger.info(" 📊 Modification statut...") nouveau_statut = commande_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") - + if modif_ref: logger.info(" 📖 Modification référence...") try: doc.DO_Ref = commande_data["reference"] champs_modifies.append("reference") - logger.info(f" ✅ Référence définie: {commande_data['reference']}") + logger.info( + f" ✅ Référence définie: {commande_data['reference']}" + ) except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") - + # Écrire sans réassocier le client logger.info(" 💾 Write() sans réassociation client...") try: doc.Write() logger.info(" ✅ Write() réussi") - + doc.Read() - + # Vérifier client client_obj = getattr(doc, "Client", None) if client_obj: @@ -4300,8 +4556,10 @@ class SageConnector: if client_apres == client_code_initial: logger.info(f" ✅ Client préservé: {client_apres}") else: - logger.error(f" ❌ Client perdu: {client_code_initial} → {client_apres}") - + logger.error( + f" ❌ Client perdu: {client_code_initial} → {client_apres}" + ) + except Exception as e: error_msg = str(e) try: @@ -4310,112 +4568,138 @@ class SageConnector: error_msg = f"{sage_error.Description} (Code: {sage_error.Number})" except: pass - + logger.error(f" ❌ Write() échoue: {error_msg}") raise ValueError(f"Sage refuse: {error_msg}") - + # ======================================== # ÉTAPE 6 : REMPLACEMENT COMPLET DES LIGNES # ======================================== elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") - + nouvelles_lignes = commande_data["lignes"] nb_nouvelles = len(nouvelles_lignes) - - logger.info(f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles lignes") - + + logger.info( + f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles lignes" + ) + try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne - + factory_article = self.cial.FactoryArticle - + # ============================================ # SOUS-ÉTAPE 1 : SUPPRIMER TOUTES LES LIGNES EXISTANTES # ============================================ if nb_lignes_initial > 0: - logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes existantes...") - + logger.info( + f" 🗑️ Suppression de {nb_lignes_initial} lignes existantes..." + ) + # Supprimer depuis la fin pour éviter les problèmes d'index for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: - ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne = win32com.client.CastTo( + ligne_p, "IBODocumentLigne3" + ) ligne.Read() - + # ✅ Utiliser .Remove() comme indiqué ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: - logger.warning(f" ⚠️ Impossible de supprimer ligne {idx}: {e}") + logger.warning( + f" ⚠️ Impossible de supprimer ligne {idx}: {e}" + ) # Continuer même si une suppression échoue - + logger.info(" ✅ Toutes les lignes existantes supprimées") - + # ============================================ # SOUS-ÉTAPE 2 : AJOUTER LES NOUVELLES LIGNES # ============================================ logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") - + for idx, ligne_data in enumerate(nouvelles_lignes, 1): - logger.info(f" --- Ligne {idx}/{nb_nouvelles}: {ligne_data['article_code']} ---") - + logger.info( + f" --- Ligne {idx}/{nb_nouvelles}: {ligne_data['article_code']} ---" + ) + # Charger l'article - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"Article {ligne_data['article_code']} introuvable") - - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + raise ValueError( + f"Article {ligne_data['article_code']} introuvable" + ) + + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() - + # Créer nouvelle ligne ligne_persist = factory_lignes.Create() - + try: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) except: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") - + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) + quantite = float(ligne_data["quantite"]) - + # Associer article try: - ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite - + # Prix if ligne_data.get("prix_unitaire_ht"): - ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) - + ligne_obj.DL_PrixUnitaire = float( + ligne_data["prix_unitaire_ht"] + ) + # Remise if ligne_data.get("remise_pourcentage", 0) > 0: try: - ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) + ligne_obj.DL_Remise01REM_Valeur = float( + ligne_data["remise_pourcentage"] + ) ligne_obj.DL_Remise01REM_Type = 0 except: pass - + # Écrire la ligne ligne_obj.Write() logger.info(f" ✅ Ligne {idx} ajoutée") - + logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") - + # Écrire le document logger.info(" 💾 Write() document après remplacement lignes...") doc.Write() logger.info(" ✅ Document écrit") - + doc.Read() - + # Vérifier client client_obj = getattr(doc, "Client", None) if client_obj: @@ -4424,19 +4708,20 @@ class SageConnector: logger.info(f" 👤 Client après remplacement: {client_apres}") else: logger.error(" ❌ Client NULL après remplacement") - + champs_modifies.append("lignes") - + # ======================================== # ÉTAPE 7 : RELECTURE ET RETOUR # ======================================== logger.info("📊 Relecture finale...") - + import time + time.sleep(1) - + doc.Read() - + # Vérifier client final client_obj_final = getattr(doc, "Client", None) if client_obj_final: @@ -4444,15 +4729,15 @@ class SageConnector: client_final = getattr(client_obj_final, "CT_Num", "") else: client_final = "" - + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) - + logger.info(f"✅ ✅ ✅ SUCCÈS: {numero} modifiée ✅ ✅ ✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") logger.info(f" 👤 Client final: {client_final}") logger.info(f" 📝 Champs modifiés: {champs_modifies}") - + return { "numero": numero, "total_ht": total_ht, @@ -4461,36 +4746,40 @@ class SageConnector: "statut": getattr(doc, "DO_Statut", 0), "client_code": client_final, } - + except ValueError as e: logger.error(f"❌ ERREUR MÉTIER: {e}") raise - + except Exception as e: logger.error(f"❌ ERREUR TECHNIQUE: {e}", exc_info=True) - + error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: - error_message = f"Erreur Sage: {err.Description} (Code: {err.Number})" + error_message = ( + f"Erreur Sage: {err.Description} (Code: {err.Number})" + ) except: pass - + raise RuntimeError(f"Erreur Sage: {error_message}") def creer_livraison_enrichi(self, livraison_data: dict) -> Dict: """ ➕ Création d'une livraison (type 30 = Bon de livraison) - + ✅ Gestion identique aux commandes/devis """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - - logger.info(f"🚀 Début création livraison pour client {livraison_data['client']['code']}") - + + logger.info( + f"🚀 Début création livraison pour client {livraison_data['client']['code']}" + ) + try: with self._com_context(), self._lock_com: transaction_active = False @@ -4500,47 +4789,57 @@ class SageConnector: logger.debug("✅ Transaction Sage démarrée") except: pass - + try: # Création document LIVRAISON (type 30) - process = self.cial.CreateProcess_Document(settings.SAGE_TYPE_BON_LIVRAISON) + process = self.cial.CreateProcess_Document( + settings.SAGE_TYPE_BON_LIVRAISON + ) doc = process.Document - + try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass - + logger.info("📄 Document livraison créé") - + # Date import pywintypes - + if isinstance(livraison_data["date_livraison"], str): - date_obj = datetime.fromisoformat(livraison_data["date_livraison"]) + date_obj = datetime.fromisoformat( + livraison_data["date_livraison"] + ) elif isinstance(livraison_data["date_livraison"], date): - date_obj = datetime.combine(livraison_data["date_livraison"], datetime.min.time()) + date_obj = datetime.combine( + livraison_data["date_livraison"], datetime.min.time() + ) else: date_obj = datetime.now() - + doc.DO_Date = pywintypes.Time(date_obj) logger.info(f"📅 Date définie: {date_obj.date()}") - + # Client (CRITIQUE) factory_client = self.cial.CptaApplication.FactoryClient - persist_client = factory_client.ReadNumero(livraison_data["client"]["code"]) - + persist_client = factory_client.ReadNumero( + livraison_data["client"]["code"] + ) + if not persist_client: - raise ValueError(f"Client {livraison_data['client']['code']} introuvable") - + raise ValueError( + f"Client {livraison_data['client']['code']} introuvable" + ) + client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError(f"Impossible de charger le client") - + doc.SetDefaultClient(client_obj) doc.Write() logger.info(f"👤 Client {livraison_data['client']['code']} associé") - + # Référence externe (optionnelle) if livraison_data.get("reference"): try: @@ -4548,27 +4847,37 @@ class SageConnector: logger.info(f"📖 Référence: {livraison_data['reference']}") except: pass - + # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne - + factory_article = self.cial.FactoryArticle - - logger.info(f"📦 Ajout de {len(livraison_data['lignes'])} lignes...") - + + logger.info( + f"📦 Ajout de {len(livraison_data['lignes'])} lignes..." + ) + for idx, ligne_data in enumerate(livraison_data["lignes"], 1): - logger.info(f"--- Ligne {idx}: {ligne_data['article_code']} ---") + logger.info( + f"--- Ligne {idx}: {ligne_data['article_code']} ---" + ) # Charger l'article RÉEL depuis Sage - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"❌ Article {ligne_data['article_code']} introuvable dans Sage") + raise ValueError( + f"❌ Article {ligne_data['article_code']} introuvable dans Sage" + ) - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() # Récupérer le prix de vente RÉEL @@ -4577,29 +4886,44 @@ class SageConnector: logger.info(f"💰 Prix Sage: {prix_sage}€") if prix_sage == 0: - logger.warning(f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)") + logger.warning( + f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" + ) # Créer la ligne ligne_persist = factory_lignes.Create() try: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) except: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) quantite = float(ligne_data["quantite"]) try: - ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) - logger.info(f"✅ Article associé via SetDefaultArticleReference") + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) + logger.info( + f"✅ Article associé via SetDefaultArticleReference" + ) except Exception as e: - logger.warning(f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet") + logger.warning( + f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" + ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") - ligne_obj.DL_Design = designation_sage or ligne_data.get("designation", "") + ligne_obj.DL_Design = ( + designation_sage + or ligne_data.get("designation", "") + ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") @@ -4629,54 +4953,66 @@ class SageConnector: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = 0 - montant_apres_remise = montant_ligne * (1 - remise / 100) - logger.info(f"🎁 Remise {remise}% → {montant_apres_remise}€") + montant_apres_remise = montant_ligne * ( + 1 - remise / 100 + ) + logger.info( + f"🎁 Remise {remise}% → {montant_apres_remise}€" + ) except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") # Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") - + # Validation doc.Write() process.Process() - + if transaction_active: self.cial.CptaApplication.CommitTrans() - + # Récupération numéro time.sleep(2) - + numero_livraison = None try: doc_result = process.DocumentResult if doc_result: - doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3") + doc_result = win32com.client.CastTo( + doc_result, "IBODocumentVente3" + ) doc_result.Read() numero_livraison = getattr(doc_result, "DO_Piece", "") except: pass - + if not numero_livraison: numero_livraison = getattr(doc, "DO_Piece", "") - + # Relecture factory_doc = self.cial.FactoryDocumentVente - persist_reread = factory_doc.ReadPiece(settings.SAGE_TYPE_BON_LIVRAISON, numero_livraison) - + persist_reread = factory_doc.ReadPiece( + settings.SAGE_TYPE_BON_LIVRAISON, numero_livraison + ) + if persist_reread: - doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3") + doc_final = win32com.client.CastTo( + persist_reread, "IBODocumentVente3" + ) doc_final.Read() - + total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) else: total_ht = 0.0 total_ttc = 0.0 - - logger.info(f"✅✅✅ LIVRAISON CRÉÉE: {numero_livraison} - {total_ttc}€ TTC ✅✅✅") - + + logger.info( + f"✅✅✅ LIVRAISON CRÉÉE: {numero_livraison} - {total_ttc}€ TTC ✅✅✅" + ) + return { "numero_livraison": numero_livraison, "total_ht": total_ht, @@ -4685,7 +5021,7 @@ class SageConnector: "client_code": livraison_data["client"]["code"], "date_livraison": str(date_obj.date()), } - + except Exception as e: if transaction_active: try: @@ -4693,35 +5029,34 @@ class SageConnector: except: pass raise - + except Exception as e: logger.error(f"❌ Erreur création livraison: {e}", exc_info=True) raise RuntimeError(f"Échec création livraison: {str(e)}") - def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: """ ✏️ Modification d'une livraison existante - + 🔧 STRATÉGIE REMPLACEMENT LIGNES: - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - Utilise .Remove() pour la suppression """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - + try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION LIVRAISON {numero} ===") - + # ======================================== # ÉTAPE 1 : CHARGER LE DOCUMENT # ======================================== logger.info("📂 Chargement document...") - + factory = self.cial.FactoryDocumentVente persist = None - + # Chercher le document for type_test in [30, settings.SAGE_TYPE_BON_LIVRAISON]: try: @@ -4732,28 +5067,30 @@ class SageConnector: break except: continue - + if not persist: raise ValueError(f"❌ Livraison {numero} INTROUVABLE") - + doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() - + statut_actuel = getattr(doc, "DO_Statut", 0) - + logger.info(f" 📊 Statut={statut_actuel}") - + # Vérifier qu'elle n'est pas transformée if statut_actuel == 5: raise ValueError(f"La livraison {numero} a déjà été transformée") - + if statut_actuel == 6: raise ValueError(f"La livraison {numero} est annulée") - + # Compter les lignes initiales nb_lignes_initial = 0 try: - factory_lignes = getattr(doc, "FactoryDocumentLigne", None) or getattr(doc, "FactoryDocumentVenteLigne", None) + factory_lignes = getattr( + doc, "FactoryDocumentLigne", None + ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: @@ -4764,54 +5101,57 @@ class SageConnector: index += 1 except: break - + logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") - + # ======================================== # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS # ======================================== champs_modifies = [] - + modif_date = "date_livraison" in livraison_data modif_statut = "statut" in livraison_data modif_ref = "reference" in livraison_data - modif_lignes = "lignes" in livraison_data and livraison_data["lignes"] is not None - + modif_lignes = ( + "lignes" in livraison_data and livraison_data["lignes"] is not None + ) + logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") - + # ======================================== # ÉTAPE 3 : MODIFICATIONS SIMPLES # ======================================== if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") - + if modif_date: import pywintypes + date_str = livraison_data["date_livraison"] - + if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str - + doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") - + if modif_statut: nouveau_statut = livraison_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") - + if modif_ref: try: doc.DO_Ref = livraison_data["reference"] @@ -4819,114 +5159,137 @@ class SageConnector: logger.info(f" ✅ Référence définie") except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") - + doc.Write() logger.info(" ✅ Write() réussi") - + # ======================================== # ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES # ======================================== elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") - + nouvelles_lignes = livraison_data["lignes"] nb_nouvelles = len(nouvelles_lignes) - - logger.info(f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles") - + + logger.info( + f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles" + ) + try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne - + factory_article = self.cial.FactoryArticle - + # SUPPRESSION TOUTES LES LIGNES if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") - + for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: - ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne = win32com.client.CastTo( + ligne_p, "IBODocumentLigne3" + ) ligne.Read() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: - logger.warning(f" ⚠️ Erreur suppression ligne {idx}: {e}") - + logger.warning( + f" ⚠️ Erreur suppression ligne {idx}: {e}" + ) + logger.info(" ✅ Toutes les lignes supprimées") - + # AJOUT NOUVELLES LIGNES logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") - + for idx, ligne_data in enumerate(nouvelles_lignes, 1): - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"Article {ligne_data['article_code']} introuvable") - - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + raise ValueError( + f"Article {ligne_data['article_code']} introuvable" + ) + + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() - + ligne_persist = factory_lignes.Create() - + try: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) except: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") - + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) + quantite = float(ligne_data["quantite"]) - + try: - ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite - + if ligne_data.get("prix_unitaire_ht"): - ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) - + ligne_obj.DL_PrixUnitaire = float( + ligne_data["prix_unitaire_ht"] + ) + if ligne_data.get("remise_pourcentage", 0) > 0: try: - ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) + ligne_obj.DL_Remise01REM_Valeur = float( + ligne_data["remise_pourcentage"] + ) ligne_obj.DL_Remise01REM_Type = 0 except: pass - + ligne_obj.Write() logger.debug(f" ✅ Ligne {idx} ajoutée") - + logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") - + doc.Write() champs_modifies.append("lignes") - + # ======================================== # ÉTAPE 5 : RELECTURE ET RETOUR # ======================================== import time + time.sleep(1) - + doc.Read() - + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) - + logger.info(f"✅✅✅ LIVRAISON MODIFIÉE: {numero} ✅✅✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") - + return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, - "statut": getattr(doc, "DO_Statut", 0) + "statut": getattr(doc, "DO_Statut", 0), } - + except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise @@ -4934,18 +5297,19 @@ class SageConnector: logger.error(f"❌ Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") - def creer_avoir_enrichi(self, avoir_data: dict) -> Dict: """ ➕ Création d'un avoir (type 50 = Bon d'avoir) - + ✅ Gestion identique aux commandes/devis/livraisons """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - - logger.info(f"🚀 Début création avoir pour client {avoir_data['client']['code']}") - + + logger.info( + f"🚀 Début création avoir pour client {avoir_data['client']['code']}" + ) + try: with self._com_context(), self._lock_com: transaction_active = False @@ -4955,47 +5319,55 @@ class SageConnector: logger.debug("✅ Transaction Sage démarrée") except: pass - + try: # Création document AVOIR (type 50) - process = self.cial.CreateProcess_Document(settings.SAGE_TYPE_BON_AVOIR) + process = self.cial.CreateProcess_Document( + settings.SAGE_TYPE_BON_AVOIR + ) doc = process.Document - + try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass - + logger.info("📄 Document avoir créé") - + # Date import pywintypes - + if isinstance(avoir_data["date_avoir"], str): date_obj = datetime.fromisoformat(avoir_data["date_avoir"]) elif isinstance(avoir_data["date_avoir"], date): - date_obj = datetime.combine(avoir_data["date_avoir"], datetime.min.time()) + date_obj = datetime.combine( + avoir_data["date_avoir"], datetime.min.time() + ) else: date_obj = datetime.now() - + doc.DO_Date = pywintypes.Time(date_obj) logger.info(f"📅 Date définie: {date_obj.date()}") - + # Client (CRITIQUE) factory_client = self.cial.CptaApplication.FactoryClient - persist_client = factory_client.ReadNumero(avoir_data["client"]["code"]) - + persist_client = factory_client.ReadNumero( + avoir_data["client"]["code"] + ) + if not persist_client: - raise ValueError(f"Client {avoir_data['client']['code']} introuvable") - + raise ValueError( + f"Client {avoir_data['client']['code']} introuvable" + ) + client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError(f"Impossible de charger le client") - + doc.SetDefaultClient(client_obj) doc.Write() logger.info(f"👤 Client {avoir_data['client']['code']} associé") - + # Référence externe (optionnelle) if avoir_data.get("reference"): try: @@ -5003,27 +5375,35 @@ class SageConnector: logger.info(f"📖 Référence: {avoir_data['reference']}") except: pass - + # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne - + factory_article = self.cial.FactoryArticle - + logger.info(f"📦 Ajout de {len(avoir_data['lignes'])} lignes...") - + for idx, ligne_data in enumerate(avoir_data["lignes"], 1): - logger.info(f"--- Ligne {idx}: {ligne_data['article_code']} ---") + logger.info( + f"--- Ligne {idx}: {ligne_data['article_code']} ---" + ) # Charger l'article RÉEL depuis Sage - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"❌ Article {ligne_data['article_code']} introuvable dans Sage") + raise ValueError( + f"❌ Article {ligne_data['article_code']} introuvable dans Sage" + ) - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() # Récupérer le prix de vente RÉEL @@ -5032,29 +5412,44 @@ class SageConnector: logger.info(f"💰 Prix Sage: {prix_sage}€") if prix_sage == 0: - logger.warning(f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)") + logger.warning( + f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" + ) # Créer la ligne ligne_persist = factory_lignes.Create() try: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) except: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) quantite = float(ligne_data["quantite"]) try: - ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) - logger.info(f"✅ Article associé via SetDefaultArticleReference") + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) + logger.info( + f"✅ Article associé via SetDefaultArticleReference" + ) except Exception as e: - logger.warning(f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet") + logger.warning( + f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" + ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") - ligne_obj.DL_Design = designation_sage or ligne_data.get("designation", "") + ligne_obj.DL_Design = ( + designation_sage + or ligne_data.get("designation", "") + ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") @@ -5084,54 +5479,66 @@ class SageConnector: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = 0 - montant_apres_remise = montant_ligne * (1 - remise / 100) - logger.info(f"🎁 Remise {remise}% → {montant_apres_remise}€") + montant_apres_remise = montant_ligne * ( + 1 - remise / 100 + ) + logger.info( + f"🎁 Remise {remise}% → {montant_apres_remise}€" + ) except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") # Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") - + # Validation doc.Write() process.Process() - + if transaction_active: self.cial.CptaApplication.CommitTrans() - + # Récupération numéro time.sleep(2) - + numero_avoir = None try: doc_result = process.DocumentResult if doc_result: - doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3") + doc_result = win32com.client.CastTo( + doc_result, "IBODocumentVente3" + ) doc_result.Read() numero_avoir = getattr(doc_result, "DO_Piece", "") except: pass - + if not numero_avoir: numero_avoir = getattr(doc, "DO_Piece", "") - + # Relecture factory_doc = self.cial.FactoryDocumentVente - persist_reread = factory_doc.ReadPiece(settings.SAGE_TYPE_BON_AVOIR, numero_avoir) - + persist_reread = factory_doc.ReadPiece( + settings.SAGE_TYPE_BON_AVOIR, numero_avoir + ) + if persist_reread: - doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3") + doc_final = win32com.client.CastTo( + persist_reread, "IBODocumentVente3" + ) doc_final.Read() - + total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) else: total_ht = 0.0 total_ttc = 0.0 - - logger.info(f"✅✅✅ AVOIR CRÉÉ: {numero_avoir} - {total_ttc}€ TTC ✅✅✅") - + + logger.info( + f"✅✅✅ AVOIR CRÉÉ: {numero_avoir} - {total_ttc}€ TTC ✅✅✅" + ) + return { "numero_avoir": numero_avoir, "total_ht": total_ht, @@ -5140,7 +5547,7 @@ class SageConnector: "client_code": avoir_data["client"]["code"], "date_avoir": str(date_obj.date()), } - + except Exception as e: if transaction_active: try: @@ -5148,33 +5555,32 @@ class SageConnector: except: pass raise - + except Exception as e: logger.error(f"❌ Erreur création avoir: {e}", exc_info=True) raise RuntimeError(f"Échec création avoir: {str(e)}") - def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: """ ✏️ Modification d'un avoir existant - + 🔧 STRATÉGIE REMPLACEMENT LIGNES: - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - Utilise .Remove() pour la suppression """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - + try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION AVOIR {numero} ===") - + # ÉTAPE 1 : CHARGER LE DOCUMENT logger.info("📂 Chargement document...") - + factory = self.cial.FactoryDocumentVente persist = None - + # Chercher le document for type_test in [50, settings.SAGE_TYPE_BON_AVOIR]: try: @@ -5185,28 +5591,30 @@ class SageConnector: break except: continue - + if not persist: raise ValueError(f"❌ Avoir {numero} INTROUVABLE") - + doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() - + statut_actuel = getattr(doc, "DO_Statut", 0) - + logger.info(f" 📊 Statut={statut_actuel}") - + # Vérifier qu'il n'est pas transformé if statut_actuel == 5: raise ValueError(f"L'avoir {numero} a déjà été transformé") - + if statut_actuel == 6: raise ValueError(f"L'avoir {numero} est annulé") - + # Compter les lignes initiales nb_lignes_initial = 0 try: - factory_lignes = getattr(doc, "FactoryDocumentLigne", None) or getattr(doc, "FactoryDocumentVenteLigne", None) + factory_lignes = getattr( + doc, "FactoryDocumentLigne", None + ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: @@ -5217,50 +5625,53 @@ class SageConnector: index += 1 except: break - + logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") - + # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS champs_modifies = [] - + modif_date = "date_avoir" in avoir_data modif_statut = "statut" in avoir_data modif_ref = "reference" in avoir_data - modif_lignes = "lignes" in avoir_data and avoir_data["lignes"] is not None - + modif_lignes = ( + "lignes" in avoir_data and avoir_data["lignes"] is not None + ) + logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") - + # ÉTAPE 3 : MODIFICATIONS SIMPLES if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") - + if modif_date: import pywintypes + date_str = avoir_data["date_avoir"] - + if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str - + doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") - + if modif_statut: nouveau_statut = avoir_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") - + if modif_ref: try: doc.DO_Ref = avoir_data["reference"] @@ -5268,117 +5679,140 @@ class SageConnector: logger.info(f" ✅ Référence définie") except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") - + doc.Write() logger.info(" ✅ Write() réussi") - + # ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") - + nouvelles_lignes = avoir_data["lignes"] nb_nouvelles = len(nouvelles_lignes) - - logger.info(f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles") - + + logger.info( + f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles" + ) + try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne - + factory_article = self.cial.FactoryArticle - + # SUPPRESSION TOUTES LES LIGNES if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") - + for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: - ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne = win32com.client.CastTo( + ligne_p, "IBODocumentLigne3" + ) ligne.Read() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: - logger.warning(f" ⚠️ Erreur suppression ligne {idx}: {e}") - + logger.warning( + f" ⚠️ Erreur suppression ligne {idx}: {e}" + ) + logger.info(" ✅ Toutes les lignes supprimées") - + # AJOUT NOUVELLES LIGNES logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") - + for idx, ligne_data in enumerate(nouvelles_lignes, 1): - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"Article {ligne_data['article_code']} introuvable") - - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + raise ValueError( + f"Article {ligne_data['article_code']} introuvable" + ) + + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() - + ligne_persist = factory_lignes.Create() - + try: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) except: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") - + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) + quantite = float(ligne_data["quantite"]) - + try: - ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite - + if ligne_data.get("prix_unitaire_ht"): - ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) - + ligne_obj.DL_PrixUnitaire = float( + ligne_data["prix_unitaire_ht"] + ) + if ligne_data.get("remise_pourcentage", 0) > 0: try: - ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) + ligne_obj.DL_Remise01REM_Valeur = float( + ligne_data["remise_pourcentage"] + ) ligne_obj.DL_Remise01REM_Type = 0 except: pass - + ligne_obj.Write() logger.debug(f" ✅ Ligne {idx} ajoutée") - + logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") - + doc.Write() champs_modifies.append("lignes") - + # ÉTAPE 5 : RELECTURE ET RETOUR import time + time.sleep(1) - + doc.Read() - + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) - + logger.info(f"✅✅✅ AVOIR MODIFIÉ: {numero} ✅✅✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") - + return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, - "statut": getattr(doc, "DO_Statut", 0) + "statut": getattr(doc, "DO_Statut", 0), } - + except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") - + except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise @@ -5389,17 +5823,19 @@ class SageConnector: def creer_facture_enrichi(self, facture_data: dict) -> Dict: """ ➕ Création d'une facture (type 60 = Facture) - + ⚠️ ATTENTION: Les factures ont souvent des champs obligatoires supplémentaires selon la configuration Sage (DO_CodeJournal, DO_Souche, DO_Regime, etc.) - + ✅ Gestion identique aux autres documents + champs spécifiques factures """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - - logger.info(f"🚀 Début création facture pour client {facture_data['client']['code']}") - + + logger.info( + f"🚀 Début création facture pour client {facture_data['client']['code']}" + ) + try: with self._com_context(), self._lock_com: transaction_active = False @@ -5409,47 +5845,55 @@ class SageConnector: logger.debug("✅ Transaction Sage démarrée") except: pass - + try: # Création document FACTURE (type 60) - process = self.cial.CreateProcess_Document(settings.SAGE_TYPE_FACTURE) + process = self.cial.CreateProcess_Document( + settings.SAGE_TYPE_FACTURE + ) doc = process.Document - + try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass - + logger.info("📄 Document facture créé") - + # Date import pywintypes - + if isinstance(facture_data["date_facture"], str): date_obj = datetime.fromisoformat(facture_data["date_facture"]) elif isinstance(facture_data["date_facture"], date): - date_obj = datetime.combine(facture_data["date_facture"], datetime.min.time()) + date_obj = datetime.combine( + facture_data["date_facture"], datetime.min.time() + ) else: date_obj = datetime.now() - + doc.DO_Date = pywintypes.Time(date_obj) logger.info(f"📅 Date définie: {date_obj.date()}") - + # Client (CRITIQUE) factory_client = self.cial.CptaApplication.FactoryClient - persist_client = factory_client.ReadNumero(facture_data["client"]["code"]) - + persist_client = factory_client.ReadNumero( + facture_data["client"]["code"] + ) + if not persist_client: - raise ValueError(f"Client {facture_data['client']['code']} introuvable") - + raise ValueError( + f"Client {facture_data['client']['code']} introuvable" + ) + client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError(f"Impossible de charger le client") - + doc.SetDefaultClient(client_obj) doc.Write() logger.info(f"👤 Client {facture_data['client']['code']} associé") - + # Référence externe (optionnelle) if facture_data.get("reference"): try: @@ -5457,19 +5901,23 @@ class SageConnector: logger.info(f"📖 Référence: {facture_data['reference']}") except: pass - + # ============================================ # CHAMPS SPÉCIFIQUES FACTURES # ============================================ logger.info("⚙️ Configuration champs spécifiques factures...") - + # Code journal (si disponible) try: if hasattr(doc, "DO_CodeJournal"): # Essayer de récupérer le code journal par défaut try: - param_societe = self.cial.CptaApplication.ParametreSociete - journal_defaut = getattr(param_societe, "P_CodeJournalVte", "VTE") + param_societe = ( + self.cial.CptaApplication.ParametreSociete + ) + journal_defaut = getattr( + param_societe, "P_CodeJournalVte", "VTE" + ) doc.DO_CodeJournal = journal_defaut logger.info(f" ✅ Code journal: {journal_defaut}") except: @@ -5477,7 +5925,7 @@ class SageConnector: logger.info(" ✅ Code journal: VTE (défaut)") except Exception as e: logger.debug(f" ⚠️ Code journal: {e}") - + # Souche (si disponible) try: if hasattr(doc, "DO_Souche"): @@ -5485,7 +5933,7 @@ class SageConnector: logger.debug(" ✅ Souche: 0 (défaut)") except: pass - + # Régime (si disponible) try: if hasattr(doc, "DO_Regime"): @@ -5493,27 +5941,35 @@ class SageConnector: logger.debug(" ✅ Régime: 0 (défaut)") except: pass - + # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne - + factory_article = self.cial.FactoryArticle - + logger.info(f"📦 Ajout de {len(facture_data['lignes'])} lignes...") - + for idx, ligne_data in enumerate(facture_data["lignes"], 1): - logger.info(f"--- Ligne {idx}: {ligne_data['article_code']} ---") + logger.info( + f"--- Ligne {idx}: {ligne_data['article_code']} ---" + ) # Charger l'article RÉEL depuis Sage - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"❌ Article {ligne_data['article_code']} introuvable dans Sage") + raise ValueError( + f"❌ Article {ligne_data['article_code']} introuvable dans Sage" + ) - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() # Récupérer le prix de vente RÉEL @@ -5522,29 +5978,44 @@ class SageConnector: logger.info(f"💰 Prix Sage: {prix_sage}€") if prix_sage == 0: - logger.warning(f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)") + logger.warning( + f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" + ) # Créer la ligne ligne_persist = factory_lignes.Create() try: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) except: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) quantite = float(ligne_data["quantite"]) try: - ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) - logger.info(f"✅ Article associé via SetDefaultArticleReference") + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) + logger.info( + f"✅ Article associé via SetDefaultArticleReference" + ) except Exception as e: - logger.warning(f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet") + logger.warning( + f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" + ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") - ligne_obj.DL_Design = designation_sage or ligne_data.get("designation", "") + ligne_obj.DL_Design = ( + designation_sage + or ligne_data.get("designation", "") + ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") @@ -5574,20 +6045,24 @@ class SageConnector: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = 0 - montant_apres_remise = montant_ligne * (1 - remise / 100) - logger.info(f"🎁 Remise {remise}% → {montant_apres_remise}€") + montant_apres_remise = montant_ligne * ( + 1 - remise / 100 + ) + logger.info( + f"🎁 Remise {remise}% → {montant_apres_remise}€" + ) except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") # Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") - + # ============================================ # VALIDATION FINALE # ============================================ logger.info("💾 Validation facture...") - + # Réassocier le client avant validation (critique pour factures) try: doc.SetClient(client_obj) @@ -5597,53 +6072,61 @@ class SageConnector: doc.SetDefaultClient(client_obj) except: pass - + doc.Write() - + logger.info("🔄 Process()...") process.Process() - + if transaction_active: self.cial.CptaApplication.CommitTrans() logger.info("✅ Transaction committée") - + # Récupération numéro time.sleep(2) - + numero_facture = None try: doc_result = process.DocumentResult if doc_result: - doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3") + doc_result = win32com.client.CastTo( + doc_result, "IBODocumentVente3" + ) doc_result.Read() numero_facture = getattr(doc_result, "DO_Piece", "") except: pass - + if not numero_facture: numero_facture = getattr(doc, "DO_Piece", "") - + if not numero_facture: raise RuntimeError("Numéro facture vide après création") - + logger.info(f"📄 Numéro facture: {numero_facture}") - + # Relecture factory_doc = self.cial.FactoryDocumentVente - persist_reread = factory_doc.ReadPiece(settings.SAGE_TYPE_FACTURE, numero_facture) - + persist_reread = factory_doc.ReadPiece( + settings.SAGE_TYPE_FACTURE, numero_facture + ) + if persist_reread: - doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3") + doc_final = win32com.client.CastTo( + persist_reread, "IBODocumentVente3" + ) doc_final.Read() - + total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) else: total_ht = 0.0 total_ttc = 0.0 - - logger.info(f"✅✅✅ FACTURE CRÉÉE: {numero_facture} - {total_ttc}€ TTC ✅✅✅") - + + logger.info( + f"✅✅✅ FACTURE CRÉÉE: {numero_facture} - {total_ttc}€ TTC ✅✅✅" + ) + return { "numero_facture": numero_facture, "total_ht": total_ht, @@ -5652,7 +6135,7 @@ class SageConnector: "client_code": facture_data["client"]["code"], "date_facture": str(date_obj.date()), } - + except Exception as e: if transaction_active: try: @@ -5661,35 +6144,34 @@ class SageConnector: except: pass raise - + except Exception as e: logger.error(f"❌ Erreur création facture: {e}", exc_info=True) raise RuntimeError(f"Échec création facture: {str(e)}") - def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: """ ✏️ Modification d'une facture existante - + ⚠️ ATTENTION: Les factures comptabilisées peuvent être verrouillées par Sage - + 🔧 STRATÉGIE REMPLACEMENT LIGNES: - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles - Utilise .Remove() pour la suppression """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - + try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION FACTURE {numero} ===") - + # ÉTAPE 1 : CHARGER LE DOCUMENT logger.info("📂 Chargement document...") - + factory = self.cial.FactoryDocumentVente persist = None - + # Chercher le document for type_test in [60, 5, settings.SAGE_TYPE_FACTURE]: try: @@ -5700,24 +6182,24 @@ class SageConnector: break except: continue - + if not persist: raise ValueError(f"❌ Facture {numero} INTROUVABLE") - + doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() - + statut_actuel = getattr(doc, "DO_Statut", 0) - + logger.info(f" 📊 Statut={statut_actuel}") - + # Vérifier qu'elle n'est pas transformée ou annulée if statut_actuel == 5: raise ValueError(f"La facture {numero} a déjà été transformée") - + if statut_actuel == 6: raise ValueError(f"La facture {numero} est annulée") - + # Vérifier client initial client_code_initial = "" try: @@ -5728,14 +6210,16 @@ class SageConnector: logger.info(f" 👤 Client initial: {client_code_initial}") except Exception as e: logger.error(f" ❌ Erreur lecture client initial: {e}") - + if not client_code_initial: raise ValueError("❌ Client introuvable dans le document") - + # Compter les lignes initiales nb_lignes_initial = 0 try: - factory_lignes = getattr(doc, "FactoryDocumentLigne", None) or getattr(doc, "FactoryDocumentVenteLigne", None) + factory_lignes = getattr( + doc, "FactoryDocumentLigne", None + ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: @@ -5746,28 +6230,30 @@ class SageConnector: index += 1 except: break - + logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") - + # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS champs_modifies = [] - + modif_date = "date_facture" in facture_data modif_statut = "statut" in facture_data modif_ref = "reference" in facture_data - modif_lignes = "lignes" in facture_data and facture_data["lignes"] is not None - + modif_lignes = ( + "lignes" in facture_data and facture_data["lignes"] is not None + ) + logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") - + # ÉTAPE 3 : TEST WRITE() BASIQUE logger.info("🧪 Test Write() basique (sans modification)...") - + try: doc.Write() logger.info(" ✅ Write() basique OK") @@ -5775,33 +6261,36 @@ class SageConnector: except Exception as e: logger.error(f" ❌ Write() basique ÉCHOUE: {e}") logger.error(f" ❌ ABANDON: Le document est VERROUILLÉ") - raise ValueError(f"Document verrouillé, impossible de modifier: {e}") - + raise ValueError( + f"Document verrouillé, impossible de modifier: {e}" + ) + # ÉTAPE 4 : MODIFICATIONS SIMPLES if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") - + if modif_date: import pywintypes + date_str = facture_data["date_facture"] - + if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str - + doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") - + if modif_statut: nouveau_statut = facture_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") - + if modif_ref: try: doc.DO_Ref = facture_data["reference"] @@ -5809,128 +6298,136 @@ class SageConnector: logger.info(f" ✅ Référence définie") except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") - + doc.Write() logger.info(" ✅ Write() réussi") - + # ÉTAPE 5 : REMPLACEMENT COMPLET LIGNES elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") - + nouvelles_lignes = facture_data["lignes"] nb_nouvelles = len(nouvelles_lignes) - - logger.info(f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles") - + + logger.info( + f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles" + ) + try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne - + factory_article = self.cial.FactoryArticle - + # SUPPRESSION TOUTES LES LIGNES if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") - + for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: - ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne = win32com.client.CastTo( + ligne_p, "IBODocumentLigne3" + ) ligne.Read() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: - logger.warning(f" ⚠️ Erreur suppression ligne {idx}: {e}") - + logger.warning( + f" ⚠️ Erreur suppression ligne {idx}: {e}" + ) + logger.info(" ✅ Toutes les lignes supprimées") - + # AJOUT NOUVELLES LIGNES logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") - + for idx, ligne_data in enumerate(nouvelles_lignes, 1): - persist_article = factory_article.ReadReference(ligne_data["article_code"]) + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) if not persist_article: - raise ValueError(f"Article {ligne_data['article_code']} introuvable") - - article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + raise ValueError( + f"Article {ligne_data['article_code']} introuvable" + ) + + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) article_obj.Read() - + ligne_persist = factory_lignes.Create() - + try: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) except: - ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") - + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) + quantite = float(ligne_data["quantite"]) - + try: - ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite - + if ligne_data.get("prix_unitaire_ht"): - ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) - + ligne_obj.DL_PrixUnitaire = float( + ligne_data["prix_unitaire_ht"] + ) + if ligne_data.get("remise_pourcentage", 0) > 0: try: - ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) + ligne_obj.DL_Remise01REM_Valeur = float( + ligne_data["remise_pourcentage"] + ) ligne_obj.DL_Remise01REM_Type = 0 except: pass - + ligne_obj.Write() logger.debug(f" ✅ Ligne {idx} ajoutée") - + logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") - + doc.Write() champs_modifies.append("lignes") - + # ÉTAPE 6 : RELECTURE ET RETOUR import time + time.sleep(1) - + doc.Read() - + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) - + logger.info(f"✅✅✅ FACTURE MODIFIÉE: {numero} ✅✅✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") - + return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, - "statut": getattr(doc, "DO_Statut", 0) + "statut": getattr(doc, "DO_Statut", 0), } - + except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") - - - - - - - - - - - - - - -