diff --git a/main.py b/main.py index dd10725..5da092c 100644 --- a/main.py +++ b/main.py @@ -172,6 +172,19 @@ class AvoirUpdateGatewayRequest(BaseModel): 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] + reference: Optional[str] = None + + +class FactureUpdateGatewayRequest(BaseModel): + """Modèle pour modification facture côté gateway""" + numero: str + facture_data: Dict + # ===================================================== # SÉCURITÉ # ===================================================== @@ -2957,6 +2970,57 @@ def modifier_avoir_endpoint(req: AvoirUpdateGatewayRequest): 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.) + """ + try: + # Vérifier que le client existe + 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": ""}, + "date_facture": req.date_facture or date.today(), + "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)) + except Exception as e: + logger.error(f"Erreur technique création facture: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/factures/update", dependencies=[Depends(verify_token)]) +def modifier_facture_endpoint(req: FactureUpdateGatewayRequest): + """ + ✏️ Modification d'une facture dans Sage + + ⚠️ ATTENTION: Les factures comptabilisées peuvent être verrouillées + """ + try: + resultat = sage.modifier_facture(req.numero, req.facture_data) + return {"success": True, "data": resultat} + + 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)) + # ===================================================== # LANCEMENT diff --git a/sage_connector.py b/sage_connector.py index b102b7c..fe960ec 100644 --- a/sage_connector.py +++ b/sage_connector.py @@ -5386,6 +5386,539 @@ class SageConnector: logger.error(f"❌ Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") + def creer_facture_enrichi(self, facture_data: dict) -> Dict: + """ + ➕ Création d'une facture (type 60 = Facture) + + ⚠️ ATTENTION: Les factures ont souvent des champs obligatoires supplémentaires + selon la configuration Sage (DO_CodeJournal, DO_Souche, DO_Regime, etc.) + + ✅ Gestion identique aux autres documents + champs spécifiques factures + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + logger.info(f"🚀 Début création facture pour client {facture_data['client']['code']}") + + try: + with self._com_context(), self._lock_com: + transaction_active = False + try: + self.cial.CptaApplication.BeginTrans() + transaction_active = True + logger.debug("✅ Transaction Sage démarrée") + except: + pass + + try: + # Création document FACTURE (type 60) + process = self.cial.CreateProcess_Document(settings.SAGE_TYPE_FACTURE) + doc = process.Document + + try: + doc = win32com.client.CastTo(doc, "IBODocumentVente3") + except: + pass + + logger.info("📄 Document facture créé") + + # Date + import pywintypes + + if isinstance(facture_data["date_facture"], str): + date_obj = datetime.fromisoformat(facture_data["date_facture"]) + elif isinstance(facture_data["date_facture"], date): + date_obj = datetime.combine(facture_data["date_facture"], datetime.min.time()) + else: + date_obj = datetime.now() + + doc.DO_Date = pywintypes.Time(date_obj) + logger.info(f"📅 Date définie: {date_obj.date()}") + + # Client (CRITIQUE) + factory_client = self.cial.CptaApplication.FactoryClient + persist_client = factory_client.ReadNumero(facture_data["client"]["code"]) + + if not persist_client: + raise ValueError(f"Client {facture_data['client']['code']} introuvable") + + client_obj = self._cast_client(persist_client) + if not client_obj: + raise ValueError(f"Impossible de charger le client") + + doc.SetDefaultClient(client_obj) + doc.Write() + logger.info(f"👤 Client {facture_data['client']['code']} associé") + + # Référence externe (optionnelle) + if facture_data.get("reference"): + try: + doc.DO_Ref = facture_data["reference"] + logger.info(f"📖 Référence: {facture_data['reference']}") + except: + pass + + # ============================================ + # CHAMPS SPÉCIFIQUES FACTURES + # ============================================ + logger.info("⚙️ Configuration champs spécifiques factures...") + + # Code journal (si disponible) + try: + if hasattr(doc, "DO_CodeJournal"): + # Essayer de récupérer le code journal par défaut + try: + param_societe = self.cial.CptaApplication.ParametreSociete + journal_defaut = getattr(param_societe, "P_CodeJournalVte", "VTE") + doc.DO_CodeJournal = journal_defaut + logger.info(f" ✅ Code journal: {journal_defaut}") + except: + doc.DO_CodeJournal = "VTE" + logger.info(" ✅ Code journal: VTE (défaut)") + except Exception as e: + logger.debug(f" ⚠️ Code journal: {e}") + + # Souche (si disponible) + try: + if hasattr(doc, "DO_Souche"): + doc.DO_Souche = 0 + logger.debug(" ✅ Souche: 0 (défaut)") + except: + pass + + # Régime (si disponible) + try: + if hasattr(doc, "DO_Regime"): + doc.DO_Regime = 0 + logger.debug(" ✅ Régime: 0 (défaut)") + except: + pass + + # Lignes + try: + factory_lignes = doc.FactoryDocumentLigne + except: + factory_lignes = doc.FactoryDocumentVenteLigne + + factory_article = self.cial.FactoryArticle + + logger.info(f"📦 Ajout de {len(facture_data['lignes'])} lignes...") + + for idx, ligne_data in enumerate(facture_data["lignes"], 1): + logger.info(f"--- Ligne {idx}: {ligne_data['article_code']} ---") + + # Charger l'article RÉEL depuis Sage + persist_article = factory_article.ReadReference(ligne_data["article_code"]) + + if not persist_article: + raise ValueError(f"❌ Article {ligne_data['article_code']} introuvable dans Sage") + + article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj.Read() + + # Récupérer le prix de vente RÉEL + prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0)) + designation_sage = getattr(article_obj, "AR_Design", "") + logger.info(f"💰 Prix Sage: {prix_sage}€") + + if prix_sage == 0: + logger.warning(f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)") + + # Créer la ligne + ligne_persist = factory_lignes.Create() + + try: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + except: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + + quantite = float(ligne_data["quantite"]) + + try: + ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) + logger.info(f"✅ Article associé via SetDefaultArticleReference") + except Exception as e: + logger.warning(f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet") + try: + ligne_obj.SetDefaultArticle(article_obj, quantite) + logger.info(f"✅ Article associé via SetDefaultArticle") + except Exception as e2: + logger.error(f"❌ Toutes les méthodes ont échoué") + ligne_obj.DL_Design = designation_sage or ligne_data.get("designation", "") + ligne_obj.DL_Qte = quantite + logger.warning("⚠️ Configuration manuelle appliquée") + + # Vérifier le prix automatique + prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) + logger.info(f"💰 Prix auto chargé: {prix_auto}€") + + # Ajuster le prix si nécessaire + prix_a_utiliser = ligne_data.get("prix_unitaire_ht") + + if prix_a_utiliser is not None and prix_a_utiliser > 0: + ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) + logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") + elif prix_auto == 0 and prix_sage > 0: + ligne_obj.DL_PrixUnitaire = float(prix_sage) + logger.info(f"💰 Prix Sage forcé: {prix_sage}€") + elif prix_auto > 0: + logger.info(f"💰 Prix auto conservé: {prix_auto}€") + + prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) + montant_ligne = quantite * prix_final + logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€") + + # Remise + remise = ligne_data.get("remise_pourcentage", 0) + if remise > 0: + try: + ligne_obj.DL_Remise01REM_Valeur = float(remise) + ligne_obj.DL_Remise01REM_Type = 0 + montant_apres_remise = montant_ligne * (1 - remise / 100) + logger.info(f"🎁 Remise {remise}% → {montant_apres_remise}€") + except Exception as e: + logger.warning(f"⚠️ Remise non appliquée: {e}") + + # Écrire la ligne + ligne_obj.Write() + logger.info(f"✅ Ligne {idx} écrite") + + # ============================================ + # VALIDATION FINALE + # ============================================ + logger.info("💾 Validation facture...") + + # Réassocier le client avant validation (critique pour factures) + try: + doc.SetClient(client_obj) + logger.debug(" ✅ Client réassocié avant validation") + except: + try: + doc.SetDefaultClient(client_obj) + except: + pass + + doc.Write() + + logger.info("🔄 Process()...") + process.Process() + + if transaction_active: + self.cial.CptaApplication.CommitTrans() + logger.info("✅ Transaction committée") + + # Récupération numéro + time.sleep(2) + + numero_facture = None + try: + doc_result = process.DocumentResult + if doc_result: + doc_result = win32com.client.CastTo(doc_result, "IBODocumentVente3") + doc_result.Read() + numero_facture = getattr(doc_result, "DO_Piece", "") + except: + pass + + if not numero_facture: + numero_facture = getattr(doc, "DO_Piece", "") + + if not numero_facture: + raise RuntimeError("Numéro facture vide après création") + + logger.info(f"📄 Numéro facture: {numero_facture}") + + # Relecture + factory_doc = self.cial.FactoryDocumentVente + persist_reread = factory_doc.ReadPiece(settings.SAGE_TYPE_FACTURE, numero_facture) + + if persist_reread: + doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3") + doc_final.Read() + + total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) + total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) + else: + total_ht = 0.0 + total_ttc = 0.0 + + logger.info(f"✅✅✅ FACTURE CRÉÉE: {numero_facture} - {total_ttc}€ TTC ✅✅✅") + + return { + "numero_facture": numero_facture, + "total_ht": total_ht, + "total_ttc": total_ttc, + "nb_lignes": len(facture_data["lignes"]), + "client_code": facture_data["client"]["code"], + "date_facture": str(date_obj.date()), + } + + except Exception as e: + if transaction_active: + try: + self.cial.CptaApplication.RollbackTrans() + logger.error("❌ Transaction annulée (rollback)") + except: + pass + raise + + except Exception as e: + logger.error(f"❌ Erreur création facture: {e}", exc_info=True) + raise RuntimeError(f"Échec création facture: {str(e)}") + + + def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: + """ + ✏️ Modification d'une facture existante + + ⚠️ ATTENTION: Les factures comptabilisées peuvent être verrouillées par Sage + + 🔧 STRATÉGIE REMPLACEMENT LIGNES: + - Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles + - Utilise .Remove() pour la suppression + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + try: + with self._com_context(), self._lock_com: + logger.info(f"🔬 === MODIFICATION FACTURE {numero} ===") + + # ÉTAPE 1 : CHARGER LE DOCUMENT + logger.info("📂 Chargement document...") + + factory = self.cial.FactoryDocumentVente + persist = None + + # Chercher le document + for type_test in [60, 5, settings.SAGE_TYPE_FACTURE]: + try: + persist_test = factory.ReadPiece(type_test, numero) + if persist_test: + persist = persist_test + logger.info(f" ✅ Document trouvé (type={type_test})") + break + except: + continue + + if not persist: + raise ValueError(f"❌ Facture {numero} INTROUVABLE") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + statut_actuel = getattr(doc, "DO_Statut", 0) + + logger.info(f" 📊 Statut={statut_actuel}") + + # Vérifier qu'elle n'est pas transformée ou annulée + if statut_actuel == 5: + raise ValueError(f"La facture {numero} a déjà été transformée") + + if statut_actuel == 6: + raise ValueError(f"La facture {numero} est annulée") + + # Vérifier client initial + client_code_initial = "" + try: + client_obj = getattr(doc, "Client", None) + if client_obj: + client_obj.Read() + client_code_initial = getattr(client_obj, "CT_Num", "").strip() + logger.info(f" 👤 Client initial: {client_code_initial}") + except Exception as e: + logger.error(f" ❌ Erreur lecture client initial: {e}") + + if not client_code_initial: + raise ValueError("❌ Client introuvable dans le document") + + # Compter les lignes initiales + nb_lignes_initial = 0 + try: + factory_lignes = getattr(doc, "FactoryDocumentLigne", None) or getattr(doc, "FactoryDocumentVenteLigne", None) + index = 1 + while index <= 100: + try: + ligne_p = factory_lignes.List(index) + if ligne_p is None: + break + nb_lignes_initial += 1 + index += 1 + except: + break + + logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") + except Exception as e: + logger.warning(f" ⚠️ Erreur comptage lignes: {e}") + + # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS + champs_modifies = [] + + modif_date = "date_facture" in facture_data + modif_statut = "statut" in facture_data + modif_ref = "reference" in facture_data + modif_lignes = "lignes" in facture_data and facture_data["lignes"] is not None + + logger.info(f"📋 Modifications demandées:") + logger.info(f" Date: {modif_date}") + logger.info(f" Statut: {modif_statut}") + logger.info(f" Référence: {modif_ref}") + logger.info(f" Lignes: {modif_lignes}") + + # ÉTAPE 3 : TEST WRITE() BASIQUE + logger.info("🧪 Test Write() basique (sans modification)...") + + try: + doc.Write() + logger.info(" ✅ Write() basique OK") + doc.Read() + except Exception as e: + logger.error(f" ❌ Write() basique ÉCHOUE: {e}") + logger.error(f" ❌ ABANDON: Le document est VERROUILLÉ") + raise ValueError(f"Document verrouillé, impossible de modifier: {e}") + + # ÉTAPE 4 : MODIFICATIONS SIMPLES + if not modif_lignes and (modif_date or modif_statut or modif_ref): + logger.info("🎯 Modifications simples (sans lignes)...") + + if modif_date: + import pywintypes + date_str = facture_data["date_facture"] + + if isinstance(date_str, str): + date_obj = datetime.fromisoformat(date_str) + elif isinstance(date_str, date): + date_obj = datetime.combine(date_str, datetime.min.time()) + else: + date_obj = date_str + + doc.DO_Date = pywintypes.Time(date_obj) + champs_modifies.append("date") + logger.info(f" ✅ Date définie: {date_obj.date()}") + + if modif_statut: + nouveau_statut = facture_data["statut"] + doc.DO_Statut = nouveau_statut + champs_modifies.append("statut") + logger.info(f" ✅ Statut défini: {nouveau_statut}") + + if modif_ref: + try: + doc.DO_Ref = facture_data["reference"] + champs_modifies.append("reference") + logger.info(f" ✅ Référence définie") + except Exception as e: + logger.warning(f" ⚠️ Référence non définie: {e}") + + doc.Write() + logger.info(" ✅ Write() réussi") + + # ÉTAPE 5 : REMPLACEMENT COMPLET LIGNES + elif modif_lignes: + logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") + + nouvelles_lignes = facture_data["lignes"] + nb_nouvelles = len(nouvelles_lignes) + + logger.info(f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles") + + try: + factory_lignes = doc.FactoryDocumentLigne + except: + factory_lignes = doc.FactoryDocumentVenteLigne + + factory_article = self.cial.FactoryArticle + + # SUPPRESSION TOUTES LES LIGNES + if nb_lignes_initial > 0: + logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") + + for idx in range(nb_lignes_initial, 0, -1): + try: + ligne_p = factory_lignes.List(idx) + if ligne_p: + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne.Read() + ligne.Remove() + logger.debug(f" ✅ Ligne {idx} supprimée") + except Exception as e: + logger.warning(f" ⚠️ Erreur suppression ligne {idx}: {e}") + + logger.info(" ✅ Toutes les lignes supprimées") + + # AJOUT NOUVELLES LIGNES + logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") + + for idx, ligne_data in enumerate(nouvelles_lignes, 1): + persist_article = factory_article.ReadReference(ligne_data["article_code"]) + if not persist_article: + raise ValueError(f"Article {ligne_data['article_code']} introuvable") + + article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj.Read() + + ligne_persist = factory_lignes.Create() + + try: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + except: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + + quantite = float(ligne_data["quantite"]) + + try: + ligne_obj.SetDefaultArticleReference(ligne_data["article_code"], quantite) + except: + try: + ligne_obj.SetDefaultArticle(article_obj, quantite) + except: + ligne_obj.DL_Design = ligne_data.get("designation", "") + ligne_obj.DL_Qte = quantite + + if ligne_data.get("prix_unitaire_ht"): + ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"]) + + if ligne_data.get("remise_pourcentage", 0) > 0: + try: + ligne_obj.DL_Remise01REM_Valeur = float(ligne_data["remise_pourcentage"]) + ligne_obj.DL_Remise01REM_Type = 0 + except: + pass + + ligne_obj.Write() + logger.debug(f" ✅ Ligne {idx} ajoutée") + + logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") + + doc.Write() + champs_modifies.append("lignes") + + # ÉTAPE 6 : RELECTURE ET RETOUR + import time + time.sleep(1) + + doc.Read() + + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) + total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) + + logger.info(f"✅✅✅ FACTURE MODIFIÉE: {numero} ✅✅✅") + logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") + + return { + "numero": numero, + "total_ht": total_ht, + "total_ttc": total_ttc, + "champs_modifies": champs_modifies, + "statut": getattr(doc, "DO_Statut", 0) + } + + except ValueError as e: + logger.error(f"❌ Erreur métier: {e}") + raise + except Exception as e: + logger.error(f"❌ Erreur technique: {e}", exc_info=True) + raise RuntimeError(f"Erreur Sage: {str(e)}")