From eba4011dd4a5a696951c8eba12fa5f4064975318 Mon Sep 17 00:00:00 2001 From: mickael Date: Thu, 18 Dec 2025 13:47:09 +0100 Subject: [PATCH] Forgot all modifications that were done ! --- main.py | 80 + main.py.bak | 4514 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 +- sage_connector.py | 1141 ++++++++++-- 4 files changed, 5569 insertions(+), 169 deletions(-) create mode 100644 main.py.bak diff --git a/main.py b/main.py index 8aa4430..3fb0c5c 100644 --- a/main.py +++ b/main.py @@ -1615,6 +1615,86 @@ def lire_mouvement_stock(numero: str): logger.error(f"❌ Erreur lecture mouvement : {e}") raise HTTPException(500, str(e)) +@app.get("/sage/modeles/list") +def lister_modeles_disponibles(): + """Liste tous les modèles .bgc disponibles pour chaque type de document""" + try: + modeles = sage.lister_modeles_crystal() + + return { + "success": True, + "data": modeles + } + except Exception as e: + logger.error(f"❌ Erreur listage modèles: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/sage/documents/{numero}/pdf", dependencies=[Depends(verify_token)]) +def generer_pdf_document( + numero: str, + type_doc: int = Query(..., description="Type document (0=devis, 60=facture, etc.)"), + modele: str = Query(None, description="Nom du modèle .bgc (optionnel)"), + base64_encode: bool = Query(True, description="Retourner en base64") +): + """ + 📄 Génère un PDF d'un document Sage avec le modèle spécifié + """ + try: + # ✅ LOG pour debug + logger.info(f"📄 PDF Request: numero={numero}, type={type_doc}, modele={modele}, base64={base64_encode}") + + # Générer le PDF + pdf_bytes = sage.generer_pdf_document( + numero=numero, + type_doc=type_doc, + modele=modele + ) + + if not pdf_bytes: + raise HTTPException(404, f"Impossible de générer le PDF pour {numero}") + + # ✅ LOG taille PDF + logger.info(f"✅ PDF généré: {len(pdf_bytes)} octets") + + if base64_encode: + # Retour en JSON avec base64 + import base64 + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + "success": True, + "data": { + "numero": numero, + "type": type_doc, + "modele": modele or "défaut", + "pdf_base64": pdf_base64, + "size_bytes": len(pdf_bytes), + "size_readable": f"{len(pdf_bytes) / 1024:.1f} KB" + } + } + else: + # Retour direct du fichier PDF + from fastapi.responses import Response + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'inline; filename="{numero}.pdf"', + "Content-Length": str(len(pdf_bytes)) # ✅ Taille explicite + } + ) + + except HTTPException: + raise + except ValueError as e: + logger.error(f"❌ Erreur métier: {e}") + raise HTTPException(400, str(e)) + except Exception as e: + logger.error(f"❌ Erreur technique: {e}", exc_info=True) + raise HTTPException(500, str(e)) + # ===================================================== # LANCEMENT diff --git a/main.py.bak b/main.py.bak new file mode 100644 index 0000000..5aafae5 --- /dev/null +++ b/main.py.bak @@ -0,0 +1,4514 @@ +from fastapi import FastAPI, HTTPException, Header, Depends, Query +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from typing import Optional, List, Dict +from datetime import datetime, date +from enum import Enum +import uvicorn +import logging +import win32com.client +import time +from config import settings, validate_settings +from sage_connector import SageConnector + +# ===================================================== +# LOGGING +# ===================================================== +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("sage_gateway.log"), logging.StreamHandler()], +) +logger = logging.getLogger(__name__) + + +# ===================================================== +# ENUMS +# ===================================================== +class TypeDocument(int, Enum): + DEVIS = 0 + BON_LIVRAISON = 1 + BON_RETOUR = 2 + COMMANDE = 3 + PREPARATION = 4 + FACTURE = 5 + + +# ===================================================== +# MODÈLES +# ===================================================== + + +class DocumentGetRequest(BaseModel): + numero: str + type_doc: int + + +class FiltreRequest(BaseModel): + filtre: Optional[str] = "" + + +class CodeRequest(BaseModel): + code: str + + +class ChampLibreRequest(BaseModel): + doc_id: str + type_doc: int + nom_champ: str + valeur: str + + +class DevisRequest(BaseModel): + client_id: str + date_devis: Optional[date] = None + lignes: List[ + Dict + ] # Format: {article_code, quantite, prix_unitaire_ht, remise_pourcentage} + + +class TransformationRequest(BaseModel): + numero_source: str + type_source: int + type_cible: int + + +class StatutRequest(BaseModel): + nouveau_statut: int + + +class ClientCreateRequest(BaseModel): + intitule: str = Field(..., description="Nom du client (CT_Intitule)") + compte_collectif: str = Field("411000", description="Compte général rattaché") + num: Optional[str] = Field(None, description="Laisser vide pour numérotation auto") + adresse: Optional[str] = None + code_postal: Optional[str] = None + ville: Optional[str] = None + pays: Optional[str] = None + email: Optional[str] = None + telephone: Optional[str] = None + 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é") + num: Optional[str] = Field(None, description="Code fournisseur (auto si vide)") + adresse: Optional[str] = None + code_postal: Optional[str] = None + ville: Optional[str] = None + pays: Optional[str] = None + email: Optional[str] = None + telephone: Optional[str] = None + siret: Optional[str] = None + tva_intra: Optional[str] = None + + +class FournisseurCreateRequest(BaseModel): + intitule: str = Field(..., description="Raison sociale du fournisseur") + compte_collectif: str = Field("401000", description="Compte général rattaché") + num: Optional[str] = Field(None, description="Code fournisseur (auto si vide)") + adresse: Optional[str] = None + code_postal: Optional[str] = None + ville: Optional[str] = None + pays: Optional[str] = None + email: Optional[str] = None + telephone: Optional[str] = None + siret: Optional[str] = None + tva_intra: Optional[str] = None + + +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 + lignes: List[Dict] + + +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] + reference: Optional[str] = None + + +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] + reference: Optional[str] = None + + +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] + reference: Optional[str] = None + + +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") + + +class ArticleCreateRequest(BaseModel): + reference: str = Field(..., description="Référence article (max 18 car)") + designation: str = Field(..., description="Désignation (max 69 car)") + famille: Optional[str] = Field(None, description="Code famille") + prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT") + prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT") + stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial") + stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum") + code_ean: Optional[str] = Field(None, description="Code-barres EAN") + unite_vente: Optional[str] = Field("UN", description="Unité de vente") + tva_code: Optional[str] = Field(None, description="Code TVA") + description: Optional[str] = Field(None, description="Description/Commentaire") + + +class ArticleUpdateGatewayRequest(BaseModel): + """Modèle pour modification article côté gateway""" + reference: str + article_data: Dict + + +# ===================================================== +# SÉCURITÉ +# ===================================================== +def verify_token(x_sage_token: str = Header(...)): + """Vérification du token d'authentification""" + if x_sage_token != settings.sage_gateway_token: + logger.warning(f"❌ Token invalide reçu: {x_sage_token[:20]}...") + raise HTTPException(401, "Token invalide") + return True + + +# ===================================================== +# APPLICATION +# ===================================================== +app = FastAPI( + title="Sage Gateway - Windows Server", + version="1.0.0", + description="Passerelle d'accès à Sage 100c pour VPS Linux", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, +) + +sage: Optional[SageConnector] = None + + +# ===================================================== +# LIFECYCLE +# ===================================================== +@app.on_event("startup") +def startup(): + global sage + + logger.info("🚀 Démarrage Sage Gateway Windows...") + + # Validation config + try: + validate_settings() + logger.info("✅ Configuration validée") + except ValueError as e: + logger.error(f"❌ Configuration invalide: {e}") + raise + + # Connexion Sage + sage = SageConnector( + settings.chemin_base, settings.utilisateur, settings.mot_de_passe + ) + + if not sage.connecter(): + raise RuntimeError("❌ Impossible de se connecter à Sage 100c") + + logger.info("✅ Sage Gateway démarré et connecté") + + +@app.on_event("shutdown") +def shutdown(): + if sage: + sage.deconnecter() + logger.info("👋 Sage Gateway arrêté") + + +# ===================================================== +# ENDPOINTS - SYSTÈME +# ===================================================== +@app.get("/health") +def health(): + """Health check""" + return { + "status": "ok", + "sage_connected": sage is not None and sage.cial is not None, + "cache_info": sage.get_cache_info() if sage else None, + "timestamp": datetime.now().isoformat(), + } + + +# ===================================================== +# ENDPOINTS - CLIENTS +# ===================================================== +@app.post("/sage/clients/list", dependencies=[Depends(verify_token)]) +def clients_list(req: FiltreRequest): + """Liste des clients avec filtre optionnel""" + try: + clients = sage.lister_tous_clients(req.filtre) + return {"success": True, "data": clients} + except Exception as e: + 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): + """ + ✏️ Modification d'un client dans Sage + """ + 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)) + except Exception as e: + 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""" + try: + client = sage.lire_client(req.code) + if not client: + raise HTTPException(404, f"Client {req.code} non trouvé") + return {"success": True, "data": client} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture client: {e}") + raise HTTPException(500, str(e)) + + +# DANS main.py +@app.post("/sage/clients/create", dependencies=[Depends(verify_token)]) +def create_client_endpoint(req: ClientCreateRequest): + """Création d'un client dans Sage""" + try: + # L'appel au connecteur est fait ici + resultat = sage.creer_client(req.dict()) + return {"success": True, "data": resultat} + except ValueError as e: + logger.warning(f"Erreur métier création client: {e}") + # Erreur métier (ex: doublon) -> 400 Bad Request + raise HTTPException(400, str(e)) + except Exception as e: + logger.error(f"Erreur technique création client: {e}") + # Erreur technique (ex: COM) -> 500 Internal Server Error + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - ARTICLES +# ===================================================== +@app.post("/sage/articles/list", dependencies=[Depends(verify_token)]) +def articles_list(req: FiltreRequest): + """Liste des articles avec filtre optionnel""" + try: + articles = sage.lister_tous_articles(req.filtre) + return {"success": True, "data": articles} + except Exception as e: + logger.error(f"Erreur liste articles: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/articles/get", dependencies=[Depends(verify_token)]) +def article_get(req: CodeRequest): + """Lecture d'un article par référence""" + try: + article = sage.lire_article(req.code) + if not article: + raise HTTPException(404, f"Article {req.code} non trouvé") + return {"success": True, "data": article} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture article: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - DEVIS +# ===================================================== +@app.post("/sage/devis/create", dependencies=[Depends(verify_token)]) +def creer_devis(req: DevisRequest): + """Création d'un devis""" + try: + # Transformer en format attendu par sage_connector + devis_data = { + "client": {"code": req.client_id, "intitule": ""}, + "date_devis": req.date_devis or date.today(), + "lignes": req.lignes, + } + + resultat = sage.creer_devis_enrichi(devis_data) + return {"success": True, "data": resultat} + except Exception as e: + logger.error(f"Erreur création devis: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/devis/get", dependencies=[Depends(verify_token)]) +def lire_devis(req: CodeRequest): + """ + 📄 Lecture d'un devis AVEC ses lignes (lecture Sage directe) + + ⚠️ Plus lent que /list car charge les lignes depuis Sage + 💡 Utiliser /list pour afficher une table rapide + """ + try: + # ✅ Lecture complète depuis Sage (avec lignes) + devis = sage.lire_devis(req.code) + if not devis: + raise HTTPException(404, f"Devis {req.code} non trouvé") + return {"success": True, "data": devis} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture devis: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/devis/list", dependencies=[Depends(verify_token)]) +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)"), +): + """ + 📋 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""" + try: + with sage._com_context(), sage._lock_com: + factory = sage.cial.FactoryDocumentVente + persist = factory.ReadPiece(0, numero) + + if not persist: + raise HTTPException(404, f"Devis {numero} introuvable") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + statut_actuel = getattr(doc, "DO_Statut", 0) + doc.DO_Statut = nouveau_statut + doc.Write() + + logger.info(f"✅ Statut devis {numero}: {statut_actuel} → {nouveau_statut}") + + return { + "success": True, + "data": { + "numero": numero, + "statut_ancien": statut_actuel, + "statut_nouveau": nouveau_statut, + }, + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur changement statut: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - DOCUMENTS +# ===================================================== +@app.post("/sage/documents/get", dependencies=[Depends(verify_token)]) +def lire_document(req: DocumentGetRequest): + """Lecture d'un document (commande, facture, etc.)""" + try: + doc = sage.lire_document(req.numero, req.type_doc) + if not doc: + raise HTTPException(404, f"Document {req.numero} non trouvé") + return {"success": True, "data": doc} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture document: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/documents/transform", dependencies=[Depends(verify_token)]) +def transformer_document( + numero_source: str = Query(..., description="Numéro du document source"), + type_source: int = Query(..., description="Type document source"), + type_cible: int = Query(..., description="Type document cible"), +): + """ + 🔧 Transformation de document + + ✅ CORRECTION : Utilise les VRAIS types Sage Dataven + + Types valides : + - 0: Devis + - 10: Bon de commande + - 20: Préparation + - 30: Bon de livraison + - 40: Bon de retour + - 50: Bon d'avoir + - 60: Facture + + Transformations autorisées : + - Devis (0) → Commande (10) + - Commande (10) → Bon livraison (30) + - Commande (10) → Facture (60) + - Bon livraison (30) → Facture (60) + """ + try: + logger.info( + f"🔄 Transformation demandée: {numero_source} " + f"(type {type_source}) → type {type_cible}" + ) + + # ✅ Matrice des transformations valides pour VOTRE Sage + transformations_valides = { + (0, 10), # Devis → Commande + (10, 30), # Commande → Bon de livraison + (10, 60), # Commande → Facture + (30, 60), # Bon de livraison → Facture + (0, 60), # Devis → Facture (si autorisé) + } + + if (type_source, type_cible) not in transformations_valides: + logger.error( + f"❌ Transformation non autorisée: {type_source} → {type_cible}" + ) + raise HTTPException( + 400, + f"Transformation non autorisée: type {type_source} → type {type_cible}. " + f"Transformations valides: {transformations_valides}", + ) + + # Appel au connecteur Sage + resultat = sage.transformer_document(numero_source, type_source, type_cible) + + logger.info( + f"✅ Transformation réussie: {numero_source} → " + f"{resultat.get('document_cible', '?')} " + f"({resultat.get('nb_lignes', 0)} lignes)" + ) + + return {"success": True, "data": resultat} + + except HTTPException: + raise + except ValueError as e: + logger.error(f"❌ Erreur métier transformation: {e}") + raise HTTPException(400, str(e)) + except Exception as e: + logger.error(f"❌ Erreur technique transformation: {e}", exc_info=True) + raise HTTPException(500, f"Erreur transformation: {str(e)}") + + +@app.post("/sage/documents/champ-libre", dependencies=[Depends(verify_token)]) +def maj_champ_libre(req: ChampLibreRequest): + """Mise à jour d'un champ libre""" + try: + success = sage.mettre_a_jour_champ_libre( + req.doc_id, req.type_doc, req.nom_champ, req.valeur + ) + return {"success": success} + except Exception as e: + logger.error(f"Erreur MAJ champ libre: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/documents/derniere-relance", dependencies=[Depends(verify_token)]) +def maj_derniere_relance(doc_id: str, type_doc: int): + """📅 Met à jour le champ 'Dernière relance' d'un document""" + try: + success = sage.mettre_a_jour_derniere_relance(doc_id, type_doc) + return {"success": success} + except Exception as e: + logger.error(f"Erreur MAJ dernière relance: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - CONTACTS +# ===================================================== +@app.post("/sage/contact/read", dependencies=[Depends(verify_token)]) +def contact_read(req: CodeRequest): + """Lecture du contact principal d'un client""" + try: + contact = sage.lire_contact_principal_client(req.code) + if not contact: + raise HTTPException(404, f"Contact non trouvé pour client {req.code}") + return {"success": True, "data": contact} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture contact: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/commandes/list", dependencies=[Depends(verify_token)]) +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"), +): + """ + 📋 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"), +): + """ + 📋 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""" + try: + client_obj = sage._lire_client_obj(code) + + if not client_obj: + raise HTTPException(404, f"Client {code} introuvable") + + remise_max = 10.0 # Défaut + + try: + remise_max = float(getattr(client_obj, "CT_RemiseMax", 10.0)) + except: + pass + + logger.info(f"✅ Remise max client {code}: {remise_max}%") + + return { + "success": True, + "data": {"client_code": code, "remise_max": remise_max}, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture remise: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - ADMIN +# ===================================================== +@app.post("/sage/cache/refresh", dependencies=[Depends(verify_token)]) +def refresh_cache(): + """Force le rafraîchissement du cache""" + try: + sage.forcer_actualisation_cache() + return { + "success": True, + "message": "Cache actualisé", + "info": sage.get_cache_info(), + } + except Exception as e: + logger.error(f"Erreur refresh cache: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/sage/cache/info", dependencies=[Depends(verify_token)]) +def cache_info_get(): + """Informations sur le cache (endpoint GET)""" + try: + return {"success": True, "data": sage.get_cache_info()} + except Exception as e: + logger.error(f"Erreur info cache: {e}") + raise HTTPException(500, str(e)) + + +# Script à ajouter temporairement dans main.py pour diagnostiquer + + +@app.get("/sage/devis/{numero}/diagnostic", dependencies=[Depends(verify_token)]) +def diagnostiquer_devis(numero: str): + """ + ENDPOINT DE DIAGNOSTIC: Affiche TOUTES les infos d'un devis + + Permet de comprendre pourquoi un devis ne peut pas être transformé + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + factory = sage.cial.FactoryDocumentVente + + # Essayer ReadPiece + persist = factory.ReadPiece(0, numero) + + # Si échec, chercher dans List() + if not persist: + logger.info(f"[DIAG] ReadPiece echoue, recherche dans List()...") + index = 1 + while index < 10000: + try: + persist_test = factory.List(index) + if persist_test is None: + break + + doc_test = win32com.client.CastTo( + persist_test, "IBODocumentVente3" + ) + doc_test.Read() + + if ( + getattr(doc_test, "DO_Type", -1) == 0 + and getattr(doc_test, "DO_Piece", "") == numero + ): + persist = persist_test + logger.info(f"[DIAG] Trouve a l'index {index}") + break + + index += 1 + except: + index += 1 + + if not persist: + raise HTTPException(404, f"Devis {numero} introuvable") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # EXTRACTION COMPLÈTE + diagnostic = { + "numero": getattr(doc, "DO_Piece", ""), + "type": getattr(doc, "DO_Type", -1), + "statut": getattr(doc, "DO_Statut", -1), + "statut_libelle": { + 0: "Brouillon", + 1: "Soumis", + 2: "Accepte", + 3: "Realise partiellement", + 4: "Realise totalement", + 5: "Transforme", + 6: "Annule", + }.get(getattr(doc, "DO_Statut", -1), "Inconnu"), + "date": str(getattr(doc, "DO_Date", "")), + "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), + "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), + "est_transformable": False, + "raison_blocage": None, + } + + # Client + try: + client_obj = getattr(doc, "Client", None) + if client_obj: + client_obj.Read() + diagnostic["client_code"] = getattr( + client_obj, "CT_Num", "" + ).strip() + diagnostic["client_intitule"] = getattr( + client_obj, "CT_Intitule", "" + ).strip() + except Exception as e: + diagnostic["erreur_client"] = str(e) + + # Lignes + try: + factory_lignes = doc.FactoryDocumentLigne + except: + factory_lignes = doc.FactoryDocumentVenteLigne + + lignes = [] + index = 1 + while index <= 100: + try: + ligne_p = factory_lignes.List(index) + if ligne_p is None: + break + + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne.Read() + + article_ref = "" + try: + article_ref = getattr(ligne, "AR_Ref", "").strip() + if not article_ref: + article_obj = getattr(ligne, "Article", None) + if article_obj: + article_obj.Read() + article_ref = getattr(article_obj, "AR_Ref", "").strip() + except: + pass + + lignes.append( + { + "index": index, + "article": article_ref, + "designation": getattr(ligne, "DL_Design", ""), + "quantite": float(getattr(ligne, "DL_Qte", 0.0)), + "prix_unitaire": float( + getattr(ligne, "DL_PrixUnitaire", 0.0) + ), + "montant_ht": float(getattr(ligne, "DL_MontantHT", 0.0)), + } + ) + + index += 1 + except: + break + + diagnostic["nb_lignes"] = len(lignes) + diagnostic["lignes"] = lignes + + # ANALYSE TRANSFORMABILITÉ + statut = diagnostic["statut"] + + if statut == 5: + diagnostic["raison_blocage"] = "Document deja transforme (statut=5)" + elif statut == 6: + diagnostic["raison_blocage"] = "Document annule (statut=6)" + elif statut in [3, 4]: + diagnostic["raison_blocage"] = ( + f"Document deja realise partiellement ou totalement (statut={statut}). " + f"Une commande/BL/facture existe probablement deja." + ) + diagnostic["suggestion"] = ( + "Cherchez les documents lies a ce devis dans Sage. " + "Il a peut-etre deja ete transforme manuellement." + ) + elif statut == 0: + diagnostic["est_transformable"] = True + diagnostic["action_requise"] = ( + "Statut 'Brouillon'. Le systeme le passera automatiquement a 'Accepte' " + "avant transformation." + ) + elif statut == 2: + diagnostic["est_transformable"] = True + diagnostic["action_requise"] = ( + "Statut 'Accepte'. Transformation possible." + ) + else: + diagnostic["raison_blocage"] = f"Statut inconnu ou non gere: {statut}" + + # Champs libres (pour Universign, etc.) + champs_libres = {} + try: + for champ in ["UniversignID", "DerniereRelance", "DO_Ref"]: + try: + valeur = getattr(doc, f"DO_{champ}", None) + if valeur: + champs_libres[champ] = str(valeur) + except: + pass + except: + pass + + if champs_libres: + diagnostic["champs_libres"] = champs_libres + + logger.info( + f"[DIAG] Devis {numero}: statut={statut}, transformable={diagnostic['est_transformable']}" + ) + + return {"success": True, "diagnostic": diagnostic} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[DIAG] Erreur diagnostic: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get("/sage/diagnostic/configuration", dependencies=[Depends(verify_token)]) +def diagnostic_configuration(): + """ + DIAGNOSTIC COMPLET de la configuration Sage + + Teste: + - Quelles méthodes COM sont disponibles + - Quels types de documents sont autorisés + - Quelles permissions l'utilisateur a + - Version de Sage + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + diagnostic = { + "connexion": "OK", + "chemin_base": sage.chemin_base, + "utilisateur": sage.utilisateur, + } + + # Version Sage + try: + version = getattr(sage.cial, "Version", "Inconnue") + diagnostic["version_sage"] = str(version) + except: + diagnostic["version_sage"] = "Non disponible" + + # Test des méthodes disponibles sur IBSCIALApplication3 + methodes_disponibles = [] + methodes_a_tester = [ + "CreateProcess_Document", + "FactoryDocumentVente", + "FactoryArticle", + "CptaApplication", + "BeginTrans", + "CommitTrans", + "RollbackTrans", + ] + + for methode in methodes_a_tester: + try: + if hasattr(sage.cial, methode): + methodes_disponibles.append(methode) + except: + pass + + diagnostic["methodes_cial_disponibles"] = methodes_disponibles + + # Test des types de documents autorisés + types_autorises = [] + types_bloques = [] + + for type_doc in range(6): # 0-5 + try: + # Essayer de créer un process (sans le valider) + process = sage.cial.CreateProcess_Document(type_doc) + if process: + types_autorises.append( + { + "type": type_doc, + "libelle": { + 0: "Devis", + 1: "Bon de livraison", + 2: "Bon de retour", + 3: "Commande", + 4: "Preparation", + 5: "Facture", + }[type_doc], + } + ) + # Ne pas valider, juste tester + del process + except Exception as e: + types_bloques.append( + { + "type": type_doc, + "libelle": { + 0: "Devis", + 1: "Bon de livraison", + 2: "Bon de retour", + 3: "Commande", + 4: "Preparation", + 5: "Facture", + }[type_doc], + "erreur": str(e)[:200], + } + ) + + diagnostic["types_documents_autorises"] = types_autorises + diagnostic["types_documents_bloques"] = types_bloques + + # Test TransformInto() sur un devis test + try: + factory = sage.cial.FactoryDocumentVente + + # Chercher n'importe quel devis + index = 1 + devis_test = None + + while index < 100: + try: + persist = factory.List(index) + if persist is None: + break + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + if getattr(doc, "DO_Type", -1) == 0: # Devis + devis_test = doc + break + + index += 1 + except: + index += 1 + + if devis_test: + # Tester si TransformInto existe + if hasattr(devis_test, "TransformInto"): + diagnostic["transforminto_disponible"] = True + diagnostic["transforminto_test"] = "Methode existe (non testee)" + else: + diagnostic["transforminto_disponible"] = False + diagnostic["transforminto_test"] = ( + "Methode TransformInto() inexistante" + ) + else: + diagnostic["transforminto_disponible"] = ( + "Impossible de tester (aucun devis trouve)" + ) + + except Exception as e: + diagnostic["transforminto_disponible"] = False + diagnostic["transforminto_erreur"] = str(e) + + # Modules Sage actifs + try: + # Tester l'accès aux différentes factories + modules = {} + + try: + sage.cial.FactoryDocumentVente + modules["Ventes"] = "OK" + except: + modules["Ventes"] = "INACCESSIBLE" + + try: + sage.cial.CptaApplication.FactoryClient + modules["Clients"] = "OK" + except: + modules["Clients"] = "INACCESSIBLE" + + try: + sage.cial.FactoryArticle + modules["Articles"] = "OK" + except: + modules["Articles"] = "INACCESSIBLE" + + diagnostic["modules_actifs"] = modules + except Exception as e: + diagnostic["modules_actifs_erreur"] = str(e) + + # Compter documents existants + try: + counts = {} + factory = sage.cial.FactoryDocumentVente + + for type_doc in range(6): + count = 0 + index = 1 + + while index < 1000: + try: + persist = factory.List(index) + if persist is None: + break + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + if getattr(doc, "DO_Type", -1) == type_doc: + count += 1 + + index += 1 + except: + index += 1 + break + + counts[ + { + 0: "Devis", + 1: "Bons_livraison", + 2: "Bons_retour", + 3: "Commandes", + 4: "Preparations", + 5: "Factures", + }[type_doc] + ] = count + + diagnostic["documents_existants"] = counts + except Exception as e: + diagnostic["documents_existants_erreur"] = str(e) + + logger.info("[DIAG] Configuration Sage analysee") + + return {"success": True, "diagnostic": diagnostic} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[DIAG] Erreur diagnostic config: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get("/sage/diagnostic/types-reels", dependencies=[Depends(verify_token)]) +def decouvrir_types_reels(): + """ + DIAGNOSTIC CRITIQUE: Découvre les VRAIS types de documents Sage + + Au lieu de deviner les types (0-5), on va: + 1. Créer manuellement un document de chaque type dans Sage + 2. Les lister ici pour voir leurs vrais numéros de type + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + factory = sage.cial.FactoryDocumentVente + + # Parcourir TOUS les documents + documents_par_type = {} + index = 1 + max_docs = 500 # Limiter pour ne pas bloquer + + logger.info("[DIAG] Scan de tous les documents...") + + while index < max_docs: + try: + persist = factory.List(index) + if persist is None: + break + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # Récupérer le type ET le sous-type + type_doc = getattr(doc, "DO_Type", -1) + piece = getattr(doc, "DO_Piece", "") + statut = getattr(doc, "DO_Statut", -1) + + # Essayer de récupérer le domaine (vente/achat) + domaine = "Inconnu" + try: + domaine_val = getattr(doc, "DO_Domaine", -1) + domaine = {0: "Vente", 1: "Achat"}.get( + domaine_val, f"Code {domaine_val}" + ) + except: + pass + + # Récupérer la catégorie + categorie = "Inconnue" + try: + cat_val = getattr(doc, "DO_Categorie", -1) + categorie = str(cat_val) + except: + pass + + # Grouper par type + if type_doc not in documents_par_type: + documents_par_type[type_doc] = { + "count": 0, + "exemples": [], + "domaine": domaine, + "categorie": categorie, + } + + documents_par_type[type_doc]["count"] += 1 + + # Garder quelques exemples + if len(documents_par_type[type_doc]["exemples"]) < 3: + documents_par_type[type_doc]["exemples"].append( + { + "numero": piece, + "statut": statut, + "domaine": domaine, + "categorie": categorie, + } + ) + + index += 1 + + except Exception as e: + logger.debug(f"Erreur index {index}: {e}") + index += 1 + + # Formater le résultat + types_trouves = [] + + for type_num, infos in sorted(documents_par_type.items()): + types_trouves.append( + { + "type_code": type_num, + "nombre_documents": infos["count"], + "domaine": infos["domaine"], + "categorie": infos["categorie"], + "exemples": infos["exemples"], + "suggestion_libelle": _deviner_libelle_type( + type_num, infos["exemples"] + ), + } + ) + + logger.info( + f"[DIAG] {len(types_trouves)} types de documents distincts trouves" + ) + + return { + "success": True, + "types_documents_reels": types_trouves, + "instructions": ( + "Pour identifier les types corrects:\n" + "1. Creez manuellement dans Sage: 1 Bon de commande, 1 BL, 1 Facture\n" + "2. Appelez de nouveau cet endpoint\n" + "3. Les nouveaux types apparaitront avec leurs numeros corrects" + ), + "total_documents_scannes": index - 1, + } + + except Exception as e: + logger.error(f"[DIAG] Erreur decouverte types: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +def _deviner_libelle_type(type_num, exemples): + """Devine le libellé d'un type basé sur les numéros de pièce""" + if not exemples: + return "Type inconnu" + + # Analyser les préfixes des numéros + prefixes = [ex["numero"][:2] for ex in exemples if ex["numero"]] + prefix_commun = max(set(prefixes), key=prefixes.count) if prefixes else "" + + # Deviner selon le type_num et les préfixes + suggestions = { + 0: "Devis (DE)", + 1: "Bon de livraison (BL)", + 2: "Bon de retour (BR)", + 3: "Bon de commande (BC)", + 4: "Preparation de livraison (PL)", + 5: "Facture (FA)", + 6: "Facture d'avoir (AV)", + 7: "Bon d'avoir financier (BA)", + } + + libelle_base = suggestions.get(type_num, f"Type {type_num}") + + if prefix_commun: + libelle_base += f" - Detecte: prefix '{prefix_commun}'" + + return libelle_base + + +@app.post("/sage/test-creation-par-type", dependencies=[Depends(verify_token)]) +def tester_creation_par_type(type_doc: int = Query(..., ge=0, le=20)): + """ + TEST: Essaie de créer un document d'un type spécifique + + Permet de tester tous les types possibles (0-20) pour trouver + lesquels fonctionnent sur votre installation + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + logger.info(f"[TEST] Tentative creation type {type_doc}...") + + try: + # Essayer de créer un process + process = sage.cial.CreateProcess_Document(type_doc) + + if not process: + return { + "success": False, + "type": type_doc, + "resultat": "Process NULL retourne", + } + + # Si on arrive ici, le type est valide ! + doc = process.Document + + try: + doc = win32com.client.CastTo(doc, "IBODocumentVente3") + except: + pass + + # Récupérer les infos du document créé + type_reel = getattr(doc, "DO_Type", -1) + domaine = getattr(doc, "DO_Domaine", -1) + + # NE PAS VALIDER le document (pas de Write/Process) + # On veut juste savoir si la création est possible + + del process + del doc + + return { + "success": True, + "type_demande": type_doc, + "type_reel_doc": type_reel, + "domaine": {0: "Vente", 1: "Achat"}.get(domaine, domaine), + "resultat": "CREATION POSSIBLE", + "note": "Document non valide (test uniquement)", + } + + except Exception as e: + return { + "success": False, + "type": type_doc, + "erreur": str(e), + "resultat": "CREATION IMPOSSIBLE", + } + + except Exception as e: + logger.error(f"[TEST] Erreur test type {type_doc}: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/sage/diagnostic/facture-requirements", dependencies=[Depends(verify_token)]) +def diagnostiquer_exigences_facture(): + """ + DIAGNOSTIC: Découvre les champs obligatoires pour créer une facture + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + # Créer un process facture de test + process = sage.cial.CreateProcess_Document(60) + doc_test = process.Document + + try: + doc_test = win32com.client.CastTo(doc_test, "IBODocumentVente3") + except: + pass + + # Tester tous les champs potentiellement obligatoires + champs_a_tester = [ + "DO_ModeRegl", + "DO_CondRegl", + "DO_CodeJournal", + "DO_Souche", + "DO_TypeCalcul", + "DO_CodeTaxe1", + "CT_Num", + "DO_Date", + "DO_Statut", + ] + + resultats = {} + + for champ in champs_a_tester: + try: + valeur = getattr(doc_test, champ, None) + resultats[champ] = { + "valeur_defaut": str(valeur) if valeur is not None else "None", + "accessible": True, + } + except Exception as e: + resultats[champ] = { + "valeur_defaut": "N/A", + "accessible": False, + "erreur": str(e)[:100], + } + + # Ne pas valider le document de test + del process + del doc_test + + return { + "success": True, + "champs_facture": resultats, + "conseil": "Les champs avec valeur_defaut=None ou 0 sont souvent obligatoires", + } + + except Exception as e: + logger.error(f"[DIAG] Erreur: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/sage/test/creer-facture-vide", dependencies=[Depends(verify_token)]) +def tester_creation_facture_vide(): + """ + 🧪 TEST: Crée une facture vide pour identifier les champs obligatoires + + Ce test permet de découvrir EXACTEMENT quels champs Sage exige + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + logger.info("[TEST] Creation facture test...") + + # 1. Créer le process + process = sage.cial.CreateProcess_Document(60) + doc = process.Document + + try: + doc = win32com.client.CastTo(doc, "IBODocumentVente3") + except: + pass + + # 2. Définir UNIQUEMENT les champs absolument critiques + import pywintypes + + # Date (obligatoire) + doc.DO_Date = pywintypes.Time(datetime.now()) + + # Client (obligatoire) - Utiliser le premier client disponible + factory_client = sage.cial.CptaApplication.FactoryClient + persist_client = factory_client.List(1) + + if persist_client: + client = sage._cast_client(persist_client) + if client: + doc.SetDefaultClient(client) + client_code = getattr(client, "CT_Num", "?") + logger.info(f"[TEST] Client test: {client_code}") + + # 3. Écrire sans Process() pour voir les valeurs par défaut + doc.Write() + doc.Read() + + # 4. Analyser tous les champs + champs_analyse = {} + + for attr in dir(doc): + if attr.startswith("DO_") or attr.startswith("CT_"): + try: + valeur = getattr(doc, attr, None) + if valeur is not None: + champs_analyse[attr] = { + "valeur": str(valeur), + "type": type(valeur).__name__, + } + except: + pass + + logger.info(f"[TEST] {len(champs_analyse)} champs analyses") + + # 5. Tester Process() pour voir l'erreur exacte + erreur_process = None + try: + process.Process() + logger.info("[TEST] Process() reussi (inattendu!)") + except Exception as e: + erreur_process = str(e) + logger.info(f"[TEST] Process() echoue comme prevu: {e}") + + # Ne pas commit - c'est juste un test + try: + sage.cial.CptaApplication.RollbackTrans() + except: + pass + + return { + "success": True, + "champs_definis": champs_analyse, + "erreur_process": erreur_process, + "conseil": "Les champs manquants dans l'erreur sont probablement obligatoires", + } + + except Exception as e: + logger.error(f"[TEST] Erreur: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get("/sage/config/parametres-facture", dependencies=[Depends(verify_token)]) +def verifier_parametres_facture(): + """ + 🔍 Vérifie les paramètres Sage pour la création de factures + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + parametres = {} + + # Paramètres société + try: + param_societe = sage.cial.CptaApplication.ParametreSociete + + parametres["societe"] = { + "journal_vente_defaut": getattr( + param_societe, "P_CodeJournalVte", "N/A" + ), + "mode_reglement_defaut": getattr( + param_societe, "P_ModeRegl", "N/A" + ), + "souche_facture": getattr(param_societe, "P_SoucheFacture", "N/A"), + } + except Exception as e: + parametres["erreur_societe"] = str(e) + + # Tester un client existant + try: + factory_client = sage.cial.CptaApplication.FactoryClient + persist = factory_client.List(1) + + if persist: + client = sage._cast_client(persist) + if client: + parametres["exemple_client"] = { + "code": getattr(client, "CT_Num", "?"), + "mode_reglement": getattr(client, "CT_ModeRegl", "N/A"), + "conditions_reglement": getattr( + client, "CT_CondRegl", "N/A" + ), + } + except Exception as e: + parametres["erreur_client"] = str(e) + + # Journaux disponibles + try: + factory_journal = sage.cial.CptaApplication.FactoryJournal + journaux = [] + + index = 1 + while index <= 20: # Max 20 journaux + try: + persist_journal = factory_journal.List(index) + if persist_journal is None: + break + + # Cast en journal + journal = win32com.client.CastTo(persist_journal, "IBOJournal3") + journal.Read() + + journaux.append( + { + "code": getattr(journal, "JO_Num", "?"), + "intitule": getattr(journal, "JO_Intitule", "?"), + "type": getattr(journal, "JO_Type", "?"), + } + ) + + index += 1 + except: + index += 1 + break + + parametres["journaux_disponibles"] = journaux + + except Exception as e: + parametres["erreur_journaux"] = str(e) + + return { + "success": True, + "parametres": parametres, + "conseil": "Utilisez ces valeurs pour remplir les champs obligatoires des factures", + } + + except Exception as e: + logger.error(f"Erreur verification config: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get("/sage/diagnostic/statuts-globaux", dependencies=[Depends(verify_token)]) +def diagnostiquer_statuts_globaux(): + """ + 📊 MATRICE COMPLÈTE DES STATUTS SAGE + + Retourne pour CHAQUE type de document : + - Tous les statuts possibles avec leurs descriptions + - Les statuts requis pour transformation + - Les changements de statuts après transformation + - Les restrictions de changement de statut + + Cette route analyse la base Sage pour découvrir les règles réelles + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + factory = sage.cial.FactoryDocumentVente + + # Définition des types de documents + types_documents = { + 0: "Devis", + 10: "Bon de commande", + 20: "Préparation", + 30: "Bon de livraison", + 40: "Bon de retour", + 50: "Bon d'avoir", + 60: "Facture", + } + + # Descriptions standard des statuts Sage + descriptions_statuts = { + 0: "Brouillon", + 1: "Soumis/En attente", + 2: "Accepté/Validé", + 3: "Réalisé partiellement", + 4: "Réalisé totalement", + 5: "Transformé", + 6: "Annulé", + } + + matrice_complete = {} + + logger.info( + "[DIAG] 🔍 Analyse des statuts pour tous les types de documents..." + ) + + # Pour chaque type de document + for type_doc, libelle_type in types_documents.items(): + logger.info(f"[DIAG] Analyse type {type_doc} ({libelle_type})...") + + analyse_type = { + "type": type_doc, + "libelle": libelle_type, + "statuts_observes": {}, + "exemples_par_statut": {}, + "nb_documents_total": 0, + } + + # Scanner tous les documents de ce type + index = 1 + max_scan = 1000 + + while index < max_scan: + try: + persist = factory.List(index) + if persist is None: + break + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + doc_type = getattr(doc, "DO_Type", -1) + + # Filtrer sur le type qu'on analyse + if doc_type != type_doc: + index += 1 + continue + + analyse_type["nb_documents_total"] += 1 + + # Récupérer le statut + statut = getattr(doc, "DO_Statut", -1) + + # Compter les statuts observés + if statut not in analyse_type["statuts_observes"]: + analyse_type["statuts_observes"][statut] = { + "count": 0, + "description": descriptions_statuts.get( + statut, f"Statut {statut}" + ), + "exemples": [], + } + + analyse_type["statuts_observes"][statut]["count"] += 1 + + # Garder quelques exemples + if ( + len(analyse_type["statuts_observes"][statut]["exemples"]) + < 3 + ): + numero = getattr(doc, "DO_Piece", "") + date = str(getattr(doc, "DO_Date", "")) + + analyse_type["statuts_observes"][statut]["exemples"].append( + { + "numero": numero, + "date": date, + "total_ttc": float( + getattr(doc, "DO_TotalTTC", 0.0) + ), + } + ) + + index += 1 + + except Exception as e: + index += 1 + continue + + # Trier les statuts par nombre d'occurrences + analyse_type["statuts_par_frequence"] = sorted( + [ + { + "statut": s, + "description": info["description"], + "count": info["count"], + "pourcentage": ( + round( + info["count"] + / analyse_type["nb_documents_total"] + * 100, + 1, + ) + if analyse_type["nb_documents_total"] > 0 + else 0 + ), + } + for s, info in analyse_type["statuts_observes"].items() + ], + key=lambda x: x["count"], + reverse=True, + ) + + matrice_complete[type_doc] = analyse_type + + logger.info( + f"[DIAG] ✅ Type {type_doc}: {analyse_type['nb_documents_total']} docs, " + f"{len(analyse_type['statuts_observes'])} statuts différents" + ) + + # RÈGLES DE TRANSFORMATION + regles_transformation = { + "transformations_valides": [ + { + "source_type": 0, + "source_libelle": "Devis", + "cible_type": 10, + "cible_libelle": "Bon de commande", + "statut_source_requis": [2], + "statut_source_requis_description": ["Accepté/Validé"], + "statut_source_apres": 5, + "statut_source_apres_description": "Transformé", + "statut_cible_initial": 2, + "statut_cible_initial_description": "Accepté/Validé", + }, + { + "source_type": 10, + "source_libelle": "Bon de commande", + "cible_type": 30, + "cible_libelle": "Bon de livraison", + "statut_source_requis": [2], + "statut_source_requis_description": ["Accepté/Validé"], + "statut_source_apres": 5, + "statut_source_apres_description": "Transformé", + "statut_cible_initial": 2, + "statut_cible_initial_description": "Accepté/Validé", + }, + { + "source_type": 10, + "source_libelle": "Bon de commande", + "cible_type": 60, + "cible_libelle": "Facture", + "statut_source_requis": [2], + "statut_source_requis_description": ["Accepté/Validé"], + "statut_source_apres": 5, + "statut_source_apres_description": "Transformé", + "statut_cible_initial": 2, + "statut_cible_initial_description": "Accepté/Validé", + }, + { + "source_type": 30, + "source_libelle": "Bon de livraison", + "cible_type": 60, + "cible_libelle": "Facture", + "statut_source_requis": [2], + "statut_source_requis_description": ["Accepté/Validé"], + "statut_source_apres": 5, + "statut_source_apres_description": "Transformé", + "statut_cible_initial": 2, + "statut_cible_initial_description": "Accepté/Validé", + }, + { + "source_type": 0, + "source_libelle": "Devis", + "cible_type": 60, + "cible_libelle": "Facture", + "statut_source_requis": [2], + "statut_source_requis_description": ["Accepté/Validé"], + "statut_source_apres": 5, + "statut_source_apres_description": "Transformé", + "statut_cible_initial": 2, + "statut_cible_initial_description": "Accepté/Validé", + }, + ], + "statuts_bloquants_pour_transformation": [ + { + "statut": 5, + "description": "Transformé", + "raison": "Le document a déjà été transformé", + }, + { + "statut": 6, + "description": "Annulé", + "raison": "Le document est annulé", + }, + { + "statut": 3, + "description": "Réalisé partiellement", + "raison": "Un document cible existe probablement déjà (transformation partielle effectuée)", + }, + { + "statut": 4, + "description": "Réalisé totalement", + "raison": "Le document a été entièrement réalisé (transformation déjà effectuée)", + }, + ], + "changements_statut_autorises": { + "0_Brouillon": { + "vers": [2, 6], + "descriptions": ["Accepté/Validé", "Annulé"], + "note": "Un brouillon peut être accepté ou annulé", + }, + "2_Accepte": { + "vers": [5, 6], + "descriptions": ["Transformé", "Annulé"], + "note": "Un document accepté peut être transformé ou annulé", + }, + "5_Transforme": { + "vers": [], + "descriptions": [], + "note": "Un document transformé ne peut plus changer de statut", + }, + "6_Annule": { + "vers": [], + "descriptions": [], + "note": "Un document annulé ne peut plus changer de statut", + }, + }, + } + + return { + "success": True, + "matrice_statuts_par_type": matrice_complete, + "regles_transformation": regles_transformation, + "legende_statuts": descriptions_statuts, + "types_documents": types_documents, + "date_analyse": datetime.now().isoformat(), + "note": "Cette matrice est construite à partir des documents réels dans votre base Sage", + } + + except Exception as e: + logger.error(f"[DIAG] Erreur diagnostic global: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get( + "/sage/diagnostic/statuts-permis/{numero}", dependencies=[Depends(verify_token)] +) +def diagnostiquer_statuts_permis(numero: str): + """ + 🔍 DIAGNOSTIC CRITIQUE: Découvre TOUS les statuts possibles pour un document + + Teste tous les statuts de 0 à 10 pour identifier lesquels sont valides + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + factory = sage.cial.FactoryDocumentVente + + # Chercher le document (tous types confondus) + persist = None + type_doc_trouve = None + + # Essayer ReadPiece pour différents types + for type_test in range(7): # 0-6 + try: + persist_test = factory.ReadPiece(type_test, numero) + if persist_test: + persist = persist_test + type_doc_trouve = type_test + logger.info( + f"[DIAG] Document {numero} trouvé avec ReadPiece(type={type_test})" + ) + break + except: + continue + + # Si pas trouvé, chercher dans List() + if not persist: + index = 1 + while index < 10000: + try: + persist_test = factory.List(index) + if persist_test is None: + break + + doc_test = win32com.client.CastTo( + persist_test, "IBODocumentVente3" + ) + doc_test.Read() + + if getattr(doc_test, "DO_Piece", "") == numero: + persist = persist_test + type_doc_trouve = getattr(doc_test, "DO_Type", -1) + logger.info( + f"[DIAG] Document {numero} trouvé dans List() à l'index {index}" + ) + break + + index += 1 + except: + index += 1 + + if not persist: + raise HTTPException(404, f"Document {numero} introuvable") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # Infos du document + statut_actuel = getattr(doc, "DO_Statut", -1) + type_actuel = getattr(doc, "DO_Type", -1) + + diagnostic = { + "numero": numero, + "type_document": type_actuel, + "type_libelle": { + 0: "Devis", + 10: "Bon de commande", + 20: "Préparation", + 30: "Bon de livraison", + 40: "Bon de retour", + 50: "Bon d'avoir", + 60: "Facture", + }.get(type_actuel, f"Type {type_actuel}"), + "statut_actuel": statut_actuel, + "statut_actuel_libelle": { + 0: "Brouillon", + 1: "Soumis/En attente", + 2: "Accepté/Validé", + 3: "Réalisé partiellement", + 4: "Réalisé totalement", + 5: "Transformé", + 6: "Annulé", + }.get(statut_actuel, f"Statut {statut_actuel}"), + "tests_statuts": [], + } + + # Tester tous les statuts de 0 à 10 + logger.info(f"[DIAG] Test des statuts pour {numero}...") + + for statut_test in range(11): + resultat_test = { + "statut": statut_test, + "libelle": { + 0: "Brouillon", + 1: "Soumis/En attente", + 2: "Accepté/Validé", + 3: "Réalisé partiellement", + 4: "Réalisé totalement", + 5: "Transformé", + 6: "Annulé", + 7: "Statut 7", + 8: "Statut 8", + 9: "Statut 9", + 10: "Statut 10", + }.get(statut_test, f"Statut {statut_test}"), + "autorise": False, + "erreur": None, + "est_statut_actuel": (statut_test == statut_actuel), + } + + # Si c'est le statut actuel, on sait qu'il est valide + if statut_test == statut_actuel: + resultat_test["autorise"] = True + resultat_test["note"] = "Statut actuel du document" + else: + # Tester le changement de statut + try: + # Relire le document + doc.Read() + + # Essayer de changer le statut + doc.DO_Statut = statut_test + + # Essayer d'écrire + doc.Write() + + # Si on arrive ici, le statut est valide ! + resultat_test["autorise"] = True + resultat_test["note"] = "Changement de statut réussi" + + logger.info(f"[DIAG] ✅ Statut {statut_test} AUTORISÉ") + + # Restaurer le statut d'origine immédiatement + doc.Read() + doc.DO_Statut = statut_actuel + doc.Write() + + except Exception as e: + erreur_str = str(e) + resultat_test["autorise"] = False + resultat_test["erreur"] = erreur_str + + logger.debug( + f"[DIAG] ❌ Statut {statut_test} REFUSÉ: {erreur_str[:100]}" + ) + + # Restaurer en cas d'erreur + try: + doc.Read() + except: + pass + + diagnostic["tests_statuts"].append(resultat_test) + + # Résumé + statuts_autorises = [ + t["statut"] for t in diagnostic["tests_statuts"] if t["autorise"] + ] + statuts_refuses = [ + t["statut"] for t in diagnostic["tests_statuts"] if not t["autorise"] + ] + + diagnostic["resume"] = { + "nb_statuts_autorises": len(statuts_autorises), + "statuts_autorises": statuts_autorises, + "statuts_autorises_libelles": [ + t["libelle"] for t in diagnostic["tests_statuts"] if t["autorise"] + ], + "nb_statuts_refuses": len(statuts_refuses), + "statuts_refuses": statuts_refuses, + } + + # Recommandations + recommendations = [] + + if 2 in statuts_autorises and statut_actuel == 0: + recommendations.append( + "✅ Vous pouvez passer ce document de 'Brouillon' (0) à 'Accepté' (2)" + ) + + if 5 in statuts_autorises: + recommendations.append( + "✅ Le statut 'Transformé' (5) est disponible - utilisé après transformation" + ) + + if 6 in statuts_autorises: + recommendations.append("✅ Vous pouvez annuler ce document (statut 6)") + + if not any(s in statuts_autorises for s in [2, 3, 4]): + recommendations.append( + "⚠️ Aucun statut de validation (2/3/4) n'est disponible - " + "le document a peut-être déjà été traité" + ) + + diagnostic["recommendations"] = recommendations + + logger.info( + f"[DIAG] Statuts autorisés pour {numero}: " + f"{statuts_autorises} / Refusés: {statuts_refuses}" + ) + + return {"success": True, "diagnostic": diagnostic} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[DIAG] Erreur diagnostic statuts: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get( + "/sage/diagnostic/erreur-transformation/{numero}", + dependencies=[Depends(verify_token)], +) +def diagnostiquer_erreur_transformation( + numero: str, type_source: int = Query(...), type_cible: int = Query(...) +): + """ + 🔍 DIAGNOSTIC AVANCÉ: Analyse pourquoi une transformation échoue + + Vérifie: + - Statut du document source + - Statuts autorisés + - Lignes du document + - Client associé + - Champs obligatoires manquants + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + factory = sage.cial.FactoryDocumentVente + + # Lire le document source + persist = factory.ReadPiece(type_source, numero) + + if not persist: + persist = sage._find_document_in_list(numero, type_source) + + if not persist: + raise HTTPException( + 404, f"Document {numero} (type {type_source}) introuvable" + ) + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + diagnostic = { + "numero": numero, + "type_source": type_source, + "type_cible": type_cible, + "problemes_detectes": [], + "avertissements": [], + "suggestions": [], + } + + # 1. Vérifier le statut + statut_actuel = getattr(doc, "DO_Statut", -1) + diagnostic["statut_actuel"] = statut_actuel + + if statut_actuel == 5: + diagnostic["problemes_detectes"].append( + { + "severite": "BLOQUANT", + "champ": "DO_Statut", + "valeur": 5, + "message": "Document déjà transformé (statut=5)", + } + ) + + elif statut_actuel == 6: + diagnostic["problemes_detectes"].append( + { + "severite": "BLOQUANT", + "champ": "DO_Statut", + "valeur": 6, + "message": "Document annulé (statut=6)", + } + ) + + elif statut_actuel in [3, 4]: + diagnostic["avertissements"].append( + { + "severite": "ATTENTION", + "champ": "DO_Statut", + "valeur": statut_actuel, + "message": f"Document déjà réalisé (statut={statut_actuel}). " + f"Un document cible existe peut-être déjà.", + } + ) + + elif statut_actuel == 0: + diagnostic["suggestions"].append( + "Le document est en 'Brouillon' (statut=0). " + "Le système le passera automatiquement à 'Accepté' (statut=2) avant transformation." + ) + + # 2. Vérifier le client + client_code = "" + try: + client_obj = getattr(doc, "Client", None) + if client_obj: + client_obj.Read() + client_code = getattr(client_obj, "CT_Num", "").strip() + except: + pass + + if not client_code: + diagnostic["problemes_detectes"].append( + { + "severite": "BLOQUANT", + "champ": "CT_Num", + "valeur": None, + "message": "Aucun client associé au document", + } + ) + else: + diagnostic["client_code"] = client_code + + # 3. Vérifier les lignes + try: + factory_lignes = getattr(doc, "FactoryDocumentLigne", None) or getattr( + doc, "FactoryDocumentVenteLigne", None + ) + + nb_lignes = 0 + lignes_problemes = [] + + if factory_lignes: + index = 1 + while index <= 100: + try: + ligne_p = factory_lignes.List(index) + if ligne_p is None: + break + + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne.Read() + + nb_lignes += 1 + + # Vérifier article + article_ref = "" + try: + article_ref = getattr(ligne, "AR_Ref", "").strip() + if not article_ref: + article_obj = getattr(ligne, "Article", None) + if article_obj: + article_obj.Read() + article_ref = getattr( + article_obj, "AR_Ref", "" + ).strip() + except: + pass + + if not article_ref: + lignes_problemes.append( + { + "ligne": index, + "probleme": "Aucune référence article", + } + ) + + # Vérifier prix + prix = float(getattr(ligne, "DL_PrixUnitaire", 0.0)) + if prix == 0: + lignes_problemes.append( + {"ligne": index, "probleme": "Prix unitaire = 0"} + ) + + index += 1 + except: + break + + diagnostic["nb_lignes"] = nb_lignes + + if nb_lignes == 0: + diagnostic["problemes_detectes"].append( + { + "severite": "BLOQUANT", + "champ": "Lignes", + "valeur": 0, + "message": "Document vide (aucune ligne)", + } + ) + + if lignes_problemes: + diagnostic["avertissements"].append( + { + "severite": "ATTENTION", + "champ": "Lignes", + "message": f"{len(lignes_problemes)} ligne(s) avec des problèmes", + "details": lignes_problemes, + } + ) + + except Exception as e: + diagnostic["avertissements"].append( + { + "severite": "ERREUR", + "champ": "Lignes", + "message": f"Impossible de lire les lignes: {e}", + } + ) + + # 4. Vérifier les totaux + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) + total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) + + diagnostic["totaux"] = {"total_ht": total_ht, "total_ttc": total_ttc} + + if total_ht == 0 and total_ttc == 0: + diagnostic["avertissements"].append( + { + "severite": "ATTENTION", + "champ": "Totaux", + "message": "Tous les totaux sont à 0", + } + ) + + # 5. Vérifier si la transformation est autorisée + transformations_valides = {(0, 10), (10, 30), (10, 60), (30, 60), (0, 60)} + + if (type_source, type_cible) not in transformations_valides: + diagnostic["problemes_detectes"].append( + { + "severite": "BLOQUANT", + "champ": "Transformation", + "message": f"Transformation {type_source} → {type_cible} non autorisée. " + f"Transformations valides: {transformations_valides}", + } + ) + + # Résumé + nb_bloquants = sum( + 1 + for p in diagnostic["problemes_detectes"] + if p.get("severite") == "BLOQUANT" + ) + nb_avertissements = len(diagnostic["avertissements"]) + + diagnostic["resume"] = { + "peut_transformer": nb_bloquants == 0, + "nb_problemes_bloquants": nb_bloquants, + "nb_avertissements": nb_avertissements, + } + + if nb_bloquants == 0: + diagnostic["suggestions"].append( + "✅ Aucun problème bloquant détecté. La transformation devrait fonctionner." + ) + else: + diagnostic["suggestions"].append( + f"❌ {nb_bloquants} problème(s) bloquant(s) doivent être résolus avant la transformation." + ) + + return {"success": True, "diagnostic": diagnostic} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[DIAG] Erreur diagnostic transformation: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - PROSPECTS +# ===================================================== +@app.post("/sage/prospects/list", dependencies=[Depends(verify_token)]) +def prospects_list(req: FiltreRequest): + """📋 Liste tous les prospects (CT_Type=0 AND CT_Prospect=1)""" + try: + prospects = sage.lister_tous_prospects(req.filtre) + return {"success": True, "data": prospects} + except Exception as e: + logger.error(f"Erreur liste prospects: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/prospects/get", dependencies=[Depends(verify_token)]) +def prospect_get(req: CodeRequest): + """📄 Lecture d'un prospect par code""" + try: + prospect = sage.lire_prospect(req.code) + if not prospect: + raise HTTPException(404, f"Prospect {req.code} non trouvé") + return {"success": True, "data": prospect} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture prospect: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - FOURNISSEURS +# ===================================================== +@app.post("/sage/fournisseurs/list", dependencies=[Depends(verify_token)]) +def fournisseurs_list(req: FiltreRequest): + """ + ⚡ Liste rapide des fournisseurs depuis le CACHE + + ✅ Utilise le cache mémoire pour une réponse instantanée + 🔄 Cache actualisé automatiquement toutes les 15 minutes + """ + try: + # ✅ Utiliser le cache au lieu de la lecture directe + fournisseurs = sage.lister_tous_fournisseurs_cache(req.filtre) + + 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} + + 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): + """ + ✏️ Modification d'un fournisseur dans Sage + """ + 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)) + except Exception as e: + 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): + """ + ✅ NOUVEAU : Lecture d'un fournisseur par code + """ + try: + fournisseur = sage.lire_fournisseur(req.code) + if not fournisseur: + raise HTTPException(404, f"Fournisseur {req.code} non trouvé") + return {"success": True, "data": fournisseur} + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture fournisseur: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - AVOIRS +# ===================================================== +@app.post("/sage/avoirs/list", dependencies=[Depends(verify_token)]) +def avoirs_list( + limit: int = Query(100, description="Nombre max d'avoirs"), + statut: Optional[int] = Query(None, description="Filtrer par statut"), + filtre: str = Query("", description="Filtre texte"), +): + """ + 📋 Liste rapide des avoirs depuis le CACHE (avec lignes) + + ⚡ ULTRA-RAPIDE: Utilise le cache mémoire + ✅ LIGNES INCLUSES: Contrairement aux anciennes méthodes + 💡 Pour forcer une relecture depuis Sage, utiliser /sage/avoirs/get + """ + try: + # ✅ Récupération depuis le cache (instantané) + avoirs = sage.lister_tous_avoirs_cache(filtre) + + # Filtrer par statut si demandé + if statut is not None: + avoirs = [a for a in avoirs if a.get("statut") == statut] + + # Limiter le nombre de résultats + avoirs = avoirs[:limit] + + logger.info(f"✅ {len(avoirs)} avoirs retournés depuis le cache") + + return {"success": True, "data": avoirs} + + except Exception as e: + logger.error(f"❌ Erreur liste avoirs: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/sage/avoirs/get", dependencies=[Depends(verify_token)]) +def avoir_get(req: CodeRequest): + """ + 📄 Lecture d'un avoir (depuis cache en priorité) + + ⚡ Essaie d'abord le cache (instantané) + 🔄 Si introuvable, force une relecture depuis Sage + """ + try: + # ✅ Essayer le cache d'abord + avoir = sage.lire_avoir_cache(req.code) + + if avoir: + logger.info(f"✅ Avoir {req.code} retourné depuis le cache") + return {"success": True, "data": avoir, "source": "cache"} + + # ❌ Pas dans le cache → Lecture directe depuis Sage + logger.info(f"⚠️ Avoir {req.code} absent du cache, lecture depuis Sage...") + avoir = sage.lire_avoir(req.code) + + if not avoir: + raise HTTPException(404, f"Avoir {req.code} non trouvé") + + return {"success": True, "data": avoir, "source": "sage"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture avoir: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - LIVRAISONS +# ===================================================== +@app.post("/sage/livraisons/list", dependencies=[Depends(verify_token)]) +def livraisons_list( + limit: int = Query(100, description="Nombre max de livraisons"), + statut: Optional[int] = Query(None, description="Filtrer par statut"), + filtre: str = Query("", description="Filtre texte"), +): + """ + 📋 Liste rapide des livraisons depuis le CACHE (avec lignes) + + ⚡ ULTRA-RAPIDE: Utilise le cache mémoire + ✅ LIGNES INCLUSES: Contrairement aux anciennes méthodes + 💡 Pour forcer une relecture depuis Sage, utiliser /sage/livraisons/get + """ + try: + # ✅ Récupération depuis le cache (instantané) + livraisons = sage.lister_toutes_livraisons_cache(filtre) + + # Filtrer par statut si demandé + if statut is not None: + livraisons = [l for l in livraisons if l.get("statut") == statut] + + # Limiter le nombre de résultats + livraisons = livraisons[:limit] + + logger.info(f"✅ {len(livraisons)} livraisons retournées depuis le cache") + + return {"success": True, "data": livraisons} + + except Exception as e: + logger.error(f"❌ Erreur liste livraisons: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/sage/livraisons/get", dependencies=[Depends(verify_token)]) +def livraison_get(req: CodeRequest): + """ + 📄 Lecture d'une livraison (depuis cache en priorité) + + ⚡ Essaie d'abord le cache (instantané) + 🔄 Si introuvable, force une relecture depuis Sage + """ + try: + # ✅ Essayer le cache d'abord + livraison = sage.lire_livraison_cache(req.code) + + if livraison: + logger.info(f"✅ Livraison {req.code} retournée depuis le cache") + return {"success": True, "data": livraison, "source": "cache"} + + # ❌ Pas dans le cache → Lecture directe depuis Sage + logger.info(f"⚠️ Livraison {req.code} absente du cache, lecture depuis Sage...") + livraison = sage.lire_livraison(req.code) + + if not livraison: + raise HTTPException(404, f"Livraison {req.code} non trouvée") + + return {"success": True, "data": livraison, "source": "sage"} + + except HTTPException: + raise + except Exception as e: + 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)) + + +@app.post("/sage/livraisons/create", dependencies=[Depends(verify_token)]) +def creer_livraison_endpoint(req: LivraisonCreateGatewayRequest): + """ + ➕ Création d'une livraison (Bon de livraison) dans Sage + """ + try: + # Vérifier que le client existe + client = sage.lire_client(req.client_id) + 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": ""}, + "date_livraison": req.date_livraison or date.today(), + "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)) + except Exception as e: + logger.error(f"Erreur technique création livraison: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/livraisons/update", dependencies=[Depends(verify_token)]) +def modifier_livraison_endpoint(req: LivraisonUpdateGatewayRequest): + """ + ✏️ Modification d'une livraison dans Sage + """ + try: + resultat = sage.modifier_livraison(req.numero, req.livraison_data) + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"Erreur métier modification livraison: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + 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): + """ + ➕ Création d'un avoir (Bon d'avoir) dans Sage + """ + 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 + avoir_data = { + "client": {"code": req.client_id, "intitule": ""}, + "date_avoir": req.date_avoir or date.today(), + "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)) + except Exception as e: + logger.error(f"Erreur technique création avoir: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/avoirs/update", dependencies=[Depends(verify_token)]) +def modifier_avoir_endpoint(req: AvoirUpdateGatewayRequest): + """ + ✏️ Modification d'un avoir dans Sage + """ + 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.) + """ + 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)) + + +@app.post("/sage/articles/create", dependencies=[Depends(verify_token)]) +def create_article_endpoint(req: ArticleCreateRequest): + """ + ➕ Création d'un article dans Sage + + **Usage typique**: Créer un article avec stock pour éviter l'erreur 2881 + """ + try: + resultat = sage.creer_article(req.dict()) + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"Erreur métier création article: {e}") + raise HTTPException(400, str(e)) + + except Exception as e: + logger.error(f"Erreur technique création article: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/articles/update", dependencies=[Depends(verify_token)]) +def modifier_article_endpoint(req: ArticleUpdateGatewayRequest): + """ + ✏️ Modification d'un article dans Sage + + **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 + + Example: +```json + { + "reference": "ART001", + "article_data": { + "stock_reel": 100.0 + } + } +``` + """ + try: + resultat = sage.modifier_article(req.reference, req.article_data) + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"Erreur métier modification article: {e}") + raise HTTPException(404, str(e)) + + except Exception as e: + logger.error(f"Erreur technique modification article: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/sage/documents/generate-pdf", dependencies=[Depends(verify_token)]) +def generer_pdf_document(req: PDFGenerationRequest): + """ + 📄 Génération PDF d'un document (endpoint généralisé) + + **Supporte tous les types de documents Sage:** + - Devis (0) + - Bons de commande (10) + - Bons de livraison (30) + - Factures (60) + - Avoirs (50) + + **Process:** + 1. Charge le document depuis Sage + 2. Génère le PDF via l'état Sage correspondant + 3. Retourne le PDF en base64 + + Args: + req: Requête contenant doc_id et type_doc + + Returns: + { + "success": true, + "data": { + "pdf_base64": "JVBERi0xLjQK...", + "taille_octets": 12345, + "type_doc": 0, + "numero": "DE00001" + } + } + """ + try: + logger.info(f"📄 Génération PDF: {req.doc_id} (type={req.type_doc})") + + # 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") + + 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, + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/sage/admin/clean-locks") +def nettoyer_verrous_sage(): + """ + 🧹 Nettoyage des verrous Sage (cbRegFile) + + ⚠️ À utiliser uniquement si l'API est bloquée + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + # Forcer la fermeture de toutes les transactions en attente + for _ in range(10): + try: + sage.cial.CptaApplication.RollbackTrans() + logger.info("✅ Rollback effectué") + except: + break + + # Déconnecter/reconnecter + sage.deconnecter() + time.sleep(2) + sage.connecter() + + return { + "success": True, + "message": "Verrous nettoyés, connexion Sage réinitialisée" + } + + except Exception as e: + logger.error(f"Erreur nettoyage verrous: {e}") + raise HTTPException(500, str(e)) + +@app.get("/sage/diagnostic/transform-deep/{numero_source}", dependencies=[Depends(verify_token)]) +def diagnostic_transformation_approfondi( + numero_source: str, + type_source: int = Query(..., description="Type document source"), + type_cible: int = Query(..., description="Type document cible") +): + """ + 🔬 DIAGNOSTIC ULTRA-APPROFONDI : Transformation de document + + Cette route va : + 1. Créer le document cible comme dans transformer_document() + 2. Scanner TOUS les champs DO_* du document + 3. Comparer avec une facture manuelle réussie + 4. Lister les champs manquants ou invalides + 5. NE PAS commit (rollback à la fin) + + **Process** : + - Lit le devis source + - Crée une facture cible + - Associe le client + - Copie les lignes + - SCAN complet des champs AVANT Process() + - Tentative Process() et lecture des erreurs + - Rollback (rien n'est sauvegardé) + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + logger.info(f"[DIAG] === DIAGNOSTIC APPROFONDI TRANSFORMATION ===") + logger.info(f"[DIAG] Source: {numero_source} (type {type_source})") + logger.info(f"[DIAG] Cible: type {type_cible}") + + diagnostic = { + "numero_source": numero_source, + "type_source": type_source, + "type_cible": type_cible, + "etapes": [], + "champs_document": {}, + "champs_manquants": [], + "erreurs_sage": [], + "recommandations": [] + } + + # ======================================== + # ÉTAPE 1 : LIRE LE DOCUMENT SOURCE + # ======================================== + diagnostic["etapes"].append("Lecture document source") + + factory = sage.cial.FactoryDocumentVente + persist_source = factory.ReadPiece(type_source, numero_source) + + if not persist_source: + raise HTTPException(404, f"Document {numero_source} introuvable") + + doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3") + doc_source.Read() + + # Client + client_code = "" + try: + client_obj = getattr(doc_source, "Client", None) + if client_obj: + client_obj.Read() + client_code = getattr(client_obj, "CT_Num", "").strip() + except: + pass + + if not client_code: + raise HTTPException(400, "Client introuvable dans document source") + + diagnostic["client_code"] = client_code + + # Lignes + lignes_source = [] + try: + factory_lignes_source = getattr(doc_source, "FactoryDocumentLigne", None) or \ + getattr(doc_source, "FactoryDocumentVenteLigne", None) + + if factory_lignes_source: + index = 1 + while index <= 100: + try: + ligne_p = factory_lignes_source.List(index) + if ligne_p is None: + break + + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne.Read() + + article_ref = "" + try: + article_ref = getattr(ligne, "AR_Ref", "").strip() + if not article_ref: + article_obj = getattr(ligne, "Article", None) + if article_obj: + article_obj.Read() + article_ref = getattr(article_obj, "AR_Ref", "").strip() + except: + pass + + lignes_source.append({ + "article_ref": article_ref, + "quantite": float(getattr(ligne, "DL_Qte", 0.0)), + "prix_unitaire": float(getattr(ligne, "DL_PrixUnitaire", 0.0)) + }) + + index += 1 + except: + break + except: + pass + + diagnostic["nb_lignes_source"] = len(lignes_source) + diagnostic["etapes"].append(f"Source lu: {len(lignes_source)} lignes") + + # ======================================== + # ÉTAPE 2 : TRANSACTION DE TEST + # ======================================== + try: + sage.cial.CptaApplication.BeginTrans() + diagnostic["etapes"].append("Transaction démarrée") + except: + diagnostic["etapes"].append("Transaction non supportée") + + try: + # ======================================== + # ÉTAPE 3 : CRÉER DOCUMENT CIBLE + # ======================================== + diagnostic["etapes"].append(f"Création document type {type_cible}") + + process = sage.cial.CreateProcess_Document(type_cible) + doc_cible = process.Document + + try: + doc_cible = win32com.client.CastTo(doc_cible, "IBODocumentVente3") + except: + pass + + # Date + import pywintypes + date_source = getattr(doc_source, "DO_Date", None) + if date_source: + doc_cible.DO_Date = date_source + else: + doc_cible.DO_Date = pywintypes.Time(datetime.now()) + + # ======================================== + # ÉTAPE 4 : ASSOCIER CLIENT + # ======================================== + diagnostic["etapes"].append(f"Association client {client_code}") + + factory_client = sage.cial.CptaApplication.FactoryClient + persist_client = factory_client.ReadNumero(client_code) + + if not persist_client: + raise ValueError(f"Client {client_code} introuvable") + + client_obj_cible = sage._cast_client(persist_client) + + try: + doc_cible.SetDefaultClient(client_obj_cible) + except: + doc_cible.SetClient(client_obj_cible) + + doc_cible.DO_Ref = numero_source + doc_cible.Write() + + diagnostic["etapes"].append("Client associé + 1er Write()") + + # ======================================== + # ÉTAPE 5 : COPIER LIGNES + # ======================================== + try: + factory_lignes_cible = doc_cible.FactoryDocumentLigne + except: + factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne + + factory_article = sage.cial.FactoryArticle + + for idx, ligne_data in enumerate(lignes_source, 1): + article_ref = ligne_data["article_ref"] + if not article_ref: + continue + + persist_article = factory_article.ReadReference(article_ref) + if not persist_article: + continue + + article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj.Read() + + ligne_persist = factory_lignes_cible.Create() + try: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + except: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + + quantite = ligne_data["quantite"] + + try: + ligne_obj.SetDefaultArticleReference(article_ref, quantite) + except: + try: + ligne_obj.SetDefaultArticle(article_obj, quantite) + except: + ligne_obj.DL_Qte = quantite + + prix = ligne_data["prix_unitaire"] + if prix > 0: + ligne_obj.DL_PrixUnitaire = float(prix) + + ligne_obj.Write() + + diagnostic["etapes"].append(f"{len(lignes_source)} lignes copiées") + + # ======================================== + # ÉTAPE 6 : CHAMPS FACTURES + # ======================================== + if type_cible == 60: + doc_cible.DO_CodeJournal = "VTE" + doc_cible.DO_Souche = 0 + doc_cible.DO_Regime = 0 + diagnostic["etapes"].append("Champs factures définis") + + # Write final + doc_cible.Write() + doc_cible.Read() + + diagnostic["etapes"].append("Write() final effectué") + + # ======================================== + # ÉTAPE 7 : SCAN COMPLET DES CHAMPS + # ======================================== + diagnostic["etapes"].append("Scan complet des champs DO_*") + + champs = {} + champs_vides = [] + champs_null = [] + + # Liste TOUS les attributs du document + for attr in dir(doc_cible): + if attr.startswith("DO_"): + try: + valeur = getattr(doc_cible, attr, None) + + # Ignorer les méthodes + if callable(valeur): + continue + + # Catégoriser selon la valeur + if valeur is None: + champs_null.append(attr) + champs[attr] = {"valeur": "NULL", "type": "null"} + elif isinstance(valeur, str): + if valeur == "": + champs_vides.append(attr) + champs[attr] = {"valeur": "", "type": "string_vide"} + else: + champs[attr] = {"valeur": valeur, "type": "string", "len": len(valeur)} + elif isinstance(valeur, (int, float)): + if valeur == 0: + champs[attr] = {"valeur": 0, "type": "zero"} + else: + champs[attr] = {"valeur": valeur, "type": type(valeur).__name__} + else: + champs[attr] = {"valeur": str(valeur)[:50], "type": type(valeur).__name__} + except Exception as e: + champs[attr] = {"erreur": str(e)[:100]} + + diagnostic["champs_document"] = champs + diagnostic["nb_champs_total"] = len(champs) + diagnostic["nb_champs_null"] = len(champs_null) + diagnostic["nb_champs_vides"] = len(champs_vides) + diagnostic["champs_null"] = champs_null + diagnostic["champs_vides"] = champs_vides + + # ======================================== + # ÉTAPE 8 : LIRE LES ERREURS DU DOCUMENT + # ======================================== + erreurs_doc = [] + try: + if hasattr(doc_cible, "Errors"): + nb_erreurs = doc_cible.Errors.Count + if nb_erreurs > 0: + for i in range(nb_erreurs): + try: + err = doc_cible.Errors.Item(i) + erreurs_doc.append({ + "description": str(getattr(err, "Description", "?")), + "type": str(getattr(err, "Type", "?")), + "code": str(getattr(err, "Number", "?")), + "field": str(getattr(err, "Field", "?")) + }) + except: + pass + except: + pass + + diagnostic["erreurs_document"] = erreurs_doc + diagnostic["etapes"].append(f"Erreurs document: {len(erreurs_doc)}") + + # ======================================== + # ÉTAPE 9 : TENTER PROCESS() + # ======================================== + diagnostic["etapes"].append("Tentative Process()...") + + try: + process.Process() + diagnostic["process_resultat"] = "SUCCÈS" + diagnostic["etapes"].append("Process() RÉUSSI !") + + except Exception as e: + diagnostic["process_resultat"] = "ÉCHEC" + diagnostic["process_erreur"] = str(e) + diagnostic["etapes"].append(f"Process() échoué: {str(e)[:100]}") + + # Lire erreurs du process + erreurs_process = [] + try: + if hasattr(process, "Errors"): + nb_err_process = process.Errors.Count + if nb_err_process > 0: + for i in range(nb_err_process): + try: + err = process.Errors.Item(i) + erreurs_process.append({ + "description": str(getattr(err, "Description", "?")), + "type": str(getattr(err, "Type", "?")), + "field": str(getattr(err, "Field", "?")) + }) + except: + pass + except: + pass + + diagnostic["erreurs_process"] = erreurs_process + + # ======================================== + # ROLLBACK (NE PAS SAUVEGARDER) + # ======================================== + try: + sage.cial.CptaApplication.RollbackTrans() + diagnostic["etapes"].append("Rollback effectué (rien sauvegardé)") + except: + diagnostic["etapes"].append("Pas de rollback (pas de transaction)") + + # ======================================== + # RECOMMANDATIONS + # ======================================== + if diagnostic["process_resultat"] == "ÉCHEC": + diagnostic["recommandations"].append( + "Process() a échoué. Analysez les champs NULL et vides ci-dessus." + ) + + # Champs suspects + champs_suspects = [] + + if type_cible == 60: # Facture + champs_critiques_facture = [ + "DO_ModeRegl", "DO_CondRegl", "DO_CodeJournal", + "DO_Souche", "DO_Regime", "DO_TypeCalcul" + ] + + for champ in champs_critiques_facture: + if champ in champs_vides or champ in champs_null: + champs_suspects.append(champ) + + if champs_suspects: + diagnostic["champs_manquants"] = champs_suspects + diagnostic["recommandations"].append( + f"Champs critiques manquants pour facture: {', '.join(champs_suspects)}" + ) + + # Comparer avec une facture manuelle + diagnostic["recommandations"].append( + "SOLUTION : Créez une facture MANUELLEMENT dans Sage, " + "puis appelez GET /sage/diagnostic/facture-manuelle/{numero} " + "pour voir les différences" + ) + else: + diagnostic["recommandations"].append( + "Process() a RÉUSSI ! La transformation devrait fonctionner." + ) + + return { + "success": True, + "diagnostic": diagnostic + } + + except Exception as e: + # Rollback en cas d'erreur + try: + sage.cial.CptaApplication.RollbackTrans() + except: + pass + + raise HTTPException(500, f"Erreur diagnostic: {str(e)}") + + except HTTPException: + raise + except Exception as e: + logger.error(f"[DIAG] Erreur: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get("/sage/diagnostic/facture-manuelle/{numero}", dependencies=[Depends(verify_token)]) +def analyser_facture_manuelle(numero: str): + """ + 🔍 ANALYSE D'UNE FACTURE CRÉÉE MANUELLEMENT DANS SAGE + + Scanne TOUS les champs d'une facture qui a été créée avec succès + pour voir la différence avec ce qu'on crée par API + + **Process** : + 1. Créer une facture manuellement dans Sage + 2. Noter son numéro (ex: FA00001) + 3. Appeler cette route : GET /sage/diagnostic/facture-manuelle/FA00001 + 4. Comparer les champs avec ceux du diagnostic transformation + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + factory = sage.cial.FactoryDocumentVente + + # Essayer type 60 (facture) + persist = factory.ReadPiece(60, numero) + + if not persist: + # Chercher dans List() + index = 1 + while index < 1000: + try: + persist_test = factory.List(index) + if persist_test is None: + break + + doc_test = win32com.client.CastTo(persist_test, "IBODocumentVente3") + doc_test.Read() + + if getattr(doc_test, "DO_Piece", "") == numero: + persist = persist_test + break + + index += 1 + except: + index += 1 + + if not persist: + raise HTTPException(404, f"Facture {numero} introuvable") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # Scanner TOUS les champs DO_* + champs = {} + champs_remplis = [] + champs_vides = [] + champs_null = [] + + for attr in dir(doc): + if attr.startswith("DO_"): + try: + valeur = getattr(doc, attr, None) + + if callable(valeur): + continue + + if valeur is None: + champs_null.append(attr) + champs[attr] = "NULL" + elif isinstance(valeur, str): + if valeur == "": + champs_vides.append(attr) + champs[attr] = "" + else: + champs_remplis.append(attr) + champs[attr] = valeur + elif isinstance(valeur, (int, float)): + if valeur != 0: + champs_remplis.append(attr) + champs[attr] = valeur + else: + champs[attr] = str(valeur)[:50] + except: + pass + + return { + "success": True, + "facture_numero": numero, + "type_doc": getattr(doc, "DO_Type", -1), + "statut": getattr(doc, "DO_Statut", -1), + "champs_analyse": { + "total": len(champs), + "remplis": len(champs_remplis), + "vides": len(champs_vides), + "null": len(champs_null) + }, + "champs_remplis": champs_remplis, + "champs_vides": champs_vides, + "champs_null": champs_null, + "tous_champs": champs, + "conseil": ( + "Comparez ces champs avec ceux du diagnostic transformation. " + "Les champs présents ici mais NULL/vides dans le diagnostic " + "sont probablement les champs manquants." + ) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur analyse facture: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +# À AJOUTER dans main.py + +@app.get("/sage/diagnostic/transform-final/{numero_source}", dependencies=[Depends(verify_token)]) +def diagnostic_transformation_final( + numero_source: str, + type_source: int = Query(..., description="Type document source"), + type_cible: int = Query(..., description="Type document cible") +): + """ + 🔬 DIAGNOSTIC ULTIME : Scanner le document AVANT Process() + + Cette route va : + 1. Créer le document EXACTEMENT comme transformer_document() + 2. Lire doc_cible.Errors APRÈS chaque Write() + 3. Scanner TOUS les champs DO_* du document + 4. Comparer avec une facture manuelle (FA00001) + 5. NE PAS appeler Process() (rollback à la fin) + + **Process** : + - Reproduit transformer_document() jusqu'au Process() + - S'arrête juste avant pour lire les erreurs + - Rollback (rien n'est sauvegardé) + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + logger.info(f"[DIAG-FINAL] === DIAGNOSTIC TRANSFORMATION FINALE ===") + logger.info(f"[DIAG-FINAL] Source: {numero_source} (type {type_source})") + logger.info(f"[DIAG-FINAL] Cible: type {type_cible}") + + diagnostic = { + "numero_source": numero_source, + "type_source": type_source, + "type_cible": type_cible, + "etapes": [], + "champs_document": {}, + "erreurs_sage": [], + "comparaison_facture_manuelle": {} + } + + # ======================================== + # ÉTAPE 1 : LIRE LE DOCUMENT SOURCE + # ======================================== + diagnostic["etapes"].append("Lecture document source") + + factory = sage.cial.FactoryDocumentVente + persist_source = factory.ReadPiece(type_source, numero_source) + + if not persist_source: + raise HTTPException(404, f"Document {numero_source} introuvable") + + doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3") + doc_source.Read() + + # Client + client_code = "" + try: + client_obj = getattr(doc_source, "Client", None) + if client_obj: + client_obj.Read() + client_code = getattr(client_obj, "CT_Num", "").strip() + except: + pass + + if not client_code: + raise HTTPException(400, "Client introuvable dans document source") + + diagnostic["client_code"] = client_code + + # Lignes + lignes_source = [] + try: + factory_lignes_source = getattr(doc_source, "FactoryDocumentLigne", None) or \ + getattr(doc_source, "FactoryDocumentVenteLigne", None) + + if factory_lignes_source: + index = 1 + while index <= 100: + try: + ligne_p = factory_lignes_source.List(index) + if ligne_p is None: + break + + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne.Read() + + article_ref = "" + try: + article_ref = getattr(ligne, "AR_Ref", "").strip() + if not article_ref: + article_obj = getattr(ligne, "Article", None) + if article_obj: + article_obj.Read() + article_ref = getattr(article_obj, "AR_Ref", "").strip() + except: + pass + + lignes_source.append({ + "article_ref": article_ref, + "quantite": float(getattr(ligne, "DL_Qte", 0.0)), + "prix_unitaire": float(getattr(ligne, "DL_PrixUnitaire", 0.0)) + }) + + index += 1 + except: + break + except: + pass + + diagnostic["nb_lignes_source"] = len(lignes_source) + diagnostic["etapes"].append(f"Source lu: {len(lignes_source)} lignes") + + # ======================================== + # ÉTAPE 2 : TRANSACTION DE TEST + # ======================================== + try: + sage.cial.CptaApplication.BeginTrans() + diagnostic["etapes"].append("Transaction démarrée") + except: + diagnostic["etapes"].append("Transaction non supportée") + + try: + # ======================================== + # ÉTAPE 3 : CRÉER DOCUMENT CIBLE + # ======================================== + diagnostic["etapes"].append(f"Création document type {type_cible}") + + process = sage.cial.CreateProcess_Document(type_cible) + doc_cible = process.Document + + try: + doc_cible = win32com.client.CastTo(doc_cible, "IBODocumentVente3") + except: + pass + + # Date + import pywintypes + date_source = getattr(doc_source, "DO_Date", None) + if date_source: + doc_cible.DO_Date = date_source + else: + doc_cible.DO_Date = pywintypes.Time(datetime.now()) + + # ======================================== + # ÉTAPE 4 : ASSOCIER CLIENT + # ======================================== + diagnostic["etapes"].append(f"Association client {client_code}") + + factory_client = sage.cial.CptaApplication.FactoryClient + persist_client = factory_client.ReadNumero(client_code) + + if not persist_client: + raise ValueError(f"Client {client_code} introuvable") + + client_obj_cible = sage._cast_client(persist_client) + + try: + doc_cible.SetDefaultClient(client_obj_cible) + except: + doc_cible.SetClient(client_obj_cible) + + doc_cible.DO_Ref = numero_source + + # PREMIER WRITE + doc_cible.Write() + diagnostic["etapes"].append("1er Write() effectué") + + # LIRE ERREURS APRÈS 1ER WRITE + erreurs_apres_write1 = [] + try: + if hasattr(doc_cible, "Errors"): + nb_err = doc_cible.Errors.Count + if nb_err > 0: + for i in range(nb_err): + try: + err = doc_cible.Errors.Item(i) + erreurs_apres_write1.append({ + "description": str(getattr(err, "Description", "?")), + "type": str(getattr(err, "Type", "?")), + "field": str(getattr(err, "Field", "?")) + }) + except: + pass + except: + pass + + diagnostic["erreurs_apres_write1"] = erreurs_apres_write1 + + # ======================================== + # ÉTAPE 5 : COPIER LIGNES + # ======================================== + try: + factory_lignes_cible = doc_cible.FactoryDocumentLigne + except: + factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne + + factory_article = sage.cial.FactoryArticle + + for idx, ligne_data in enumerate(lignes_source, 1): + article_ref = ligne_data["article_ref"] + if not article_ref: + continue + + persist_article = factory_article.ReadReference(article_ref) + if not persist_article: + continue + + article_obj = win32com.client.CastTo(persist_article, "IBOArticle3") + article_obj.Read() + + ligne_persist = factory_lignes_cible.Create() + try: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3") + except: + ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3") + + quantite = ligne_data["quantite"] + + try: + ligne_obj.SetDefaultArticleReference(article_ref, quantite) + except: + try: + ligne_obj.SetDefaultArticle(article_obj, quantite) + except: + ligne_obj.DL_Qte = quantite + + prix = ligne_data["prix_unitaire"] + if prix > 0: + ligne_obj.DL_PrixUnitaire = float(prix) + + ligne_obj.Write() + + diagnostic["etapes"].append(f"{len(lignes_source)} lignes copiées") + + # ======================================== + # ÉTAPE 6 : CHAMPS FACTURES + # ======================================== + if type_cible == 60: + # DO_Souche + try: + souche = getattr(doc_source, "DO_Souche", 0) + doc_cible.DO_Souche = souche + except: + pass + + # DO_Regime + try: + regime = getattr(doc_source, "DO_Regime", None) + if regime is not None: + doc_cible.DO_Regime = regime + except: + pass + + # DO_Transaction + try: + doc_cible.DO_Transaction = 11 + except: + pass + + diagnostic["etapes"].append("Champs factures définis") + + # Write final + doc_cible.Write() + doc_cible.Read() + + diagnostic["etapes"].append("Write() final effectué") + + # ======================================== + # ÉTAPE 7 : LIRE ERREURS DOCUMENT + # ======================================== + erreurs_document = [] + try: + if hasattr(doc_cible, "Errors"): + nb_erreurs = doc_cible.Errors.Count + if nb_erreurs > 0: + for i in range(nb_erreurs): + try: + err = doc_cible.Errors.Item(i) + erreurs_document.append({ + "description": str(getattr(err, "Description", "?")), + "type": str(getattr(err, "Type", "?")), + "code": str(getattr(err, "Number", "?")), + "field": str(getattr(err, "Field", "?")) + }) + except: + pass + except: + pass + + diagnostic["erreurs_document"] = erreurs_document + diagnostic["nb_erreurs_document"] = len(erreurs_document) + + # ======================================== + # ÉTAPE 8 : SCAN COMPLET DES CHAMPS + # ======================================== + champs = {} + champs_vides = [] + champs_null = [] + champs_zero = [] + + for attr in dir(doc_cible): + if attr.startswith("DO_"): + try: + valeur = getattr(doc_cible, attr, None) + + if callable(valeur): + continue + + if valeur is None: + champs_null.append(attr) + champs[attr] = {"valeur": "NULL", "type": "null"} + elif isinstance(valeur, str): + if valeur == "": + champs_vides.append(attr) + champs[attr] = {"valeur": "", "type": "string_vide"} + else: + champs[attr] = {"valeur": valeur, "type": "string", "len": len(valeur)} + elif isinstance(valeur, (int, float)): + if valeur == 0: + champs_zero.append(attr) + champs[attr] = {"valeur": 0, "type": "zero"} + else: + champs[attr] = {"valeur": valeur, "type": type(valeur).__name__} + else: + champs[attr] = {"valeur": str(valeur)[:50], "type": type(valeur).__name__} + except Exception as e: + champs[attr] = {"erreur": str(e)[:100]} + + diagnostic["champs_document"] = champs + diagnostic["nb_champs_total"] = len(champs) + diagnostic["nb_champs_null"] = len(champs_null) + diagnostic["nb_champs_vides"] = len(champs_vides) + diagnostic["nb_champs_zero"] = len(champs_zero) + diagnostic["champs_null"] = champs_null + diagnostic["champs_vides"] = champs_vides + diagnostic["champs_zero"] = champs_zero + + # ======================================== + # ÉTAPE 9 : COMPARER AVEC FACTURE MANUELLE + # ======================================== + # Charger FA00001 + try: + persist_ref = factory.ReadPiece(60, "FA00001") + if persist_ref: + doc_ref = win32com.client.CastTo(persist_ref, "IBODocumentVente3") + doc_ref.Read() + + champs_ref = {} + for attr in dir(doc_ref): + if attr.startswith("DO_"): + try: + valeur = getattr(doc_ref, attr, None) + if not callable(valeur): + if valeur is None: + champs_ref[attr] = "NULL" + elif isinstance(valeur, str): + champs_ref[attr] = valeur if valeur else "" + elif isinstance(valeur, (int, float)): + champs_ref[attr] = valeur + else: + champs_ref[attr] = str(valeur)[:50] + except: + pass + + # Comparer + champs_manquants = [] + champs_differents = [] + + for attr, val_ref in champs_ref.items(): + if attr in champs: + val_cible = champs[attr].get("valeur") + + # Si la référence a une valeur mais pas nous + if val_ref != "" and val_ref != 0 and val_ref != "NULL": + if val_cible == "" or val_cible == 0 or val_cible == "NULL": + champs_manquants.append({ + "champ": attr, + "valeur_ref": val_ref, + "valeur_cible": val_cible, + "severite": "CRITIQUE" + }) + elif val_ref != val_cible: + champs_differents.append({ + "champ": attr, + "valeur_ref": val_ref, + "valeur_cible": val_cible + }) + + diagnostic["comparaison_facture_manuelle"] = { + "champs_manquants": champs_manquants, + "champs_differents": champs_differents, + "nb_champs_manquants": len(champs_manquants), + "nb_champs_differents": len(champs_differents) + } + except Exception as e: + diagnostic["comparaison_facture_manuelle"] = {"erreur": str(e)} + + # ======================================== + # ROLLBACK (NE PAS SAUVEGARDER) + # ======================================== + try: + sage.cial.CptaApplication.RollbackTrans() + diagnostic["etapes"].append("Rollback effectué (rien sauvegardé)") + except: + diagnostic["etapes"].append("Pas de rollback (pas de transaction)") + + # ======================================== + # ANALYSE ET RECOMMANDATIONS + # ======================================== + recommendations = [] + + if erreurs_document: + recommendations.append( + f"CRITIQUE: {len(erreurs_document)} erreur(s) détectée(s) dans le document ! " + f"Voir 'erreurs_document' pour les détails." + ) + + for err in erreurs_document: + if err.get("field"): + recommendations.append( + f"Champ problématique: {err['field']} - {err['description']}" + ) + + if diagnostic["comparaison_facture_manuelle"].get("champs_manquants"): + recommendations.append( + f"CRITIQUE: {len(diagnostic['comparaison_facture_manuelle']['champs_manquants'])} " + f"champ(s) manquant(s) par rapport à FA00001 ! " + f"Voir 'comparaison_facture_manuelle.champs_manquants'." + ) + + if not erreurs_document and not diagnostic["comparaison_facture_manuelle"].get("champs_manquants"): + recommendations.append( + "Aucune erreur détectée ! Le document devrait pouvoir être validé." + ) + + diagnostic["recommendations"] = recommendations + + return { + "success": True, + "diagnostic": diagnostic + } + + except Exception as e: + # Rollback en cas d'erreur + try: + sage.cial.CptaApplication.RollbackTrans() + except: + pass + + raise HTTPException(500, f"Erreur diagnostic: {str(e)}") + + except HTTPException: + raise + except Exception as e: + logger.error(f"[DIAG-FINAL] Erreur: {e}", exc_info=True) + raise HTTPException(500, str(e)) + +# À ajouter dans main.py + +@app.get("/sage/diagnostic/article-requirements", dependencies=[Depends(verify_token)]) +def diagnostiquer_exigences_article(): + """ + 🔍 DIAGNOSTIC: Découvre les champs obligatoires pour créer un article + + Cette route va : + 1. Créer un article de test + 2. Scanner TOUS les champs AR_* + 3. Identifier les champs avec des valeurs par défaut + 4. NE PAS sauvegarder (rollback) + """ + try: + if not sage or not sage.cial: + raise HTTPException(503, "Service Sage indisponible") + + with sage._com_context(), sage._lock_com: + # Transaction de test + try: + sage.cial.CptaApplication.BeginTrans() + except: + pass + + try: + factory = sage.cial.FactoryArticle + + # Créer un article test + persist = factory.Create() + article_test = win32com.client.CastTo(persist, "IBOArticle3") + article_test.SetDefault() + + # Scanner TOUS les champs AR_* + champs = {} + champs_null = [] + champs_vides = [] + champs_remplis = [] + + for attr in dir(article_test): + if attr.startswith("AR_") or attr.startswith("FA_") or attr.startswith("TA_"): + try: + valeur = getattr(article_test, attr, None) + + if callable(valeur): + continue + + if valeur is None: + champs_null.append(attr) + champs[attr] = {"valeur": "NULL", "type": "null", "obligatoire_probable": True} + elif isinstance(valeur, str): + if valeur == "": + champs_vides.append(attr) + champs[attr] = {"valeur": "", "type": "string_vide", "obligatoire_probable": True} + else: + champs_remplis.append(attr) + champs[attr] = {"valeur": valeur, "type": "string", "len": len(valeur)} + elif isinstance(valeur, (int, float, bool)): + if valeur == 0 or valeur == False: + champs[attr] = {"valeur": valeur, "type": type(valeur).__name__, "obligatoire_probable": False} + else: + champs_remplis.append(attr) + champs[attr] = {"valeur": valeur, "type": type(valeur).__name__} + else: + champs[attr] = {"valeur": str(valeur)[:50], "type": type(valeur).__name__} + except Exception as e: + champs[attr] = {"erreur": str(e)[:100]} + + # Lire un article existant pour comparaison + champs_article_reel = {} + try: + # Charger le premier article de la liste + persist_reel = factory.List(1) + if persist_reel: + article_reel = win32com.client.CastTo(persist_reel, "IBOArticle3") + article_reel.Read() + + for attr in champs.keys(): + try: + val_reel = getattr(article_reel, attr, None) + if val_reel is not None and val_reel != "" and val_reel != 0: + champs_article_reel[attr] = val_reel + except: + pass + except: + pass + + # Rollback + try: + sage.cial.CptaApplication.RollbackTrans() + except: + pass + + return { + "success": True, + "champs_analyse": { + "total": len(champs), + "null": len(champs_null), + "vides": len(champs_vides), + "remplis": len(champs_remplis) + }, + "champs_probablement_obligatoires": champs_null + champs_vides, + "champs_remplis_par_defaut": champs_remplis, + "tous_champs": champs, + "exemple_article_reel": champs_article_reel, + "conseil": ( + "Les champs NULL ou vides sont probablement obligatoires. " + "Les champs remplis par défaut ont des valeurs automatiques." + ) + } + + except Exception as e: + try: + sage.cial.CptaApplication.RollbackTrans() + except: + pass + raise HTTPException(500, f"Erreur diagnostic: {str(e)}") + + except HTTPException: + raise + except Exception as e: + logger.error(f"[DIAG] Erreur: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/sage/diagnostic/article", dependencies=[Depends(verify_token)]) +def diagnostic_article_complet( + mode: str = Query("create", regex="^(inspect|create)$"), + reference: str = Query("TEST_DIAG"), + reference_modele: str = Query(None, description="Article existant à copier/inspecter") +): + """ + 🔧 DIAGNOSTIC UNIFIÉ pour articles Sage + + Modes: + - inspect: Analyse un article existant pour voir sa structure + - create: Crée un article de test en copiant la structure d'un modèle + + Paramètres: + - reference: Référence du nouvel article (mode create) + - reference_modele: Référence d'un article existant à analyser/copier + """ + + def est_serializable(val): + """Vérifie si une valeur est sérialisable en JSON""" + if val is None: + return True + if isinstance(val, (str, int, float, bool)): + return True + if str(type(val)).startswith(" bytes: - if not self.cial: - raise RuntimeError("Connexion Sage non établie") - - try: - with self._com_context(), self._lock_com: - logger.info(f"📄 Génération PDF: {numero} (type={type_doc})") - - # ======================================== - # ÉTAPE 1 : CHARGER LE DOCUMENT - # ======================================== - factory = self.cial.FactoryDocumentVente - - # Essayer ReadPiece - persist = factory.ReadPiece(type_doc, numero) - - if not persist: - # Fallback: chercher dans List() - logger.debug(f"ReadPiece échoué, recherche dans List()...") - persist = self._find_document_in_list(numero, type_doc) - - if not persist: - raise ValueError(f"Document {numero} (type {type_doc}) introuvable") - - doc = win32com.client.CastTo(persist, "IBODocumentVente3") - doc.Read() - - logger.info(f"✅ Document chargé: {numero}") - - # ======================================== - # ÉTAPE 2 : IDENTIFIER L'ÉTAT CRYSTAL - # ======================================== - # Mapping des types vers les noms d'états Sage - etats_sage = { - 0: "VT_DEVIS.RPT", # Devis - 10: "VT_CMDE.RPT", # Bon de commande - 20: "VT_PREP.RPT", # Préparation - 30: "VT_BL.RPT", # Bon de livraison - 40: "VT_BR.RPT", # Bon de retour - 50: "VT_AVOIR.RPT", # Bon d'avoir - 60: "VT_FACT.RPT", # Facture - } - - etat_nom = etats_sage.get(type_doc) - - if not etat_nom: - raise ValueError(f"Type de document non supporté: {type_doc}") - - logger.info(f"📋 État Sage: {etat_nom}") - - # ======================================== - # ÉTAPE 3 : GÉNÉRER LE PDF VIA L'ÉTAT - # ======================================== - try: - # Accéder au gestionnaire d'états - factory_etat = self.cial.FactoryEtat - - # Charger l'état - etat = factory_etat.ReadNom(etat_nom) - - if not etat: - raise RuntimeError(f"État {etat_nom} non trouvé dans Sage") - - # Paramétrer l'état - etat.Destination = 6 # 6 = PDF - etat.Preview = False # Pas de prévisualisation - - # Définir le fichier de sortie temporaire - import tempfile - import os - - temp_dir = tempfile.gettempdir() - pdf_filename = f"sage_pdf_{numero}_{int(time.time())}.pdf" - pdf_path = os.path.join(temp_dir, pdf_filename) - - etat.FileName = pdf_path - - # Définir le filtre (seulement ce document) - etat.Selection = f"{{F_DOCENTETE.DO_Piece}} = '{numero}'" - - logger.info(f"📁 Fichier temporaire: {pdf_path}") - - # Exécuter l'état - logger.info("🔄 Exécution état Crystal...") - etat.Start() - - # Attendre que le fichier soit créé - max_wait = 30 # 30 secondes max - waited = 0 - - while not os.path.exists(pdf_path) and waited < max_wait: - time.sleep(0.5) - waited += 0.5 - - if not os.path.exists(pdf_path): - raise RuntimeError( - f"Le fichier PDF n'a pas été créé après {max_wait}s" - ) - - # Lire le fichier PDF - with open(pdf_path, "rb") as f: - pdf_bytes = f.read() - - logger.info(f"✅ PDF lu: {len(pdf_bytes)} octets") - - # Nettoyer le fichier temporaire - try: - os.remove(pdf_path) - logger.debug(f"🗑️ Fichier temporaire supprimé") - except: - pass - - if len(pdf_bytes) == 0: - raise RuntimeError("Le PDF généré est vide") - - logger.info( - f"✅✅✅ PDF GÉNÉRÉ: {numero} ({len(pdf_bytes)} octets) ✅✅✅" - ) - - return pdf_bytes - - except Exception as e: - logger.error(f"❌ Erreur génération état: {e}", exc_info=True) - raise RuntimeError(f"Erreur génération PDF: {str(e)}") - - 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)}") def creer_article(self, article_data: dict) -> dict: with self._com_context(), self._lock_com: @@ -10682,41 +10550,978 @@ class SageConnector: logger.error(f"[MOUVEMENT] Erreur : {e}", exc_info=True) raise ValueError(f"Erreur lecture mouvement : {str(e)}") - def verifier_stock_suffisant(self, article_ref, quantite_demandee, depot_code=None): + def verifier_stock_suffisant(self, article_ref, quantite, depot=None): + """Version thread-safe avec lock SQL""" try: - stock_info = self.lire_stock_article(article_ref) - - if depot_code: - # Vérifier dans un dépôt spécifique - depot_trouve = next( - (d for d in stock_info["depots"] if d["code"] == depot_code), None - ) - - if not depot_trouve: + with self._get_sql_connection() as conn: + cursor = conn.cursor() + + # ✅ LOCK pour éviter les race conditions + cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + cursor.execute("BEGIN TRANSACTION") + + try: + # Lire stock avec lock + cursor.execute( + """ + SELECT SUM(AS_QteSto) + FROM F_ARTSTOCK WITH (UPDLOCK, ROWLOCK) + WHERE AR_Ref = ? + """, + (article_ref.upper(),) + ) + + row = cursor.fetchone() + stock_dispo = float(row[0]) if row and row[0] else 0.0 + + suffisant = stock_dispo >= quantite + + cursor.execute("COMMIT") + return { - "suffisant": False, - "stock_disponible": 0.0, - "quantite_demandee": quantite_demandee, - "stock_apres": -quantite_demandee, - "erreur": f"Article non présent dans le dépôt {depot_code}", + "suffisant": suffisant, + "stock_disponible": stock_dispo, + "quantite_demandee": quantite } - - stock_dispo = depot_trouve["quantite"] - else: - # Vérifier sur le stock total - stock_dispo = stock_info["stock_total"] - - suffisant = stock_dispo >= quantite_demandee - stock_apres = stock_dispo - quantite_demandee - - return { - "suffisant": suffisant, - "stock_disponible": stock_dispo, - "quantite_demandee": quantite_demandee, - "stock_apres": stock_apres, - "depot": depot_code or "TOUS", - } - + + except: + cursor.execute("ROLLBACK") + raise + except Exception as e: - logger.error(f"Erreur vérification stock : {e}") + logger.error(f"Erreur vérification stock: {e}") raise + + def lister_modeles_crystal(self) -> Dict: + """ + 📋 Liste les modèles en scannant le répertoire Sage + + ✅ FONCTIONNE MÊME SI FactoryEtat N'EXISTE PAS + """ + try: + logger.info("[MODELES] Scan du répertoire des modèles...") + + # Chemin typique des modèles Sage 100c + # Adapter selon votre installation + chemins_possibles = [ + r"C:\Users\Public\Documents\Sage\Entreprise 100c\fr-FR\Documents standards\Gestion commerciale\Ventes", + ] + + # Essayer de détecter depuis la base Sage + chemin_base = self.chemin_base + if chemin_base: + # Extraire le répertoire Sage + import os + dossier_sage = os.path.dirname(os.path.dirname(chemin_base)) + chemins_possibles.insert(0, os.path.join(dossier_sage, "Modeles")) + + modeles_par_type = { + "devis": [], + "commandes": [], + "livraisons": [], + "factures": [], + "avoirs": [], + "autres": [] + } + + # Scanner les répertoires + import os + import glob + + for chemin in chemins_possibles: + if not os.path.exists(chemin): + continue + + logger.info(f"[MODELES] Scan: {chemin}") + + # Chercher tous les fichiers .RPT et .BGC + for pattern in ["*.RPT", "*.rpt", "*.BGC", "*.bgc"]: + fichiers = glob.glob(os.path.join(chemin, pattern)) + + for fichier in fichiers: + nom_fichier = os.path.basename(fichier) + + # Déterminer la catégorie + categorie = "autres" + + nom_upper = nom_fichier.upper() + if "DEVIS" in nom_upper or nom_upper.startswith("VT_DE"): + categorie = "devis" + elif "CMDE" in nom_upper or "COMMANDE" in nom_upper or nom_upper.startswith("VT_BC"): + categorie = "commandes" + elif nom_upper.startswith("VT_BL") or "LIVRAISON" in nom_upper: + categorie = "livraisons" + elif "FACT" in nom_upper or nom_upper.startswith("VT_FA"): + categorie = "factures" + elif "AVOIR" in nom_upper or nom_upper.startswith("VT_AV"): + categorie = "avoirs" + + modeles_par_type[categorie].append({ + "fichier": nom_fichier, + "nom": nom_fichier.replace(".RPT", "").replace(".rpt", "").replace(".BGC", "").replace(".bgc", ""), + "chemin_complet": fichier + }) + + # Si on a trouvé des fichiers, pas besoin de continuer + if any(len(v) > 0 for v in modeles_par_type.values()): + break + + total = sum(len(v) for v in modeles_par_type.values()) + logger.info(f"[MODELES] {total} modèles trouvés") + + return modeles_par_type + + except Exception as e: + logger.error(f"[MODELES] Erreur: {e}", exc_info=True) + raise RuntimeError(f"Erreur listage modèles: {str(e)}") + + + def _detecter_methodes_impression(self, doc) -> dict: + """🔍 Détecte les méthodes d'impression disponibles""" + methodes = {} + + # Tester FactoryEtat + try: + factory_etat = self.cial.CptaApplication.FactoryEtat + if factory_etat: + methodes["FactoryEtat"] = True + except: + try: + factory_etat = self.cial.FactoryEtat + if factory_etat: + methodes["FactoryEtat"] = True + except: + pass + + # Tester Imprimer() + if hasattr(doc, "Imprimer"): + methodes["Imprimer"] = True + + # Tester Print() + if hasattr(doc, "Print"): + methodes["Print"] = True + + # Tester ExportPDF() + if hasattr(doc, "ExportPDF"): + methodes["ExportPDF"] = True + + return methodes + + def generer_pdf_document( + self, + numero: str, + type_doc: int, + modele: str = None + ) -> bytes: + """ + 📄 Génération PDF Sage 100c - UTILISE LES .BGC DIRECTEMENT + + Args: + numero: Numéro document (ex: "FA00123") + type_doc: Type Sage (0=devis, 60=facture, etc.) + modele: Nom fichier .bgc (optionnel) + + Returns: + bytes: Contenu PDF + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + try: + with self._com_context(), self._lock_com: + logger.info(f"[PDF] === GÉNÉRATION PDF AVEC .BGC ===") + logger.info(f"[PDF] Document: {numero} (type={type_doc})") + + # ======================================== + # 1. CHARGER LE DOCUMENT SAGE + # ======================================== + factory = self.cial.FactoryDocumentVente + persist = factory.ReadPiece(type_doc, numero) + + if not persist: + persist = self._find_document_in_list(numero, type_doc) + + if not persist: + raise ValueError(f"Document {numero} introuvable") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + logger.info(f"[PDF] ✅ Document chargé") + + # ======================================== + # 2. DÉTERMINER LE MODÈLE .BGC + # ======================================== + chemin_modele = self._determiner_modele(type_doc, modele) + logger.info(f"[PDF] 📄 Modèle: {os.path.basename(chemin_modele)}") + logger.info(f"[PDF] 📁 Chemin: {chemin_modele}") + + # ======================================== + # 3. VÉRIFIER QUE LE FICHIER EXISTE + # ======================================== + import os + if not os.path.exists(chemin_modele): + raise ValueError(f"Modèle introuvable: {chemin_modele}") + + # ======================================== + # 4. CRÉER FICHIER TEMPORAIRE + # ======================================== + import tempfile + import time + + temp_dir = tempfile.gettempdir() + pdf_path = os.path.join(temp_dir, f"sage_{numero}_{int(time.time())}.pdf") + + pdf_bytes = None + + # ======================================== + # MÉTHODE 1 : Crystal Reports Runtime (PRIORITAIRE) + # ======================================== + logger.info("[PDF] 🔷 Méthode 1: Crystal Reports Runtime...") + + try: + pdf_bytes = self._generer_pdf_crystal_runtime( + numero, type_doc, chemin_modele, pdf_path + ) + if pdf_bytes: + logger.info("[PDF] ✅ Méthode 1 réussie (Crystal Runtime)") + except Exception as e: + logger.warning(f"[PDF] Méthode 1 échouée: {e}") + + # ======================================== + # MÉTHODE 2 : Crystal via DLL Sage + # ======================================== + if not pdf_bytes: + logger.info("[PDF] 🔷 Méthode 2: Crystal via DLL Sage...") + + try: + pdf_bytes = self._generer_pdf_crystal_sage_dll( + numero, type_doc, chemin_modele, pdf_path + ) + if pdf_bytes: + logger.info("[PDF] ✅ Méthode 2 réussie (DLL Sage)") + except Exception as e: + logger.warning(f"[PDF] Méthode 2 échouée: {e}") + + # ======================================== + # MÉTHODE 3 : Sage Reports Viewer (si installé) + # ======================================== + if not pdf_bytes: + logger.info("[PDF] 🔷 Méthode 3: Sage Reports Viewer...") + + try: + pdf_bytes = self._generer_pdf_sage_viewer( + numero, type_doc, chemin_modele, pdf_path + ) + if pdf_bytes: + logger.info("[PDF] ✅ Méthode 3 réussie (Sage Viewer)") + except Exception as e: + logger.warning(f"[PDF] Méthode 3 échouée: {e}") + + # ======================================== + # MÉTHODE 4 : Python reportlab (FALLBACK) + # ======================================== + if not pdf_bytes: + logger.warning("[PDF] ⚠️ TOUTES LES MÉTHODES .BGC ONT ÉCHOUÉ") + logger.info("[PDF] 🔷 Méthode 4: PDF Custom (fallback)...") + + try: + pdf_bytes = self._generer_pdf_custom(doc, numero, type_doc) + if pdf_bytes: + logger.info("[PDF] ✅ Méthode 4 réussie (PDF custom)") + except Exception as e: + logger.error(f"[PDF] Méthode 4 échouée: {e}") + + # ======================================== + # VALIDATION & NETTOYAGE + # ======================================== + try: + if os.path.exists(pdf_path): + os.remove(pdf_path) + except: + pass + + if not pdf_bytes: + raise RuntimeError( + "❌ ÉCHEC GÉNÉRATION PDF\n\n" + "🔍 DIAGNOSTIC:\n" + f"- Modèle .bgc trouvé: ✅ ({os.path.basename(chemin_modele)})\n" + f"- Crystal Reports installé: ❌ NON DÉTECTÉ\n\n" + "💡 SOLUTIONS:\n" + "1. Installer SAP Crystal Reports Runtime (gratuit):\n" + " https://www.sap.com/products/technology-platform/crystal-reports/trial.html\n" + " Choisir: Crystal Reports Runtime (64-bit)\n\n" + "2. OU installer depuis DVD Sage 100c:\n" + " Composants/Crystal Reports Runtime\n\n" + "3. Vérifier que le service 'Crystal Reports' est démarré:\n" + " services.msc → SAP Crystal Reports Processing Server\n\n" + "4. En attendant, utiliser /pdf-custom pour un PDF simple" + ) + + if len(pdf_bytes) < 500: + raise RuntimeError("PDF généré trop petit (probablement corrompu)") + + logger.info(f"[PDF] ✅✅✅ SUCCÈS: {len(pdf_bytes):,} octets") + return pdf_bytes + + except ValueError as e: + logger.error(f"[PDF] Erreur métier: {e}") + raise + except Exception as e: + logger.error(f"[PDF] Erreur technique: {e}", exc_info=True) + raise RuntimeError(f"Erreur PDF: {str(e)}") + + def _generer_pdf_crystal_runtime(self, numero, type_doc, chemin_modele, pdf_path): + """🔷 Méthode 1: Crystal Reports Runtime API""" + try: + import os + + # Essayer différentes ProgID Crystal Reports + prog_ids_crystal = [ + "CrystalRuntime.Application.140", # Crystal Reports 2020 + "CrystalRuntime.Application.13", # Crystal Reports 2016 + "CrystalRuntime.Application.12", # Crystal Reports 2013 + "CrystalRuntime.Application.11", # Crystal Reports 2011 + "CrystalRuntime.Application", # Générique + "CrystalDesignRunTime.Application", # Alternative + ] + + crystal = None + prog_id_utilisee = None + + for prog_id in prog_ids_crystal: + try: + crystal = win32com.client.Dispatch(prog_id) + prog_id_utilisee = prog_id + logger.info(f" ✅ Crystal trouvé: {prog_id}") + break + except Exception as e: + logger.debug(f" {prog_id}: {e}") + continue + + if not crystal: + logger.info(" ❌ Aucune ProgID Crystal trouvée") + return None + + # Ouvrir le rapport .bgc + logger.info(f" 📂 Ouverture: {os.path.basename(chemin_modele)}") + rapport = crystal.OpenReport(chemin_modele) + + # Configurer la connexion SQL + logger.info(" 🔌 Configuration connexion SQL...") + + for table in rapport.Database.Tables: + try: + # Méthode 1: SetDataSource + table.SetDataSource( + self.sql_server, + self.sql_database, + "", + "" + ) + except: + try: + # Méthode 2: ConnectionProperties + table.ConnectionProperties.Item["Server Name"] = self.sql_server + table.ConnectionProperties.Item["Database Name"] = self.sql_database + table.ConnectionProperties.Item["Integrated Security"] = True + except: + pass + + # Appliquer le filtre Crystal Reports + logger.info(f" 🔍 Filtre: DO_Piece = '{numero}'") + rapport.RecordSelectionFormula = f"{{F_DOCENTETE.DO_Piece}} = '{numero}'" + + # Exporter en PDF + logger.info(" 📄 Export PDF...") + rapport.ExportOptions.DestinationType = 1 # crEDTDiskFile + rapport.ExportOptions.FormatType = 31 # crEFTPortableDocFormat (PDF) + rapport.ExportOptions.DiskFileName = pdf_path + rapport.Export(False) + + # Attendre la création du fichier + import time + max_wait = 30 + waited = 0 + + while not os.path.exists(pdf_path) and waited < max_wait: + time.sleep(0.5) + waited += 0.5 + + if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: + with open(pdf_path, 'rb') as f: + return f.read() + + logger.warning(" ⚠️ Fichier PDF non créé") + return None + + except Exception as e: + logger.debug(f" Crystal Runtime: {e}") + return None + + def _generer_pdf_crystal_sage_dll(self, numero, type_doc, chemin_modele, pdf_path): + """🔷 Méthode 2: Utiliser les DLL Crystal de Sage directement""" + try: + import os + import ctypes + + # Chercher les DLL Crystal dans le dossier Sage + dossier_sage = os.path.dirname(os.path.dirname(self.chemin_base)) + chemins_dll = [ + os.path.join(dossier_sage, "CrystalReports", "crpe32.dll"), + os.path.join(dossier_sage, "Crystal", "crpe32.dll"), + r"C:\Program Files (x86)\SAP BusinessObjects\Crystal Reports for .NET Framework 4.0\Common\SAP BusinessObjects Enterprise XI 4.0\win64_x64\crpe32.dll", + ] + + dll_trouvee = None + for chemin_dll in chemins_dll: + if os.path.exists(chemin_dll): + dll_trouvee = chemin_dll + break + + if not dll_trouvee: + logger.info(" ❌ DLL Crystal Sage non trouvée") + return None + + logger.info(f" ✅ DLL trouvée: {dll_trouvee}") + + # Charger la DLL + crpe = ctypes.cdll.LoadLibrary(dll_trouvee) + + # Ouvrir le rapport (API C Crystal Reports) + # Note: Ceci est une approche bas niveau, peut nécessiter des ajustements + job_handle = crpe.PEOpenPrintJob(chemin_modele.encode()) + + if job_handle == 0: + logger.warning(" ⚠️ Impossible d'ouvrir le rapport") + return None + + # Définir les paramètres de connexion + # ... (code simplifié, nécessiterait plus de configuration) + + # Exporter + crpe.PEExportTo(job_handle, pdf_path.encode(), 31) # 31 = PDF + + # Fermer + crpe.PEClosePrintJob(job_handle) + + import time + time.sleep(2) + + if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: + with open(pdf_path, 'rb') as f: + return f.read() + + return None + + except Exception as e: + logger.debug(f" DLL Sage: {e}") + return None + + def _generer_pdf_sage_viewer(self, numero, type_doc, chemin_modele, pdf_path): + """🔷 Méthode 3: Sage Reports Viewer (si installé)""" + try: + import os + + # Chercher l'exécutable Sage Reports + executables_possibles = [ + r"C:\Program Files\Sage\Reports\SageReports.exe", + r"C:\Program Files (x86)\Sage\Reports\SageReports.exe", + os.path.join( + os.path.dirname(os.path.dirname(self.chemin_base)), + "Reports", + "SageReports.exe" + ) + ] + + exe_trouve = None + for exe in executables_possibles: + if os.path.exists(exe): + exe_trouve = exe + break + + if not exe_trouve: + logger.info(" ❌ SageReports.exe non trouvé") + return None + + logger.info(f" ✅ SageReports trouvé: {exe_trouve}") + + # Lancer en ligne de commande avec paramètres + import subprocess + + cmd = [ + exe_trouve, + "/report", chemin_modele, + "/export", pdf_path, + "/format", "PDF", + "/filter", f"DO_Piece='{numero}'", + "/silent" + ] + + logger.info(" 🚀 Lancement SageReports...") + result = subprocess.run(cmd, capture_output=True, timeout=30) + + import time + time.sleep(2) + + if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: + with open(pdf_path, 'rb') as f: + return f.read() + + logger.warning(" ⚠️ PDF non généré par SageReports") + return None + + except Exception as e: + logger.debug(f" Sage Viewer: {e}") + return None + + def _generer_pdf_custom(self, doc, numero, type_doc): + """🎨 Génère un PDF simple avec les données du document (FALLBACK)""" + try: + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import cm + from reportlab.pdfgen import canvas + from reportlab.lib import colors + from io import BytesIO + + buffer = BytesIO() + pdf = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 + + # En-tête + pdf.setFont("Helvetica-Bold", 20) + type_libelles = {0: "DEVIS", 10: "BON DE COMMANDE", 30: "BON DE LIVRAISON", + 60: "FACTURE", 50: "AVOIR"} + type_libelle = type_libelles.get(type_doc, "DOCUMENT") + + pdf.drawString(2*cm, height - 3*cm, type_libelle) + + # Numéro + pdf.setFont("Helvetica", 12) + pdf.drawString(2*cm, height - 4*cm, f"Numéro: {numero}") + + # Date + date_doc = getattr(doc, "DO_Date", "") + pdf.drawString(2*cm, height - 4.5*cm, f"Date: {date_doc}") + + # Client + try: + client = getattr(doc, "Client", None) + if client: + client.Read() + client_nom = getattr(client, "CT_Intitule", "") + pdf.drawString(2*cm, height - 5.5*cm, f"Client: {client_nom}") + except: + pass + + # Ligne séparatrice + pdf.line(2*cm, height - 6*cm, width - 2*cm, height - 6*cm) + + # Lignes du document + y_pos = height - 7*cm + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString(2*cm, y_pos, "Article") + pdf.drawString(8*cm, y_pos, "Qté") + pdf.drawString(11*cm, y_pos, "Prix U.") + pdf.drawString(15*cm, y_pos, "Total") + + y_pos -= 0.5*cm + pdf.line(2*cm, y_pos, width - 2*cm, y_pos) + + # Lire lignes + pdf.setFont("Helvetica", 9) + try: + factory_lignes = getattr(doc, "FactoryDocumentLigne", None) + if factory_lignes: + idx = 1 + while idx <= 50 and y_pos > 5*cm: + try: + ligne_p = factory_lignes.List(idx) + if ligne_p is None: + break + + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") + ligne.Read() + + y_pos -= 0.7*cm + + design = getattr(ligne, "DL_Design", "")[:40] + qte = float(getattr(ligne, "DL_Qte", 0)) + prix = float(getattr(ligne, "DL_PrixUnitaire", 0)) + total = float(getattr(ligne, "DL_MontantHT", 0)) + + pdf.drawString(2*cm, y_pos, design) + pdf.drawString(8*cm, y_pos, f"{qte:.2f}") + pdf.drawString(11*cm, y_pos, f"{prix:.2f}€") + pdf.drawString(15*cm, y_pos, f"{total:.2f}€") + + idx += 1 + except: + break + except: + pass + + # Totaux + y_pos = 5*cm + pdf.line(2*cm, y_pos, width - 2*cm, y_pos) + + y_pos -= 0.7*cm + pdf.setFont("Helvetica-Bold", 11) + + total_ht = float(getattr(doc, "DO_TotalHT", 0)) + total_ttc = float(getattr(doc, "DO_TotalTTC", 0)) + + pdf.drawString(13*cm, y_pos, f"Total HT:") + pdf.drawString(16*cm, y_pos, f"{total_ht:.2f}€") + + y_pos -= 0.7*cm + pdf.drawString(13*cm, y_pos, f"TVA:") + pdf.drawString(16*cm, y_pos, f"{(total_ttc - total_ht):.2f}€") + + y_pos -= 0.7*cm + pdf.setFont("Helvetica-Bold", 14) + pdf.drawString(13*cm, y_pos, f"Total TTC:") + pdf.drawString(16*cm, y_pos, f"{total_ttc:.2f}€") + + # Pied de page + pdf.setFont("Helvetica", 8) + pdf.drawString(2*cm, 2*cm, "PDF généré par l'API Sage - Version simplifiée") + pdf.drawString(2*cm, 1.5*cm, "Pour un rendu Crystal Reports complet, installez SAP BusinessObjects") + + pdf.showPage() + pdf.save() + + return buffer.getvalue() + + except Exception as e: + logger.error(f"PDF custom: {e}") + return None + + def _determiner_modele(self, type_doc: int, modele_demande: str = None) -> str: + """ + 🔍 Détermine le chemin du modèle Crystal Reports à utiliser + + Args: + type_doc: Type Sage (0=devis, 60=facture, etc.) + modele_demande: Nom fichier .bgc spécifique (optionnel) + + Returns: + str: Chemin complet du modèle + """ + if modele_demande: + # Modèle spécifié + modeles_dispo = self.lister_modeles_crystal() + + for categorie, liste in modeles_dispo.items(): + for m in liste: + if m["fichier"].lower() == modele_demande.lower(): + return m["chemin_complet"] + + raise ValueError(f"Modèle '{modele_demande}' introuvable") + + # Modèle par défaut selon type + modeles = self.lister_modeles_crystal() + + mapping = { + 0: "devis", + 10: "commandes", + 30: "livraisons", + 60: "factures", + 50: "avoirs" + } + + categorie = mapping.get(type_doc) + + if not categorie or categorie not in modeles: + raise ValueError(f"Aucun modèle disponible pour type {type_doc}") + + liste = modeles[categorie] + if not liste: + raise ValueError(f"Aucun modèle {categorie} trouvé") + + # Prioriser modèle "standard" (sans FlyDoc, email, etc.) + modele_std = next( + (m for m in liste + if "flydoc" not in m["fichier"].lower() + and "email" not in m["fichier"].lower()), + liste[0] + ) + + return modele_std["chemin_complet"] + + def diagnostiquer_impression_approfondi(self): + """🔬 Diagnostic ultra-complet pour trouver les objets d'impression""" + try: + with self._com_context(), self._lock_com: + logger.info("=" * 80) + logger.info("DIAGNOSTIC IMPRESSION APPROFONDI") + logger.info("=" * 80) + + objets_a_tester = [ + ("self.cial", self.cial), + ("CptaApplication", self.cial.CptaApplication), + ] + + # Charger un document pour tester + try: + factory = self.cial.FactoryDocumentVente + persist = factory.List(1) + if persist: + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + objets_a_tester.append(("Document", doc)) + except: + pass + + for nom_objet, objet in objets_a_tester: + logger.info(f"\n{'='*60}") + logger.info(f"OBJET: {nom_objet}") + logger.info(f"{'='*60}") + + # Chercher tous les attributs qui contiennent "print", "etat", "bilan", "crystal", "report" + mots_cles = ["print", "etat", "bilan", "crystal", "report", "pdf", "export", "impression", "imprimer"] + + attributs_trouves = [] + + for attr in dir(objet): + if attr.startswith('_'): + continue + + attr_lower = attr.lower() + + # Vérifier si contient un des mots-clés + if any(mot in attr_lower for mot in mots_cles): + try: + val = getattr(objet, attr) + type_val = type(val).__name__ + is_callable = callable(val) + + attributs_trouves.append({ + "nom": attr, + "type": type_val, + "callable": is_callable + }) + + logger.info(f" ✅ TROUVÉ: {attr} ({type_val}) {'[MÉTHODE]' if is_callable else '[PROPRIÉTÉ]'}") + + except Exception as e: + logger.debug(f" Erreur {attr}: {e}") + + if not attributs_trouves: + logger.warning(f" ❌ Aucun objet d'impression trouvé sur {nom_objet}") + + # Tester des noms de méthodes spécifiques + logger.info(f"\n{'='*60}") + logger.info("TESTS DIRECTS") + logger.info(f"{'='*60}") + + methodes_a_tester = [ + ("self.cial.BilanEtat", lambda: self.cial.BilanEtat), + ("self.cial.Etat", lambda: self.cial.Etat), + ("self.cial.CptaApplication.BilanEtat", lambda: self.cial.CptaApplication.BilanEtat), + ("self.cial.CptaApplication.Etat", lambda: self.cial.CptaApplication.Etat), + ("self.cial.FactoryEtat", lambda: self.cial.FactoryEtat), + ("self.cial.CptaApplication.FactoryEtat", lambda: self.cial.CptaApplication.FactoryEtat), + ] + + for nom, getter in methodes_a_tester: + try: + obj = getter() + logger.info(f" ✅ {nom} EXISTE : {type(obj).__name__}") + except AttributeError as e: + logger.info(f" ❌ {nom} N'EXISTE PAS : {e}") + except Exception as e: + logger.info(f" ⚠️ {nom} ERREUR : {e}") + + logger.info("=" * 80) + + return {"diagnostic": "terminé"} + + except Exception as e: + logger.error(f"Erreur diagnostic: {e}", exc_info=True) + raise + + def lister_objets_com_disponibles(self): + """🔍 Liste tous les objets COM disponibles dans Sage""" + try: + with self._com_context(), self._lock_com: + objets_trouves = { + "cial": [], + "cpta_application": [], + "document": [] + } + + # 1. Objets sur self.cial + for attr in dir(self.cial): + if not attr.startswith('_'): + try: + obj = getattr(self.cial, attr) + objets_trouves["cial"].append({ + "nom": attr, + "type": str(type(obj)), + "callable": callable(obj) + }) + except: + pass + + # 2. Objets sur CptaApplication + try: + cpta = self.cial.CptaApplication + for attr in dir(cpta): + if not attr.startswith('_'): + try: + obj = getattr(cpta, attr) + objets_trouves["cpta_application"].append({ + "nom": attr, + "type": str(type(obj)), + "callable": callable(obj) + }) + except: + pass + except: + pass + + # 3. Objets sur un document + try: + factory = self.cial.FactoryDocumentVente + persist = factory.List(1) + if persist: + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + for attr in dir(doc): + if not attr.startswith('_'): + try: + obj = getattr(doc, attr) + objets_trouves["document"].append({ + "nom": attr, + "type": str(type(obj)), + "callable": callable(obj) + }) + except: + pass + except: + pass + + return objets_trouves + + except Exception as e: + logger.error(f"Erreur listage objets COM: {e}", exc_info=True) + raise + + + def explorer_methodes_impression(self): + """Explore toutes les méthodes d'impression disponibles""" + try: + with self._com_context(), self._lock_com: + # Charger un document de test + factory = self.cial.FactoryDocumentVente + persist = factory.List(1) + + if not persist: + return {"error": "Aucun document trouvé"} + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + methods = {} + + # Tester différentes signatures de Print + signatures_to_test = [ + "Print", + "PrintToFile", + "Export", + "ExportToPDF", + "SaveAs", + "GeneratePDF" + ] + + for method_name in signatures_to_test: + if hasattr(doc, method_name): + try: + # Essayer d'appeler pour voir les paramètres + method = getattr(doc, method_name) + methods[method_name] = { + "exists": True, + "callable": callable(method) + } + except: + methods[method_name] = {"exists": True, "error": "Access error"} + + return methods + + except Exception as e: + return {"error": str(e)} + + + def generer_pdf_document_via_print(self, numero: str, type_doc: int) -> bytes: + """Utilise la méthode Print() native des documents Sage""" + try: + with self._com_context(), self._lock_com: + # Charger le document + factory = self.cial.FactoryDocumentVente + persist = factory.ReadPiece(type_doc, numero) + + if not persist: + persist = self._find_document_in_list(numero, type_doc) + + if not persist: + raise ValueError(f"Document {numero} introuvable") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # Créer un fichier temporaire + import tempfile + import os + temp_dir = tempfile.gettempdir() + pdf_path = os.path.join(temp_dir, f"document_{numero}.pdf") + + # Utiliser Print() avec destination fichier PDF + # Les codes de destination typiques dans Sage : + # 0 = Imprimante par défaut + # 1 = Aperçu + # 2 = Fichier + # 6 = PDF (dans certaines versions) + + try: + # Tentative 1 : Print() avec paramètres + doc.Print(Destination=6, FileName=pdf_path, Preview=False) + except: + # Tentative 2 : Print() simplifié + try: + doc.Print(pdf_path) # Certaines versions acceptent juste le chemin + except: + # Tentative 3 : PrintToFile() + try: + doc.PrintToFile(pdf_path) + except AttributeError: + raise RuntimeError("Aucune méthode d'impression disponible") + + # Lire le fichier PDF + import time + max_wait = 10 + waited = 0 + while not os.path.exists(pdf_path) and waited < max_wait: + time.sleep(0.5) + waited += 0.5 + + if not os.path.exists(pdf_path): + raise RuntimeError("Le fichier PDF n'a pas été généré") + + with open(pdf_path, 'rb') as f: + pdf_bytes = f.read() + + # Nettoyer + try: + os.remove(pdf_path) + except: + pass + + return pdf_bytes + + except Exception as e: + logger.error(f"Erreur génération PDF via Print(): {e}") + raise + +