4514 lines
173 KiB
Python
4514 lines
173 KiB
Python
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("<COMObject"):
|
||
return False
|
||
try:
|
||
import json
|
||
json.dumps(val)
|
||
return True
|
||
except:
|
||
return False
|
||
|
||
try:
|
||
if not sage or not sage.cial:
|
||
raise HTTPException(503, "Service Sage indisponible")
|
||
|
||
factory = sage.cial.FactoryArticle
|
||
|
||
# ========================================
|
||
# MODE 1: INSPECTION d'un article existant
|
||
# ========================================
|
||
if mode == "inspect":
|
||
if not reference_modele:
|
||
raise HTTPException(400, "reference_modele requis en mode inspect")
|
||
|
||
try:
|
||
with sage._com_context(), sage._lock_com:
|
||
article_modele = factory.ReadReference(reference_modele)
|
||
|
||
# Scanner TOUS les attributs
|
||
attributs_unite = []
|
||
champs_remplis = {}
|
||
champs_vides = {}
|
||
champs_non_serialisables = []
|
||
|
||
for attr in dir(article_modele):
|
||
if attr.startswith('_') or attr[0].islower():
|
||
continue
|
||
|
||
try:
|
||
val = getattr(article_modele, attr, None)
|
||
|
||
# Ignorer les méthodes
|
||
if callable(val):
|
||
continue
|
||
|
||
# Vérifier si sérialisable
|
||
if not est_serializable(val):
|
||
champs_non_serialisables.append(attr)
|
||
continue
|
||
|
||
# Catégoriser les unités
|
||
if 'Unit' in attr or 'Vent' in attr or 'unite' in attr.lower():
|
||
if val is not None and str(val) not in ['None', '']:
|
||
attributs_unite.append({attr: str(val)})
|
||
|
||
# Catégoriser remplis/vides
|
||
val_str = str(val) if val is not None else 'None'
|
||
if val is not None and val_str not in ['', 'None', '0', '0.0', 'False']:
|
||
champs_remplis[attr] = val_str[:100]
|
||
else:
|
||
champs_vides[attr] = val_str
|
||
|
||
except Exception as e:
|
||
logger.debug(f"Erreur lecture {attr}: {e}")
|
||
continue
|
||
|
||
return {
|
||
"mode": "inspect",
|
||
"reference_inspectee": reference_modele,
|
||
"resume": {
|
||
"champs_remplis": len(champs_remplis),
|
||
"champs_vides": len(champs_vides),
|
||
"champs_non_serialisables": len(champs_non_serialisables),
|
||
"attributs_unite_detectes": len(attributs_unite)
|
||
},
|
||
"attributs_unite": attributs_unite,
|
||
"champs_critiques_remplis": {
|
||
k: v for k, v in champs_remplis.items()
|
||
if k in ["AR_Design", "AR_Type", "FA_CodeFamille", "TA_Code",
|
||
"AR_PrixVen", "AR_Coef", "AR_PrixAch"]
|
||
},
|
||
"tous_champs_remplis": champs_remplis,
|
||
"champs_vides": champs_vides,
|
||
"conseil": (
|
||
f"✅ {len(attributs_unite)} attribut(s) d'unité trouvé(s). "
|
||
f"Utilisez mode='create' avec ce reference_modele pour tester la création."
|
||
)
|
||
}
|
||
|
||
except Exception as e:
|
||
raise HTTPException(404, f"Article '{reference_modele}' introuvable: {str(e)}")
|
||
|
||
# ========================================
|
||
# MODE 2: CRÉATION avec modèle (si fourni)
|
||
# ========================================
|
||
with sage._com_context(), sage._lock_com:
|
||
try:
|
||
sage.cial.CptaApplication.BeginTrans()
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
# 📋 Étape 1: Charger le modèle si fourni
|
||
champs_modele = {}
|
||
logs_copie = []
|
||
article_modele_obj = None
|
||
|
||
# ✅ CHAMPS À EXCLURE (doivent être uniques ou auto-générés)
|
||
champs_exclus = {
|
||
"AR_Ref", # Référence (on met la nôtre)
|
||
"AR_Raccourci", # Doit être unique !
|
||
"AR_CodeBarre", # Code-barres doit être unique ! ⚠️ CRITIQUE
|
||
"AR_Photo", # Chemin photo spécifique
|
||
"cbMarq", # ID interne Sage
|
||
"cbCreateur", # Créateur
|
||
"cbModification",# Date modif
|
||
"IsPersistant" # Flag interne
|
||
}
|
||
|
||
if reference_modele:
|
||
try:
|
||
article_modele_obj = factory.ReadReference(reference_modele)
|
||
|
||
# Extraire TOUS les champs lisibles (valeurs simples uniquement)
|
||
for attr in dir(article_modele_obj):
|
||
if attr.startswith('_') or attr[0].islower():
|
||
continue
|
||
|
||
# ✅ Exclure les champs problématiques
|
||
if attr in champs_exclus:
|
||
logs_copie.append(f"⏭️ {attr} EXCLU (doit être unique)")
|
||
continue
|
||
|
||
try:
|
||
val = getattr(article_modele_obj, attr, None)
|
||
|
||
# Ignorer méthodes et objets COM
|
||
if callable(val) or not est_serializable(val):
|
||
continue
|
||
|
||
# Garder les valeurs non-None et non-vides
|
||
if val is not None and str(val) not in ['None', '']:
|
||
champs_modele[attr] = val
|
||
logs_copie.append(f"✅ {attr} = {val}")
|
||
except:
|
||
continue
|
||
|
||
logger.info(f"📋 Modèle: {len(champs_modele)} champs extraits")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Modèle '{reference_modele}' non chargé: {e}")
|
||
|
||
# 🆕 Étape 2: Créer le nouvel article
|
||
persist = factory.Create()
|
||
article = win32com.client.CastTo(persist, "IBOArticle3")
|
||
article.SetDefault()
|
||
|
||
# 📝 Étape 3: Appliquer le modèle OU défauts
|
||
article.AR_Ref = reference.upper()
|
||
logs_application = []
|
||
|
||
if champs_modele:
|
||
# Appliquer TOUS les champs du modèle
|
||
for champ, valeur in champs_modele.items():
|
||
try:
|
||
setattr(article, champ, valeur)
|
||
logs_application.append(f"✅ {champ} = {valeur}")
|
||
except Exception as e:
|
||
logs_application.append(f"❌ {champ}: {str(e)[:50]}")
|
||
else:
|
||
# Défauts minimaux si pas de modèle
|
||
article.AR_Design = f"Test {reference}"
|
||
article.AR_Type = 0
|
||
article.AR_Sommeil = False
|
||
|
||
# 🔧 Étape 4: Copier les objets COM critiques (OBLIGATOIRES pour Sage)
|
||
fallbacks_appliques = []
|
||
fallbacks_echecs = []
|
||
|
||
if article_modele_obj:
|
||
logger.info(f"[DIAGNOSTIC] Copie des objets COM depuis {reference_modele}...")
|
||
|
||
# ===== FAMILLE =====
|
||
try:
|
||
famille_obj = getattr(article_modele_obj, "Famille", None)
|
||
if famille_obj is not None:
|
||
article.Famille = famille_obj
|
||
fallbacks_appliques.append(f"✅ Famille copiée depuis {reference_modele}")
|
||
logger.info("[DIAGNOSTIC] Famille copiée avec succès")
|
||
else:
|
||
fallbacks_echecs.append("⚠️ Famille: objet NULL dans modèle")
|
||
logger.warning("[DIAGNOSTIC] Famille NULL dans modèle")
|
||
except Exception as e:
|
||
fallbacks_echecs.append(f"Famille: {str(e)[:80]}")
|
||
logger.error(f"[DIAGNOSTIC] Erreur copie Famille: {e}")
|
||
|
||
# ===== TAXE =====
|
||
try:
|
||
taxe_obj = getattr(article_modele_obj, "Taxe1", None)
|
||
if taxe_obj is not None:
|
||
article.Taxe1 = taxe_obj
|
||
fallbacks_appliques.append(f"✅ Taxe1 copiée depuis {reference_modele}")
|
||
logger.info("[DIAGNOSTIC] Taxe1 copiée avec succès")
|
||
else:
|
||
# Taxe NULL - essayer de charger une taxe par défaut
|
||
logger.warning("[DIAGNOSTIC] Taxe1 NULL dans modèle, recherche taxe par défaut...")
|
||
factory_taxe = sage.cial.FactoryTaxe
|
||
taxe_trouvee = False
|
||
|
||
for code_taxe in ["1", "2", "Normal", "20.00"]:
|
||
try:
|
||
taxe_defaut = factory_taxe.ReadIntitule(code_taxe)
|
||
if taxe_defaut:
|
||
article.Taxe1 = taxe_defaut
|
||
fallbacks_appliques.append(f"✅ Taxe par défaut '{code_taxe}' chargée")
|
||
logger.info(f"[DIAGNOSTIC] Taxe '{code_taxe}' chargée")
|
||
taxe_trouvee = True
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"[DIAGNOSTIC] Taxe '{code_taxe}' échouée: {e}")
|
||
continue
|
||
|
||
if not taxe_trouvee:
|
||
fallbacks_echecs.append("⚠️ Taxe: aucune taxe par défaut trouvée")
|
||
except Exception as e:
|
||
fallbacks_echecs.append(f"Taxe: {str(e)[:80]}")
|
||
logger.error(f"[DIAGNOSTIC] Erreur copie Taxe: {e}")
|
||
|
||
# ===== UNITÉ DE VENTE =====
|
||
try:
|
||
unite_obj = getattr(article_modele_obj, "UniteVente", None)
|
||
if unite_obj is not None:
|
||
article.UniteVente = unite_obj
|
||
fallbacks_appliques.append(f"✅ UniteVente copiée depuis {reference_modele}")
|
||
logger.info("[DIAGNOSTIC] UniteVente copiée avec succès")
|
||
else:
|
||
# Unité NULL - essayer de charger une unité par défaut
|
||
logger.warning("[DIAGNOSTIC] UniteVente NULL dans modèle, recherche unité par défaut...")
|
||
factory_unite = sage.cial.FactoryUnite
|
||
unite_trouvee = False
|
||
|
||
for code_unite in ["UN", "U", "PCE", "Unité"]:
|
||
try:
|
||
unite_defaut = factory_unite.ReadIntitule(code_unite)
|
||
if unite_defaut:
|
||
article.UniteVente = unite_defaut
|
||
fallbacks_appliques.append(f"✅ Unité par défaut '{code_unite}' chargée")
|
||
logger.info(f"[DIAGNOSTIC] Unité '{code_unite}' chargée")
|
||
unite_trouvee = True
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"[DIAGNOSTIC] Unité '{code_unite}' échouée: {e}")
|
||
continue
|
||
|
||
if not unite_trouvee:
|
||
fallbacks_echecs.append("⚠️ Unité: aucune unité par défaut trouvée")
|
||
except Exception as e:
|
||
fallbacks_echecs.append(f"Unité: {str(e)[:80]}")
|
||
logger.error(f"[DIAGNOSTIC] Erreur copie Unité: {e}")
|
||
|
||
else:
|
||
fallbacks_echecs.append("⚠️ Aucun modèle fourni - objets COM non définis")
|
||
|
||
# 📊 Étape 5: Scanner l'état final avant Write
|
||
etat_final = {}
|
||
champs_scan = [
|
||
"AR_Ref", "AR_Design", "AR_Type", "AR_Sommeil", "AR_Raccourci", "AR_CodeBarre",
|
||
"FA_CodeFamille", "TA_Code", "AR_PrixVen",
|
||
"AR_UniteVen", "AR_UniteVente", "AR_Unite"
|
||
]
|
||
|
||
for champ in champs_scan:
|
||
try:
|
||
val = getattr(article, champ, None)
|
||
etat_final[champ] = str(val) if val is not None else "None"
|
||
except:
|
||
etat_final[champ] = "N/A"
|
||
|
||
# 💾 Étape 6: Tenter Write()
|
||
erreur_write = None
|
||
sage_errors = []
|
||
write_success = False
|
||
|
||
try:
|
||
article.Write()
|
||
write_success = True
|
||
erreur_write = "✅ SUCCESS - Article créé !"
|
||
logger.info(f"[DIAGNOSTIC] ✅ Article {reference} créé avec succès")
|
||
|
||
except Exception as e:
|
||
erreur_write = str(e)
|
||
logger.error(f"[DIAGNOSTIC] ❌ Échec Write(): {e}")
|
||
|
||
# Extraire erreurs Sage
|
||
try:
|
||
sage_error = sage.cial.CptaApplication.LastError
|
||
if sage_error:
|
||
sage_errors.append({
|
||
"source": "CptaApplication",
|
||
"description": sage_error.Description,
|
||
"number": sage_error.Number
|
||
})
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
if hasattr(article, "Errors") and article.Errors:
|
||
for i in range(1, article.Errors.Count + 1):
|
||
try:
|
||
err = article.Errors.Item(i)
|
||
sage_errors.append({
|
||
"source": "article.Errors",
|
||
"field": getattr(err, "Field", ""),
|
||
"description": getattr(err, "Description", ""),
|
||
"number": getattr(err, "Number", "")
|
||
})
|
||
except:
|
||
pass
|
||
except:
|
||
pass
|
||
|
||
# 🔄 Rollback
|
||
try:
|
||
sage.cial.CptaApplication.RollbackTrans()
|
||
except:
|
||
pass
|
||
|
||
# 📋 Diagnostic final
|
||
diagnostic = {
|
||
"mode": "create",
|
||
"success": write_success,
|
||
"reference_test": reference,
|
||
"modele_utilise": reference_modele if reference_modele else None,
|
||
"champs_copies_du_modele": len(champs_modele) if champs_modele else 0,
|
||
"fallbacks_appliques": fallbacks_appliques,
|
||
"fallbacks_echecs": fallbacks_echecs if fallbacks_echecs else None,
|
||
"etat_final": etat_final,
|
||
"resultat_write": erreur_write,
|
||
"sage_errors": sage_errors,
|
||
}
|
||
|
||
# Logs détaillés si échec
|
||
if not write_success and reference_modele:
|
||
diagnostic["logs_copie_modele"] = logs_copie[:30]
|
||
diagnostic["logs_application"] = logs_application[:30]
|
||
|
||
# Conseils
|
||
if write_success:
|
||
diagnostic["message"] = "🎉 Article créé avec succès (rollback effectué)"
|
||
else:
|
||
diagnostic["message"] = "❌ Échec de création"
|
||
|
||
# Analyser quels champs posent problème
|
||
champs_none = [k for k, v in etat_final.items() if v in ['None', 'N/A']]
|
||
if champs_none:
|
||
diagnostic["champs_encore_vides"] = champs_none
|
||
diagnostic["conseil"] = (
|
||
f"Les champs suivants sont toujours vides: {', '.join(champs_none)}. "
|
||
"Ils sont probablement obligatoires. Vérifiez votre paramétrage Sage "
|
||
"ou utilisez mode='inspect' pour voir les valeurs dans un article valide."
|
||
)
|
||
|
||
return diagnostic
|
||
|
||
except Exception as e:
|
||
try:
|
||
sage.cial.CptaApplication.RollbackTrans()
|
||
except:
|
||
pass
|
||
raise HTTPException(500, f"Erreur création: {str(e)}")
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"[DIAGNOSTIC] Erreur: {e}", exc_info=True)
|
||
raise HTTPException(500, str(e))
|
||
|
||
|
||
# =====================================================
|
||
# LANCEMENT
|
||
# =====================================================
|
||
if __name__ == "__main__":
|
||
uvicorn.run(
|
||
"main:app",
|
||
host=settings.api_host,
|
||
port=settings.api_port,
|
||
reload=False, # Pas de reload en production
|
||
log_level="info",
|
||
)
|