From de1771749d8af748b91b8f8eb81e368c7b91b6fb Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 17:02:30 +0300 Subject: [PATCH] Update devis, Create and Update Command --- main.py | 94 +++++++ sage_connector.py | 680 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 774 insertions(+) diff --git a/main.py b/main.py index 5fbe2a7..68b6f49 100644 --- a/main.py +++ b/main.py @@ -127,6 +127,26 @@ class FournisseurUpdateGatewayRequest(BaseModel): 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 + lignes: List[Dict] + + +class CommandeUpdateGatewayRequest(BaseModel): + """Modèle pour modification commande côté gateway""" + numero: str + commande_data: Dict + + # ===================================================== # SÉCURITÉ # ===================================================== @@ -2745,6 +2765,80 @@ 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) + - Le statut + """ + 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)) + except Exception as e: + logger.error(f"Erreur technique modification devis: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - CRÉATION ET MODIFICATION COMMANDES +# ===================================================== + +@app.post("/sage/commandes/create", dependencies=[Depends(verify_token)]) +def creer_commande_endpoint(req: CommandeCreateRequest): + """ + ➕ Création d'une commande (Bon de commande) dans Sage + """ + try: + # Transformer en format attendu par sage_connector + commande_data = { + "client": {"code": req.client_id, "intitule": ""}, + "date_commande": req.date_commande or date.today(), + "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)) + except Exception as e: + logger.error(f"Erreur technique création commande: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/commandes/update", dependencies=[Depends(verify_token)]) +def modifier_commande_endpoint(req: CommandeUpdateGatewayRequest): + """ + ✏️ Modification d'une commande dans Sage + + Permet de modifier: + - La date de la commande + - Les lignes (remplace toutes les lignes) + - Le statut + - La référence externe + """ + try: + resultat = sage.modifier_commande(req.numero, req.commande_data) + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"Erreur métier modification commande: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + logger.error(f"Erreur technique modification commande: {e}") + raise HTTPException(500, str(e)) + + # ===================================================== # LANCEMENT # ===================================================== diff --git a/sage_connector.py b/sage_connector.py index bf4e52d..65e220a 100644 --- a/sage_connector.py +++ b/sage_connector.py @@ -3629,4 +3629,684 @@ class SageConnector: pass raise RuntimeError(f"Erreur technique Sage: {error_message}") + + def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: + """ + ✏️ Modification d'un devis existant dans Sage + + Permet de modifier la date, les lignes et le statut. + Si les lignes sont modifiées, elles remplacent TOUTES les lignes existantes. + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + try: + with self._com_context(), self._lock_com: + # ======================================== + # ÉTAPE 1 : CHARGER LE DEVIS EXISTANT + # ======================================== + logger.info(f"🔍 Recherche devis {numero}...") + + factory = self.cial.FactoryDocumentVente + persist = factory.ReadPiece(0, numero) + + # Si ReadPiece échoue, chercher dans List() + if not persist: + index = 1 + while index < 10000: + try: + persist_test = factory.List(index) + if persist_test is None: + break + + doc_test = win32com.client.CastTo(persist_test, "IBODocumentVente3") + doc_test.Read() + + if (getattr(doc_test, "DO_Type", -1) == 0 + and getattr(doc_test, "DO_Piece", "") == numero): + persist = persist_test + break + + index += 1 + except: + index += 1 + + if not persist: + raise ValueError(f"Devis {numero} introuvable") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + logger.info(f"✅ Devis {numero} trouvé") + + # Vérifier le statut (ne pas modifier si déjà transformé) + statut_actuel = getattr(doc, "DO_Statut", 0) + if statut_actuel == 5: + raise ValueError(f"Le devis {numero} a déjà été transformé") + + # ======================================== + # ÉTAPE 2 : METTRE À JOUR LES CHAMPS + # ======================================== + champs_modifies = [] + + # Mise à jour de la date + if "date_devis" in devis_data: + import pywintypes + + date_str = devis_data["date_devis"] + if isinstance(date_str, str): + date_obj = datetime.fromisoformat(date_str) + else: + date_obj = date_str + + doc.DO_Date = pywintypes.Time(date_obj) + champs_modifies.append("date") + logger.info(f"📅 Date modifiée: {date_obj.date()}") + + # Mise à jour du statut + if "statut" in devis_data: + nouveau_statut = devis_data["statut"] + doc.DO_Statut = nouveau_statut + champs_modifies.append("statut") + logger.info(f"📊 Statut modifié: {statut_actuel} → {nouveau_statut}") + + # Écriture des modifications de base + if champs_modifies: + doc.Write() + + # ======================================== + # ÉTAPE 3 : REMPLACEMENT DES LIGNES (si demandé) + # ======================================== + if "lignes" in devis_data and devis_data["lignes"] is not None: + logger.info(f"🔄 Remplacement des lignes...") + + # Supprimer TOUTES les lignes existantes + try: + factory_lignes = doc.FactoryDocumentLigne + except: + factory_lignes = doc.FactoryDocumentVenteLigne + + # Compter et supprimer les lignes existantes + index_ligne = 1 + while index_ligne <= 100: + try: + ligne_p = factory_lignes.List(index_ligne) + if ligne_p is None: + break + + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne.Read() + ligne.Delete() + + index_ligne += 1 + except: + break + + logger.info(f"🗑️ {index_ligne - 1} ligne(s) supprimée(s)") + + # Ajouter les nouvelles lignes + factory_article = self.cial.FactoryArticle + + for idx, ligne_data in enumerate(devis_data["lignes"], 1): + logger.info(f"➕ Ajout ligne {idx}: {ligne_data['article_code']}") + + # Charger l'article + persist_article = factory_article.ReadReference(ligne_data["article_code"]) + + if not persist_article: + raise ValueError(f"Article {ligne_data['article_code']} introuvable") + + article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj.Read() + + # Créer la nouvelle ligne + ligne_persist = factory_lignes.Create() + + try: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + except: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + + # Associer article + quantite = float(ligne_data["quantite"]) + + try: + ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) + except: + try: + ligne_obj.SetDefaultArticle(article_obj, quantite) + except: + ligne_obj.DL_Design = ligne_data.get("designation", "") + ligne_obj.DL_Qte = quantite + + # Définir le prix (si fourni) + if ligne_data.get("prix_unitaire_ht"): + ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) + + # Définir la remise (si fournie) + if ligne_data.get("remise_pourcentage", 0) > 0: + try: + ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) + ligne_obj.DL_Remise01REM_Type = 0 + except: + pass + + ligne_obj.Write() + + logger.info(f"✅ {len(devis_data['lignes'])} nouvelle(s) ligne(s) ajoutée(s)") + champs_modifies.append("lignes") + + # ======================================== + # ÉTAPE 4 : VALIDATION FINALE + # ======================================== + doc.Write() + + # Attente indexation + time.sleep(1) + + # Relecture pour récupérer les totaux + doc.Read() + + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) + total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) + + logger.info(f"✅✅✅ DEVIS MODIFIÉ: {numero} ({', '.join(champs_modifies)}) ✅✅✅") + logger.info(f"💰 Nouveaux totaux: {total_ht}€ HT / {total_ttc}€ TTC") + + return { + "numero": numero, + "total_ht": total_ht, + "total_ttc": total_ttc, + "champs_modifies": champs_modifies, + "statut": getattr(doc, "DO_Statut", 0) + } + + except ValueError as e: + logger.error(f"❌ Erreur métier: {e}") + raise + + except Exception as e: + logger.error(f"❌ Erreur modification devis: {e}", exc_info=True) + raise RuntimeError(f"Erreur technique Sage: {str(e)}") + + + def creer_commande_enrichi(self, commande_data: dict) -> Dict: + """ + ➕ Création d'une commande (type 10 = Bon de commande) + + Similaire à creer_devis_enrichi mais pour les commandes. + Utilise CreateProcess_Document(10) au lieu de (0). + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + logger.info(f"🚀 Début création commande pour client {commande_data['client']['code']}") + + try: + with self._com_context(), self._lock_com: + transaction_active = False + try: + self.cial.CptaApplication.BeginTrans() + transaction_active = True + logger.debug("✅ Transaction Sage démarrée") + except: + pass + + try: + # Création document COMMANDE (type 10) + process = self.cial.CreateProcess_Document(settings.SAGE_TYPE_BON_COMMANDE) + doc = process.Document + + try: + doc = win32com.client.CastTo(doc, "IBODocumentVente3") + except: + pass + + logger.info("📄 Document commande créé") + + # Date + import pywintypes + + if isinstance(commande_data["date_commande"], str): + date_obj = datetime.fromisoformat(commande_data["date_commande"]) + elif isinstance(commande_data["date_commande"], date): + date_obj = datetime.combine(commande_data["date_commande"], datetime.min.time()) + else: + date_obj = datetime.now() + + doc.DO_Date = pywintypes.Time(date_obj) + logger.info(f"📅 Date définie: {date_obj.date()}") + + # Client (CRITIQUE) + factory_client = self.cial.CptaApplication.FactoryClient + persist_client = factory_client.ReadNumero(commande_data["client"]["code"]) + + if not persist_client: + raise ValueError(f"Client {commande_data['client']['code']} introuvable") + + client_obj = self._cast_client(persist_client) + if not client_obj: + raise ValueError(f"Impossible de charger le client") + + doc.SetDefaultClient(client_obj) + doc.Write() + logger.info(f"👤 Client {commande_data['client']['code']} associé") + + # Référence externe (optionnelle) + if commande_data.get("reference"): + try: + doc.DO_Ref = commande_data["reference"] + logger.info(f"🔖 Référence: {commande_data['reference']}") + except: + pass + + # Lignes + try: + factory_lignes = doc.FactoryDocumentLigne + except: + factory_lignes = doc.FactoryDocumentVenteLigne + + factory_article = self.cial.FactoryArticle + + logger.info(f"📦 Ajout de {len(commande_data['lignes'])} lignes...") + + for idx, ligne_data in enumerate(commande_data["lignes"], 1): + logger.info( + f"--- Ligne {idx}: {ligne_data['article_code']} ---" + ) + + # 🔍 ÉTAPE 1: Charger l'article RÉEL depuis Sage pour le prix + persist_article = factory_article.ReadReference( + ligne_data["article_code"] + ) + + if not persist_article: + raise ValueError( + f"❌ Article {ligne_data['article_code']} introuvable dans Sage" + ) + + article_obj = win32com.client.CastTo( + persist_article, "IBOArticle3" + ) + article_obj.Read() + + # 💰 ÉTAPE 2: Récupérer le prix de vente RÉEL + prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0)) + designation_sage = getattr(article_obj, "AR_Design", "") + logger.info(f"💰 Prix Sage: {prix_sage}€") + + if prix_sage == 0: + logger.warning( + f"⚠️ ATTENTION: Article {ligne_data['article_code']} a un prix de vente = 0€" + ) + + # 📝 ÉTAPE 3: Créer la ligne de devis + ligne_persist = factory_lignes.Create() + + try: + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) + except: + ligne_obj = win32com.client.CastTo( + ligne_persist, "IBODocumentVenteLigne3" + ) + + # ✅✅✅ SOLUTION FINALE: SetDefaultArticleReference avec 2 paramètres ✅✅✅ + quantite = float(ligne_data["quantite"]) + + try: + # Méthode 1: Via référence (plus simple et plus fiable) + ligne_obj.SetDefaultArticleReference( + ligne_data["article_code"], quantite + ) + logger.info( + f"✅ Article associé via SetDefaultArticleReference('{ligne_data['article_code']}', {quantite})" + ) + except Exception as e: + logger.warning( + f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet article" + ) + try: + # Méthode 2: Via objet article + ligne_obj.SetDefaultArticle(article_obj, quantite) + logger.info( + f"✅ Article associé via SetDefaultArticle(obj, {quantite})" + ) + except Exception as e2: + logger.error( + f"❌ Toutes les méthodes d'association ont échoué" + ) + # Fallback: définir manuellement + ligne_obj.DL_Design = ( + designation_sage or ligne_data["designation"] + ) + ligne_obj.DL_Qte = quantite + logger.warning("⚠️ Configuration manuelle appliquée") + + # ⚙️ ÉTAPE 4: Vérifier le prix automatique chargé + prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) + logger.info(f"💰 Prix auto chargé: {prix_auto}€") + + # 💵 ÉTAPE 5: Ajuster le prix si nécessaire + prix_a_utiliser = ligne_data.get("prix_unitaire_ht") + + if prix_a_utiliser is not None and prix_a_utiliser > 0: + # Prix personnalisé fourni + ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) + logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") + elif prix_auto == 0: + # Pas de prix auto, forcer le prix Sage + if prix_sage == 0: + raise ValueError( + f"Prix nul pour article {ligne_data['article_code']}" + ) + ligne_obj.DL_PrixUnitaire = float(prix_sage) + logger.info(f"💰 Prix Sage forcé: {prix_sage}€") + else: + # Prix auto correct, on le garde + logger.info(f"💰 Prix auto conservé: {prix_auto}€") + + prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) + montant_ligne = quantite * prix_final + logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€") + + # 🎁 Remise + remise = ligne_data.get("remise_pourcentage", 0) + if remise > 0: + try: + ligne_obj.DL_Remise01REM_Valeur = float(remise) + ligne_obj.DL_Remise01REM_Type = 0 + montant_apres_remise = montant_ligne * ( + 1 - remise / 100 + ) + logger.info( + f"🎁 Remise {remise}% → {montant_apres_remise}€" + ) + except Exception as e: + logger.warning(f"⚠️ Remise non appliquée: {e}") + + # 💾 ÉTAPE 6: Écrire la ligne + ligne_obj.Write() + logger.info(f"✅ Ligne {idx} écrite") + + # 🔍 VÉRIFICATION: Relire la ligne pour confirmer + try: + ligne_obj.Read() + prix_enregistre = float( + getattr(ligne_obj, "DL_PrixUnitaire", 0.0) + ) + montant_enregistre = float( + getattr(ligne_obj, "DL_MontantHT", 0.0) + ) + logger.info( + f"🔍 Vérif: Prix={prix_enregistre}€, Montant HT={montant_enregistre}€" + ) + + if montant_enregistre == 0: + logger.error( + f"❌ PROBLÈME: Montant enregistré = 0 pour ligne {idx}" + ) + else: + logger.info(f"✅ Ligne {idx} OK: {montant_enregistre}€") + except Exception as e: + logger.warning(f"⚠️ Impossible de vérifier la ligne: {e}") + + # Validation + doc.Write() + process.Process() + + if transaction_active: + self.cial.CptaApplication.CommitTrans() + + # Récupération numéro + time.sleep(2) + + numero_commande = None + try: + doc_result = process.DocumentResult + if doc_result: + doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3") + doc_result.Read() + numero_commande = getattr(doc_result, "DO_Piece", "") + except: + pass + + if not numero_commande: + numero_commande = getattr(doc, "DO_Piece", "") + + # Relecture + factory_doc = self.cial.FactoryDocumentVente + persist_reread = factory_doc.ReadPiece(settings.SAGE_TYPE_BON_COMMANDE, numero_commande) + + if persist_reread: + doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3") + doc_final.Read() + + total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) + total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) + else: + total_ht = 0.0 + total_ttc = 0.0 + + logger.info(f"✅✅✅ COMMANDE CRÉÉE: {numero_commande} - {total_ttc}€ TTC ✅✅✅") + + return { + "numero_commande": numero_commande, + "total_ht": total_ht, + "total_ttc": total_ttc, + "nb_lignes": len(commande_data["lignes"]), + "client_code": commande_data["client"]["code"], + "date_commande": str(date_obj.date()), + } + + except Exception as e: + if transaction_active: + try: + self.cial.CptaApplication.RollbackTrans() + except: + pass + raise + + except Exception as e: + logger.error(f"❌ Erreur création commande: {e}", exc_info=True) + raise RuntimeError(f"Échec création commande: {str(e)}") + + def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: + """ + ✏️ Modification d'une commande existante + + Code similaire à modifier_devis mais pour type 10 (Bon de commande) + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + try: + with self._com_context(), self._lock_com: + # ======================================== + # ÉTAPE 1 : CHARGER LE DEVIS EXISTANT + # ======================================== + logger.info(f"🔍 Recherche devis {numero}...") + + factory = self.cial.FactoryDocumentVente + persist = factory.ReadPiece(10, numero) + + # Si ReadPiece échoue, chercher dans List() + if not persist: + index = 1 + while index < 10000: + try: + persist_test = factory.List(index) + if persist_test is None: + break + + doc_test = win32com.client.CastTo(persist_test, "IBODocumentVente3") + doc_test.Read() + + if (getattr(doc_test, "DO_Type", -1) == 0 + and getattr(doc_test, "DO_Piece", "") == numero): + persist = persist_test + break + + index += 1 + except: + index += 1 + + if not persist: + raise ValueError(f"Devis {numero} introuvable") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + logger.info(f"✅ Devis {numero} trouvé") + + # Vérifier le statut (ne pas modifier si déjà transformé) + statut_actuel = getattr(doc, "DO_Statut", 0) + if statut_actuel == 5: + raise ValueError(f"Le devis {numero} a déjà été transformé") + + # ======================================== + # ÉTAPE 2 : METTRE À JOUR LES CHAMPS + # ======================================== + champs_modifies = [] + + # Mise à jour de la date + if "date_devis" in commande_data: + import pywintypes + + date_str = commande_data["date_devis"] + if isinstance(date_str, str): + date_obj = datetime.fromisoformat(date_str) + else: + date_obj = date_str + + doc.DO_Date = pywintypes.Time(date_obj) + champs_modifies.append("date") + logger.info(f"📅 Date modifiée: {date_obj.date()}") + + # Mise à jour du statut + if "statut" in commande_data: + nouveau_statut = commande_data["statut"] + doc.DO_Statut = nouveau_statut + champs_modifies.append("statut") + logger.info(f"📊 Statut modifié: {statut_actuel} → {nouveau_statut}") + + # Écriture des modifications de base + if champs_modifies: + doc.Write() + + # ======================================== + # ÉTAPE 3 : REMPLACEMENT DES LIGNES (si demandé) + # ======================================== + if "lignes" in commande_data and commande_data["lignes"] is not None: + logger.info(f"🔄 Remplacement des lignes...") + + # Supprimer TOUTES les lignes existantes + try: + factory_lignes = doc.FactoryDocumentLigne + except: + factory_lignes = doc.FactoryDocumentVenteLigne + + # Compter et supprimer les lignes existantes + index_ligne = 1 + while index_ligne <= 100: + try: + ligne_p = factory_lignes.List(index_ligne) + if ligne_p is None: + break + + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne.Read() + ligne.Delete() + + index_ligne += 1 + except: + break + + logger.info(f"🗑️ {index_ligne - 1} ligne(s) supprimée(s)") + + # Ajouter les nouvelles lignes + factory_article = self.cial.FactoryArticle + + for idx, ligne_data in enumerate(commande_data["lignes"], 1): + logger.info(f"➕ Ajout ligne {idx}: {ligne_data['article_code']}") + + # Charger l'article + persist_article = factory_article.ReadReference(ligne_data["article_code"]) + + if not persist_article: + raise ValueError(f"Article {ligne_data['article_code']} introuvable") + + article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj.Read() + + # Créer la nouvelle ligne + ligne_persist = factory_lignes.Create() + + try: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + except: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + + # Associer article + quantite = float(ligne_data["quantite"]) + + try: + ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) + except: + try: + ligne_obj.SetDefaultArticle(article_obj, quantite) + except: + ligne_obj.DL_Design = ligne_data.get("designation", "") + ligne_obj.DL_Qte = quantite + + # Définir le prix (si fourni) + if ligne_data.get("prix_unitaire_ht"): + ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) + + # Définir la remise (si fournie) + if ligne_data.get("remise_pourcentage", 0) > 0: + try: + ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) + ligne_obj.DL_Remise01REM_Type = 0 + except: + pass + + ligne_obj.Write() + + logger.info(f"✅ {len(commande_data['lignes'])} nouvelle(s) ligne(s) ajoutée(s)") + champs_modifies.append("lignes") + + # ======================================== + # ÉTAPE 4 : VALIDATION FINALE + # ======================================== + doc.Write() + + # Attente indexation + time.sleep(1) + + # Relecture pour récupérer les totaux + doc.Read() + + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) + total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) + + logger.info(f"✅✅✅ DEVIS MODIFIÉ: {numero} ({', '.join(champs_modifies)}) ✅✅✅") + logger.info(f"💰 Nouveaux totaux: {total_ht}€ HT / {total_ttc}€ TTC") + + return { + "numero": numero, + "total_ht": total_ht, + "total_ttc": total_ttc, + "champs_modifies": champs_modifies, + "statut": getattr(doc, "DO_Statut", 0) + } + + except ValueError as e: + logger.error(f"❌ Erreur métier: {e}") + raise + + except Exception as e: + logger.error(f"❌ Erreur modification devis: {e}", exc_info=True) + raise RuntimeError(f"Erreur technique Sage: {str(e)}") \ No newline at end of file