From 5ff01c6c4532ff818ec78e4bdcffe58ccef3ba87 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 3 Dec 2025 11:00:55 +0300 Subject: [PATCH 01/62] Users debug --- api.py | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/api.py b/api.py index cf91dc8..462d6aa 100644 --- a/api.py +++ b/api.py @@ -144,6 +144,32 @@ class BaremeRemiseResponse(BaseModel): message: str +# À ajouter dans api.py après les imports et avant les endpoints existants + +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime + +# ===================================================== +# MODÈLES PYDANTIC POUR USERS +# ===================================================== + +class UserResponse(BaseModel): + """Modèle de réponse pour un utilisateur""" + id: str + email: str + nom: str + prenom: str + role: str + is_verified: bool + is_active: bool + created_at: str + last_login: Optional[str] = None + failed_login_attempts: int = 0 + + class Config: + from_attributes = True + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1557,6 +1583,162 @@ async def statut_queue(): } + +@app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) +async def lister_utilisateurs_debug( + session: AsyncSession = Depends(get_session), + limit: int = Query(100, le=1000), + role: Optional[str] = Query(None), + verified_only: bool = Query(False) +): + """ + 🔓 **ROUTE DEBUG** - Liste tous les utilisateurs inscrits + + ⚠️ **ATTENTION**: Cette route n'est PAS protégée par authentification. + À utiliser uniquement en développement ou à sécuriser en production. + + Args: + limit: Nombre maximum d'utilisateurs à retourner + role: Filtrer par rôle (user, admin, commercial) + verified_only: Afficher uniquement les utilisateurs vérifiés + + Returns: + Liste des utilisateurs avec leurs informations (mot de passe masqué) + """ + from database import User + from sqlalchemy import select + + try: + # Construction de la requête + query = select(User) + + # Filtres optionnels + if role: + query = query.where(User.role == role) + + if verified_only: + query = query.where(User.is_verified == True) + + # Tri par date de création (plus récents en premier) + query = query.order_by(User.created_at.desc()).limit(limit) + + # Exécution + result = await session.execute(query) + users = result.scalars().all() + + # Conversion en réponse + users_response = [] + for user in users: + users_response.append(UserResponse( + id=user.id, + email=user.email, + nom=user.nom, + prenom=user.prenom, + role=user.role, + is_verified=user.is_verified, + is_active=user.is_active, + created_at=user.created_at.isoformat() if user.created_at else "", + last_login=user.last_login.isoformat() if user.last_login else None, + failed_login_attempts=user.failed_login_attempts or 0 + )) + + logger.info(f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)") + + return users_response + + except Exception as e: + logger.error(f"❌ Erreur liste utilisateurs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/debug/users/stats", tags=["Debug"]) +async def statistiques_utilisateurs( + session: AsyncSession = Depends(get_session) +): + """ + 📊 **ROUTE DEBUG** - Statistiques sur les utilisateurs + + ⚠️ Non protégée - à sécuriser en production + """ + from database import User + from sqlalchemy import select, func + + try: + # Total utilisateurs + total_query = select(func.count(User.id)) + total_result = await session.execute(total_query) + total = total_result.scalar() + + # Utilisateurs vérifiés + verified_query = select(func.count(User.id)).where(User.is_verified == True) + verified_result = await session.execute(verified_query) + verified = verified_result.scalar() + + # Utilisateurs actifs + active_query = select(func.count(User.id)).where(User.is_active == True) + active_result = await session.execute(active_query) + active = active_result.scalar() + + # Par rôle + roles_query = select(User.role, func.count(User.id)).group_by(User.role) + roles_result = await session.execute(roles_query) + roles_stats = {role: count for role, count in roles_result.all()} + + return { + "total_utilisateurs": total, + "utilisateurs_verifies": verified, + "utilisateurs_actifs": active, + "utilisateurs_non_verifies": total - verified, + "repartition_roles": roles_stats, + "taux_verification": f"{(verified/total*100):.1f}%" if total > 0 else "0%" + } + + except Exception as e: + logger.error(f"❌ Erreur stats utilisateurs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/debug/users/{user_id}", response_model=UserResponse, tags=["Debug"]) +async def lire_utilisateur_debug( + user_id: str, + session: AsyncSession = Depends(get_session) +): + """ + 👤 **ROUTE DEBUG** - Détails d'un utilisateur par ID + + ⚠️ Non protégée - à sécuriser en production + """ + from database import User + from sqlalchemy import select + + try: + query = select(User).where(User.id == user_id) + result = await session.execute(query) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(404, f"Utilisateur {user_id} introuvable") + + return UserResponse( + id=user.id, + email=user.email, + nom=user.nom, + prenom=user.prenom, + role=user.role, + is_verified=user.is_verified, + is_active=user.is_active, + created_at=user.created_at.isoformat() if user.created_at else "", + last_login=user.last_login.isoformat() if user.last_login else None, + failed_login_attempts=user.failed_login_attempts or 0 + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur lecture utilisateur: {e}") + raise HTTPException(500, str(e)) + + # ===================================================== # LANCEMENT # ===================================================== From a73bdc4d9eee47b5eb518cd8f2d99142061937f4 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 3 Dec 2025 13:54:28 +0300 Subject: [PATCH 02/62] Corrected error on sending document to universign --- api.py | 214 ++++++++++++++++++++++++++++++++++++++++++- core/dependencies.py | 3 +- email_queue.py | 2 +- 3 files changed, 213 insertions(+), 6 deletions(-) diff --git a/api.py b/api.py index 462d6aa..f01767f 100644 --- a/api.py +++ b/api.py @@ -297,11 +297,11 @@ async def lifespan(app: FastAPI): await init_db() logger.info("✅ Base de données initialisée") - # Injecter session_factory dans email_queue + # ✅ CORRECTION: Injecter session_factory ET sage_client dans email_queue email_queue.session_factory = async_session_factory - - # ⚠️ PAS de sage_connector ici (c'est sur Windows !) - # email_queue utilisera sage_client pour générer les PDFs via HTTP + email_queue.sage_client = sage_client + + logger.info("✅ sage_client injecté dans email_queue") # Démarrer queue email_queue.start(num_workers=settings.max_email_workers) @@ -1739,6 +1739,212 @@ async def lire_utilisateur_debug( raise HTTPException(500, str(e)) +# À ajouter dans api.py dans la section Debug + +@app.get("/debug/database/check", tags=["Debug"]) +async def verifier_integrite_database(session: AsyncSession = Depends(get_session)): + """ + 🔍 Vérification de l'intégrité de la base de données + + Retourne des statistiques détaillées sur toutes les tables + """ + from database import User, RefreshToken, LoginAttempt, EmailLog, SignatureLog + from sqlalchemy import func, text + + try: + diagnostics = {} + + # === TABLE USERS === + # Compter tous les users + total_users = await session.execute(select(func.count(User.id))) + diagnostics["users"] = { + "total": total_users.scalar(), + "details": [] + } + + # Lister tous les users avec détails + all_users = await session.execute(select(User)) + users_list = all_users.scalars().all() + + for u in users_list: + diagnostics["users"]["details"].append({ + "id": u.id, + "email": u.email, + "nom": f"{u.prenom} {u.nom}", + "role": u.role, + "is_active": u.is_active, + "is_verified": u.is_verified, + "created_at": u.created_at.isoformat() if u.created_at else None, + "has_reset_token": u.reset_token is not None, + "has_verification_token": u.verification_token is not None, + }) + + # === TABLE REFRESH_TOKENS === + total_tokens = await session.execute(select(func.count(RefreshToken.id))) + diagnostics["refresh_tokens"] = { + "total": total_tokens.scalar() + } + + # === TABLE LOGIN_ATTEMPTS === + total_attempts = await session.execute(select(func.count(LoginAttempt.id))) + diagnostics["login_attempts"] = { + "total": total_attempts.scalar() + } + + # === TABLE EMAIL_LOGS === + total_emails = await session.execute(select(func.count(EmailLog.id))) + diagnostics["email_logs"] = { + "total": total_emails.scalar() + } + + # === TABLE SIGNATURE_LOGS === + total_signatures = await session.execute(select(func.count(SignatureLog.id))) + diagnostics["signature_logs"] = { + "total": total_signatures.scalar() + } + + # === VÉRIFIER LES FICHIERS SQLITE === + import os + db_file = "sage_dataven.db" + diagnostics["database_file"] = { + "exists": os.path.exists(db_file), + "size_bytes": os.path.getsize(db_file) if os.path.exists(db_file) else 0, + "path": os.path.abspath(db_file) + } + + # === TESTER UNE REQUÊTE RAW SQL === + try: + raw_count = await session.execute(text("SELECT COUNT(*) FROM users")) + diagnostics["raw_sql_check"] = { + "users_count": raw_count.scalar(), + "status": "✅ Connexion DB OK" + } + except Exception as e: + diagnostics["raw_sql_check"] = { + "status": "❌ Erreur", + "error": str(e) + } + + return { + "success": True, + "timestamp": datetime.now().isoformat(), + "diagnostics": diagnostics + } + + except Exception as e: + logger.error(f"❌ Erreur diagnostic DB: {e}", exc_info=True) + raise HTTPException(500, f"Erreur diagnostic: {str(e)}") + + +@app.post("/debug/database/test-user-persistence", tags=["Debug"]) +async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_session)): + """ + 🧪 Test de création/lecture/modification d'un utilisateur de test + + Crée un utilisateur de test, le modifie, et vérifie la persistance + """ + import uuid + from database import User + from security.auth import hash_password + + try: + test_email = f"test_{uuid.uuid4().hex[:8]}@example.com" + + # === ÉTAPE 1: CRÉATION === + test_user = User( + id=str(uuid.uuid4()), + email=test_email, + hashed_password=hash_password("TestPassword123!"), + nom="Test", + prenom="User", + role="user", + is_verified=True, + is_active=True, + created_at=datetime.now() + ) + + session.add(test_user) + await session.flush() + user_id = test_user.id + await session.commit() + + logger.info(f"✅ ÉTAPE 1: User créé - {user_id}") + + # === ÉTAPE 2: LECTURE === + result = await session.execute( + select(User).where(User.id == user_id) + ) + loaded_user = result.scalar_one_or_none() + + if not loaded_user: + return { + "success": False, + "error": "❌ User introuvable après création !", + "step": "LECTURE" + } + + logger.info(f"✅ ÉTAPE 2: User chargé - {loaded_user.email}") + + # === ÉTAPE 3: MODIFICATION (simulate reset password) === + loaded_user.hashed_password = hash_password("NewPassword456!") + loaded_user.reset_token = None + loaded_user.reset_token_expires = None + + session.add(loaded_user) + await session.flush() + await session.commit() + await session.refresh(loaded_user) + + logger.info(f"✅ ÉTAPE 3: User modifié") + + # === ÉTAPE 4: RE-LECTURE === + result2 = await session.execute( + select(User).where(User.id == user_id) + ) + reloaded_user = result2.scalar_one_or_none() + + if not reloaded_user: + return { + "success": False, + "error": "❌ User DISPARU après modification !", + "step": "RE-LECTURE", + "user_id": user_id + } + + logger.info(f"✅ ÉTAPE 4: User re-chargé - {reloaded_user.email}") + + # === ÉTAPE 5: SUPPRESSION DU TEST === + await session.delete(reloaded_user) + await session.commit() + + logger.info(f"✅ ÉTAPE 5: User test supprimé") + + return { + "success": True, + "message": "✅ Tous les tests de persistance sont OK", + "test_user_id": user_id, + "test_email": test_email, + "steps_completed": [ + "1. Création", + "2. Lecture", + "3. Modification (reset password simulé)", + "4. Re-lecture (vérification persistance)", + "5. Suppression (cleanup)" + ] + } + + except Exception as e: + logger.error(f"❌ Erreur test persistance: {e}", exc_info=True) + + # Rollback en cas d'erreur + await session.rollback() + + return { + "success": False, + "error": str(e), + "traceback": str(e.__class__.__name__) + } + # ===================================================== # LANCEMENT # ===================================================== diff --git a/core/dependencies.py b/core/dependencies.py index a860f5c..48bb868 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -5,6 +5,7 @@ from sqlalchemy import select from database import get_session, User from security.auth import decode_token from typing import Optional +from datetime import datetime # ✅ AJOUT MANQUANT - C'ÉTAIT LE BUG ! security = HTTPBearer() @@ -75,7 +76,7 @@ async def get_current_user( detail="Email non vérifié. Consultez votre boîte de réception." ) - # Vérifier si le compte est verrouillé + # ✅ FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!) if user.locked_until and user.locked_until > datetime.now(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/email_queue.py b/email_queue.py index 9c8451d..beda62e 100644 --- a/email_queue.py +++ b/email_queue.py @@ -31,7 +31,7 @@ class EmailQueue: self.workers = [] self.running = False self.session_factory = None - self.sage_client = None # Sera injecté depuis api.py + self.sage_client = None def start(self, num_workers: int = 3): """Démarre les workers""" From b4a76579b81936dc262a8cd6a1d5dd6fc4705ff1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 4 Dec 2025 13:47:28 +0300 Subject: [PATCH 03/62] feat: Add API endpoints and SageClient methods for managing prospects, suppliers, credit notes, and delivery notes. --- api.py | 361 ++++++++++++++++++++++++++++++++----------------- sage_client.py | 54 ++++++++ 2 files changed, 288 insertions(+), 127 deletions(-) diff --git a/api.py b/api.py index f01767f..daec94d 100644 --- a/api.py +++ b/api.py @@ -154,8 +154,10 @@ from datetime import datetime # MODÈLES PYDANTIC POUR USERS # ===================================================== + class UserResponse(BaseModel): """Modèle de réponse pour un utilisateur""" + id: str email: str nom: str @@ -166,10 +168,11 @@ class UserResponse(BaseModel): created_at: str last_login: Optional[str] = None failed_login_attempts: int = 0 - + class Config: from_attributes = True + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -300,7 +303,7 @@ async def lifespan(app: FastAPI): # ✅ CORRECTION: Injecter session_factory ET sage_client dans email_queue email_queue.session_factory = async_session_factory email_queue.sage_client = sage_client - + logger.info("✅ sage_client injecté dans email_queue") # Démarrer queue @@ -1583,116 +1586,237 @@ async def statut_queue(): } +# ===================================================== +# ENDPOINTS - PROSPECTS +# ===================================================== +@app.get("/prospects", tags=["Prospects"]) +async def rechercher_prospects(query: Optional[str] = Query(None)): + """🔍 Recherche prospects via gateway Windows""" + try: + prospects = sage_client.lister_prospects(filtre=query or "") + return prospects + except Exception as e: + logger.error(f"Erreur recherche prospects: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/prospects/{code}", tags=["Prospects"]) +async def lire_prospect(code: str): + """📄 Lecture d'un prospect par code""" + try: + prospect = sage_client.lire_prospect(code) + if not prospect: + raise HTTPException(404, f"Prospect {code} introuvable") + return prospect + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture prospect: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - FOURNISSEURS +# ===================================================== +@app.get("/fournisseurs", tags=["Fournisseurs"]) +async def rechercher_fournisseurs(query: Optional[str] = Query(None)): + """🔍 Recherche fournisseurs via gateway Windows""" + try: + fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") + return fournisseurs + except Exception as e: + logger.error(f"Erreur recherche fournisseurs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) +async def lire_fournisseur(code: str): + """📄 Lecture d'un fournisseur par code""" + try: + fournisseur = sage_client.lire_fournisseur(code) + if not fournisseur: + raise HTTPException(404, f"Fournisseur {code} introuvable") + return fournisseur + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture fournisseur: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - AVOIRS +# ===================================================== +@app.get("/avoirs", tags=["Avoirs"]) +async def lister_avoirs( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + """📋 Liste tous les avoirs via gateway Windows""" + try: + avoirs = sage_client.lister_avoirs(limit=limit, statut=statut) + return avoirs + except Exception as e: + logger.error(f"Erreur liste avoirs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/avoirs/{numero}", tags=["Avoirs"]) +async def lire_avoir(numero: str): + """📄 Lecture d'un avoir avec ses lignes""" + try: + avoir = sage_client.lire_avoir(numero) + if not avoir: + raise HTTPException(404, f"Avoir {numero} introuvable") + return avoir + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture avoir: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - LIVRAISONS +# ===================================================== +@app.get("/livraisons", tags=["Livraisons"]) +async def lister_livraisons( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + """📋 Liste tous les bons de livraison via gateway Windows""" + try: + livraisons = sage_client.lister_livraisons(limit=limit, statut=statut) + return livraisons + except Exception as e: + logger.error(f"Erreur liste livraisons: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/livraisons/{numero}", tags=["Livraisons"]) +async def lire_livraison(numero: str): + """📄 Lecture d'une livraison avec ses lignes""" + try: + livraison = sage_client.lire_livraison(numero) + if not livraison: + raise HTTPException(404, f"Livraison {numero} introuvable") + return livraison + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture livraison: {e}") + raise HTTPException(500, str(e)) + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( session: AsyncSession = Depends(get_session), limit: int = Query(100, le=1000), role: Optional[str] = Query(None), - verified_only: bool = Query(False) + verified_only: bool = Query(False), ): """ 🔓 **ROUTE DEBUG** - Liste tous les utilisateurs inscrits - + ⚠️ **ATTENTION**: Cette route n'est PAS protégée par authentification. À utiliser uniquement en développement ou à sécuriser en production. - + Args: limit: Nombre maximum d'utilisateurs à retourner role: Filtrer par rôle (user, admin, commercial) verified_only: Afficher uniquement les utilisateurs vérifiés - + Returns: Liste des utilisateurs avec leurs informations (mot de passe masqué) """ from database import User from sqlalchemy import select - + try: # Construction de la requête query = select(User) - + # Filtres optionnels if role: query = query.where(User.role == role) - + if verified_only: query = query.where(User.is_verified == True) - + # Tri par date de création (plus récents en premier) query = query.order_by(User.created_at.desc()).limit(limit) - + # Exécution result = await session.execute(query) users = result.scalars().all() - + # Conversion en réponse users_response = [] for user in users: - users_response.append(UserResponse( - id=user.id, - email=user.email, - nom=user.nom, - prenom=user.prenom, - role=user.role, - is_verified=user.is_verified, - is_active=user.is_active, - created_at=user.created_at.isoformat() if user.created_at else "", - last_login=user.last_login.isoformat() if user.last_login else None, - failed_login_attempts=user.failed_login_attempts or 0 - )) - - logger.info(f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)") - + users_response.append( + UserResponse( + id=user.id, + email=user.email, + nom=user.nom, + prenom=user.prenom, + role=user.role, + is_verified=user.is_verified, + is_active=user.is_active, + created_at=user.created_at.isoformat() if user.created_at else "", + last_login=user.last_login.isoformat() if user.last_login else None, + failed_login_attempts=user.failed_login_attempts or 0, + ) + ) + + logger.info( + f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)" + ) + return users_response - + except Exception as e: logger.error(f"❌ Erreur liste utilisateurs: {e}") raise HTTPException(500, str(e)) @app.get("/debug/users/stats", tags=["Debug"]) -async def statistiques_utilisateurs( - session: AsyncSession = Depends(get_session) -): +async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)): """ 📊 **ROUTE DEBUG** - Statistiques sur les utilisateurs - + ⚠️ Non protégée - à sécuriser en production """ from database import User from sqlalchemy import select, func - + try: # Total utilisateurs total_query = select(func.count(User.id)) total_result = await session.execute(total_query) total = total_result.scalar() - + # Utilisateurs vérifiés verified_query = select(func.count(User.id)).where(User.is_verified == True) verified_result = await session.execute(verified_query) verified = verified_result.scalar() - + # Utilisateurs actifs active_query = select(func.count(User.id)).where(User.is_active == True) active_result = await session.execute(active_query) active = active_result.scalar() - + # Par rôle roles_query = select(User.role, func.count(User.id)).group_by(User.role) roles_result = await session.execute(roles_query) roles_stats = {role: count for role, count in roles_result.all()} - + return { "total_utilisateurs": total, "utilisateurs_verifies": verified, "utilisateurs_actifs": active, "utilisateurs_non_verifies": total - verified, "repartition_roles": roles_stats, - "taux_verification": f"{(verified/total*100):.1f}%" if total > 0 else "0%" + "taux_verification": f"{(verified/total*100):.1f}%" if total > 0 else "0%", } - + except Exception as e: logger.error(f"❌ Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) @@ -1700,25 +1824,24 @@ async def statistiques_utilisateurs( @app.get("/debug/users/{user_id}", response_model=UserResponse, tags=["Debug"]) async def lire_utilisateur_debug( - user_id: str, - session: AsyncSession = Depends(get_session) + user_id: str, session: AsyncSession = Depends(get_session) ): """ 👤 **ROUTE DEBUG** - Détails d'un utilisateur par ID - + ⚠️ Non protégée - à sécuriser en production """ from database import User from sqlalchemy import select - + try: query = select(User).where(User.id == user_id) result = await session.execute(query) user = result.scalar_one_or_none() - + if not user: raise HTTPException(404, f"Utilisateur {user_id} introuvable") - + return UserResponse( id=user.id, email=user.email, @@ -1729,9 +1852,9 @@ async def lire_utilisateur_debug( is_active=user.is_active, created_at=user.created_at.isoformat() if user.created_at else "", last_login=user.last_login.isoformat() if user.last_login else None, - failed_login_attempts=user.failed_login_attempts or 0 + failed_login_attempts=user.failed_login_attempts or 0, ) - + except HTTPException: raise except Exception as e: @@ -1739,98 +1862,85 @@ async def lire_utilisateur_debug( raise HTTPException(500, str(e)) -# À ajouter dans api.py dans la section Debug - @app.get("/debug/database/check", tags=["Debug"]) async def verifier_integrite_database(session: AsyncSession = Depends(get_session)): """ 🔍 Vérification de l'intégrité de la base de données - + Retourne des statistiques détaillées sur toutes les tables """ from database import User, RefreshToken, LoginAttempt, EmailLog, SignatureLog from sqlalchemy import func, text - + try: diagnostics = {} - + # === TABLE USERS === # Compter tous les users total_users = await session.execute(select(func.count(User.id))) - diagnostics["users"] = { - "total": total_users.scalar(), - "details": [] - } - + diagnostics["users"] = {"total": total_users.scalar(), "details": []} + # Lister tous les users avec détails all_users = await session.execute(select(User)) users_list = all_users.scalars().all() - + for u in users_list: - diagnostics["users"]["details"].append({ - "id": u.id, - "email": u.email, - "nom": f"{u.prenom} {u.nom}", - "role": u.role, - "is_active": u.is_active, - "is_verified": u.is_verified, - "created_at": u.created_at.isoformat() if u.created_at else None, - "has_reset_token": u.reset_token is not None, - "has_verification_token": u.verification_token is not None, - }) - + diagnostics["users"]["details"].append( + { + "id": u.id, + "email": u.email, + "nom": f"{u.prenom} {u.nom}", + "role": u.role, + "is_active": u.is_active, + "is_verified": u.is_verified, + "created_at": u.created_at.isoformat() if u.created_at else None, + "has_reset_token": u.reset_token is not None, + "has_verification_token": u.verification_token is not None, + } + ) + # === TABLE REFRESH_TOKENS === total_tokens = await session.execute(select(func.count(RefreshToken.id))) - diagnostics["refresh_tokens"] = { - "total": total_tokens.scalar() - } - + diagnostics["refresh_tokens"] = {"total": total_tokens.scalar()} + # === TABLE LOGIN_ATTEMPTS === total_attempts = await session.execute(select(func.count(LoginAttempt.id))) - diagnostics["login_attempts"] = { - "total": total_attempts.scalar() - } - + diagnostics["login_attempts"] = {"total": total_attempts.scalar()} + # === TABLE EMAIL_LOGS === total_emails = await session.execute(select(func.count(EmailLog.id))) - diagnostics["email_logs"] = { - "total": total_emails.scalar() - } - + diagnostics["email_logs"] = {"total": total_emails.scalar()} + # === TABLE SIGNATURE_LOGS === total_signatures = await session.execute(select(func.count(SignatureLog.id))) - diagnostics["signature_logs"] = { - "total": total_signatures.scalar() - } - + diagnostics["signature_logs"] = {"total": total_signatures.scalar()} + # === VÉRIFIER LES FICHIERS SQLITE === import os + db_file = "sage_dataven.db" diagnostics["database_file"] = { "exists": os.path.exists(db_file), "size_bytes": os.path.getsize(db_file) if os.path.exists(db_file) else 0, - "path": os.path.abspath(db_file) + "path": os.path.abspath(db_file), } - + # === TESTER UNE REQUÊTE RAW SQL === try: raw_count = await session.execute(text("SELECT COUNT(*) FROM users")) diagnostics["raw_sql_check"] = { "users_count": raw_count.scalar(), - "status": "✅ Connexion DB OK" + "status": "✅ Connexion DB OK", } except Exception as e: - diagnostics["raw_sql_check"] = { - "status": "❌ Erreur", - "error": str(e) - } - + diagnostics["raw_sql_check"] = {"status": "❌ Erreur", "error": str(e)} + return { "success": True, "timestamp": datetime.now().isoformat(), - "diagnostics": diagnostics + "diagnostics": diagnostics, } - + except Exception as e: logger.error(f"❌ Erreur diagnostic DB: {e}", exc_info=True) raise HTTPException(500, f"Erreur diagnostic: {str(e)}") @@ -1840,7 +1950,7 @@ async def verifier_integrite_database(session: AsyncSession = Depends(get_sessio async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_session)): """ 🧪 Test de création/lecture/modification d'un utilisateur de test - + Crée un utilisateur de test, le modifie, et vérifie la persistance """ import uuid @@ -1849,7 +1959,7 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses try: test_email = f"test_{uuid.uuid4().hex[:8]}@example.com" - + # === ÉTAPE 1: CRÉATION === test_user = User( id=str(uuid.uuid4()), @@ -1860,65 +1970,61 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses role="user", is_verified=True, is_active=True, - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(test_user) await session.flush() user_id = test_user.id await session.commit() - + logger.info(f"✅ ÉTAPE 1: User créé - {user_id}") - + # === ÉTAPE 2: LECTURE === - result = await session.execute( - select(User).where(User.id == user_id) - ) + result = await session.execute(select(User).where(User.id == user_id)) loaded_user = result.scalar_one_or_none() - + if not loaded_user: return { "success": False, "error": "❌ User introuvable après création !", - "step": "LECTURE" + "step": "LECTURE", } - + logger.info(f"✅ ÉTAPE 2: User chargé - {loaded_user.email}") - + # === ÉTAPE 3: MODIFICATION (simulate reset password) === loaded_user.hashed_password = hash_password("NewPassword456!") loaded_user.reset_token = None loaded_user.reset_token_expires = None - + session.add(loaded_user) await session.flush() await session.commit() await session.refresh(loaded_user) - + logger.info(f"✅ ÉTAPE 3: User modifié") - + # === ÉTAPE 4: RE-LECTURE === - result2 = await session.execute( - select(User).where(User.id == user_id) - ) + result2 = await session.execute(select(User).where(User.id == user_id)) reloaded_user = result2.scalar_one_or_none() - + if not reloaded_user: return { "success": False, "error": "❌ User DISPARU après modification !", "step": "RE-LECTURE", - "user_id": user_id + "user_id": user_id, } - + logger.info(f"✅ ÉTAPE 4: User re-chargé - {reloaded_user.email}") - + # === ÉTAPE 5: SUPPRESSION DU TEST === await session.delete(reloaded_user) await session.commit() - + logger.info(f"✅ ÉTAPE 5: User test supprimé") - + return { "success": True, "message": "✅ Tous les tests de persistance sont OK", @@ -1929,22 +2035,23 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses "2. Lecture", "3. Modification (reset password simulé)", "4. Re-lecture (vérification persistance)", - "5. Suppression (cleanup)" - ] + "5. Suppression (cleanup)", + ], } - + except Exception as e: logger.error(f"❌ Erreur test persistance: {e}", exc_info=True) - + # Rollback en cas d'erreur await session.rollback() - + return { "success": False, "error": str(e), - "traceback": str(e.__class__.__name__) + "traceback": str(e.__class__.__name__), } + # ===================================================== # LANCEMENT # ===================================================== diff --git a/sage_client.py b/sage_client.py index 9ea5265..bc5568f 100644 --- a/sage_client.py +++ b/sage_client.py @@ -256,6 +256,60 @@ class SageGatewayClient: logger.error(f"Erreur génération PDF: {e}") raise + # ===================================================== + # PROSPECTS + # ===================================================== + def lister_prospects(self, filtre: str = "") -> List[Dict]: + """Liste tous les prospects avec filtre optionnel""" + return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", []) + + def lire_prospect(self, code: str) -> Optional[Dict]: + """Lecture d'un prospect par code""" + return self._post("/sage/prospects/get", {"code": code}).get("data") + + # ===================================================== + # FOURNISSEURS + # ===================================================== + def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: + """Liste tous les fournisseurs avec filtre optionnel""" + return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", []) + + def lire_fournisseur(self, code: str) -> Optional[Dict]: + """Lecture d'un fournisseur par code""" + return self._post("/sage/fournisseurs/get", {"code": code}).get("data") + + # ===================================================== + # AVOIRS + # ===================================================== + def lister_avoirs( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + """Liste tous les avoirs""" + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/avoirs/list", payload).get("data", []) + + def lire_avoir(self, numero: str) -> Optional[Dict]: + """Lecture d'un avoir avec ses lignes""" + return self._post("/sage/avoirs/get", {"code": numero}).get("data") + + # ===================================================== + # LIVRAISONS + # ===================================================== + def lister_livraisons( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + """Liste tous les bons de livraison""" + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/livraisons/list", payload).get("data", []) + + def lire_livraison(self, numero: str) -> Optional[Dict]: + """Lecture d'une livraison avec ses lignes""" + return self._post("/sage/livraisons/get", {"code": numero}).get("data") + # ===================================================== # CACHE (ADMIN) # ===================================================== From 2bf982f60e6df60796b02c4b1d3d822d35b9c304 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 13:34:40 +0300 Subject: [PATCH 04/62] clearing insecable spaces --- api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api.py b/api.py index daec94d..b11a450 100644 --- a/api.py +++ b/api.py @@ -97,6 +97,10 @@ class LigneDevis(BaseModel): quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 + + @validator("article_code", pre=True) + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() class DevisRequest(BaseModel): From 511435d58e0daf893a282cfd2c91cf81d0c4188f Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 14:00:40 +0300 Subject: [PATCH 05/62] moved database in WORKDIR/data --- .env.example | 2 +- api.py | 4 ++-- config.py | 2 +- database/db_config.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 0995196..314aa07 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ SAGE_GATEWAY_URL=http://192.168.1.50:8100 SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f # === Base de données === -DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db +DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db # === SMTP === SMTP_HOST=smtp.office365.com diff --git a/api.py b/api.py index b11a450..6a0dc37 100644 --- a/api.py +++ b/api.py @@ -1,7 +1,7 @@ from fastapi import FastAPI, HTTPException, Query, Depends, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field, EmailStr +from pydantic import BaseModel, Field, EmailStr, validator, field_validator from typing import List, Optional, Dict from datetime import date, datetime from enum import Enum @@ -98,7 +98,7 @@ class LigneDevis(BaseModel): prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - @validator("article_code", pre=True) + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() diff --git a/config.py b/config.py index bc36e64..7e1c020 100644 --- a/config.py +++ b/config.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): frontend_url: str # === Base de données === - database_url: str = "sqlite+aiosqlite:///./sage_dataven.db" + database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db" # === SMTP === smtp_host: str diff --git a/database/db_config.py b/database/db_config.py index 0bbba98..1973799 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -6,7 +6,7 @@ import logging logger = logging.getLogger(__name__) -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./sage_dataven.db") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./data/sage_dataven.db") engine = create_async_engine( DATABASE_URL, From 588ea6c4f41bb8625b7bb0bd06fdf79bfc1eff24 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 14:17:32 +0300 Subject: [PATCH 06/62] chore: update gitignore and docker configuration --- .gitignore | 4 +--- data/sage_dataven.db | Bin 0 -> 147456 bytes docker-compose.yml | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 data/sage_dataven.db diff --git a/.gitignore b/.gitignore index 023d3fc..e1a8191 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,4 @@ htmlcov/ # Docker *~ .build/ -dist/ - -*.db +dist/ \ No newline at end of file diff --git a/data/sage_dataven.db b/data/sage_dataven.db new file mode 100644 index 0000000000000000000000000000000000000000..25506034829bb9e4f108f3f871e9a7db96753c11 GIT binary patch literal 147456 zcmeI5TW{RP6~{%{TCP@-W%PYs^f8akLq9@MG+&`Wzd(UJq;CcC(lf&)xrcj^)hv(|@HgOSXU@zy zXMc0fnHe%lesg=(i(Gxn4;ogaUmiO-rYK{-*7dQmu?zI~HTs+W5$)q?gMKOk0;CJ{nA(Em69?M9X~zs&iJ?Ej`F>-r>rS! z{S=ONzj3alUD1>=uj#mt!q4l3*0kctmu)i+-5@j<ZId4_Z(Lhh-k4vwbje`y z9Bs6>iYU> zZFx=TaoouA>Y=__ySpjcZV(8SX|rP;^baU{HgDXjv9Iva#*JIc8~5~^wR`%!=NJ{^ zNp)7$3`5y}EK%B)z3rL}GDEs7-a5&Ua+yBg7i%)Gh>W>ax59|>V2dDZXO4^SM=8}X zZ9i^C>57To_DIu-153oO*$DLySJ#&%5~+*0_&FBY>Q+;PAynU4yK(zYO`p%5CPqsq zOWL~8FVQ&&bAh+#T9p&3cI%?jdLzws!W>N&0aJHBrOllUU ziezB}^!dUFqovNM+D&rCIKUa(qr76uPCTbCa^@gcIVaCEUQnF(QT;+ zxQ5@1EL#2TC=cPZFN*BEQ|5LQSj{lmzA5grRAJr@sx{perC34Yz@_|WE*A5oV!S(D z5+U7Z=VNPRF0kfIYrLXr*A1mrO(QTQCRRjt3;EzVTryP*KrYU+f$A@10+)8A0+qdR zQq``I4>Nsy2qSVR;uPvYbea)?8WDZj;r;K33fZ)pj$nYI#%(8JNif>=$SE z@}xM%Xq{52IFV0FeSDGumu!r5JmDUd+a9%RW_D8HqfD-%ms@;<@w7A{x1?v+Woh8* zc&pTQA=%}*?0Kt0VY_CzBr8Lj{%P9s5h5cqK&-}y^cF6;ff)z&OtjcO6BHLC8}R1h zA}?f3ztIsE{l~RKFt1PY6d?GuWOqiaTSS{|9I|d#ZqjwDX}g_g5-Mwf zD|=bh2rMt8tvOMX9#H74lbcNaY@c?z%gj<~(7u}~O6tY}Z>#r@^K)o|Tb{TRPI<>f zF|~4$nWuH$sn54lIzCo7*5_kPhj4jR*hD7h-Ny%*>YO3cB%ejqt3#Wyzo*FK@3Sj1 zLGp3=r*W1TdH+F4(P5^4oY{7E;jq*Q%BBia6xVNjm`XH_HQ#EO@CMUditBn+~niQ za(Tb>Re7bPOhm^|PrNh!?YN_Suk0yn%G!v3rJvBf^2)@OE1ELqH68a+_<7xnT+@mp zU$)ISbc4`bkUzBejH)eHmDX7)^B&PS$BWFm{~$C2Hw^tauwB!0^6s6z%QML8>sZQBj?tLy8lwdFOT$8jUetB3k#?e3;%yFnmSrp=CZ z&_AH)*}QS9#=gQw8#it(Z`{*w*6!)^o?}#u^2+!l%`lYx#}cJ&+1sw!ATy-P;;oYm zDVOQX=1c=vZSpW{SuvnFc)}xuDqgX+O3O9>y0!c z9{Ry&TXp|olKzfw#|^g`nMn~~!r5YiFsWIXDw2f_(B}&yjFvj1YB$Ll;{a!DkMfEs zJMo;p$eDv&kpqnV_oh_s;zi}l%QEhmDb|=~QO88>pi7hO^4mOL3tv9VuCBxuvDwC- zh8YXbOi}XPQhUAiBx8Ejm*#!5?mcKyMz^IR;2M52vS{_UqdbJuz9_QuPMO-?1eM={`FjTO)IUHD_Am6;-=#D6MK5 zfgv%mBC=b^2hZV>sbT5Vv?CR$?1hu6c7=SH>ElBfkwX#JP&vSSGN+g2 zL}Ik&%BpsotSNcphn~7D+qgu$oOdeprIJ=i8#W6deJE`zdCRfqREk43{TAEPBmh|kpEDc;8Z{IhI?VKsGuzHi zoVM*{pTqc5H_B#-FlU)!`@v2~`&^?gXE0fsVxq=m`g$gxOe$HPGfNA}8CwRl9k>Iw z#zCpz$GdJIwjmbt6x5TgZI5wtV~>-Oc7;?_v`agm+^(JYS+l(`92mSuR*oNYV7doKR>`~ zHm#TCOlbV!RaL8z^_Tlt&+pjW{Lc-MKbIO$UVTNLS@!p2N#<5~tIA3#p_~dT?cdJB zceD@j-dBr(!o}&NUZy2>x^z7G-HS3ro>Cgn<(eztt(RGdaM=Mnc-vaGUd2ms4k8=U z6-$o0>xsva%`HD0lX1iL-k1zF!#g1_z1Y|Rf zE^?)G+VHkKJ9&U5S4GacFwjExqz-f4Lc`TmZINtGaoe;^Zj@w}Ka=eWce-9> zVczvjXD4t6$^*aN^(dGkIav!H%H8XenN6!|_Ykw4R#{Go=qCf>%DKz#VWMEu_bqHF z9gmvozD>zx(!*4*&eU}*WtT@CvYxISl-2Q$ICQhD7vDfWrIoHSPrB0@Z%)KJqT~TX z{&oXzPGlPVq;F1s)LVfk)!F2{h}SM+C40VhX;n_p!%4h3$sUd*Z%*EwE(t3C&%<~8 z`~OGpDi4oA00ck)1V8`;KmY_l00ck)1VG@h1aSX9EHR7+0T2KI5C8!X009sH0T2KI z5CDOrM*#Q#qqk=87z9871V8`;KmY_l00ck)1V8`;4od+4|G&c$!*~z?0T2KI5C8!X z009sH0T2KI5IA}S@c#ektrjO&)(jql00@8p2!H?xfB*=900@8p2!Oz0 z3E=*JSYj9t0w4eaAOHd&00JNY0w4eaAOHeKj{xrfM{mvGF$jPF2!H?xfB*=900@8p M2!H?x9G1ZU06#Eq0{{R3 literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml index 3787019..e9ee1bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: container_name: vps-sage-api env_file: .env volumes: - # ✅ Monter un DOSSIER entier au lieu d'un fichier - ./data:/app/data ports: - "8000:8000" From df5ed76ec678891731400f041ce2f5236ffc1e95 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 14:44:53 +0300 Subject: [PATCH 07/62] feat(api): add endpoint to read order with its lines --- api.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/api.py b/api.py index 6a0dc37..81e7e3a 100644 --- a/api.py +++ b/api.py @@ -550,6 +550,20 @@ async def changer_statut_devis(id: str, nouveau_statut: int = Query(..., ge=0, l # ENDPOINTS - US-A2 (WORKFLOW SANS RESSAISIE) # ===================================================== +@app.get("/commandes/{id}", tags=["US-A2"]) +async def lire_commande(id: str): + """📄 Lecture d'une commande avec ses lignes""" + try: + commande = sage_client.lire_document(id, TypeDocument.BON_COMMANDE) + if not commande: + raise HTTPException(404, f"Commande {id} introuvable") + return commande + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture commande: {e}") + raise HTTPException(500, str(e)) + @app.get("/commandes", tags=["US-A2"]) async def lister_commandes( From 77dcb21e4a99cfb7957ad332d24a31e154233023 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 18:21:52 +0300 Subject: [PATCH 08/62] Exceeded access token for 24 hours --- routes/auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 961f1c3..771cb38 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -45,7 +45,7 @@ class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" - expires_in: int = 1800 # 30 minutes en secondes + expires_in: int = 86400 # 30 minutes en secondes class RefreshTokenRequest(BaseModel): @@ -432,7 +432,7 @@ async def login( return TokenResponse( access_token=access_token, refresh_token=refresh_token_jwt, - expires_in=1800 # 30 minutes + expires_in=86400 # 30 minutes ) @@ -502,7 +502,7 @@ async def refresh_access_token( return TokenResponse( access_token=new_access_token, refresh_token=data.refresh_token, # Refresh token reste le même - expires_in=1800 + expires_in=86400 ) From 2f9b2fc1a9799cd6cc013a543c2641eaabe91aa0 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 19:11:36 +0300 Subject: [PATCH 09/62] Added create client logics --- api.py | 38 +++++++++++++++++++++++++++++++++++++- sage_client.py | 8 ++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/api.py b/api.py index 81e7e3a..555f6b7 100644 --- a/api.py +++ b/api.py @@ -148,7 +148,19 @@ class BaremeRemiseResponse(BaseModel): message: str -# À ajouter dans api.py après les imports et avant les endpoints existants +class ClientCreateAPIRequest(BaseModel): + intitule: str = Field(..., min_length=1, description="Raison sociale ou Nom") + compte_collectif: str = Field("411000", description="Compte Comptable (ex: 411000)") + num: Optional[str] = Field(None, description="Code client souhaité (optionnel)") + adresse: Optional[str] = None + code_postal: Optional[str] = None + ville: Optional[str] = None + pays: Optional[str] = None + email: Optional[EmailStr] = None + telephone: Optional[str] = None + siret: Optional[str] = None + tva_intra: Optional[str] = None + from pydantic import BaseModel from typing import List, Optional @@ -356,6 +368,30 @@ async def rechercher_clients(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) +@app.post("/clients", status_code=201, tags=["US-A8"]) +async def ajouter_client( + client: ClientCreateAPIRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'un nouveau client dans Sage 100c + """ + try: + nouveau_client = sage_client.creer_client(client.dict()) + + logger.info(f"✅ Client créé via API: {nouveau_client.get('numero')}") + + return { + "success": True, + "message": "Client créé avec succès", + "client": nouveau_client + } + + except Exception as e: + logger.error(f"Erreur lors de la création du client: {e}") + # On renvoie une 400 si c'est une erreur métier (ex: doublon), sinon 500 + status = 400 if "existe déjà" in str(e) else 500 + raise HTTPException(status, str(e)) @app.get("/articles", response_model=List[ArticleResponse], tags=["US-A1"]) async def rechercher_articles(query: Optional[str] = Query(None)): diff --git a/sage_client.py b/sage_client.py index bc5568f..0037c63 100644 --- a/sage_client.py +++ b/sage_client.py @@ -331,6 +331,14 @@ class SageGatewayClient: return r.json() except: return {"status": "down"} + + def creer_client(self, client_data: Dict) -> Dict: + """ + Envoie la requête de création de client à la gateway Windows. + :param client_data: Dict contenant intitule, compte_collectif, etc. + """ + # On appelle la route définie dans main.py + return self._post("/sage/clients/create", client_data).get("data", {}) # Instance globale From 36554b9ebecb800b347f772161b8961e5b203941 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 10:01:18 +0300 Subject: [PATCH 10/62] changed return field from client to data --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 555f6b7..61e9a85 100644 --- a/api.py +++ b/api.py @@ -384,7 +384,7 @@ async def ajouter_client( return { "success": True, "message": "Client créé avec succès", - "client": nouveau_client + "data": nouveau_client } except Exception as e: From 72bd14a44ecb749fda0c74ffdd739a92d3c92ac1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 10:14:58 +0300 Subject: [PATCH 11/62] Updates for GET on client & facture, and PUT on clients --- api.py | 126 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 20 ++++++-- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/api.py b/api.py index 61e9a85..6b5d2cb 100644 --- a/api.py +++ b/api.py @@ -162,6 +162,31 @@ class ClientCreateAPIRequest(BaseModel): tva_intra: Optional[str] = None +class ClientUpdateRequest(BaseModel): + """Modèle pour modification d'un client existant""" + intitule: Optional[str] = Field(None, min_length=1, max_length=69) + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "SARL TEST MODIFIÉ", + "adresse": "456 Avenue des Champs", + "code_postal": "75008", + "ville": "Paris", + "email": "nouveau@email.fr", + "telephone": "0198765432" + } + } + + from pydantic import BaseModel from typing import List, Optional from datetime import datetime @@ -368,6 +393,79 @@ async def rechercher_clients(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) +@app.get("/clients/{code}", tags=["US-A1"]) +async def lire_client_detail(code: str): + """ + 📄 Lecture détaillée d'un client par son code + + Args: + code: Code du client (ex: "CLI000001", "SARL", etc.) + + Returns: + Toutes les informations du client + """ + try: + client = sage_client.lire_client(code) + + if not client: + raise HTTPException(404, f"Client {code} introuvable") + + return { + "success": True, + "data": client + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture client {code}: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/clients/{code}", tags=["US-A1"]) +async def modifier_client( + code: str, + client_update: ClientUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'un client existant + + Args: + code: Code du client à modifier + client_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) + + Returns: + Client modifié avec ses nouvelles valeurs + + Example: + PUT /clients/SARL + { + "email": "nouveau@email.fr", + "telephone": "0198765432" + } + """ + try: + # Appel à la gateway Windows + resultat = sage_client.modifier_client(code, client_update.dict(exclude_none=True)) + + logger.info(f"✅ Client {code} modifié avec succès") + + return { + "success": True, + "message": f"Client {code} modifié avec succès", + "client": resultat + } + + except ValueError as e: + # Erreur métier (client introuvable, etc.) + logger.warning(f"Erreur métier modification client {code}: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + # Erreur technique + logger.error(f"Erreur technique modification client {code}: {e}") + raise HTTPException(500, str(e)) + @app.post("/clients", status_code=201, tags=["US-A8"]) async def ajouter_client( client: ClientCreateAPIRequest, @@ -1213,6 +1311,34 @@ async def lister_factures( raise HTTPException(500, str(e)) +@app.get("/factures/{numero}", tags=["US-A7"]) +async def lire_facture_detail(numero: str): + """ + 📄 Lecture détaillée d'une facture avec ses lignes + + Args: + numero: Numéro de la facture (ex: "FA000001") + + Returns: + Facture complète avec lignes, client, totaux, etc. + """ + try: + facture = sage_client.lire_document(numero, TypeDocument.FACTURE) + + if not facture: + raise HTTPException(404, f"Facture {numero} introuvable") + + return { + "success": True, + "data": facture + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture facture {numero}: {e}") + raise HTTPException(500, str(e)) + class RelanceFactureRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None diff --git a/sage_client.py b/sage_client.py index 0037c63..732fe2d 100644 --- a/sage_client.py +++ b/sage_client.py @@ -191,9 +191,6 @@ class SageGatewayClient: payload["statut"] = statut return self._post("/sage/commandes/list", payload).get("data", []) - # ===================================================== - # FACTURES (US-A7) - # ===================================================== def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: @@ -339,6 +336,23 @@ class SageGatewayClient: """ # On appelle la route définie dans main.py return self._post("/sage/clients/create", client_data).get("data", {}) + + def modifier_client(self, code: str, client_data: Dict) -> Dict: + """ + ✏️ Modification d'un client existant + + Args: + code: Code du client à modifier + client_data: Dictionnaire contenant les champs à modifier + (seuls les champs présents seront mis à jour) + + Returns: + Client modifié + """ + return self._post("/sage/clients/update", { + "code": code, + "client_data": client_data + }).get("data", {}) # Instance globale From 4867f4dc225b3611e2d68f5fef355f9a156c8d5e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 12:15:36 +0300 Subject: [PATCH 12/62] Cache problem --- api.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/api.py b/api.py index 6b5d2cb..f5951f2 100644 --- a/api.py +++ b/api.py @@ -2231,7 +2231,88 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses "traceback": str(e.__class__.__name__), } +@app.get("/debug/fournisseurs/cache", tags=["Debug"]) +async def debug_cache_fournisseurs(): + """ + 🔍 Debug : État du cache côté VPS Linux + """ + try: + # Appeler la gateway Windows pour récupérer l'info cache + cache_info = sage_client.get_cache_info() + + # Tenter de lister les fournisseurs + try: + fournisseurs = sage_client.lister_fournisseurs(filtre="") + nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 + exemple = fournisseurs[:3] if fournisseurs else [] + except Exception as e: + nb_fournisseurs = -1 + exemple = [] + error = str(e) + + return { + "success": True, + "cache_info_windows": cache_info, + "test_liste_fournisseurs": { + "nb_fournisseurs": nb_fournisseurs, + "exemples": exemple, + "erreur": error if nb_fournisseurs == -1 else None + }, + "diagnostic": { + "gateway_accessible": cache_info is not None, + "cache_fournisseurs_existe": "fournisseurs" in cache_info if cache_info else False, + "probleme_probable": ( + "Cache fournisseurs non initialisé côté Windows" + if cache_info and "fournisseurs" not in cache_info + else "OK" if nb_fournisseurs > 0 + else "Erreur lors de la récupération" + ) + } + } + + except Exception as e: + logger.error(f"❌ Erreur debug cache: {e}", exc_info=True) + raise HTTPException(500, str(e)) + +@app.post("/debug/fournisseurs/force-refresh", tags=["Debug"]) +async def force_refresh_fournisseurs(): + """ + 🔄 Force le refresh du cache fournisseurs côté Windows + """ + try: + # Appeler la gateway Windows pour forcer le refresh + resultat = sage_client.refresh_cache() + + # Attendre 2 secondes + import time + time.sleep(2) + + # Récupérer le cache info après refresh + cache_info = sage_client.get_cache_info() + + # Tester la liste + fournisseurs = sage_client.lister_fournisseurs(filtre="") + nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 + + return { + "success": True, + "refresh_result": resultat, + "cache_apres_refresh": cache_info, + "nb_fournisseurs_maintenant": nb_fournisseurs, + "exemples": fournisseurs[:3] if fournisseurs else [], + "message": ( + f"✅ Refresh OK : {nb_fournisseurs} fournisseurs disponibles" + if nb_fournisseurs > 0 + else "❌ Problème : aucun fournisseur après refresh" + ) + } + + except Exception as e: + logger.error(f"❌ Erreur force refresh: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + # ===================================================== # LANCEMENT # ===================================================== From 2c13c086a56b20727a1c9a8873f46a074d87f566 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 12:50:31 +0300 Subject: [PATCH 13/62] Evicted passing through cache for "fournisseurs" --- api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index f5951f2..48f8ff1 100644 --- a/api.py +++ b/api.py @@ -1800,12 +1800,16 @@ async def lire_prospect(code: str): # ===================================================== @app.get("/fournisseurs", tags=["Fournisseurs"]) async def rechercher_fournisseurs(query: Optional[str] = Query(None)): - """🔍 Recherche fournisseurs via gateway Windows""" + """ + 🔍 Recherche fournisseurs via gateway Windows + ✅ CORRECTION : Utilise maintenant l'endpoint direct (pas de cache) + """ try: fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") + logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés") return fournisseurs except Exception as e: - logger.error(f"Erreur recherche fournisseurs: {e}") + logger.error(f"❌ Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) From 709de0cb2c2afd2763ecd8825a6355c77610a38f Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 14:10:39 +0300 Subject: [PATCH 14/62] Test, again --- api.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 48f8ff1..fbab037 100644 --- a/api.py +++ b/api.py @@ -1802,12 +1802,19 @@ async def lire_prospect(code: str): async def rechercher_fournisseurs(query: Optional[str] = Query(None)): """ 🔍 Recherche fournisseurs via gateway Windows - ✅ CORRECTION : Utilise maintenant l'endpoint direct (pas de cache) + ✅ CORRECTION : Appel direct sans cache """ try: + # ✅ APPEL DIRECT vers Windows (pas de cache) fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") - logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés") + + logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés depuis Windows") + + if len(fournisseurs) == 0: + logger.warning("⚠️ Aucun fournisseur retourné - vérifier la gateway Windows") + return fournisseurs + except Exception as e: logger.error(f"❌ Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) From ba793543861fce0a01289b3b70f46078f5be6e11 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 15:03:23 +0300 Subject: [PATCH 15/62] Create new fournisseur --- api.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 12 ++++++++ 2 files changed, 88 insertions(+) diff --git a/api.py b/api.py index fbab037..16a7acf 100644 --- a/api.py +++ b/api.py @@ -214,6 +214,36 @@ class UserResponse(BaseModel): from_attributes = True +class FournisseurCreateAPIRequest(BaseModel): + intitule: str = Field(..., min_length=1, max_length=69, description="Raison sociale du fournisseur") + compte_collectif: str = Field("401000", description="Compte comptable fournisseur (ex: 401000)") + num: Optional[str] = Field(None, max_length=17, description="Code fournisseur souhaité (optionnel)") + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "ACME SUPPLIES SARL", + "compte_collectif": "401000", + "num": "FOUR001", + "adresse": "15 Rue du Commerce", + "code_postal": "75001", + "ville": "Paris", + "pays": "France", + "email": "contact@acmesupplies.fr", + "telephone": "0145678901", + "siret": "12345678901234", + "tva_intra": "FR12345678901" + } + } + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1819,6 +1849,52 @@ async def rechercher_fournisseurs(query: Optional[str] = Query(None)): logger.error(f"❌ Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) +@app.post("/fournisseurs", status_code=201, tags=["Fournisseurs"]) +async def ajouter_fournisseur( + fournisseur: FournisseurCreateAPIRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'un nouveau fournisseur dans Sage 100c + + **Champs obligatoires:** + - `intitule`: Raison sociale (max 69 caractères) + + **Champs optionnels:** + - `compte_collectif`: Compte comptable (défaut: 401000) + - `num`: Code fournisseur personnalisé (auto-généré si vide) + - `adresse`, `code_postal`, `ville`, `pays` + - `email`, `telephone` + - `siret`, `tva_intra` + + **Retour:** + - Fournisseur créé avec son numéro définitif + + **Erreurs possibles:** + - 400: Fournisseur existe déjà (doublon) + - 500: Erreur technique Sage + """ + try: + # Appel à la gateway Windows via sage_client + nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) + + logger.info(f"✅ Fournisseur créé via API: {nouveau_fournisseur.get('numero')}") + + return { + "success": True, + "message": "Fournisseur créé avec succès", + "data": nouveau_fournisseur + } + + except ValueError as e: + # Erreur métier (doublon, validation) + logger.warning(f"⚠️ Erreur métier création fournisseur: {e}") + raise HTTPException(400, str(e)) + + except Exception as e: + # Erreur technique (COM, connexion) + logger.error(f"❌ Erreur technique création fournisseur: {e}") + raise HTTPException(500, str(e)) @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): diff --git a/sage_client.py b/sage_client.py index 732fe2d..3074119 100644 --- a/sage_client.py +++ b/sage_client.py @@ -275,6 +275,18 @@ class SageGatewayClient: """Lecture d'un fournisseur par code""" return self._post("/sage/fournisseurs/get", {"code": code}).get("data") + def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: + """ + Envoie la requête de création de fournisseur à la gateway Windows. + + Args: + fournisseur_data: Dict contenant intitule, compte_collectif, etc. + + Returns: + Fournisseur créé avec son numéro définitif + """ + return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) + # ===================================================== # AVOIRS # ===================================================== From a5dd81ddfb3204620e1aa9d3adb8693005ab8025 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 15:17:15 +0300 Subject: [PATCH 16/62] Update fournisseur --- api.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 17 +++++++++++++ 2 files changed, 84 insertions(+) diff --git a/api.py b/api.py index 16a7acf..a49060c 100644 --- a/api.py +++ b/api.py @@ -244,6 +244,28 @@ class FournisseurCreateAPIRequest(BaseModel): } } +class FournisseurUpdateRequest(BaseModel): + """Modèle pour modification d'un fournisseur existant""" + intitule: Optional[str] = Field(None, min_length=1, max_length=69) + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "ACME SUPPLIES MODIFIÉ", + "email": "nouveau@acme.fr", + "telephone": "0198765432" + } + } + + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1896,6 +1918,51 @@ async def ajouter_fournisseur( logger.error(f"❌ Erreur technique création fournisseur: {e}") raise HTTPException(500, str(e)) +@app.put("/fournisseurs/{code}", tags=["Fournisseurs"]) +async def modifier_fournisseur( + code: str, + fournisseur_update: FournisseurUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'un fournisseur existant + + Args: + code: Code du fournisseur à modifier + fournisseur_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) + + Returns: + Fournisseur modifié avec ses nouvelles valeurs + + Example: + PUT /fournisseurs/DUPONT + { + "email": "nouveau@email.fr", + "telephone": "0198765432" + } + """ + try: + # Appel à la gateway Windows + resultat = sage_client.modifier_fournisseur(code, fournisseur_update.dict(exclude_none=True)) + + logger.info(f"✅ Fournisseur {code} modifié avec succès") + + return { + "success": True, + "message": f"Fournisseur {code} modifié avec succès", + "fournisseur": resultat + } + + except ValueError as e: + # Erreur métier (fournisseur introuvable, etc.) + logger.warning(f"Erreur métier modification fournisseur {code}: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + # Erreur technique + logger.error(f"Erreur technique modification fournisseur {code}: {e}") + raise HTTPException(500, str(e)) + + @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): """📄 Lecture d'un fournisseur par code""" diff --git a/sage_client.py b/sage_client.py index 3074119..4d5a0c5 100644 --- a/sage_client.py +++ b/sage_client.py @@ -287,6 +287,23 @@ class SageGatewayClient: """ return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) + def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: + """ + ✏️ Modification d'un fournisseur existant + + Args: + code: Code du fournisseur à modifier + fournisseur_data: Dictionnaire contenant les champs à modifier + (seuls les champs présents seront mis à jour) + + Returns: + Fournisseur modifié + """ + return self._post("/sage/fournisseurs/update", { + "code": code, + "fournisseur_data": fournisseur_data + }).get("data", {}) + # ===================================================== # AVOIRS # ===================================================== From 608ba12c50f97fc4802ff7a6b9a629836f163459 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 17:03:12 +0300 Subject: [PATCH 17/62] Update devis, Create and Update Command --- api.py | 331 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 62 +++++++++ 2 files changed, 393 insertions(+) diff --git a/api.py b/api.py index a49060c..dff2e31 100644 --- a/api.py +++ b/api.py @@ -265,6 +265,87 @@ class FournisseurUpdateRequest(BaseModel): } } +class DevisUpdateRequest(BaseModel): + """Modèle pour modification d'un devis existant""" + date_devis: Optional[date] = None + lignes: Optional[List[LigneDevis]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + + class Config: + json_schema_extra = { + "example": { + "date_devis": "2024-01-15", + "lignes": [ + { + "article_code": "ART001", + "quantite": 5.0, + "prix_unitaire_ht": 100.0, + "remise_pourcentage": 10.0 + } + ], + "statut": 2 + } + } + + +class LigneCommande(BaseModel): + """Ligne de commande""" + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + +class CommandeCreateRequest(BaseModel): + """Création d'une commande""" + client_id: str + date_commande: Optional[date] = None + lignes: List[LigneCommande] + reference: Optional[str] = None # Référence externe + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_commande": "2024-01-15", + "reference": "CMD-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0 + } + ] + } + } + + +class CommandeUpdateRequest(BaseModel): + """Modification d'une commande existante""" + date_commande: Optional[date] = None + lignes: Optional[List[LigneCommande]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0 + } + ], + "statut": 2 + } + } + # ===================================================== # SERVICES EXTERNES (Universign) @@ -592,6 +673,256 @@ async def creer_devis(devis: DevisRequest): raise HTTPException(500, str(e)) +@app.put("/devis/{id}", tags=["US-A1"]) +async def modifier_devis( + id: str, + devis_update: DevisUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'un devis existant + + **Champs modifiables:** + - `date_devis`: Nouvelle date du devis + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut (0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé) + + **Note importante:** + - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées + - Pour ajouter/supprimer une ligne, il faut envoyer TOUTES les lignes + - Un devis transformé (statut=5) ne peut plus être modifié + + Args: + id: Numéro du devis à modifier + devis_update: Champs à mettre à jour + + Returns: + Devis modifié avec ses nouvelles valeurs + """ + try: + # Vérifier que le devis existe + devis_existant = sage_client.lire_devis(id) + if not devis_existant: + raise HTTPException(404, f"Devis {id} introuvable") + + # Vérifier qu'il n'est pas déjà transformé + if devis_existant.get("statut") == 5: + raise HTTPException( + 400, + f"Le devis {id} a déjà été transformé et ne peut plus être modifié" + ) + + # Construire les données de mise à jour + update_data = {} + + if devis_update.date_devis: + update_data["date_devis"] = devis_update.date_devis.isoformat() + + if devis_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in devis_update.lignes + ] + + if devis_update.statut is not None: + update_data["statut"] = devis_update.statut + + # Appel à la gateway Windows + resultat = sage_client.modifier_devis(id, update_data) + + logger.info(f"✅ Devis {id} modifié avec succès") + + return { + "success": True, + "message": f"Devis {id} modifié avec succès", + "devis": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification devis {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/commandes", status_code=201, tags=["US-A2"]) +async def creer_commande( + commande: CommandeCreateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'une nouvelle commande (Bon de commande) + + **Workflow typique:** + 1. Création d'un devis → transformation en commande (automatique) + 2. OU création directe d'une commande (cette route) + + **Champs obligatoires:** + - `client_id`: Code du client + - `lignes`: Liste des lignes (min 1) + + **Champs optionnels:** + - `date_commande`: Date de la commande (par défaut: aujourd'hui) + - `reference`: Référence externe (ex: numéro de commande client) + + Args: + commande: Données de la commande à créer + + Returns: + Commande créée avec son numéro et ses totaux + """ + try: + # Vérifier que le client existe + client = sage_client.lire_client(commande.client_id) + if not client: + raise HTTPException(404, f"Client {commande.client_id} introuvable") + + # Préparer les données pour la gateway + commande_data = { + "client_id": commande.client_id, + "date_commande": ( + commande.date_commande.isoformat() + if commande.date_commande + else None + ), + "reference": commande.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in commande.lignes + ], + } + + # Appel à la gateway Windows + resultat = sage_client.creer_commande(commande_data) + + logger.info(f"✅ Commande créée: {resultat.get('numero_commande')}") + + return { + "success": True, + "message": "Commande créée avec succès", + "data": { + "numero_commande": resultat["numero_commande"], + "client_id": commande.client_id, + "date_commande": resultat["date_commande"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": commande.reference + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création commande: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/commandes/{id}", tags=["US-A2"]) +async def modifier_commande( + id: str, + commande_update: CommandeUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'une commande existante + + **Champs modifiables:** + - `date_commande`: Nouvelle date + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut + - `reference`: Référence externe + + **Restrictions:** + - Une commande transformée (statut=5) ne peut plus être modifiée + - Une commande annulée (statut=6) ne peut plus être modifiée + + **Note importante:** + Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées + + Args: + id: Numéro de la commande à modifier + commande_update: Champs à mettre à jour + + Returns: + Commande modifiée avec ses nouvelles valeurs + """ + try: + # Vérifier que la commande existe + commande_existante = sage_client.lire_document( + id, + settings.SAGE_TYPE_BON_COMMANDE + ) + + if not commande_existante: + raise HTTPException(404, f"Commande {id} introuvable") + + # Vérifier le statut + statut_actuel = commande_existante.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"La commande {id} a déjà été transformée et ne peut plus être modifiée" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"La commande {id} est annulée et ne peut plus être modifiée" + ) + + # Construire les données de mise à jour + update_data = {} + + if commande_update.date_commande: + update_data["date_commande"] = commande_update.date_commande.isoformat() + + if commande_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in commande_update.lignes + ] + + if commande_update.statut is not None: + update_data["statut"] = commande_update.statut + + if commande_update.reference is not None: + update_data["reference"] = commande_update.reference + + # Appel à la gateway Windows + resultat = sage_client.modifier_commande(id, update_data) + + logger.info(f"✅ Commande {id} modifiée avec succès") + + return { + "success": True, + "message": f"Commande {id} modifiée avec succès", + "commande": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification commande {id}: {e}") + raise HTTPException(500, str(e)) + + @app.get("/devis", tags=["US-A1"]) async def lister_devis( limit: int = Query(100, le=1000), diff --git a/sage_client.py b/sage_client.py index 4d5a0c5..ed51bf0 100644 --- a/sage_client.py +++ b/sage_client.py @@ -382,6 +382,68 @@ class SageGatewayClient: "code": code, "client_data": client_data }).get("data", {}) + + + def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: + """ + ✏️ Modification d'un devis existant + + Args: + numero: Numéro du devis à modifier + devis_data: Dictionnaire contenant les champs à modifier: + - date_devis (str, optional): Nouvelle date + - lignes (List, optional): Nouvelles lignes + - statut (int, optional): Nouveau statut + + Returns: + Devis modifié avec totaux recalculés + """ + return self._post("/sage/devis/update", { + "numero": numero, + "devis_data": devis_data + }).get("data", {}) + + def creer_commande(self, commande_data: Dict) -> Dict: + """ + ➕ Création d'une nouvelle commande (Bon de commande) + + Args: + commande_data: Dictionnaire contenant: + - client_id (str): Code du client + - date_commande (str, optional): Date au format ISO + - reference (str, optional): Référence externe + - lignes (List[Dict]): Liste des lignes avec: + - article_code (str) + - quantite (float) + - prix_unitaire_ht (float, optional) + - remise_pourcentage (float, optional) + + Returns: + Commande créée avec son numéro et ses totaux + """ + return self._post("/sage/commandes/create", commande_data).get("data", {}) + + + def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: + """ + ✏️ Modification d'une commande existante + + Args: + numero: Numéro de la commande à modifier + commande_data: Dictionnaire contenant les champs à modifier: + - date_commande (str, optional): Nouvelle date + - lignes (List, optional): Nouvelles lignes + - statut (int, optional): Nouveau statut + - reference (str, optional): Nouvelle référence + + Returns: + Commande modifiée avec totaux recalculés + """ + return self._post("/sage/commandes/update", { + "numero": numero, + "commande_data": commande_data + }).get("data", {}) + # Instance globale From b7a8af5ed5c15de14f8a779c4500127c9335d142 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sun, 7 Dec 2025 06:53:21 +0300 Subject: [PATCH 18/62] Better catch for errors --- api.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/api.py b/api.py index dff2e31..2c946c8 100644 --- a/api.py +++ b/api.py @@ -1040,26 +1040,67 @@ async def envoyer_devis_email( @app.put("/devis/{id}/statut", tags=["US-A1"]) -async def changer_statut_devis(id: str, nouveau_statut: int = Query(..., ge=0, le=5)): +async def changer_statut_devis( + id: str, + nouveau_statut: int = Query(..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé") +): """ - 📄 Changement de statut d'un devis via gateway Windows + 📊 Changement de statut d'un devis + + **Statuts possibles:** + - 0: Brouillon + - 2: Accepté/Validé + - 5: Transformé (automatique lors d'une transformation) + - 6: Annulé + + **Restrictions:** + - Un devis transformé (5) ne peut plus changer de statut + - Un devis annulé (6) ne peut plus changer de statut + + Args: + id: Numéro du devis + nouveau_statut: Nouveau statut (0-6) + + Returns: + Confirmation du changement avec ancien et nouveau statut """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) + # Vérifier que le devis existe + devis_existant = sage_client.lire_devis(id) + if not devis_existant: + raise HTTPException(404, f"Devis {id} introuvable") + + statut_actuel = devis_existant.get("statut", 0) + + # Vérifications de cohérence + if statut_actuel == 5: + raise HTTPException( + 400, + f"Le devis {id} a déjà été transformé et ne peut plus changer de statut" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"Le devis {id} est annulé et ne peut plus changer de statut" + ) + resultat = sage_client.changer_statut_devis(id, nouveau_statut) - - logger.info(f"✅ Statut devis {id} changé: {nouveau_statut}") - + + logger.info(f"✅ Statut devis {id} changé: {statut_actuel} → {nouveau_statut}") + return { "success": True, "devis_id": id, - "statut_ancien": resultat.get("statut_ancien"), - "statut_nouveau": resultat.get("statut_nouveau"), - "message": "Statut mis à jour avec succès", + "statut_ancien": resultat.get("statut_ancien", statut_actuel), + "statut_nouveau": resultat.get("statut_nouveau", nouveau_statut), + "message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}" } - + + except HTTPException: + raise except Exception as e: - logger.error(f"Erreur changement statut: {e}") + logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) From f763d70592d106035e0d3a4986c50540858317dc Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sun, 7 Dec 2025 07:16:00 +0300 Subject: [PATCH 19/62] Change devis' statut when transformed into commande --- api.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api.py b/api.py index 2c946c8..1bea9da 100644 --- a/api.py +++ b/api.py @@ -1147,14 +1147,25 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi """ 🔧 Transformation Devis → Commande ✅ CORRECTION : Utilise les VRAIS types Sage (0 → 10) + ✅ Met à jour le statut du devis source à 5 (Transformé) """ try: + # Étape 1: Transformation resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_DEVIS, # = 0 type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10 ) + # Étape 2: Mettre à jour le statut du devis à 5 (Transformé) + try: + sage_client.changer_statut_devis(id, nouveau_statut=5) + logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") + except Exception as e: + logger.warning(f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}") + # On continue même si la MAJ statut échoue + + # Étape 3: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, @@ -1178,6 +1189,7 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi "document_source": id, "document_cible": resultat["document_cible"], "nb_lignes": resultat["nb_lignes"], + "statut_devis_mis_a_jour": True, } except Exception as e: From 35807542a3c4b4e8432c546d99af702035d2d8fc Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sun, 7 Dec 2025 13:36:47 +0300 Subject: [PATCH 20/62] Inclure transformation verification on devis retrieving --- api.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 1bea9da..6908139 100644 --- a/api.py +++ b/api.py @@ -954,12 +954,37 @@ async def lister_devis( @app.get("/devis/{id}", tags=["US-A1"]) async def lire_devis(id: str): - """📄 Lecture d'un devis via gateway Windows""" + """ + 📄 Lecture d'un devis via gateway Windows + + Returns: + Devis complet avec: + - Toutes les informations standards + - lignes: Lignes du devis + - a_deja_ete_transforme: ✅ Booléen indiquant si le devis a été transformé + - documents_cibles: ✅ Liste des documents créés depuis ce devis + + ✅ ENRICHI: Inclut maintenant l'information de transformation + """ try: devis = sage_client.lire_devis(id) + if not devis: raise HTTPException(404, f"Devis {id} introuvable") - return devis + + # Log informatif + if devis.get("a_deja_ete_transforme"): + docs = devis.get("documents_cibles", []) + logger.info( + f"📊 Devis {id} a été transformé en " + f"{len(docs)} document(s): {[d['numero'] for d in docs]}" + ) + + return { + "success": True, + "data": devis + } + except HTTPException: raise except Exception as e: From 204b7920154594064cc6018e6e7dffa1726876be Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 08:47:48 +0300 Subject: [PATCH 21/62] Integrate create and update for livraison --- api.py | 254 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 17 ++++ 2 files changed, 271 insertions(+) diff --git a/api.py b/api.py index 6908139..33a79ad 100644 --- a/api.py +++ b/api.py @@ -347,6 +347,64 @@ class CommandeUpdateRequest(BaseModel): } +class LigneLivraison(BaseModel): + """Ligne de livraison""" + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + +class LivraisonCreateRequest(BaseModel): + """Création d'une livraison""" + client_id: str + date_livraison: Optional[date] = None + lignes: List[LigneLivraison] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_livraison": "2024-01-15", + "reference": "BL-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0 + } + ] + } + } + + +class LivraisonUpdateRequest(BaseModel): + """Modification d'une livraison existante""" + date_livraison: Optional[date] = None + lignes: Optional[List[LigneLivraison]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0 + } + ], + "statut": 2 + } + } + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -2448,6 +2506,202 @@ async def lire_livraison(numero: str): logger.error(f"Erreur lecture livraison: {e}") raise HTTPException(500, str(e)) +@app.post("/livraisons", status_code=201, tags=["Livraisons"]) +async def creer_livraison( + livraison: LivraisonCreateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'une nouvelle livraison (Bon de livraison) + + **Workflow typique:** + 1. Création d'une commande → transformation en livraison (automatique) + 2. OU création directe d'une livraison (cette route) + + **Champs obligatoires:** + - `client_id`: Code du client + - `lignes`: Liste des lignes (min 1) + + **Champs optionnels:** + - `date_livraison`: Date de la livraison (par défaut: aujourd'hui) + - `reference`: Référence externe (ex: numéro de commande client) + """ + try: + # Vérifier que le client existe + client = sage_client.lire_client(livraison.client_id) + if not client: + raise HTTPException(404, f"Client {livraison.client_id} introuvable") + + # Préparer les données pour la gateway + livraison_data = { + "client_id": livraison.client_id, + "date_livraison": ( + livraison.date_livraison.isoformat() + if livraison.date_livraison + else None + ), + "reference": livraison.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in livraison.lignes + ], + } + + # Appel à la gateway Windows + resultat = sage_client.creer_livraison(livraison_data) + + logger.info(f"✅ Livraison créée: {resultat.get('numero_livraison')}") + + return { + "success": True, + "message": "Livraison créée avec succès", + "data": { + "numero_livraison": resultat["numero_livraison"], + "client_id": livraison.client_id, + "date_livraison": resultat["date_livraison"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": livraison.reference + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création livraison: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/livraisons/{id}", tags=["Livraisons"]) +async def modifier_livraison( + id: str, + livraison_update: LivraisonUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'une livraison existante + + **Champs modifiables:** + - `date_livraison`: Nouvelle date + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut + - `reference`: Référence externe + + **Restrictions:** + - Une livraison transformée (statut=5) ne peut plus être modifiée + - Une livraison annulée (statut=6) ne peut plus être modifiée + """ + try: + # Vérifier que la livraison existe + livraison_existante = sage_client.lire_livraison(id) + + if not livraison_existante: + raise HTTPException(404, f"Livraison {id} introuvable") + + # Vérifier le statut + statut_actuel = livraison_existante.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"La livraison {id} a déjà été transformée et ne peut plus être modifiée" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"La livraison {id} est annulée et ne peut plus être modifiée" + ) + + # Construire les données de mise à jour + update_data = {} + + if livraison_update.date_livraison: + update_data["date_livraison"] = livraison_update.date_livraison.isoformat() + + if livraison_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in livraison_update.lignes + ] + + if livraison_update.statut is not None: + update_data["statut"] = livraison_update.statut + + if livraison_update.reference is not None: + update_data["reference"] = livraison_update.reference + + # Appel à la gateway Windows + resultat = sage_client.modifier_livraison(id, update_data) + + logger.info(f"✅ Livraison {id} modifiée avec succès") + + return { + "success": True, + "message": f"Livraison {id} modifiée avec succès", + "livraison": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification livraison {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/workflow/livraison/{id}/to-facture", tags=["US-A2"]) +async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): + """ + 🔧 Transformation Livraison → Facture + ✅ Utilise les VRAIS types Sage (30 → 60) + """ + try: + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 + type_cible=settings.SAGE_TYPE_FACTURE, # = 60 + ) + + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.BON_LIVRAISON, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.FACTURE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"✅ Transformation: Livraison {id} → Facture {resultat['document_cible']}" + ) + + return { + "success": True, + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + } + + except Exception as e: + logger.error(f"Erreur transformation: {e}") + raise HTTPException(500, str(e)) + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( diff --git a/sage_client.py b/sage_client.py index ed51bf0..47944e8 100644 --- a/sage_client.py +++ b/sage_client.py @@ -443,6 +443,23 @@ class SageGatewayClient: "numero": numero, "commande_data": commande_data }).get("data", {}) + + + def creer_livraison(self, livraison_data: Dict) -> Dict: + """ + ➕ Création d'une nouvelle livraison (Bon de livraison) + """ + return self._post("/sage/livraisons/create", livraison_data).get("data", {}) + + + def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: + """ + ✏️ Modification d'une livraison existante + """ + return self._post("/sage/livraisons/update", { + "numero": numero, + "livraison_data": livraison_data + }).get("data", {}) From c15ae79c6a956f85fb3962210207eaa25d28b3d1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 09:18:15 +0300 Subject: [PATCH 22/62] Added create and update for avoir --- api.py | 229 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 41 +++++++++ 2 files changed, 270 insertions(+) diff --git a/api.py b/api.py index 33a79ad..2b944c6 100644 --- a/api.py +++ b/api.py @@ -405,6 +405,64 @@ class LivraisonUpdateRequest(BaseModel): } } +class LigneAvoir(BaseModel): + """Ligne d'avoir""" + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + +class AvoirCreateRequest(BaseModel): + """Création d'un avoir""" + client_id: str + date_avoir: Optional[date] = None + lignes: List[LigneAvoir] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_avoir": "2024-01-15", + "reference": "AV-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 5.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 0.0 + } + ] + } + } + + +class AvoirUpdateRequest(BaseModel): + """Modification d'un avoir existant""" + date_avoir: Optional[date] = None + lignes: Optional[List[LigneAvoir]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 45.0 + } + ], + "statut": 2 + } + } + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -2475,7 +2533,178 @@ async def lire_avoir(numero: str): logger.error(f"Erreur lecture avoir: {e}") raise HTTPException(500, str(e)) +@app.post("/avoirs", status_code=201, tags=["Avoirs"]) +async def creer_avoir( + avoir: AvoirCreateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'un avoir (Bon d'avoir) + + **Workflow typique:** + 1. Retour marchandise → création d'un avoir + 2. Geste commercial → création directe d'un avoir (cette route) + + **Champs obligatoires:** + - `client_id`: Code du client + - `lignes`: Liste des lignes (min 1) + + **Champs optionnels:** + - `date_avoir`: Date de l'avoir (par défaut: aujourd'hui) + - `reference`: Référence externe (ex: numéro de retour) + + **Note:** Les montants des avoirs sont généralement négatifs (crédits) + + Args: + avoir: Données de l'avoir à créer + + Returns: + Avoir créé avec son numéro et ses totaux + """ + try: + # Vérifier que le client existe + client = sage_client.lire_client(avoir.client_id) + if not client: + raise HTTPException(404, f"Client {avoir.client_id} introuvable") + + # Préparer les données pour la gateway + avoir_data = { + "client_id": avoir.client_id, + "date_avoir": ( + avoir.date_avoir.isoformat() + if avoir.date_avoir + else None + ), + "reference": avoir.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in avoir.lignes + ], + } + + # Appel à la gateway Windows + resultat = sage_client.creer_avoir(avoir_data) + + logger.info(f"✅ Avoir créé: {resultat.get('numero_avoir')}") + + return { + "success": True, + "message": "Avoir créé avec succès", + "data": { + "numero_avoir": resultat["numero_avoir"], + "client_id": avoir.client_id, + "date_avoir": resultat["date_avoir"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": avoir.reference + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création avoir: {e}") + raise HTTPException(500, str(e)) + +@app.put("/avoirs/{id}", tags=["Avoirs"]) +async def modifier_avoir( + id: str, + avoir_update: AvoirUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'un avoir existant + + **Champs modifiables:** + - `date_avoir`: Nouvelle date + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut + - `reference`: Référence externe + + **Restrictions:** + - Un avoir transformé (statut=5) ne peut plus être modifié + - Un avoir annulé (statut=6) ne peut plus être modifié + + **Note importante:** + Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées + + Args: + id: Numéro de l'avoir à modifier + avoir_update: Champs à mettre à jour + + Returns: + Avoir modifié avec ses nouvelles valeurs + """ + try: + # Vérifier que l'avoir existe + avoir_existant = sage_client.lire_avoir(id) + + if not avoir_existant: + raise HTTPException(404, f"Avoir {id} introuvable") + + # Vérifier le statut + statut_actuel = avoir_existant.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"L'avoir {id} a déjà été transformé et ne peut plus être modifié" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"L'avoir {id} est annulé et ne peut plus être modifié" + ) + + # Construire les données de mise à jour + update_data = {} + + if avoir_update.date_avoir: + update_data["date_avoir"] = avoir_update.date_avoir.isoformat() + + if avoir_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in avoir_update.lignes + ] + + if avoir_update.statut is not None: + update_data["statut"] = avoir_update.statut + + if avoir_update.reference is not None: + update_data["reference"] = avoir_update.reference + + # Appel à la gateway Windows + resultat = sage_client.modifier_avoir(id, update_data) + + logger.info(f"✅ Avoir {id} modifié avec succès") + + return { + "success": True, + "message": f"Avoir {id} modifié avec succès", + "avoir": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification avoir {id}: {e}") + raise HTTPException(500, str(e)) + + # ===================================================== # ENDPOINTS - LIVRAISONS # ===================================================== diff --git a/sage_client.py b/sage_client.py index 47944e8..6a07591 100644 --- a/sage_client.py +++ b/sage_client.py @@ -460,6 +460,47 @@ class SageGatewayClient: "numero": numero, "livraison_data": livraison_data }).get("data", {}) + + def creer_avoir(self, avoir_data: Dict) -> Dict: + """ + ➕ Création d'un avoir (Bon d'avoir) + + Args: + avoir_data: Dictionnaire contenant: + - client_id (str): Code du client + - date_avoir (str, optional): Date au format ISO + - reference (str, optional): Référence externe + - lignes (List[Dict]): Liste des lignes avec: + - article_code (str) + - quantite (float) + - prix_unitaire_ht (float, optional) + - remise_pourcentage (float, optional) + + Returns: + Avoir créé avec son numéro et ses totaux + """ + return self._post("/sage/avoirs/create", avoir_data).get("data", {}) + + + def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: + """ + ✏️ Modification d'un avoir existant + + Args: + numero: Numéro de l'avoir à modifier + avoir_data: Dictionnaire contenant les champs à modifier: + - date_avoir (str, optional): Nouvelle date + - lignes (List, optional): Nouvelles lignes + - statut (int, optional): Nouveau statut + - reference (str, optional): Nouvelle référence + + Returns: + Avoir modifié avec totaux recalculés + """ + return self._post("/sage/avoirs/update", { + "numero": numero, + "avoir_data": avoir_data + }).get("data", {}) From 57d1f313f4f212a2f462654d94a7d155b9ade4e3 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 09:43:34 +0300 Subject: [PATCH 23/62] feat(factures): add create and update invoice endpoints --- api.py | 238 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 42 +++++++++ 2 files changed, 280 insertions(+) diff --git a/api.py b/api.py index 2b944c6..d81bec6 100644 --- a/api.py +++ b/api.py @@ -462,7 +462,66 @@ class AvoirUpdateRequest(BaseModel): "statut": 2 } } + +class LigneFacture(BaseModel): + """Ligne de facture""" + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + +class FactureCreateRequest(BaseModel): + """Création d'une facture""" + client_id: str + date_facture: Optional[date] = None + lignes: List[LigneFacture] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_facture": "2024-01-15", + "reference": "FA-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0 + } + ] + } + } + + +class FactureUpdateRequest(BaseModel): + """Modification d'une facture existante""" + date_facture: Optional[date] = None + lignes: Optional[List[LigneFacture]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0 + } + ], + "statut": 2 + } + } + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1920,6 +1979,185 @@ class RelanceFactureRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None +@app.post("/factures", status_code=201, tags=["US-A7"]) +async def creer_facture( + facture: FactureCreateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'une facture + + **Workflow typique:** + 1. Commande → Livraison → Facture (transformations successives) + 2. OU création directe d'une facture (cette route) + + **Champs obligatoires:** + - `client_id`: Code du client + - `lignes`: Liste des lignes (min 1) + + **Champs optionnels:** + - `date_facture`: Date de la facture (par défaut: aujourd'hui) + - `reference`: Référence externe (ex: numéro de commande client) + + **Notes importantes:** + - Les factures peuvent avoir des champs obligatoires supplémentaires selon la configuration Sage + - Le statut initial est généralement 2 (Accepté/Validé) + - Les factures sont soumises aux règles de numérotation strictes + + Args: + facture: Données de la facture à créer + + Returns: + Facture créée avec son numéro et ses totaux + """ + try: + # Vérifier que le client existe + client = sage_client.lire_client(facture.client_id) + if not client: + raise HTTPException(404, f"Client {facture.client_id} introuvable") + + # Préparer les données pour la gateway + facture_data = { + "client_id": facture.client_id, + "date_facture": ( + facture.date_facture.isoformat() + if facture.date_facture + else None + ), + "reference": facture.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in facture.lignes + ], + } + + # Appel à la gateway Windows + resultat = sage_client.creer_facture(facture_data) + + logger.info(f"✅ Facture créée: {resultat.get('numero_facture')}") + + return { + "success": True, + "message": "Facture créée avec succès", + "data": { + "numero_facture": resultat["numero_facture"], + "client_id": facture.client_id, + "date_facture": resultat["date_facture"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": facture.reference + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création facture: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/factures/{id}", tags=["US-A7"]) +async def modifier_facture( + id: str, + facture_update: FactureUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'une facture existante + + **Champs modifiables:** + - `date_facture`: Nouvelle date + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut + - `reference`: Référence externe + + **Restrictions IMPORTANTES:** + - Une facture transformée (statut=5) ne peut plus être modifiée + - Une facture annulée (statut=6) ne peut plus être modifiée + - ⚠️ **ATTENTION**: Les factures comptabilisées peuvent être verrouillées par Sage + - Certaines factures peuvent être en lecture seule selon les droits utilisateur + + **Note importante:** + Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées + + Args: + id: Numéro de la facture à modifier + facture_update: Champs à mettre à jour + + Returns: + Facture modifiée avec ses nouvelles valeurs + """ + try: + # Vérifier que la facture existe + facture_existante = sage_client.lire_document( + id, + settings.SAGE_TYPE_FACTURE + ) + + if not facture_existante: + raise HTTPException(404, f"Facture {id} introuvable") + + # Vérifier le statut + statut_actuel = facture_existante.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"La facture {id} a déjà été transformée et ne peut plus être modifiée" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"La facture {id} est annulée et ne peut plus être modifiée" + ) + + # Construire les données de mise à jour + update_data = {} + + if facture_update.date_facture: + update_data["date_facture"] = facture_update.date_facture.isoformat() + + if facture_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in facture_update.lignes + ] + + if facture_update.statut is not None: + update_data["statut"] = facture_update.statut + + if facture_update.reference is not None: + update_data["reference"] = facture_update.reference + + # Appel à la gateway Windows + resultat = sage_client.modifier_facture(id, update_data) + + logger.info(f"✅ Facture {id} modifiée avec succès") + + return { + "success": True, + "message": f"Facture {id} modifiée avec succès", + "facture": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification facture {id}: {e}") + raise HTTPException(500, str(e)) + # Templates email (si pas déjà définis) templates_email_db = { diff --git a/sage_client.py b/sage_client.py index 6a07591..5f613c6 100644 --- a/sage_client.py +++ b/sage_client.py @@ -501,6 +501,48 @@ class SageGatewayClient: "numero": numero, "avoir_data": avoir_data }).get("data", {}) + + + def creer_facture(self, facture_data: Dict) -> Dict: + """ + ➕ Création d'une facture + + Args: + facture_data: Dictionnaire contenant: + - client_id (str): Code du client + - date_facture (str, optional): Date au format ISO + - reference (str, optional): Référence externe + - lignes (List[Dict]): Liste des lignes avec: + - article_code (str) + - quantite (float) + - prix_unitaire_ht (float, optional) + - remise_pourcentage (float, optional) + + Returns: + Facture créée avec son numéro et ses totaux + """ + return self._post("/sage/factures/create", facture_data).get("data", {}) + + + def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: + """ + ✏️ Modification d'une facture existante + + Args: + numero: Numéro de la facture à modifier + facture_data: Dictionnaire contenant les champs à modifier: + - date_facture (str, optional): Nouvelle date + - lignes (List, optional): Nouvelles lignes + - statut (int, optional): Nouveau statut + - reference (str, optional): Nouvelle référence + + Returns: + Facture modifiée avec totaux recalculés + """ + return self._post("/sage/factures/update", { + "numero": numero, + "facture_data": facture_data + }).get("data", {}) From 5a6a721f167e8c996fdba1f4121685e2a73be4b1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 09:54:39 +0300 Subject: [PATCH 24/62] feat(workflow): add direct quote-to-invoice and order-to-delivery endpoints --- api.py | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/api.py b/api.py index d81bec6..f574f08 100644 --- a/api.py +++ b/api.py @@ -3170,6 +3170,186 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se raise HTTPException(500, str(e)) +@app.post("/workflow/devis/{id}/to-facture", tags=["US-A2"]) +async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get_session)): + """ + 🔧 Transformation Devis → Facture (DIRECT, sans commande) + + ✅ Utilise les VRAIS types Sage (0 → 60) + ✅ Met à jour le statut du devis source à 5 (Transformé) + + **Workflow raccourci** : Permet de facturer directement depuis un devis + sans passer par la création d'une commande. + + **Cas d'usage** : + - Prestations de services facturées directement + - Petites commandes sans besoin de suivi intermédiaire + - Ventes au comptoir + + Args: + id: Numéro du devis source + + Returns: + Informations de la facture créée + """ + try: + # Étape 1: Vérifier que le devis n'a pas déjà été transformé + devis_existant = sage_client.lire_devis(id) + if not devis_existant: + raise HTTPException(404, f"Devis {id} introuvable") + + statut_devis = devis_existant.get("statut", 0) + if statut_devis == 5: + raise HTTPException( + 400, + f"Le devis {id} a déjà été transformé (statut=5). " + f"Vérifiez les documents déjà créés depuis ce devis." + ) + + # Étape 2: Transformation + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_DEVIS, # = 0 + type_cible=settings.SAGE_TYPE_FACTURE, # = 60 + ) + + # Étape 3: Mettre à jour le statut du devis à 5 (Transformé) + try: + sage_client.changer_statut_devis(id, nouveau_statut=5) + logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") + except Exception as e: + logger.warning(f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}") + # On continue même si la MAJ statut échoue + + # Étape 4: Logger la transformation + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.DEVIS, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.FACTURE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"✅ Transformation DIRECTE: Devis {id} → Facture {resultat['document_cible']}" + ) + + return { + "success": True, + "workflow": "devis_to_facture_direct", + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + "statut_devis_mis_a_jour": True, + "message": f"Facture {resultat['document_cible']} créée directement depuis le devis {id}", + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur transformation devis→facture: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/workflow/commande/{id}/to-livraison", tags=["US-A2"]) +async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_session)): + """ + 🔧 Transformation Commande → Bon de livraison + + ✅ Utilise les VRAIS types Sage (10 → 30) + + **Workflow typique** : Après validation d'une commande, génère + le bon de livraison pour préparer l'expédition. + + **Cas d'usage** : + - Préparation d'une expédition + - Génération du bordereau de livraison + - Suivi logistique + + **Workflow complet** : + 1. Devis → Commande (via `/workflow/devis/{id}/to-commande`) + 2. **Commande → Livraison** (cette route) + 3. Livraison → Facture (via `/workflow/livraison/{id}/to-facture`) + + Args: + id: Numéro de la commande source + + Returns: + Informations du bon de livraison créé + """ + try: + # Étape 1: Vérifier que la commande existe + commande_existante = sage_client.lire_document( + id, + settings.SAGE_TYPE_BON_COMMANDE + ) + + if not commande_existante: + raise HTTPException(404, f"Commande {id} introuvable") + + statut_commande = commande_existante.get("statut", 0) + if statut_commande == 5: + raise HTTPException( + 400, + f"La commande {id} a déjà été transformée (statut=5). " + f"Un bon de livraison existe probablement déjà." + ) + + if statut_commande == 6: + raise HTTPException( + 400, + f"La commande {id} est annulée (statut=6) et ne peut pas être transformée." + ) + + # Étape 2: Transformation + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 + type_cible=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 + ) + + # Étape 3: Logger la transformation + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.BON_COMMANDE, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.BON_LIVRAISON, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"✅ Transformation: Commande {id} → Livraison {resultat['document_cible']}" + ) + + return { + "success": True, + "workflow": "commande_to_livraison", + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + "message": f"Bon de livraison {resultat['document_cible']} créé depuis la commande {id}", + "next_step": f"Utilisez /workflow/livraison/{resultat['document_cible']}/to-facture pour créer la facture", + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur transformation commande→livraison: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( session: AsyncSession = Depends(get_session), From fafd5222a6766fd3eb150d0563831aa8937219e0 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 10:58:24 +0300 Subject: [PATCH 25/62] feat(api): add OpenAPI tags metadata and update endpoint tags --- api.py | 156 +++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 112 insertions(+), 44 deletions(-) diff --git a/api.py b/api.py index f574f08..bcc7bbb 100644 --- a/api.py +++ b/api.py @@ -42,6 +42,73 @@ from email_queue import email_queue from sage_client import sage_client +TAGS_METADATA = [ + { + "name": "Clients", + "description": "Gestion des clients (recherche, création, modification)" + }, + { + "name": "Articles", + "description": "Gestion des articles et produits" + }, + { + "name": "Devis", + "description": "Création, consultation et gestion des devis" + }, + { + "name": "Commandes", + "description": "Création, consultation et gestion des commandes" + }, + { + "name": "Livraisons", + "description": "Création, consultation et gestion des bons de livraison" + }, + { + "name": "Factures", + "description": "Création, consultation et gestion des factures" + }, + { + "name": "Avoirs", + "description": "Création, consultation et gestion des avoirs" + }, + { + "name": "Fournisseurs", + "description": "Gestion des fournisseurs" + }, + { + "name": "Prospects", + "description": "Gestion des prospects" + }, + { + "name": "Workflows", + "description": "Transformations de documents (devis→commande, commande→facture, etc.)" + }, + { + "name": "Signatures", + "description": "Signature électronique via Universign" + }, + { + "name": "Emails", + "description": "Envoi d'emails, templates et logs d'envoi" + }, + { + "name": "Validation", + "description": "Validation de données (remises, etc.)" + }, + { + "name": "Admin", + "description": "🔧 Administration système (cache, queue)" + }, + { + "name": "System", + "description": "🏥 Health checks et informations système" + }, + { + "name": "Debug", + "description": "🐛 Routes de debug et diagnostics" + } +] + # ===================================================== # ENUMS # ===================================================== @@ -674,6 +741,7 @@ app = FastAPI( version="2.0.0", description="API de gestion commerciale - VPS Linux", lifespan=lifespan, + openapi_tags=TAGS_METADATA ) app.add_middleware( @@ -691,7 +759,7 @@ app.include_router(auth_router) # ===================================================== # ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) # ===================================================== -@app.get("/clients", response_model=List[ClientResponse], tags=["US-A1"]) +@app.get("/clients", response_model=List[ClientResponse], tags=["Clients"]) async def rechercher_clients(query: Optional[str] = Query(None)): """🔍 Recherche clients via gateway Windows""" try: @@ -701,7 +769,7 @@ async def rechercher_clients(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) -@app.get("/clients/{code}", tags=["US-A1"]) +@app.get("/clients/{code}", tags=["Clients"]) async def lire_client_detail(code: str): """ 📄 Lecture détaillée d'un client par son code @@ -730,7 +798,7 @@ async def lire_client_detail(code: str): raise HTTPException(500, str(e)) -@app.put("/clients/{code}", tags=["US-A1"]) +@app.put("/clients/{code}", tags=["Clients"]) async def modifier_client( code: str, client_update: ClientUpdateRequest, @@ -774,7 +842,7 @@ async def modifier_client( logger.error(f"Erreur technique modification client {code}: {e}") raise HTTPException(500, str(e)) -@app.post("/clients", status_code=201, tags=["US-A8"]) +@app.post("/clients", status_code=201, tags=["Clients"]) async def ajouter_client( client: ClientCreateAPIRequest, session: AsyncSession = Depends(get_session) @@ -799,7 +867,7 @@ async def ajouter_client( status = 400 if "existe déjà" in str(e) else 500 raise HTTPException(status, str(e)) -@app.get("/articles", response_model=List[ArticleResponse], tags=["US-A1"]) +@app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): """🔍 Recherche articles via gateway Windows""" try: @@ -810,7 +878,7 @@ async def rechercher_articles(query: Optional[str] = Query(None)): raise HTTPException(500, str(e)) -@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["US-A1"]) +@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"]) async def creer_devis(devis: DevisRequest): """📝 Création de devis via gateway Windows""" try: @@ -848,7 +916,7 @@ async def creer_devis(devis: DevisRequest): raise HTTPException(500, str(e)) -@app.put("/devis/{id}", tags=["US-A1"]) +@app.put("/devis/{id}", tags=["Devis"]) async def modifier_devis( id: str, devis_update: DevisUpdateRequest, @@ -925,7 +993,7 @@ async def modifier_devis( raise HTTPException(500, str(e)) -@app.post("/commandes", status_code=201, tags=["US-A2"]) +@app.post("/commandes", status_code=201, tags=["Commandes"]) async def creer_commande( commande: CommandeCreateRequest, session: AsyncSession = Depends(get_session) @@ -1003,7 +1071,7 @@ async def creer_commande( raise HTTPException(500, str(e)) -@app.put("/commandes/{id}", tags=["US-A2"]) +@app.put("/commandes/{id}", tags=["Commandes"]) async def modifier_commande( id: str, commande_update: CommandeUpdateRequest, @@ -1098,7 +1166,7 @@ async def modifier_commande( raise HTTPException(500, str(e)) -@app.get("/devis", tags=["US-A1"]) +@app.get("/devis", tags=["Devis"]) async def lister_devis( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), @@ -1127,7 +1195,7 @@ async def lister_devis( raise HTTPException(500, str(e)) -@app.get("/devis/{id}", tags=["US-A1"]) +@app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): """ 📄 Lecture d'un devis via gateway Windows @@ -1167,7 +1235,7 @@ async def lire_devis(id: str): raise HTTPException(500, str(e)) -@app.get("/devis/{id}/pdf", tags=["US-A1"]) +@app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): """📄 Téléchargement PDF (généré via email_queue)""" try: @@ -1185,7 +1253,7 @@ async def telecharger_devis_pdf(id: str): raise HTTPException(500, str(e)) -@app.post("/devis/{id}/envoyer", tags=["US-A1"]) +@app.post("/devis/{id}/envoyer", tags=["Devis"]) async def envoyer_devis_email( id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) ): @@ -1239,7 +1307,7 @@ async def envoyer_devis_email( raise HTTPException(500, str(e)) -@app.put("/devis/{id}/statut", tags=["US-A1"]) +@app.put("/devis/{id}/statut", tags=["Devis"]) async def changer_statut_devis( id: str, nouveau_statut: int = Query(..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé") @@ -1305,10 +1373,10 @@ async def changer_statut_devis( # ===================================================== -# ENDPOINTS - US-A2 (WORKFLOW SANS RESSAISIE) +# ENDPOINTS - Commandes (WORKFLOW SANS RESSAISIE) # ===================================================== -@app.get("/commandes/{id}", tags=["US-A2"]) +@app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): """📄 Lecture d'une commande avec ses lignes""" try: @@ -1323,7 +1391,7 @@ async def lire_commande(id: str): raise HTTPException(500, str(e)) -@app.get("/commandes", tags=["US-A2"]) +@app.get("/commandes", tags=["Commandes"]) async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): @@ -1397,7 +1465,7 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) +@app.post("/workflow/commande/{id}/to-facture", tags=["Commandes"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Commande → Facture @@ -1443,7 +1511,7 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses # ===================================================== # ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE) # ===================================================== -@app.post("/signature/universign/send", tags=["US-A3"]) +@app.post("/signature/universign/send", tags=["Universign"]) async def envoyer_signature( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): @@ -1496,7 +1564,7 @@ async def envoyer_signature( raise HTTPException(500, str(e)) -@app.get("/signature/universign/status", tags=["US-A3"]) +@app.get("/signature/universign/status", tags=["Universign"]) async def statut_signature(docId: str = Query(...)): """🔍 Récupération du statut de signature en temps réel""" # Chercher dans la DB locale @@ -1524,7 +1592,7 @@ async def statut_signature(docId: str = Query(...)): raise HTTPException(500, str(e)) -@app.get("/signatures", tags=["US-A3"]) +@app.get("/signatures", tags=["Universign"]) async def lister_signatures( statut: Optional[StatutSignature] = Query(None), limit: int = Query(100, le=1000), @@ -1562,7 +1630,7 @@ async def lister_signatures( ] -@app.get("/signatures/{transaction_id}/status", tags=["US-A3"]) +@app.get("/signatures/{transaction_id}/status", tags=["Universign"]) async def statut_signature_detail( transaction_id: str, session: AsyncSession = Depends(get_session) ): @@ -1616,7 +1684,7 @@ async def statut_signature_detail( } -@app.post("/signatures/refresh-all", tags=["US-A3"]) +@app.post("/signatures/refresh-all", tags=["Universign"]) async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): """🔄 Rafraîchit TOUS les statuts des signatures en attente""" query = select(SignatureLog).where( @@ -1665,7 +1733,7 @@ async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_sess } -@app.post("/devis/{id}/signer", tags=["US-A3"]) +@app.post("/devis/{id}/signer", tags=["Devis"]) async def envoyer_devis_signature( id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session) ): @@ -1788,7 +1856,7 @@ async def envoyer_emails_lot( # ===================================================== -@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["US-A5"]) +@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Devis"]) async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), @@ -1826,7 +1894,7 @@ async def valider_remise( # ===================================================== # ENDPOINTS - US-A6 (RELANCE DEVIS) # ===================================================== -@app.post("/devis/{id}/relancer-signature", tags=["US-A6"]) +@app.post("/devis/{id}/relancer-signature", tags=["Devis"]) async def relancer_devis_signature( id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): @@ -1897,7 +1965,7 @@ class ContactClientResponse(BaseModel): peut_etre_relance: bool -@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["US-A6"]) +@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis(id: str): """👤 US-A6: Récupération du contact client associé au devis""" try: @@ -1928,7 +1996,7 @@ async def recuperer_contact_devis(id: str): # ===================================================== -@app.get("/factures", tags=["US-A7"]) +@app.get("/factures", tags=["Factures"]) async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): @@ -1947,7 +2015,7 @@ async def lister_factures( raise HTTPException(500, str(e)) -@app.get("/factures/{numero}", tags=["US-A7"]) +@app.get("/factures/{numero}", tags=["Factures"]) async def lire_facture_detail(numero: str): """ 📄 Lecture détaillée d'une facture avec ses lignes @@ -1979,7 +2047,7 @@ class RelanceFactureRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None -@app.post("/factures", status_code=201, tags=["US-A7"]) +@app.post("/factures", status_code=201, tags=["Factures"]) async def creer_facture( facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) @@ -2062,7 +2130,7 @@ async def creer_facture( raise HTTPException(500, str(e)) -@app.put("/factures/{id}", tags=["US-A7"]) +@app.put("/factures/{id}", tags=["Factures"]) async def modifier_facture( id: str, facture_update: FactureUpdateRequest, @@ -2183,7 +2251,7 @@ templates_email_db = { } -@app.post("/factures/{id}/relancer", tags=["US-A7"]) +@app.post("/factures/{id}/relancer", tags=["Factures"]) async def relancer_facture( id: str, relance: RelanceFactureRequest, @@ -2263,7 +2331,7 @@ async def relancer_facture( # ============================================ -@app.get("/emails/logs", tags=["US-A9"]) +@app.get("/emails/logs", tags=["Emails"]) async def journal_emails( statut: Optional[StatutEmail] = Query(None), destinataire: Optional[str] = Query(None), @@ -2300,7 +2368,7 @@ async def journal_emails( ] -@app.get("/emails/logs/export", tags=["US-A9"]) +@app.get("/emails/logs/export", tags=["Emails"]) async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), @@ -2362,7 +2430,7 @@ async def exporter_logs_csv( # ============================================ -# US-A10 - MODÈLES D'E-MAILS +# Devis0 - MODÈLES D'E-MAILS # ============================================ @@ -2380,14 +2448,14 @@ class TemplatePreviewRequest(BaseModel): type_document: TypeDocument -@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["US-A10"]) +@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) async def lister_templates(): - """📧 US-A10: Liste tous les templates d'emails""" + """📧 Emails: Liste tous les templates d'emails""" return [TemplateEmail(**template) for template in templates_email_db.values()] @app.get( - "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["US-A10"] + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def lire_template(template_id: str): """📖 Lecture d'un template par ID""" @@ -2397,7 +2465,7 @@ async def lire_template(template_id: str): return TemplateEmail(**templates_email_db[template_id]) -@app.post("/templates/emails", response_model=TemplateEmail, tags=["US-A10"]) +@app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) async def creer_template(template: TemplateEmail): """➕ Création d'un nouveau template""" template_id = str(uuid.uuid4()) @@ -2416,7 +2484,7 @@ async def creer_template(template: TemplateEmail): @app.put( - "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["US-A10"] + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def modifier_template(template_id: str, template: TemplateEmail): """✏️ Modification d'un template existant""" @@ -2440,7 +2508,7 @@ async def modifier_template(template_id: str, template: TemplateEmail): return TemplateEmail(id=template_id, **template.dict()) -@app.delete("/templates/emails/{template_id}", tags=["US-A10"]) +@app.delete("/templates/emails/{template_id}", tags=["Emails"]) async def supprimer_template(template_id: str): """🗑️ Suppression d'un template""" if template_id not in templates_email_db: @@ -2456,7 +2524,7 @@ async def supprimer_template(template_id: str): return {"success": True, "message": f"Template {template_id} supprimé"} -@app.post("/templates/emails/preview", tags=["US-A10"]) +@app.post("/templates/emails/preview", tags=["Emails"]) async def previsualiser_email(preview: TemplatePreviewRequest): """👁️ US-A10: Prévisualisation email avec fusion variables""" if preview.template_id not in templates_email_db: @@ -3170,7 +3238,7 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se raise HTTPException(500, str(e)) -@app.post("/workflow/devis/{id}/to-facture", tags=["US-A2"]) +@app.post("/workflow/devis/{id}/to-facture", tags=["Devis"]) async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Devis → Facture (DIRECT, sans commande) @@ -3257,7 +3325,7 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-livraison", tags=["US-A2"]) +@app.post("/workflow/commande/{id}/to-livraison", tags=["Commandes"]) async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Commande → Bon de livraison From 14b2758b68ccae990ad1b89ea455df7b561aa9cb Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 11:03:30 +0300 Subject: [PATCH 26/62] refactor(api): update endpoint tags for better organization --- api.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/api.py b/api.py index bcc7bbb..0b37c3a 100644 --- a/api.py +++ b/api.py @@ -1410,7 +1410,7 @@ async def lister_commandes( raise HTTPException(500, str(e)) -@app.post("/workflow/devis/{id}/to-commande", tags=["US-A2"]) +@app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Devis → Commande @@ -1465,7 +1465,7 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-facture", tags=["Commandes"]) +@app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Commande → Facture @@ -1511,7 +1511,7 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses # ===================================================== # ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE) # ===================================================== -@app.post("/signature/universign/send", tags=["Universign"]) +@app.post("/signature/universign/send", tags=["Signatures"]) async def envoyer_signature( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): @@ -1564,7 +1564,7 @@ async def envoyer_signature( raise HTTPException(500, str(e)) -@app.get("/signature/universign/status", tags=["Universign"]) +@app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): """🔍 Récupération du statut de signature en temps réel""" # Chercher dans la DB locale @@ -1592,7 +1592,7 @@ async def statut_signature(docId: str = Query(...)): raise HTTPException(500, str(e)) -@app.get("/signatures", tags=["Universign"]) +@app.get("/signatures", tags=["Signatures"]) async def lister_signatures( statut: Optional[StatutSignature] = Query(None), limit: int = Query(100, le=1000), @@ -1630,7 +1630,7 @@ async def lister_signatures( ] -@app.get("/signatures/{transaction_id}/status", tags=["Universign"]) +@app.get("/signatures/{transaction_id}/status", tags=["Signatures"]) async def statut_signature_detail( transaction_id: str, session: AsyncSession = Depends(get_session) ): @@ -1684,7 +1684,7 @@ async def statut_signature_detail( } -@app.post("/signatures/refresh-all", tags=["Universign"]) +@app.post("/signatures/refresh-all", tags=["Signatures"]) async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): """🔄 Rafraîchit TOUS les statuts des signatures en attente""" query = select(SignatureLog).where( @@ -1802,7 +1802,7 @@ class EmailBatchRequest(BaseModel): type_document: Optional[TypeDocument] = None -@app.post("/emails/send-batch", tags=["US-A4"]) +@app.post("/emails/send-batch", tags=["Emails"]) async def envoyer_emails_lot( batch: EmailBatchRequest, session: AsyncSession = Depends(get_session) ): @@ -1856,7 +1856,7 @@ async def envoyer_emails_lot( # ===================================================== -@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Devis"]) +@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"]) async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), @@ -3195,7 +3195,7 @@ async def modifier_livraison( raise HTTPException(500, str(e)) -@app.post("/workflow/livraison/{id}/to-facture", tags=["US-A2"]) +@app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"]) async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Livraison → Facture @@ -3238,7 +3238,7 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se raise HTTPException(500, str(e)) -@app.post("/workflow/devis/{id}/to-facture", tags=["Devis"]) +@app.post("/workflow/devis/{id}/to-facture", tags=["Workflows"]) async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Devis → Facture (DIRECT, sans commande) @@ -3325,7 +3325,7 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-livraison", tags=["Commandes"]) +@app.post("/workflow/commande/{id}/to-livraison", tags=["Workflows"]) async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Commande → Bon de livraison From a1794ac90f16ea65984134a5dea7f434c671d4d6 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 17:40:12 +0300 Subject: [PATCH 27/62] feat(documents): add generic PDF download endpoint for documents --- api.py | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/api.py b/api.py index 0b37c3a..d1f7d8e 100644 --- a/api.py +++ b/api.py @@ -1252,6 +1252,88 @@ async def telecharger_devis_pdf(id: str): logger.error(f"Erreur génération PDF: {e}") raise HTTPException(500, str(e)) +@app.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"]) +async def telecharger_document_pdf( + type_doc: int = Query(..., description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)"), + numero: str = Query(..., description="Numéro du document") +): + """ + 📄 Téléchargement PDF d'un document (route généralisée) + + **Types de documents supportés:** + - `0`: Devis + - `10`: Bon de commande + - `30`: Bon de livraison + - `60`: Facture + - `50`: Bon d'avoir + + **Exemple d'utilisation:** + - `GET /documents/0/DE00001/pdf` → PDF du devis DE00001 + - `GET /documents/60/FA00001/pdf` → PDF de la facture FA00001 + + **Retour:** + - Fichier PDF prêt à télécharger + - Nom de fichier formaté selon le type de document + + Args: + type_doc: Type de document Sage (0-60) + numero: Numéro du document + + Returns: + StreamingResponse avec le PDF + """ + try: + # Mapping des types vers les libellés + types_labels = { + 0: "Devis", + 10: "Commande", + 20: "Preparation", + 30: "BonLivraison", + 40: "BonRetour", + 50: "Avoir", + 60: "Facture" + } + + # Vérifier que le type est valide + if type_doc not in types_labels: + raise HTTPException( + 400, + f"Type de document invalide: {type_doc}. " + f"Types valides: {list(types_labels.keys())}" + ) + + label = types_labels[type_doc] + + logger.info(f"📄 Génération PDF: {label} {numero} (type={type_doc})") + + # Appel à sage_client pour générer le PDF + pdf_bytes = sage_client.generer_pdf_document(numero, type_doc) + + if not pdf_bytes: + raise HTTPException( + 500, + f"Le PDF du document {numero} est vide" + ) + + logger.info(f"✅ PDF généré: {len(pdf_bytes)} octets") + + # Nom de fichier formaté + filename = f"{label}_{numero}.pdf" + + return StreamingResponse( + iter([pdf_bytes]), + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)) + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True) + raise HTTPException(500, f"Erreur génération PDF: {str(e)}") @app.post("/devis/{id}/envoyer", tags=["Devis"]) async def envoyer_devis_email( diff --git a/sage_client.py b/sage_client.py index 5f613c6..e45c6eb 100644 --- a/sage_client.py +++ b/sage_client.py @@ -543,6 +543,87 @@ class SageGatewayClient: "numero": numero, "facture_data": facture_data }).get("data", {}) + + def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: + """ + 🆕 Génère le PDF d'un document via la gateway Windows (route généralisée) + + **Cette méthode remplace les appels spécifiques par type de document** + + Args: + doc_id: Numéro du document (ex: "DE00001", "FA00001") + type_doc: Type de document Sage: + - 0: Devis + - 10: Bon de commande + - 30: Bon de livraison + - 60: Facture + - 50: Bon d'avoir + + Returns: + bytes: Contenu du PDF (binaire) + + Raises: + ValueError: Si le PDF retourné est vide + RuntimeError: Si erreur de communication avec la gateway + + Example: + >>> pdf_bytes = sage_client.generer_pdf_document("DE00001", 0) + >>> with open("devis.pdf", "wb") as f: + ... f.write(pdf_bytes) + """ + try: + logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") + + # Appel HTTP vers la gateway Windows + r = requests.post( + f"{self.url}/sage/documents/generate-pdf", + json={ + "doc_id": doc_id, + "type_doc": type_doc + }, + headers=self.headers, + timeout=60 # Timeout élevé pour génération PDF + ) + + r.raise_for_status() + + import base64 + + response_data = r.json() + + # Vérifier que la réponse contient bien le PDF + if not response_data.get("success"): + error_msg = response_data.get("error", "Erreur inconnue") + raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}") + + pdf_base64 = response_data.get("data", {}).get("pdf_base64", "") + + if not pdf_base64: + raise ValueError( + f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})" + ) + + # Décoder le base64 + pdf_bytes = base64.b64decode(pdf_base64) + + logger.info(f"✅ PDF décodé: {len(pdf_bytes)} octets") + + return pdf_bytes + + except requests.exceptions.Timeout: + logger.error(f"⏱️ Timeout génération PDF pour {doc_id}") + raise RuntimeError( + f"Timeout lors de la génération du PDF (>60s). " + f"Le document {doc_id} est peut-être trop volumineux." + ) + + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur HTTP génération PDF: {e}") + raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}") + + except Exception as e: + logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) + raise From 61e787bf360189d0f1003ae91deb466d3e628196 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 17:48:32 +0300 Subject: [PATCH 28/62] refactor(api): change query params to path params in document endpoint --- api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api.py b/api.py index d1f7d8e..778a4c2 100644 --- a/api.py +++ b/api.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, Query, Depends, status +from fastapi import FastAPI, HTTPException, Path, Query, Depends, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, EmailStr, validator, field_validator @@ -1254,8 +1254,8 @@ async def telecharger_devis_pdf(id: str): @app.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"]) async def telecharger_document_pdf( - type_doc: int = Query(..., description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)"), - numero: str = Query(..., description="Numéro du document") + type_doc: int = Path(..., description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)"), + numero: str = Path(..., description="Numéro du document") ): """ 📄 Téléchargement PDF d'un document (route généralisée) From e95e550044330a8cf41f53b486c601a1327f7494 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 17:57:41 +0300 Subject: [PATCH 29/62] style: Reformat method calls and remove unnecessary blank lines for improved code consistency. --- api.py | 894 ++++++++++++++++++++++++------------------------- sage_client.py | 164 ++++----- 2 files changed, 519 insertions(+), 539 deletions(-) diff --git a/api.py b/api.py index 778a4c2..c5ec086 100644 --- a/api.py +++ b/api.py @@ -45,70 +45,38 @@ from sage_client import sage_client TAGS_METADATA = [ { "name": "Clients", - "description": "Gestion des clients (recherche, création, modification)" - }, - { - "name": "Articles", - "description": "Gestion des articles et produits" - }, - { - "name": "Devis", - "description": "Création, consultation et gestion des devis" + "description": "Gestion des clients (recherche, création, modification)", }, + {"name": "Articles", "description": "Gestion des articles et produits"}, + {"name": "Devis", "description": "Création, consultation et gestion des devis"}, { "name": "Commandes", - "description": "Création, consultation et gestion des commandes" + "description": "Création, consultation et gestion des commandes", }, { "name": "Livraisons", - "description": "Création, consultation et gestion des bons de livraison" + "description": "Création, consultation et gestion des bons de livraison", }, { "name": "Factures", - "description": "Création, consultation et gestion des factures" - }, - { - "name": "Avoirs", - "description": "Création, consultation et gestion des avoirs" - }, - { - "name": "Fournisseurs", - "description": "Gestion des fournisseurs" - }, - { - "name": "Prospects", - "description": "Gestion des prospects" + "description": "Création, consultation et gestion des factures", }, + {"name": "Avoirs", "description": "Création, consultation et gestion des avoirs"}, + {"name": "Fournisseurs", "description": "Gestion des fournisseurs"}, + {"name": "Prospects", "description": "Gestion des prospects"}, { "name": "Workflows", - "description": "Transformations de documents (devis→commande, commande→facture, etc.)" + "description": "Transformations de documents (devis→commande, commande→facture, etc.)", }, - { - "name": "Signatures", - "description": "Signature électronique via Universign" - }, - { - "name": "Emails", - "description": "Envoi d'emails, templates et logs d'envoi" - }, - { - "name": "Validation", - "description": "Validation de données (remises, etc.)" - }, - { - "name": "Admin", - "description": "🔧 Administration système (cache, queue)" - }, - { - "name": "System", - "description": "🏥 Health checks et informations système" - }, - { - "name": "Debug", - "description": "🐛 Routes de debug et diagnostics" - } + {"name": "Signatures", "description": "Signature électronique via Universign"}, + {"name": "Emails", "description": "Envoi d'emails, templates et logs d'envoi"}, + {"name": "Validation", "description": "Validation de données (remises, etc.)"}, + {"name": "Admin", "description": "🔧 Administration système (cache, queue)"}, + {"name": "System", "description": "🏥 Health checks et informations système"}, + {"name": "Debug", "description": "🐛 Routes de debug et diagnostics"}, ] + # ===================================================== # ENUMS # ===================================================== @@ -164,7 +132,7 @@ class LigneDevis(BaseModel): quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -227,10 +195,11 @@ class ClientCreateAPIRequest(BaseModel): telephone: Optional[str] = None siret: Optional[str] = None tva_intra: Optional[str] = None - + class ClientUpdateRequest(BaseModel): """Modèle pour modification d'un client existant""" + intitule: Optional[str] = Field(None, min_length=1, max_length=69) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) @@ -240,7 +209,7 @@ class ClientUpdateRequest(BaseModel): telephone: Optional[str] = Field(None, max_length=21) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { @@ -249,10 +218,10 @@ class ClientUpdateRequest(BaseModel): "code_postal": "75008", "ville": "Paris", "email": "nouveau@email.fr", - "telephone": "0198765432" + "telephone": "0198765432", } } - + from pydantic import BaseModel from typing import List, Optional @@ -282,9 +251,15 @@ class UserResponse(BaseModel): class FournisseurCreateAPIRequest(BaseModel): - intitule: str = Field(..., min_length=1, max_length=69, description="Raison sociale du fournisseur") - compte_collectif: str = Field("401000", description="Compte comptable fournisseur (ex: 401000)") - num: Optional[str] = Field(None, max_length=17, description="Code fournisseur souhaité (optionnel)") + intitule: str = Field( + ..., min_length=1, max_length=69, description="Raison sociale du fournisseur" + ) + compte_collectif: str = Field( + "401000", description="Compte comptable fournisseur (ex: 401000)" + ) + num: Optional[str] = Field( + None, max_length=17, description="Code fournisseur souhaité (optionnel)" + ) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) ville: Optional[str] = Field(None, max_length=35) @@ -293,7 +268,7 @@ class FournisseurCreateAPIRequest(BaseModel): telephone: Optional[str] = Field(None, max_length=21) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { @@ -307,12 +282,14 @@ class FournisseurCreateAPIRequest(BaseModel): "email": "contact@acmesupplies.fr", "telephone": "0145678901", "siret": "12345678901234", - "tva_intra": "FR12345678901" + "tva_intra": "FR12345678901", } } - + + class FournisseurUpdateRequest(BaseModel): """Modèle pour modification d'un fournisseur existant""" + intitule: Optional[str] = Field(None, min_length=1, max_length=69) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) @@ -322,22 +299,24 @@ class FournisseurUpdateRequest(BaseModel): telephone: Optional[str] = Field(None, max_length=21) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { "intitule": "ACME SUPPLIES MODIFIÉ", "email": "nouveau@acme.fr", - "telephone": "0198765432" + "telephone": "0198765432", } } - + + class DevisUpdateRequest(BaseModel): """Modèle pour modification d'un devis existant""" + date_devis: Optional[date] = None lignes: Optional[List[LigneDevis]] = None statut: Optional[int] = Field(None, ge=0, le=6) - + class Config: json_schema_extra = { "example": { @@ -347,21 +326,22 @@ class DevisUpdateRequest(BaseModel): "article_code": "ART001", "quantite": 5.0, "prix_unitaire_ht": 100.0, - "remise_pourcentage": 10.0 + "remise_pourcentage": 10.0, } ], - "statut": 2 + "statut": 2, } } class LigneCommande(BaseModel): """Ligne de commande""" + article_code: str quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -369,11 +349,12 @@ class LigneCommande(BaseModel): class CommandeCreateRequest(BaseModel): """Création d'une commande""" + client_id: str date_commande: Optional[date] = None lignes: List[LigneCommande] reference: Optional[str] = None # Référence externe - + class Config: json_schema_extra = { "example": { @@ -385,20 +366,21 @@ class CommandeCreateRequest(BaseModel): "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 50.0, - "remise_pourcentage": 5.0 + "remise_pourcentage": 5.0, } - ] + ], } } class CommandeUpdateRequest(BaseModel): """Modification d'une commande existante""" + date_commande: Optional[date] = None lignes: Optional[List[LigneCommande]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -406,21 +388,22 @@ class CommandeUpdateRequest(BaseModel): { "article_code": "ART001", "quantite": 15.0, - "prix_unitaire_ht": 45.0 + "prix_unitaire_ht": 45.0, } ], - "statut": 2 + "statut": 2, } } class LigneLivraison(BaseModel): """Ligne de livraison""" + article_code: str quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -428,11 +411,12 @@ class LigneLivraison(BaseModel): class LivraisonCreateRequest(BaseModel): """Création d'une livraison""" + client_id: str date_livraison: Optional[date] = None lignes: List[LigneLivraison] reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -444,20 +428,21 @@ class LivraisonCreateRequest(BaseModel): "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 50.0, - "remise_pourcentage": 5.0 + "remise_pourcentage": 5.0, } - ] + ], } } class LivraisonUpdateRequest(BaseModel): """Modification d'une livraison existante""" + date_livraison: Optional[date] = None lignes: Optional[List[LigneLivraison]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -465,20 +450,22 @@ class LivraisonUpdateRequest(BaseModel): { "article_code": "ART001", "quantite": 15.0, - "prix_unitaire_ht": 45.0 + "prix_unitaire_ht": 45.0, } ], - "statut": 2 + "statut": 2, } } + class LigneAvoir(BaseModel): """Ligne d'avoir""" + article_code: str quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -486,11 +473,12 @@ class LigneAvoir(BaseModel): class AvoirCreateRequest(BaseModel): """Création d'un avoir""" + client_id: str date_avoir: Optional[date] = None lignes: List[LigneAvoir] reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -502,20 +490,21 @@ class AvoirCreateRequest(BaseModel): "article_code": "ART001", "quantite": 5.0, "prix_unitaire_ht": 50.0, - "remise_pourcentage": 0.0 + "remise_pourcentage": 0.0, } - ] + ], } } class AvoirUpdateRequest(BaseModel): """Modification d'un avoir existant""" + date_avoir: Optional[date] = None lignes: Optional[List[LigneAvoir]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -523,20 +512,22 @@ class AvoirUpdateRequest(BaseModel): { "article_code": "ART001", "quantite": 10.0, - "prix_unitaire_ht": 45.0 + "prix_unitaire_ht": 45.0, } ], - "statut": 2 + "statut": 2, } } - + + class LigneFacture(BaseModel): """Ligne de facture""" + article_code: str quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -544,11 +535,12 @@ class LigneFacture(BaseModel): class FactureCreateRequest(BaseModel): """Création d'une facture""" + client_id: str date_facture: Optional[date] = None lignes: List[LigneFacture] reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -560,20 +552,21 @@ class FactureCreateRequest(BaseModel): "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 50.0, - "remise_pourcentage": 5.0 + "remise_pourcentage": 5.0, } - ] + ], } } class FactureUpdateRequest(BaseModel): """Modification d'une facture existante""" + date_facture: Optional[date] = None lignes: Optional[List[LigneFacture]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -581,14 +574,14 @@ class FactureUpdateRequest(BaseModel): { "article_code": "ART001", "quantite": 15.0, - "prix_unitaire_ht": 45.0 + "prix_unitaire_ht": 45.0, } ], - "statut": 2 + "statut": 2, } } - - + + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -741,7 +734,7 @@ app = FastAPI( version="2.0.0", description="API de gestion commerciale - VPS Linux", lifespan=lifespan, - openapi_tags=TAGS_METADATA + openapi_tags=TAGS_METADATA, ) app.add_middleware( @@ -769,28 +762,26 @@ async def rechercher_clients(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) + @app.get("/clients/{code}", tags=["Clients"]) async def lire_client_detail(code: str): """ 📄 Lecture détaillée d'un client par son code - + Args: code: Code du client (ex: "CLI000001", "SARL", etc.) - + Returns: Toutes les informations du client """ try: client = sage_client.lire_client(code) - + if not client: raise HTTPException(404, f"Client {code} introuvable") - - return { - "success": True, - "data": client - } - + + return {"success": True, "data": client} + except HTTPException: raise except Exception as e: @@ -802,18 +793,18 @@ async def lire_client_detail(code: str): async def modifier_client( code: str, client_update: ClientUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'un client existant - + Args: code: Code du client à modifier client_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - + Returns: Client modifié avec ses nouvelles valeurs - + Example: PUT /clients/SARL { @@ -823,16 +814,18 @@ async def modifier_client( """ try: # Appel à la gateway Windows - resultat = sage_client.modifier_client(code, client_update.dict(exclude_none=True)) - + resultat = sage_client.modifier_client( + code, client_update.dict(exclude_none=True) + ) + logger.info(f"✅ Client {code} modifié avec succès") - + return { "success": True, "message": f"Client {code} modifié avec succès", - "client": resultat + "client": resultat, } - + except ValueError as e: # Erreur métier (client introuvable, etc.) logger.warning(f"Erreur métier modification client {code}: {e}") @@ -841,32 +834,33 @@ async def modifier_client( # Erreur technique logger.error(f"Erreur technique modification client {code}: {e}") raise HTTPException(500, str(e)) - + + @app.post("/clients", status_code=201, tags=["Clients"]) async def ajouter_client( - client: ClientCreateAPIRequest, - session: AsyncSession = Depends(get_session) + client: ClientCreateAPIRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'un nouveau client dans Sage 100c """ try: nouveau_client = sage_client.creer_client(client.dict()) - + logger.info(f"✅ Client créé via API: {nouveau_client.get('numero')}") - + return { "success": True, "message": "Client créé avec succès", - "data": nouveau_client + "data": nouveau_client, } - + except Exception as e: logger.error(f"Erreur lors de la création du client: {e}") # On renvoie une 400 si c'est une erreur métier (ex: doublon), sinon 500 status = 400 if "existe déjà" in str(e) else 500 raise HTTPException(status, str(e)) + @app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): """🔍 Recherche articles via gateway Windows""" @@ -920,25 +914,25 @@ async def creer_devis(devis: DevisRequest): async def modifier_devis( id: str, devis_update: DevisUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'un devis existant - + **Champs modifiables:** - `date_devis`: Nouvelle date du devis - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut (0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé) - + **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - Pour ajouter/supprimer une ligne, il faut envoyer TOUTES les lignes - Un devis transformé (statut=5) ne peut plus être modifié - + Args: id: Numéro du devis à modifier devis_update: Champs à mettre à jour - + Returns: Devis modifié avec ses nouvelles valeurs """ @@ -947,20 +941,19 @@ async def modifier_devis( devis_existant = sage_client.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") - + # Vérifier qu'il n'est pas déjà transformé if devis_existant.get("statut") == 5: raise HTTPException( - 400, - f"Le devis {id} a déjà été transformé et ne peut plus être modifié" + 400, f"Le devis {id} a déjà été transformé et ne peut plus être modifié" ) - + # Construire les données de mise à jour update_data = {} - + if devis_update.date_devis: update_data["date_devis"] = devis_update.date_devis.isoformat() - + if devis_update.lignes is not None: update_data["lignes"] = [ { @@ -971,51 +964,50 @@ async def modifier_devis( } for l in devis_update.lignes ] - + if devis_update.statut is not None: update_data["statut"] = devis_update.statut - + # Appel à la gateway Windows resultat = sage_client.modifier_devis(id, update_data) - + logger.info(f"✅ Devis {id} modifié avec succès") - + return { "success": True, "message": f"Devis {id} modifié avec succès", - "devis": resultat + "devis": resultat, } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur modification devis {id}: {e}") raise HTTPException(500, str(e)) - + @app.post("/commandes", status_code=201, tags=["Commandes"]) async def creer_commande( - commande: CommandeCreateRequest, - session: AsyncSession = Depends(get_session) + commande: CommandeCreateRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'une nouvelle commande (Bon de commande) - + **Workflow typique:** 1. Création d'un devis → transformation en commande (automatique) 2. OU création directe d'une commande (cette route) - + **Champs obligatoires:** - `client_id`: Code du client - `lignes`: Liste des lignes (min 1) - + **Champs optionnels:** - `date_commande`: Date de la commande (par défaut: aujourd'hui) - `reference`: Référence externe (ex: numéro de commande client) - + Args: commande: Données de la commande à créer - + Returns: Commande créée avec son numéro et ses totaux """ @@ -1024,14 +1016,12 @@ async def creer_commande( client = sage_client.lire_client(commande.client_id) if not client: raise HTTPException(404, f"Client {commande.client_id} introuvable") - + # Préparer les données pour la gateway commande_data = { "client_id": commande.client_id, "date_commande": ( - commande.date_commande.isoformat() - if commande.date_commande - else None + commande.date_commande.isoformat() if commande.date_commande else None ), "reference": commande.reference, "lignes": [ @@ -1044,12 +1034,12 @@ async def creer_commande( for l in commande.lignes ], } - + # Appel à la gateway Windows resultat = sage_client.creer_commande(commande_data) - + logger.info(f"✅ Commande créée: {resultat.get('numero_commande')}") - + return { "success": True, "message": "Commande créée avec succès", @@ -1060,10 +1050,10 @@ async def creer_commande( "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], - "reference": commande.reference - } + "reference": commande.reference, + }, } - + except HTTPException: raise except Exception as e: @@ -1075,62 +1065,60 @@ async def creer_commande( async def modifier_commande( id: str, commande_update: CommandeUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'une commande existante - + **Champs modifiables:** - `date_commande`: Nouvelle date - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut - `reference`: Référence externe - + **Restrictions:** - Une commande transformée (statut=5) ne peut plus être modifiée - Une commande annulée (statut=6) ne peut plus être modifiée - + **Note importante:** Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - + Args: id: Numéro de la commande à modifier commande_update: Champs à mettre à jour - + Returns: Commande modifiée avec ses nouvelles valeurs """ try: # Vérifier que la commande existe commande_existante = sage_client.lire_document( - id, - settings.SAGE_TYPE_BON_COMMANDE + id, settings.SAGE_TYPE_BON_COMMANDE ) - + if not commande_existante: raise HTTPException(404, f"Commande {id} introuvable") - + # Vérifier le statut statut_actuel = commande_existante.get("statut", 0) - + if statut_actuel == 5: raise HTTPException( 400, - f"La commande {id} a déjà été transformée et ne peut plus être modifiée" + f"La commande {id} a déjà été transformée et ne peut plus être modifiée", ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"La commande {id} est annulée et ne peut plus être modifiée" + 400, f"La commande {id} est annulée et ne peut plus être modifiée" ) - + # Construire les données de mise à jour update_data = {} - + if commande_update.date_commande: update_data["date_commande"] = commande_update.date_commande.isoformat() - + if commande_update.lignes is not None: update_data["lignes"] = [ { @@ -1141,30 +1129,30 @@ async def modifier_commande( } for l in commande_update.lignes ] - + if commande_update.statut is not None: update_data["statut"] = commande_update.statut - + if commande_update.reference is not None: update_data["reference"] = commande_update.reference - + # Appel à la gateway Windows resultat = sage_client.modifier_commande(id, update_data) - + logger.info(f"✅ Commande {id} modifiée avec succès") - + return { "success": True, "message": f"Commande {id} modifiée avec succès", - "commande": resultat + "commande": resultat, } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur modification commande {id}: {e}") raise HTTPException(500, str(e)) - + @app.get("/devis", tags=["Devis"]) async def lister_devis( @@ -1199,22 +1187,22 @@ async def lister_devis( async def lire_devis(id: str): """ 📄 Lecture d'un devis via gateway Windows - + Returns: Devis complet avec: - Toutes les informations standards - lignes: Lignes du devis - a_deja_ete_transforme: ✅ Booléen indiquant si le devis a été transformé - documents_cibles: ✅ Liste des documents créés depuis ce devis - + ✅ ENRICHI: Inclut maintenant l'information de transformation """ try: devis = sage_client.lire_devis(id) - + if not devis: raise HTTPException(404, f"Devis {id} introuvable") - + # Log informatif if devis.get("a_deja_ete_transforme"): docs = devis.get("documents_cibles", []) @@ -1222,12 +1210,9 @@ async def lire_devis(id: str): f"📊 Devis {id} a été transformé en " f"{len(docs)} document(s): {[d['numero'] for d in docs]}" ) - - return { - "success": True, - "data": devis - } - + + return {"success": True, "data": devis} + except HTTPException: raise except Exception as e: @@ -1252,33 +1237,37 @@ async def telecharger_devis_pdf(id: str): logger.error(f"Erreur génération PDF: {e}") raise HTTPException(500, str(e)) + @app.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"]) async def telecharger_document_pdf( - type_doc: int = Path(..., description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)"), - numero: str = Path(..., description="Numéro du document") + type_doc: int = Path( + ..., + description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)", + ), + numero: str = Path(..., description="Numéro du document"), ): """ 📄 Téléchargement PDF d'un document (route généralisée) - + **Types de documents supportés:** - `0`: Devis - `10`: Bon de commande - `30`: Bon de livraison - `60`: Facture - `50`: Bon d'avoir - + **Exemple d'utilisation:** - `GET /documents/0/DE00001/pdf` → PDF du devis DE00001 - `GET /documents/60/FA00001/pdf` → PDF de la facture FA00001 - + **Retour:** - Fichier PDF prêt à télécharger - Nom de fichier formaté selon le type de document - + Args: type_doc: Type de document Sage (0-60) numero: Numéro du document - + Returns: StreamingResponse avec le PDF """ @@ -1291,50 +1280,50 @@ async def telecharger_document_pdf( 30: "BonLivraison", 40: "BonRetour", 50: "Avoir", - 60: "Facture" + 60: "Facture", } - + # Vérifier que le type est valide if type_doc not in types_labels: raise HTTPException( - 400, + 400, f"Type de document invalide: {type_doc}. " - f"Types valides: {list(types_labels.keys())}" + f"Types valides: {list(types_labels.keys())}", ) - + label = types_labels[type_doc] - + logger.info(f"📄 Génération PDF: {label} {numero} (type={type_doc})") - + # Appel à sage_client pour générer le PDF pdf_bytes = sage_client.generer_pdf_document(numero, type_doc) - + if not pdf_bytes: - raise HTTPException( - 500, - f"Le PDF du document {numero} est vide" - ) - + raise HTTPException(500, f"Le PDF du document {numero} est vide") + logger.info(f"✅ PDF généré: {len(pdf_bytes)} octets") - + # Nom de fichier formaté filename = f"{label}_{numero}.pdf" - + return StreamingResponse( iter([pdf_bytes]), media_type="application/pdf", headers={ "Content-Disposition": f"attachment; filename={filename}", - "Content-Length": str(len(pdf_bytes)) - } + "Content-Length": str(len(pdf_bytes)), + }, ) - + except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True) + logger.error( + f"❌ Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True + ) raise HTTPException(500, f"Erreur génération PDF: {str(e)}") + @app.post("/devis/{id}/envoyer", tags=["Devis"]) async def envoyer_devis_email( id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) @@ -1391,26 +1380,28 @@ async def envoyer_devis_email( @app.put("/devis/{id}/statut", tags=["Devis"]) async def changer_statut_devis( - id: str, - nouveau_statut: int = Query(..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé") + id: str, + nouveau_statut: int = Query( + ..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé" + ), ): """ 📊 Changement de statut d'un devis - + **Statuts possibles:** - 0: Brouillon - 2: Accepté/Validé - 5: Transformé (automatique lors d'une transformation) - 6: Annulé - + **Restrictions:** - Un devis transformé (5) ne peut plus changer de statut - Un devis annulé (6) ne peut plus changer de statut - + Args: id: Numéro du devis nouveau_statut: Nouveau statut (0-6) - + Returns: Confirmation du changement avec ancien et nouveau statut """ @@ -1419,34 +1410,33 @@ async def changer_statut_devis( devis_existant = sage_client.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") - + statut_actuel = devis_existant.get("statut", 0) - + # Vérifications de cohérence if statut_actuel == 5: raise HTTPException( 400, - f"Le devis {id} a déjà été transformé et ne peut plus changer de statut" + f"Le devis {id} a déjà été transformé et ne peut plus changer de statut", ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"Le devis {id} est annulé et ne peut plus changer de statut" + 400, f"Le devis {id} est annulé et ne peut plus changer de statut" ) - + resultat = sage_client.changer_statut_devis(id, nouveau_statut) - + logger.info(f"✅ Statut devis {id} changé: {statut_actuel} → {nouveau_statut}") - + return { "success": True, "devis_id": id, "statut_ancien": resultat.get("statut_ancien", statut_actuel), "statut_nouveau": resultat.get("statut_nouveau", nouveau_statut), - "message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}" + "message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}", } - + except HTTPException: raise except Exception as e: @@ -1458,6 +1448,7 @@ async def changer_statut_devis( # ENDPOINTS - Commandes (WORKFLOW SANS RESSAISIE) # ===================================================== + @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): """📄 Lecture d'une commande avec ses lignes""" @@ -1471,7 +1462,7 @@ async def lire_commande(id: str): except Exception as e: logger.error(f"Erreur lecture commande: {e}") raise HTTPException(500, str(e)) - + @app.get("/commandes", tags=["Commandes"]) async def lister_commandes( @@ -1512,7 +1503,9 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi sage_client.changer_statut_devis(id, nouveau_statut=5) logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") except Exception as e: - logger.warning(f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}") + logger.warning( + f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}" + ) # On continue même si la MAJ statut échoue # Étape 3: Logger la transformation @@ -1938,7 +1931,9 @@ async def envoyer_emails_lot( # ===================================================== -@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"]) +@app.post( + "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] +) async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), @@ -2101,62 +2096,60 @@ async def lister_factures( async def lire_facture_detail(numero: str): """ 📄 Lecture détaillée d'une facture avec ses lignes - + Args: numero: Numéro de la facture (ex: "FA000001") - + Returns: Facture complète avec lignes, client, totaux, etc. """ try: facture = sage_client.lire_document(numero, TypeDocument.FACTURE) - + if not facture: raise HTTPException(404, f"Facture {numero} introuvable") - - return { - "success": True, - "data": facture - } - + + return {"success": True, "data": facture} + except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture facture {numero}: {e}") raise HTTPException(500, str(e)) - + + class RelanceFactureRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None + @app.post("/factures", status_code=201, tags=["Factures"]) async def creer_facture( - facture: FactureCreateRequest, - session: AsyncSession = Depends(get_session) + facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'une facture - + **Workflow typique:** 1. Commande → Livraison → Facture (transformations successives) 2. OU création directe d'une facture (cette route) - + **Champs obligatoires:** - `client_id`: Code du client - `lignes`: Liste des lignes (min 1) - + **Champs optionnels:** - `date_facture`: Date de la facture (par défaut: aujourd'hui) - `reference`: Référence externe (ex: numéro de commande client) - + **Notes importantes:** - Les factures peuvent avoir des champs obligatoires supplémentaires selon la configuration Sage - Le statut initial est généralement 2 (Accepté/Validé) - Les factures sont soumises aux règles de numérotation strictes - + Args: facture: Données de la facture à créer - + Returns: Facture créée avec son numéro et ses totaux """ @@ -2165,14 +2158,12 @@ async def creer_facture( client = sage_client.lire_client(facture.client_id) if not client: raise HTTPException(404, f"Client {facture.client_id} introuvable") - + # Préparer les données pour la gateway facture_data = { "client_id": facture.client_id, "date_facture": ( - facture.date_facture.isoformat() - if facture.date_facture - else None + facture.date_facture.isoformat() if facture.date_facture else None ), "reference": facture.reference, "lignes": [ @@ -2185,12 +2176,12 @@ async def creer_facture( for l in facture.lignes ], } - + # Appel à la gateway Windows resultat = sage_client.creer_facture(facture_data) - + logger.info(f"✅ Facture créée: {resultat.get('numero_facture')}") - + return { "success": True, "message": "Facture créée avec succès", @@ -2201,10 +2192,10 @@ async def creer_facture( "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], - "reference": facture.reference - } + "reference": facture.reference, + }, } - + except HTTPException: raise except Exception as e: @@ -2216,64 +2207,60 @@ async def creer_facture( async def modifier_facture( id: str, facture_update: FactureUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'une facture existante - + **Champs modifiables:** - `date_facture`: Nouvelle date - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut - `reference`: Référence externe - + **Restrictions IMPORTANTES:** - Une facture transformée (statut=5) ne peut plus être modifiée - Une facture annulée (statut=6) ne peut plus être modifiée - ⚠️ **ATTENTION**: Les factures comptabilisées peuvent être verrouillées par Sage - Certaines factures peuvent être en lecture seule selon les droits utilisateur - + **Note importante:** Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - + Args: id: Numéro de la facture à modifier facture_update: Champs à mettre à jour - + Returns: Facture modifiée avec ses nouvelles valeurs """ try: # Vérifier que la facture existe - facture_existante = sage_client.lire_document( - id, - settings.SAGE_TYPE_FACTURE - ) - + facture_existante = sage_client.lire_document(id, settings.SAGE_TYPE_FACTURE) + if not facture_existante: raise HTTPException(404, f"Facture {id} introuvable") - + # Vérifier le statut statut_actuel = facture_existante.get("statut", 0) - + if statut_actuel == 5: raise HTTPException( 400, - f"La facture {id} a déjà été transformée et ne peut plus être modifiée" + f"La facture {id} a déjà été transformée et ne peut plus être modifiée", ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"La facture {id} est annulée et ne peut plus être modifiée" + 400, f"La facture {id} est annulée et ne peut plus être modifiée" ) - + # Construire les données de mise à jour update_data = {} - + if facture_update.date_facture: update_data["date_facture"] = facture_update.date_facture.isoformat() - + if facture_update.lignes is not None: update_data["lignes"] = [ { @@ -2284,30 +2271,30 @@ async def modifier_facture( } for l in facture_update.lignes ] - + if facture_update.statut is not None: update_data["statut"] = facture_update.statut - + if facture_update.reference is not None: update_data["reference"] = facture_update.reference - + # Appel à la gateway Windows resultat = sage_client.modifier_facture(id, update_data) - + logger.info(f"✅ Facture {id} modifiée avec succès") - + return { "success": True, "message": f"Facture {id} modifiée avec succès", - "facture": resultat + "facture": resultat, } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur modification facture {id}: {e}") raise HTTPException(500, str(e)) - + # Templates email (si pas déjà définis) templates_email_db = { @@ -2772,39 +2759,40 @@ async def rechercher_fournisseurs(query: Optional[str] = Query(None)): try: # ✅ APPEL DIRECT vers Windows (pas de cache) fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") - + logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés depuis Windows") - + if len(fournisseurs) == 0: logger.warning("⚠️ Aucun fournisseur retourné - vérifier la gateway Windows") - + return fournisseurs - + except Exception as e: logger.error(f"❌ Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) + @app.post("/fournisseurs", status_code=201, tags=["Fournisseurs"]) async def ajouter_fournisseur( fournisseur: FournisseurCreateAPIRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ➕ Création d'un nouveau fournisseur dans Sage 100c - + **Champs obligatoires:** - `intitule`: Raison sociale (max 69 caractères) - + **Champs optionnels:** - `compte_collectif`: Compte comptable (défaut: 401000) - `num`: Code fournisseur personnalisé (auto-généré si vide) - `adresse`, `code_postal`, `ville`, `pays` - `email`, `telephone` - `siret`, `tva_intra` - + **Retour:** - Fournisseur créé avec son numéro définitif - + **Erreurs possibles:** - 400: Fournisseur existe déjà (doublon) - 500: Erreur technique Sage @@ -2812,41 +2800,42 @@ async def ajouter_fournisseur( try: # Appel à la gateway Windows via sage_client nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) - + logger.info(f"✅ Fournisseur créé via API: {nouveau_fournisseur.get('numero')}") - + return { "success": True, "message": "Fournisseur créé avec succès", - "data": nouveau_fournisseur + "data": nouveau_fournisseur, } - + except ValueError as e: # Erreur métier (doublon, validation) logger.warning(f"⚠️ Erreur métier création fournisseur: {e}") raise HTTPException(400, str(e)) - + except Exception as e: # Erreur technique (COM, connexion) logger.error(f"❌ Erreur technique création fournisseur: {e}") raise HTTPException(500, str(e)) + @app.put("/fournisseurs/{code}", tags=["Fournisseurs"]) async def modifier_fournisseur( code: str, fournisseur_update: FournisseurUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'un fournisseur existant - + Args: code: Code du fournisseur à modifier fournisseur_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - + Returns: Fournisseur modifié avec ses nouvelles valeurs - + Example: PUT /fournisseurs/DUPONT { @@ -2856,16 +2845,18 @@ async def modifier_fournisseur( """ try: # Appel à la gateway Windows - resultat = sage_client.modifier_fournisseur(code, fournisseur_update.dict(exclude_none=True)) - + resultat = sage_client.modifier_fournisseur( + code, fournisseur_update.dict(exclude_none=True) + ) + logger.info(f"✅ Fournisseur {code} modifié avec succès") - + return { "success": True, "message": f"Fournisseur {code} modifié avec succès", - "fournisseur": resultat + "fournisseur": resultat, } - + except ValueError as e: # Erreur métier (fournisseur introuvable, etc.) logger.warning(f"Erreur métier modification fournisseur {code}: {e}") @@ -2874,8 +2865,8 @@ async def modifier_fournisseur( # Erreur technique logger.error(f"Erreur technique modification fournisseur {code}: {e}") raise HTTPException(500, str(e)) - - + + @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): """📄 Lecture d'un fournisseur par code""" @@ -2921,31 +2912,31 @@ async def lire_avoir(numero: str): logger.error(f"Erreur lecture avoir: {e}") raise HTTPException(500, str(e)) + @app.post("/avoirs", status_code=201, tags=["Avoirs"]) async def creer_avoir( - avoir: AvoirCreateRequest, - session: AsyncSession = Depends(get_session) + avoir: AvoirCreateRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'un avoir (Bon d'avoir) - + **Workflow typique:** 1. Retour marchandise → création d'un avoir 2. Geste commercial → création directe d'un avoir (cette route) - + **Champs obligatoires:** - `client_id`: Code du client - `lignes`: Liste des lignes (min 1) - + **Champs optionnels:** - `date_avoir`: Date de l'avoir (par défaut: aujourd'hui) - `reference`: Référence externe (ex: numéro de retour) - + **Note:** Les montants des avoirs sont généralement négatifs (crédits) - + Args: avoir: Données de l'avoir à créer - + Returns: Avoir créé avec son numéro et ses totaux """ @@ -2954,15 +2945,11 @@ async def creer_avoir( client = sage_client.lire_client(avoir.client_id) if not client: raise HTTPException(404, f"Client {avoir.client_id} introuvable") - + # Préparer les données pour la gateway avoir_data = { "client_id": avoir.client_id, - "date_avoir": ( - avoir.date_avoir.isoformat() - if avoir.date_avoir - else None - ), + "date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None), "reference": avoir.reference, "lignes": [ { @@ -2974,12 +2961,12 @@ async def creer_avoir( for l in avoir.lignes ], } - + # Appel à la gateway Windows resultat = sage_client.creer_avoir(avoir_data) - + logger.info(f"✅ Avoir créé: {resultat.get('numero_avoir')}") - + return { "success": True, "message": "Avoir créé avec succès", @@ -2990,10 +2977,10 @@ async def creer_avoir( "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], - "reference": avoir.reference - } + "reference": avoir.reference, + }, } - + except HTTPException: raise except Exception as e: @@ -3005,59 +2992,57 @@ async def creer_avoir( async def modifier_avoir( id: str, avoir_update: AvoirUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'un avoir existant - + **Champs modifiables:** - `date_avoir`: Nouvelle date - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut - `reference`: Référence externe - + **Restrictions:** - Un avoir transformé (statut=5) ne peut plus être modifié - Un avoir annulé (statut=6) ne peut plus être modifié - + **Note importante:** Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - + Args: id: Numéro de l'avoir à modifier avoir_update: Champs à mettre à jour - + Returns: Avoir modifié avec ses nouvelles valeurs """ try: # Vérifier que l'avoir existe avoir_existant = sage_client.lire_avoir(id) - + if not avoir_existant: raise HTTPException(404, f"Avoir {id} introuvable") - + # Vérifier le statut statut_actuel = avoir_existant.get("statut", 0) - + if statut_actuel == 5: raise HTTPException( - 400, - f"L'avoir {id} a déjà été transformé et ne peut plus être modifié" + 400, f"L'avoir {id} a déjà été transformé et ne peut plus être modifié" ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"L'avoir {id} est annulé et ne peut plus être modifié" + 400, f"L'avoir {id} est annulé et ne peut plus être modifié" ) - + # Construire les données de mise à jour update_data = {} - + if avoir_update.date_avoir: update_data["date_avoir"] = avoir_update.date_avoir.isoformat() - + if avoir_update.lignes is not None: update_data["lignes"] = [ { @@ -3068,31 +3053,31 @@ async def modifier_avoir( } for l in avoir_update.lignes ] - + if avoir_update.statut is not None: update_data["statut"] = avoir_update.statut - + if avoir_update.reference is not None: update_data["reference"] = avoir_update.reference - + # Appel à la gateway Windows resultat = sage_client.modifier_avoir(id, update_data) - + logger.info(f"✅ Avoir {id} modifié avec succès") - + return { "success": True, "message": f"Avoir {id} modifié avec succès", - "avoir": resultat + "avoir": resultat, } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur modification avoir {id}: {e}") raise HTTPException(500, str(e)) - - + + # ===================================================== # ENDPOINTS - LIVRAISONS # ===================================================== @@ -3123,22 +3108,22 @@ async def lire_livraison(numero: str): logger.error(f"Erreur lecture livraison: {e}") raise HTTPException(500, str(e)) + @app.post("/livraisons", status_code=201, tags=["Livraisons"]) async def creer_livraison( - livraison: LivraisonCreateRequest, - session: AsyncSession = Depends(get_session) + livraison: LivraisonCreateRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'une nouvelle livraison (Bon de livraison) - + **Workflow typique:** 1. Création d'une commande → transformation en livraison (automatique) 2. OU création directe d'une livraison (cette route) - + **Champs obligatoires:** - `client_id`: Code du client - `lignes`: Liste des lignes (min 1) - + **Champs optionnels:** - `date_livraison`: Date de la livraison (par défaut: aujourd'hui) - `reference`: Référence externe (ex: numéro de commande client) @@ -3148,13 +3133,13 @@ async def creer_livraison( client = sage_client.lire_client(livraison.client_id) if not client: raise HTTPException(404, f"Client {livraison.client_id} introuvable") - + # Préparer les données pour la gateway livraison_data = { "client_id": livraison.client_id, "date_livraison": ( - livraison.date_livraison.isoformat() - if livraison.date_livraison + livraison.date_livraison.isoformat() + if livraison.date_livraison else None ), "reference": livraison.reference, @@ -3168,12 +3153,12 @@ async def creer_livraison( for l in livraison.lignes ], } - + # Appel à la gateway Windows resultat = sage_client.creer_livraison(livraison_data) - + logger.info(f"✅ Livraison créée: {resultat.get('numero_livraison')}") - + return { "success": True, "message": "Livraison créée avec succès", @@ -3184,10 +3169,10 @@ async def creer_livraison( "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], - "reference": livraison.reference - } + "reference": livraison.reference, + }, } - + except HTTPException: raise except Exception as e: @@ -3199,17 +3184,17 @@ async def creer_livraison( async def modifier_livraison( id: str, livraison_update: LivraisonUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'une livraison existante - + **Champs modifiables:** - `date_livraison`: Nouvelle date - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut - `reference`: Référence externe - + **Restrictions:** - Une livraison transformée (statut=5) ne peut plus être modifiée - Une livraison annulée (statut=6) ne peut plus être modifiée @@ -3217,31 +3202,30 @@ async def modifier_livraison( try: # Vérifier que la livraison existe livraison_existante = sage_client.lire_livraison(id) - + if not livraison_existante: raise HTTPException(404, f"Livraison {id} introuvable") - + # Vérifier le statut statut_actuel = livraison_existante.get("statut", 0) - + if statut_actuel == 5: raise HTTPException( 400, - f"La livraison {id} a déjà été transformée et ne peut plus être modifiée" + f"La livraison {id} a déjà été transformée et ne peut plus être modifiée", ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"La livraison {id} est annulée et ne peut plus être modifiée" + 400, f"La livraison {id} est annulée et ne peut plus être modifiée" ) - + # Construire les données de mise à jour update_data = {} - + if livraison_update.date_livraison: update_data["date_livraison"] = livraison_update.date_livraison.isoformat() - + if livraison_update.lignes is not None: update_data["lignes"] = [ { @@ -3252,24 +3236,24 @@ async def modifier_livraison( } for l in livraison_update.lignes ] - + if livraison_update.statut is not None: update_data["statut"] = livraison_update.statut - + if livraison_update.reference is not None: update_data["reference"] = livraison_update.reference - + # Appel à la gateway Windows resultat = sage_client.modifier_livraison(id, update_data) - + logger.info(f"✅ Livraison {id} modifiée avec succès") - + return { "success": True, "message": f"Livraison {id} modifiée avec succès", - "livraison": resultat + "livraison": resultat, } - + except HTTPException: raise except Exception as e: @@ -3321,24 +3305,26 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se @app.post("/workflow/devis/{id}/to-facture", tags=["Workflows"]) -async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get_session)): +async def devis_vers_facture_direct( + id: str, session: AsyncSession = Depends(get_session) +): """ 🔧 Transformation Devis → Facture (DIRECT, sans commande) - + ✅ Utilise les VRAIS types Sage (0 → 60) ✅ Met à jour le statut du devis source à 5 (Transformé) - + **Workflow raccourci** : Permet de facturer directement depuis un devis sans passer par la création d'une commande. - + **Cas d'usage** : - Prestations de services facturées directement - Petites commandes sans besoin de suivi intermédiaire - Ventes au comptoir - + Args: id: Numéro du devis source - + Returns: Informations de la facture créée """ @@ -3347,15 +3333,15 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get devis_existant = sage_client.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") - + statut_devis = devis_existant.get("statut", 0) if statut_devis == 5: raise HTTPException( - 400, + 400, f"Le devis {id} a déjà été transformé (statut=5). " - f"Vérifiez les documents déjà créés depuis ce devis." + f"Vérifiez les documents déjà créés depuis ce devis.", ) - + # Étape 2: Transformation resultat = sage_client.transformer_document( numero_source=id, @@ -3368,7 +3354,9 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get sage_client.changer_statut_devis(id, nouveau_statut=5) logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") except Exception as e: - logger.warning(f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}") + logger.warning( + f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}" + ) # On continue même si la MAJ statut échoue # Étape 4: Logger la transformation @@ -3408,55 +3396,56 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get @app.post("/workflow/commande/{id}/to-livraison", tags=["Workflows"]) -async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_session)): +async def commande_vers_livraison( + id: str, session: AsyncSession = Depends(get_session) +): """ 🔧 Transformation Commande → Bon de livraison - + ✅ Utilise les VRAIS types Sage (10 → 30) - + **Workflow typique** : Après validation d'une commande, génère le bon de livraison pour préparer l'expédition. - + **Cas d'usage** : - Préparation d'une expédition - Génération du bordereau de livraison - Suivi logistique - + **Workflow complet** : 1. Devis → Commande (via `/workflow/devis/{id}/to-commande`) 2. **Commande → Livraison** (cette route) 3. Livraison → Facture (via `/workflow/livraison/{id}/to-facture`) - + Args: id: Numéro de la commande source - + Returns: Informations du bon de livraison créé """ try: # Étape 1: Vérifier que la commande existe commande_existante = sage_client.lire_document( - id, - settings.SAGE_TYPE_BON_COMMANDE + id, settings.SAGE_TYPE_BON_COMMANDE ) - + if not commande_existante: raise HTTPException(404, f"Commande {id} introuvable") - + statut_commande = commande_existante.get("statut", 0) if statut_commande == 5: raise HTTPException( 400, f"La commande {id} a déjà été transformée (statut=5). " - f"Un bon de livraison existe probablement déjà." + f"Un bon de livraison existe probablement déjà.", ) - + if statut_commande == 6: raise HTTPException( 400, - f"La commande {id} est annulée (statut=6) et ne peut pas être transformée." + f"La commande {id} est annulée (statut=6) et ne peut pas être transformée.", ) - + # Étape 2: Transformation resultat = sage_client.transformer_document( numero_source=id, @@ -3498,7 +3487,7 @@ async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_s except Exception as e: logger.error(f"❌ Erreur transformation commande→livraison: {e}", exc_info=True) raise HTTPException(500, str(e)) - + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( @@ -3845,6 +3834,7 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses "traceback": str(e.__class__.__name__), } + @app.get("/debug/fournisseurs/cache", tags=["Debug"]) async def debug_cache_fournisseurs(): """ @@ -3853,7 +3843,7 @@ async def debug_cache_fournisseurs(): try: # Appeler la gateway Windows pour récupérer l'info cache cache_info = sage_client.get_cache_info() - + # Tenter de lister les fournisseurs try: fournisseurs = sage_client.lister_fournisseurs(filtre="") @@ -3863,27 +3853,32 @@ async def debug_cache_fournisseurs(): nb_fournisseurs = -1 exemple = [] error = str(e) - + return { "success": True, "cache_info_windows": cache_info, "test_liste_fournisseurs": { "nb_fournisseurs": nb_fournisseurs, "exemples": exemple, - "erreur": error if nb_fournisseurs == -1 else None + "erreur": error if nb_fournisseurs == -1 else None, }, "diagnostic": { "gateway_accessible": cache_info is not None, - "cache_fournisseurs_existe": "fournisseurs" in cache_info if cache_info else False, + "cache_fournisseurs_existe": ( + "fournisseurs" in cache_info if cache_info else False + ), "probleme_probable": ( - "Cache fournisseurs non initialisé côté Windows" - if cache_info and "fournisseurs" not in cache_info - else "OK" if nb_fournisseurs > 0 - else "Erreur lors de la récupération" - ) - } + "Cache fournisseurs non initialisé côté Windows" + if cache_info and "fournisseurs" not in cache_info + else ( + "OK" + if nb_fournisseurs > 0 + else "Erreur lors de la récupération" + ) + ), + }, } - + except Exception as e: logger.error(f"❌ Erreur debug cache: {e}", exc_info=True) raise HTTPException(500, str(e)) @@ -3897,18 +3892,19 @@ async def force_refresh_fournisseurs(): try: # Appeler la gateway Windows pour forcer le refresh resultat = sage_client.refresh_cache() - + # Attendre 2 secondes import time + time.sleep(2) - + # Récupérer le cache info après refresh cache_info = sage_client.get_cache_info() - + # Tester la liste fournisseurs = sage_client.lister_fournisseurs(filtre="") nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 - + return { "success": True, "refresh_result": resultat, @@ -3916,17 +3912,17 @@ async def force_refresh_fournisseurs(): "nb_fournisseurs_maintenant": nb_fournisseurs, "exemples": fournisseurs[:3] if fournisseurs else [], "message": ( - f"✅ Refresh OK : {nb_fournisseurs} fournisseurs disponibles" - if nb_fournisseurs > 0 + f"✅ Refresh OK : {nb_fournisseurs} fournisseurs disponibles" + if nb_fournisseurs > 0 else "❌ Problème : aucun fournisseur après refresh" - ) + ), } - + except Exception as e: logger.error(f"❌ Erreur force refresh: {e}", exc_info=True) raise HTTPException(500, str(e)) - - + + # ===================================================== # LANCEMENT # ===================================================== diff --git a/sage_client.py b/sage_client.py index e45c6eb..d03e009 100644 --- a/sage_client.py +++ b/sage_client.py @@ -278,32 +278,32 @@ class SageGatewayClient: def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: """ Envoie la requête de création de fournisseur à la gateway Windows. - + Args: fournisseur_data: Dict contenant intitule, compte_collectif, etc. - + Returns: Fournisseur créé avec son numéro définitif """ return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) - + def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: """ ✏️ Modification d'un fournisseur existant - + Args: code: Code du fournisseur à modifier fournisseur_data: Dictionnaire contenant les champs à modifier (seuls les champs présents seront mis à jour) - + Returns: Fournisseur modifié """ - return self._post("/sage/fournisseurs/update", { - "code": code, - "fournisseur_data": fournisseur_data - }).get("data", {}) - + return self._post( + "/sage/fournisseurs/update", + {"code": code, "fournisseur_data": fournisseur_data}, + ).get("data", {}) + # ===================================================== # AVOIRS # ===================================================== @@ -357,7 +357,7 @@ class SageGatewayClient: return r.json() except: return {"status": "down"} - + def creer_client(self, client_data: Dict) -> Dict: """ Envoie la requête de création de client à la gateway Windows. @@ -365,48 +365,45 @@ class SageGatewayClient: """ # On appelle la route définie dans main.py return self._post("/sage/clients/create", client_data).get("data", {}) - + def modifier_client(self, code: str, client_data: Dict) -> Dict: """ ✏️ Modification d'un client existant - + Args: code: Code du client à modifier client_data: Dictionnaire contenant les champs à modifier (seuls les champs présents seront mis à jour) - + Returns: Client modifié """ - return self._post("/sage/clients/update", { - "code": code, - "client_data": client_data - }).get("data", {}) - - + return self._post( + "/sage/clients/update", {"code": code, "client_data": client_data} + ).get("data", {}) + def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: """ ✏️ Modification d'un devis existant - + Args: numero: Numéro du devis à modifier devis_data: Dictionnaire contenant les champs à modifier: - date_devis (str, optional): Nouvelle date - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - + Returns: Devis modifié avec totaux recalculés """ - return self._post("/sage/devis/update", { - "numero": numero, - "devis_data": devis_data - }).get("data", {}) - + return self._post( + "/sage/devis/update", {"numero": numero, "devis_data": devis_data} + ).get("data", {}) + def creer_commande(self, commande_data: Dict) -> Dict: """ ➕ Création d'une nouvelle commande (Bon de commande) - + Args: commande_data: Dictionnaire contenant: - client_id (str): Code du client @@ -417,17 +414,16 @@ class SageGatewayClient: - quantite (float) - prix_unitaire_ht (float, optional) - remise_pourcentage (float, optional) - + Returns: Commande créée avec son numéro et ses totaux """ return self._post("/sage/commandes/create", commande_data).get("data", {}) - - + def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: """ ✏️ Modification d'une commande existante - + Args: numero: Numéro de la commande à modifier commande_data: Dictionnaire contenant les champs à modifier: @@ -435,36 +431,33 @@ class SageGatewayClient: - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - reference (str, optional): Nouvelle référence - + Returns: Commande modifiée avec totaux recalculés """ - return self._post("/sage/commandes/update", { - "numero": numero, - "commande_data": commande_data - }).get("data", {}) - - + return self._post( + "/sage/commandes/update", {"numero": numero, "commande_data": commande_data} + ).get("data", {}) + def creer_livraison(self, livraison_data: Dict) -> Dict: """ ➕ Création d'une nouvelle livraison (Bon de livraison) """ return self._post("/sage/livraisons/create", livraison_data).get("data", {}) - def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: """ ✏️ Modification d'une livraison existante """ - return self._post("/sage/livraisons/update", { - "numero": numero, - "livraison_data": livraison_data - }).get("data", {}) - + return self._post( + "/sage/livraisons/update", + {"numero": numero, "livraison_data": livraison_data}, + ).get("data", {}) + def creer_avoir(self, avoir_data: Dict) -> Dict: """ ➕ Création d'un avoir (Bon d'avoir) - + Args: avoir_data: Dictionnaire contenant: - client_id (str): Code du client @@ -475,17 +468,16 @@ class SageGatewayClient: - quantite (float) - prix_unitaire_ht (float, optional) - remise_pourcentage (float, optional) - + Returns: Avoir créé avec son numéro et ses totaux """ return self._post("/sage/avoirs/create", avoir_data).get("data", {}) - def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: """ ✏️ Modification d'un avoir existant - + Args: numero: Numéro de l'avoir à modifier avoir_data: Dictionnaire contenant les champs à modifier: @@ -493,20 +485,18 @@ class SageGatewayClient: - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - reference (str, optional): Nouvelle référence - + Returns: Avoir modifié avec totaux recalculés """ - return self._post("/sage/avoirs/update", { - "numero": numero, - "avoir_data": avoir_data - }).get("data", {}) - - + return self._post( + "/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data} + ).get("data", {}) + def creer_facture(self, facture_data: Dict) -> Dict: """ ➕ Création d'une facture - + Args: facture_data: Dictionnaire contenant: - client_id (str): Code du client @@ -517,17 +507,16 @@ class SageGatewayClient: - quantite (float) - prix_unitaire_ht (float, optional) - remise_pourcentage (float, optional) - + Returns: Facture créée avec son numéro et ses totaux """ return self._post("/sage/factures/create", facture_data).get("data", {}) - def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: """ ✏️ Modification d'une facture existante - + Args: numero: Numéro de la facture à modifier facture_data: Dictionnaire contenant les champs à modifier: @@ -535,21 +524,20 @@ class SageGatewayClient: - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - reference (str, optional): Nouvelle référence - + Returns: Facture modifiée avec totaux recalculés """ - return self._post("/sage/factures/update", { - "numero": numero, - "facture_data": facture_data - }).get("data", {}) - + return self._post( + "/sage/factures/update", {"numero": numero, "facture_data": facture_data} + ).get("data", {}) + def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: """ 🆕 Génère le PDF d'un document via la gateway Windows (route généralisée) - + **Cette méthode remplace les appels spécifiques par type de document** - + Args: doc_id: Numéro du document (ex: "DE00001", "FA00001") type_doc: Type de document Sage: @@ -558,14 +546,14 @@ class SageGatewayClient: - 30: Bon de livraison - 60: Facture - 50: Bon d'avoir - + Returns: bytes: Contenu du PDF (binaire) - + Raises: ValueError: Si le PDF retourné est vide RuntimeError: Si erreur de communication avec la gateway - + Example: >>> pdf_bytes = sage_client.generer_pdf_document("DE00001", 0) >>> with open("devis.pdf", "wb") as f: @@ -573,59 +561,55 @@ class SageGatewayClient: """ try: logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") - + # Appel HTTP vers la gateway Windows r = requests.post( f"{self.url}/sage/documents/generate-pdf", - json={ - "doc_id": doc_id, - "type_doc": type_doc - }, + json={"doc_id": doc_id, "type_doc": type_doc}, headers=self.headers, - timeout=60 # Timeout élevé pour génération PDF + timeout=60, # Timeout élevé pour génération PDF ) - + r.raise_for_status() - + import base64 - + response_data = r.json() - + # Vérifier que la réponse contient bien le PDF if not response_data.get("success"): error_msg = response_data.get("error", "Erreur inconnue") raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}") - + pdf_base64 = response_data.get("data", {}).get("pdf_base64", "") - + if not pdf_base64: raise ValueError( f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})" ) - + # Décoder le base64 pdf_bytes = base64.b64decode(pdf_base64) - + logger.info(f"✅ PDF décodé: {len(pdf_bytes)} octets") - + return pdf_bytes - + except requests.exceptions.Timeout: logger.error(f"⏱️ Timeout génération PDF pour {doc_id}") raise RuntimeError( f"Timeout lors de la génération du PDF (>60s). " f"Le document {doc_id} est peut-être trop volumineux." ) - + except requests.exceptions.RequestException as e: logger.error(f"❌ Erreur HTTP génération PDF: {e}") raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}") - + except Exception as e: logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) raise - # Instance globale sage_client = SageGatewayClient() From 4434f0716fd6911f95a28f8f009d07edd59f76ee Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 18:03:47 +0300 Subject: [PATCH 30/62] feat: implement comprehensive user authentication including registration, login, email verification, password reset, and token management. --- config.py | 4 +- core/dependencies.py | 46 +++-- create_admin.py | 44 ++--- database/__init__.py | 39 ++-- database/db_config.py | 6 +- database/models.py | 137 ++++++++------ email_queue.py | 249 ++++++++++++------------- init_db.py | 22 +-- routes/auth.py | 370 ++++++++++++++++++-------------------- security/auth.py | 38 ++-- services/email_service.py | 60 +++---- 11 files changed, 504 insertions(+), 511 deletions(-) diff --git a/config.py b/config.py index 7e1c020..1b3125e 100644 --- a/config.py +++ b/config.py @@ -6,8 +6,8 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) - - # === JWT & Auth === + + # === JWT & Auth === jwt_secret: str jwt_algorithm: str access_token_expire_minutes: int diff --git a/core/dependencies.py b/core/dependencies.py index 48bb868..7f8a5f9 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -12,18 +12,18 @@ security = HTTPBearer() async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ) -> User: """ Dépendance FastAPI pour extraire l'utilisateur du JWT - + Usage dans un endpoint: @app.get("/protected") async def protected_route(user: User = Depends(get_current_user)): return {"user_id": user.id} """ token = credentials.credentials - + # Décoder le token payload = decode_token(token) if not payload: @@ -32,7 +32,7 @@ async def get_current_user( detail="Token invalide ou expiré", headers={"WWW-Authenticate": "Bearer"}, ) - + # Vérifier le type if payload.get("type") != "access": raise HTTPException( @@ -40,7 +40,7 @@ async def get_current_user( detail="Type de token incorrect", headers={"WWW-Authenticate": "Bearer"}, ) - + # Extraire user_id user_id: str = payload.get("sub") if not user_id: @@ -49,46 +49,43 @@ async def get_current_user( detail="Token malformé", headers={"WWW-Authenticate": "Bearer"}, ) - + # Charger l'utilisateur - result = await session.execute( - select(User).where(User.id == user_id) - ) + result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Utilisateur introuvable", headers={"WWW-Authenticate": "Bearer"}, ) - + # Vérifications de sécurité if not user.is_active: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte désactivé" + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" ) - + if not user.is_verified: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception." + detail="Email non vérifié. Consultez votre boîte de réception.", ) - + # ✅ FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!) if user.locked_until and user.locked_until > datetime.now(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé suite à trop de tentatives échouées" + detail="Compte temporairement verrouillé suite à trop de tentatives échouées", ) - + return user async def get_current_user_optional( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ) -> Optional[User]: """ Version optionnelle - ne lève pas d'erreur si pas de token @@ -96,7 +93,7 @@ async def get_current_user_optional( """ if not credentials: return None - + try: return await get_current_user(credentials, session) except HTTPException: @@ -106,18 +103,19 @@ async def get_current_user_optional( def require_role(*allowed_roles: str): """ Décorateur pour restreindre l'accès par rôle - + Usage: @app.get("/admin/users") async def list_users(user: User = Depends(require_role("admin"))): ... """ + async def role_checker(user: User = Depends(get_current_user)) -> User: if user.role not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}" + detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", ) return user - - return role_checker \ No newline at end of file + + return role_checker diff --git a/create_admin.py b/create_admin.py index a85b4df..41f11b7 100644 --- a/create_admin.py +++ b/create_admin.py @@ -25,29 +25,31 @@ logger = logging.getLogger(__name__) async def create_admin(): """Crée un utilisateur admin""" - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("🔐 Création d'un compte administrateur") - print("="*60 + "\n") - + print("=" * 60 + "\n") + # Saisie des informations email = input("Email de l'admin: ").strip().lower() - if not email or '@' not in email: + if not email or "@" not in email: print("❌ Email invalide") return False - + prenom = input("Prénom: ").strip() nom = input("Nom: ").strip() - + if not prenom or not nom: print("❌ Prénom et nom requis") return False - + # Mot de passe avec validation while True: - password = input("Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): ") + password = input( + "Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): " + ) is_valid, error_msg = validate_password_strength(password) - + if is_valid: confirm = input("Confirmez le mot de passe: ") if password == confirm: @@ -56,20 +58,18 @@ async def create_admin(): print("❌ Les mots de passe ne correspondent pas\n") else: print(f"❌ {error_msg}\n") - + # Vérifier si l'email existe déjà async with async_session_factory() as session: from sqlalchemy import select - - result = await session.execute( - select(User).where(User.email == email) - ) + + result = await session.execute(select(User).where(User.email == email)) existing = result.scalar_one_or_none() - + if existing: print(f"\n❌ Un utilisateur avec l'email {email} existe déjà") return False - + # Créer l'admin admin = User( id=str(uuid.uuid4()), @@ -80,19 +80,19 @@ async def create_admin(): role="admin", is_verified=True, # Admin vérifié par défaut is_active=True, - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(admin) await session.commit() - + print("\n✅ Administrateur créé avec succès!") print(f"📧 Email: {email}") print(f"👤 Nom: {prenom} {nom}") print(f"🔑 Rôle: admin") print(f"🆔 ID: {admin.id}") print("\n💡 Vous pouvez maintenant vous connecter à l'API\n") - + return True @@ -106,4 +106,4 @@ if __name__ == "__main__": except Exception as e: print(f"\n❌ Erreur: {e}") logger.exception("Détails:") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/database/__init__.py b/database/__init__.py index 0e2957a..579c644 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -3,7 +3,7 @@ from database.db_config import ( async_session_factory, init_db, get_session, - close_db + close_db, ) from database.models import ( @@ -23,26 +23,23 @@ from database.models import ( __all__ = [ # Config - 'engine', - 'async_session_factory', - 'init_db', - 'get_session', - 'close_db', - + "engine", + "async_session_factory", + "init_db", + "get_session", + "close_db", # Models existants - 'Base', - 'EmailLog', - 'SignatureLog', - 'WorkflowLog', - 'CacheMetadata', - 'AuditLog', - + "Base", + "EmailLog", + "SignatureLog", + "WorkflowLog", + "CacheMetadata", + "AuditLog", # Enums - 'StatutEmail', - 'StatutSignature', - + "StatutEmail", + "StatutSignature", # Modèles auth - 'User', - 'RefreshToken', - 'LoginAttempt', -] \ No newline at end of file + "User", + "RefreshToken", + "LoginAttempt", +] diff --git a/database/db_config.py b/database/db_config.py index 1973799..f5bc0b4 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -32,10 +32,10 @@ async def init_db(): try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - + logger.info("✅ Base de données initialisée avec succès") logger.info(f"📍 Fichier DB: {DATABASE_URL}") - + except Exception as e: logger.error(f"❌ Erreur initialisation DB: {e}") raise @@ -53,4 +53,4 @@ async def get_session() -> AsyncSession: async def close_db(): """Ferme proprement toutes les connexions""" await engine.dispose() - logger.info("🔌 Connexions DB fermées") \ No newline at end of file + logger.info("🔌 Connexions DB fermées") diff --git a/database/models.py b/database/models.py index 2c260ef..ff7c224 100644 --- a/database/models.py +++ b/database/models.py @@ -1,4 +1,13 @@ -from sqlalchemy import Column, Integer, String, DateTime, Float, Text, Boolean, Enum as SQLEnum +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Float, + Text, + Boolean, + Enum as SQLEnum, +) from sqlalchemy.ext.declarative import declarative_base from datetime import datetime import enum @@ -9,8 +18,10 @@ Base = declarative_base() # Enums # ============================================================================ + class StatutEmail(str, enum.Enum): """Statuts possibles d'un email""" + EN_ATTENTE = "EN_ATTENTE" EN_COURS = "EN_COURS" ENVOYE = "ENVOYE" @@ -18,54 +29,59 @@ class StatutEmail(str, enum.Enum): ERREUR = "ERREUR" BOUNCE = "BOUNCE" + class StatutSignature(str, enum.Enum): """Statuts possibles d'une signature électronique""" + EN_ATTENTE = "EN_ATTENTE" ENVOYE = "ENVOYE" SIGNE = "SIGNE" REFUSE = "REFUSE" EXPIRE = "EXPIRE" + # ============================================================================ # Tables # ============================================================================ + class EmailLog(Base): """ Journal des emails envoyés via l'API Permet le suivi et le retry automatique """ + __tablename__ = "email_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Destinataires destinataire = Column(String(255), nullable=False, index=True) cc = Column(Text, nullable=True) # JSON stringifié cci = Column(Text, nullable=True) # JSON stringifié - + # Contenu sujet = Column(String(500), nullable=False) corps_html = Column(Text, nullable=False) - + # Documents attachés document_ids = Column(Text, nullable=True) # Séparés par virgules type_document = Column(Integer, nullable=True) - + # Statut statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) - + # Tracking temporel date_creation = Column(DateTime, default=datetime.now, nullable=False) date_envoi = Column(DateTime, nullable=True) date_ouverture = Column(DateTime, nullable=True) - + # Retry automatique nb_tentatives = Column(Integer, default=0) derniere_erreur = Column(Text, nullable=True) prochain_retry = Column(DateTime, nullable=True) - + # Métadonnées ip_envoi = Column(String(45), nullable=True) user_agent = Column(String(500), nullable=True) @@ -79,33 +95,36 @@ class SignatureLog(Base): Journal des demandes de signature Universign Permet le suivi du workflow de signature """ + __tablename__ = "signature_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Document Sage associé document_id = Column(String(100), nullable=False, index=True) type_document = Column(Integer, nullable=False) - + # Universign transaction_id = Column(String(100), unique=True, index=True, nullable=True) signer_url = Column(String(500), nullable=True) - + # Signataire email_signataire = Column(String(255), nullable=False, index=True) nom_signataire = Column(String(255), nullable=False) - + # Statut - statut = Column(SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True) + statut = Column( + SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True + ) date_envoi = Column(DateTime, default=datetime.now) date_signature = Column(DateTime, nullable=True) date_refus = Column(DateTime, nullable=True) - + # Relances est_relance = Column(Boolean, default=False) nb_relances = Column(Integer, default=0) - + # Métadonnées raison_refus = Column(Text, nullable=True) ip_signature = Column(String(45), nullable=True) @@ -119,27 +138,28 @@ class WorkflowLog(Base): Journal des transformations de documents (Devis → Commande → Facture) Permet la traçabilité du workflow commercial """ + __tablename__ = "workflow_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Documents document_source = Column(String(100), nullable=False, index=True) type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc. - + document_cible = Column(String(100), nullable=False, index=True) type_cible = Column(Integer, nullable=False) - + # Métadonnées de transformation nb_lignes = Column(Integer, nullable=True) montant_ht = Column(Float, nullable=True) montant_ttc = Column(Float, nullable=True) - + # Tracking date_transformation = Column(DateTime, default=datetime.now, nullable=False) utilisateur = Column(String(100), nullable=True) - + # Résultat succes = Column(Boolean, default=True) erreur = Column(Text, nullable=True) @@ -154,18 +174,21 @@ class CacheMetadata(Base): Métadonnées sur le cache Sage (clients, articles) Permet le monitoring du cache géré par la gateway Windows """ + __tablename__ = "cache_metadata" - + id = Column(Integer, primary_key=True, autoincrement=True) - + # Type de cache - cache_type = Column(String(50), unique=True, nullable=False) # 'clients' ou 'articles' - + cache_type = Column( + String(50), unique=True, nullable=False + ) # 'clients' ou 'articles' + # Statistiques last_refresh = Column(DateTime, default=datetime.now) item_count = Column(Integer, default=0) refresh_duration_ms = Column(Float, nullable=True) - + # Santé last_error = Column(Text, nullable=True) error_count = Column(Integer, default=0) @@ -179,66 +202,72 @@ class AuditLog(Base): Journal d'audit pour la sécurité et la conformité Trace toutes les actions importantes dans l'API """ + __tablename__ = "audit_logs" - + id = Column(Integer, primary_key=True, autoincrement=True) - + # Action - action = Column(String(100), nullable=False, index=True) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. + action = Column( + String(100), nullable=False, index=True + ) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. ressource_type = Column(String(50), nullable=True) # 'devis', 'facture', etc. ressource_id = Column(String(100), nullable=True, index=True) - + # Utilisateur (si authentification ajoutée plus tard) utilisateur = Column(String(100), nullable=True) ip_address = Column(String(45), nullable=True) - + # Résultat succes = Column(Boolean, default=True) details = Column(Text, nullable=True) # JSON stringifié erreur = Column(Text, nullable=True) - + # Timestamp date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) def __repr__(self): return f"" - + + # Ajouter ces modèles à la fin de database/models.py + class User(Base): """ Utilisateurs de l'API avec validation email """ + __tablename__ = "users" - + id = Column(String(36), primary_key=True) email = Column(String(255), unique=True, nullable=False, index=True) hashed_password = Column(String(255), nullable=False) - + # Profil nom = Column(String(100), nullable=False) prenom = Column(String(100), nullable=False) role = Column(String(50), default="user") # user, admin, commercial - + # Validation email is_verified = Column(Boolean, default=False) verification_token = Column(String(255), nullable=True, unique=True, index=True) verification_token_expires = Column(DateTime, nullable=True) - + # Sécurité is_active = Column(Boolean, default=True) failed_login_attempts = Column(Integer, default=0) locked_until = Column(DateTime, nullable=True) - + # Mot de passe oublié reset_token = Column(String(255), nullable=True, unique=True, index=True) reset_token_expires = Column(DateTime, nullable=True) - + # Timestamps created_at = Column(DateTime, default=datetime.now, nullable=False) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) last_login = Column(DateTime, nullable=True) - + def __repr__(self): return f"" @@ -247,24 +276,25 @@ class RefreshToken(Base): """ Tokens de rafraîchissement JWT """ + __tablename__ = "refresh_tokens" - + id = Column(String(36), primary_key=True) user_id = Column(String(36), nullable=False, index=True) token_hash = Column(String(255), nullable=False, unique=True, index=True) - + # Métadonnées device_info = Column(String(500), nullable=True) ip_address = Column(String(45), nullable=True) - + # Expiration expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False) - + # Révocation is_revoked = Column(Boolean, default=False) revoked_at = Column(DateTime, nullable=True) - + def __repr__(self): return f"" @@ -273,18 +303,19 @@ class LoginAttempt(Base): """ Journal des tentatives de connexion (détection bruteforce) """ + __tablename__ = "login_attempts" - + id = Column(Integer, primary_key=True, autoincrement=True) - + email = Column(String(255), nullable=False, index=True) ip_address = Column(String(45), nullable=False, index=True) user_agent = Column(String(500), nullable=True) - + success = Column(Boolean, default=False) failure_reason = Column(String(255), nullable=True) - + timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True) - + def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/email_queue.py b/email_queue.py index beda62e..53d65af 100644 --- a/email_queue.py +++ b/email_queue.py @@ -25,67 +25,65 @@ class EmailQueue: """ Queue d'emails avec workers threadés et retry automatique """ - + def __init__(self): self.queue = queue.Queue() self.workers = [] self.running = False self.session_factory = None self.sage_client = None - + def start(self, num_workers: int = 3): """Démarre les workers""" if self.running: logger.warning("Queue déjà démarrée") return - + self.running = True for i in range(num_workers): worker = threading.Thread( - target=self._worker, - name=f"EmailWorker-{i}", - daemon=True + target=self._worker, name=f"EmailWorker-{i}", daemon=True ) worker.start() self.workers.append(worker) - + logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)") - + def stop(self): """Arrête les workers proprement""" logger.info("🛑 Arrêt de la queue email...") self.running = False - + # Attendre que la queue soit vide (max 30s) try: self.queue.join() logger.info("✅ Queue email arrêtée proprement") except: logger.warning("⚠️ Timeout lors de l'arrêt de la queue") - + def enqueue(self, email_log_id: str): """Ajoute un email dans la queue""" self.queue.put(email_log_id) logger.debug(f"📨 Email {email_log_id} ajouté à la queue") - + def _worker(self): """Worker qui traite les emails dans un thread""" # Créer une event loop pour ce thread loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: while self.running: try: # Récupérer un email de la queue (timeout 1s) email_log_id = self.queue.get(timeout=1) - + # Traiter l'email loop.run_until_complete(self._process_email(email_log_id)) - + # Marquer comme traité self.queue.task_done() - + except queue.Empty: continue except Exception as e: @@ -96,144 +94,147 @@ class EmailQueue: pass finally: loop.close() - + async def _process_email(self, email_log_id: str): """Traite un email avec retry automatique""" from database import EmailLog, StatutEmail from sqlalchemy import select - + if not self.session_factory: logger.error("❌ session_factory non configuré") return - + async with self.session_factory() as session: # Charger l'email log result = await session.execute( select(EmailLog).where(EmailLog.id == email_log_id) ) email_log = result.scalar_one_or_none() - + if not email_log: logger.error(f"❌ Email log {email_log_id} introuvable") return - + # Marquer comme en cours email_log.statut = StatutEmail.EN_COURS email_log.nb_tentatives += 1 await session.commit() - + try: # Envoi avec retry automatique await self._send_with_retry(email_log) - + # Succès email_log.statut = StatutEmail.ENVOYE email_log.date_envoi = datetime.now() email_log.derniere_erreur = None logger.info(f"✅ Email envoyé: {email_log.destinataire}") - + except Exception as e: # Échec email_log.statut = StatutEmail.ERREUR email_log.derniere_erreur = str(e)[:1000] # Limiter la taille - + # Programmer un retry si < max attempts if email_log.nb_tentatives < settings.max_retry_attempts: - delay = settings.retry_delay_seconds * (2 ** (email_log.nb_tentatives - 1)) + delay = settings.retry_delay_seconds * ( + 2 ** (email_log.nb_tentatives - 1) + ) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) - + # Programmer le retry timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer.daemon = True timer.start() - - logger.warning(f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}") + + logger.warning( + f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}" + ) else: logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}") - + await session.commit() - + @retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=4, max=10) + stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10) ) async def _send_with_retry(self, email_log): """Envoi SMTP avec retry Tenacity + génération PDF""" # Préparer le message msg = MIMEMultipart() - msg['From'] = settings.smtp_from - msg['To'] = email_log.destinataire - msg['Subject'] = email_log.sujet - + msg["From"] = settings.smtp_from + msg["To"] = email_log.destinataire + msg["Subject"] = email_log.sujet + # Corps HTML - msg.attach(MIMEText(email_log.corps_html, 'html')) - + msg.attach(MIMEText(email_log.corps_html, "html")) + # 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs if email_log.document_ids: - document_ids = email_log.document_ids.split(',') + document_ids = email_log.document_ids.split(",") type_doc = email_log.type_document - + for doc_id in document_ids: doc_id = doc_id.strip() if not doc_id: continue - + try: # Générer PDF (appel bloquant dans thread séparé) pdf_bytes = await asyncio.to_thread( - self._generate_pdf, - doc_id, - type_doc + self._generate_pdf, doc_id, type_doc ) - + if pdf_bytes: # Attacher PDF part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf") - part['Content-Disposition'] = f'attachment; filename="{doc_id}.pdf"' + part["Content-Disposition"] = ( + f'attachment; filename="{doc_id}.pdf"' + ) msg.attach(part) logger.info(f"📎 PDF attaché: {doc_id}.pdf") - + except Exception as e: logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") # Continuer avec les autres PDFs - + # Envoi SMTP (bloquant mais dans thread séparé) await asyncio.to_thread(self._send_smtp, msg) - + def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: """ Génération PDF via ReportLab + sage_client - + ⚠️ Cette méthode est appelée depuis un thread worker """ from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib.units import cm from io import BytesIO - + if not self.sage_client: logger.error("❌ sage_client non configuré") raise Exception("sage_client non disponible") - + # 📡 Récupérer document depuis gateway Windows via HTTP try: doc = self.sage_client.lire_document(doc_id, type_doc) except Exception as e: logger.error(f"❌ Erreur récupération document {doc_id}: {e}") raise Exception(f"Document {doc_id} inaccessible") - + if not doc: raise Exception(f"Document {doc_id} introuvable") - + # 📄 Créer PDF avec ReportLab buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 - + # === EN-TÊTE === pdf.setFont("Helvetica-Bold", 20) - pdf.drawString(2*cm, height - 3*cm, f"Document N° {doc_id}") - + pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}") + # Type de document type_labels = { 0: "DEVIS", @@ -241,101 +242,105 @@ class EmailQueue: 2: "BON DE RETOUR", 3: "COMMANDE", 4: "PRÉPARATION", - 5: "FACTURE" + 5: "FACTURE", } type_label = type_labels.get(type_doc, "DOCUMENT") - + pdf.setFont("Helvetica", 12) - pdf.drawString(2*cm, height - 4*cm, f"Type: {type_label}") - + pdf.drawString(2 * cm, height - 4 * cm, f"Type: {type_label}") + # === INFORMATIONS CLIENT === - y = height - 5*cm + y = height - 5 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "CLIENT") - - y -= 0.8*cm + pdf.drawString(2 * cm, y, "CLIENT") + + y -= 0.8 * cm pdf.setFont("Helvetica", 11) - pdf.drawString(2*cm, y, f"Code: {doc.get('client_code', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Nom: {doc.get('client_intitule', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Date: {doc.get('date', '')}") - + pdf.drawString(2 * cm, y, f"Code: {doc.get('client_code', '')}") + y -= 0.6 * cm + pdf.drawString(2 * cm, y, f"Nom: {doc.get('client_intitule', '')}") + y -= 0.6 * cm + pdf.drawString(2 * cm, y, f"Date: {doc.get('date', '')}") + # === LIGNES === - y -= 1.5*cm + y -= 1.5 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "ARTICLES") - - y -= 1*cm + pdf.drawString(2 * cm, y, "ARTICLES") + + y -= 1 * cm pdf.setFont("Helvetica-Bold", 10) - pdf.drawString(2*cm, y, "Désignation") - pdf.drawString(10*cm, y, "Qté") - pdf.drawString(12*cm, y, "Prix Unit.") - pdf.drawString(15*cm, y, "Total HT") - - y -= 0.5*cm - pdf.line(2*cm, y, width - 2*cm, y) - - y -= 0.7*cm + pdf.drawString(2 * cm, y, "Désignation") + pdf.drawString(10 * cm, y, "Qté") + pdf.drawString(12 * cm, y, "Prix Unit.") + pdf.drawString(15 * cm, y, "Total HT") + + y -= 0.5 * cm + pdf.line(2 * cm, y, width - 2 * cm, y) + + y -= 0.7 * cm pdf.setFont("Helvetica", 9) - - for ligne in doc.get('lignes', []): + + for ligne in doc.get("lignes", []): # Nouvelle page si nécessaire - if y < 3*cm: + if y < 3 * cm: pdf.showPage() - y = height - 3*cm + y = height - 3 * cm pdf.setFont("Helvetica", 9) - - designation = ligne.get('designation', '')[:50] - pdf.drawString(2*cm, y, designation) - pdf.drawString(10*cm, y, str(ligne.get('quantite', 0))) - pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€") - pdf.drawString(15*cm, y, f"{ligne.get('montant_ht', 0):.2f}€") - y -= 0.6*cm - + + designation = ligne.get("designation", "")[:50] + pdf.drawString(2 * cm, y, designation) + pdf.drawString(10 * cm, y, str(ligne.get("quantite", 0))) + pdf.drawString(12 * cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€") + pdf.drawString(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}€") + y -= 0.6 * cm + # === TOTAUX === - y -= 1*cm - pdf.line(12*cm, y, width - 2*cm, y) - - y -= 0.8*cm + y -= 1 * cm + pdf.line(12 * cm, y, width - 2 * cm, y) + + y -= 0.8 * cm pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(12*cm, y, "Total HT:") - pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}€") - - y -= 0.6*cm - pdf.drawString(12*cm, y, "TVA (20%):") - tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0) - pdf.drawString(15*cm, y, f"{tva:.2f}€") - - y -= 0.6*cm + pdf.drawString(12 * cm, y, "Total HT:") + pdf.drawString(15 * cm, y, f"{doc.get('total_ht', 0):.2f}€") + + y -= 0.6 * cm + pdf.drawString(12 * cm, y, "TVA (20%):") + tva = doc.get("total_ttc", 0) - doc.get("total_ht", 0) + pdf.drawString(15 * cm, y, f"{tva:.2f}€") + + y -= 0.6 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(12*cm, y, "Total TTC:") - pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}€") - + pdf.drawString(12 * cm, y, "Total TTC:") + pdf.drawString(15 * cm, y, f"{doc.get('total_ttc', 0):.2f}€") + # === PIED DE PAGE === pdf.setFont("Helvetica", 8) - pdf.drawString(2*cm, 2*cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}") - pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven") - + pdf.drawString( + 2 * cm, 2 * cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}" + ) + pdf.drawString(2 * cm, 1.5 * cm, "Sage 100c - API Dataven") + # Finaliser pdf.save() buffer.seek(0) - + logger.info(f"✅ PDF généré: {doc_id}.pdf") return buffer.read() - + def _send_smtp(self, msg): """Envoi SMTP bloquant (appelé depuis asyncio.to_thread)""" try: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: + with smtplib.SMTP( + settings.smtp_host, settings.smtp_port, timeout=30 + ) as server: if settings.smtp_use_tls: server.starttls() - + if settings.smtp_user and settings.smtp_password: server.login(settings.smtp_user, settings.smtp_password) - + server.send_message(msg) - + except smtplib.SMTPException as e: raise Exception(f"Erreur SMTP: {str(e)}") except Exception as e: @@ -343,4 +348,4 @@ class EmailQueue: # Instance globale -email_queue = EmailQueue() \ No newline at end of file +email_queue = EmailQueue() diff --git a/init_db.py b/init_db.py index b59d822..7f5c174 100644 --- a/init_db.py +++ b/init_db.py @@ -23,35 +23,35 @@ logger = logging.getLogger(__name__) async def main(): """Crée toutes les tables dans sage_dataven.db""" - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("🚀 Initialisation de la base de données Sage Dataven") - print("="*60 + "\n") - + print("=" * 60 + "\n") + try: # Créer les tables await init_db() - + print("\n✅ Base de données créée avec succès!") print(f"📍 Fichier: sage_dataven.db") - + print("\n📊 Tables créées:") print(" ├─ email_logs (Journalisation emails)") print(" ├─ signature_logs (Suivi signatures Universign)") print(" ├─ workflow_logs (Transformations documents)") print(" ├─ cache_metadata (Métadonnées cache)") print(" └─ audit_logs (Journal d'audit)") - + print("\n📝 Prochaines étapes:") print(" 1. Configurer le fichier .env avec vos credentials") print(" 2. Lancer la gateway Windows sur la machine Sage") print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000") print(" 4. Ou avec Docker: docker-compose up -d") print(" 5. Tester: http://votre-vps:8000/docs") - - print("\n" + "="*60 + "\n") + + print("\n" + "=" * 60 + "\n") return True - + except Exception as e: print(f"\n❌ Erreur lors de l'initialisation: {e}") logger.exception("Détails de l'erreur:") @@ -60,4 +60,4 @@ async def main(): if __name__ == "__main__": result = asyncio.run(main()) - sys.exit(0 if result else 1) \ No newline at end of file + sys.exit(0 if result else 1) diff --git a/routes/auth.py b/routes/auth.py index 771cb38..3d682e0 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -16,7 +16,7 @@ from security.auth import ( decode_token, generate_verification_token, generate_reset_token, - hash_token + hash_token, ) from services.email_service import AuthEmailService from core.dependencies import get_current_user @@ -29,6 +29,7 @@ router = APIRouter(prefix="/auth", tags=["Authentication"]) # === MODÈLES PYDANTIC === + class RegisterRequest(BaseModel): email: EmailStr password: str = Field(..., min_length=8) @@ -71,13 +72,14 @@ class ResendVerificationRequest(BaseModel): # === UTILITAIRES === + async def log_login_attempt( session: AsyncSession, email: str, ip: str, user_agent: str, success: bool, - failure_reason: Optional[str] = None + failure_reason: Optional[str] = None, ): """Enregistre une tentative de connexion""" attempt = LoginAttempt( @@ -86,76 +88,72 @@ async def log_login_attempt( user_agent=user_agent, success=success, failure_reason=failure_reason, - timestamp=datetime.now() + timestamp=datetime.now(), ) session.add(attempt) await session.commit() -async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[bool, str]: +async def check_rate_limit( + session: AsyncSession, email: str, ip: str +) -> tuple[bool, str]: """ Vérifie si l'utilisateur/IP est rate limité - + Returns: (is_allowed, error_message) """ # Vérifier les tentatives échouées des 15 dernières minutes time_window = datetime.now() - timedelta(minutes=15) - + result = await session.execute( - select(LoginAttempt) - .where( + select(LoginAttempt).where( LoginAttempt.email == email, LoginAttempt.success == False, - LoginAttempt.timestamp >= time_window + LoginAttempt.timestamp >= time_window, ) ) failed_attempts = result.scalars().all() - + if len(failed_attempts) >= 5: return False, "Trop de tentatives échouées. Réessayez dans 15 minutes." - + return True, "" # === ENDPOINTS === + @router.post("/register", status_code=status.HTTP_201_CREATED) async def register( data: RegisterRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 📝 Inscription d'un nouvel utilisateur - + - Valide le mot de passe - Crée le compte (non vérifié) - Envoie email de vérification """ # Vérifier si l'email existe déjà - result = await session.execute( - select(User).where(User.email == data.email) - ) + result = await session.execute(select(User).where(User.email == data.email)) existing_user = result.scalar_one_or_none() - + if existing_user: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cet email est déjà utilisé" + status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé" ) - + # Valider le mot de passe is_valid, error_msg = validate_password_strength(data.password) if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_msg - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + # Générer token de vérification verification_token = generate_verification_token() - + # Créer l'utilisateur new_user = User( id=str(uuid.uuid4()), @@ -166,80 +164,72 @@ async def register( is_verified=False, verification_token=verification_token, verification_token_expires=datetime.now() + timedelta(hours=24), - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(new_user) await session.commit() - + # Envoyer email de vérification - base_url = str(request.base_url).rstrip('/') + base_url = str(request.base_url).rstrip("/") email_sent = AuthEmailService.send_verification_email( - data.email, - verification_token, - base_url + data.email, verification_token, base_url ) - + if not email_sent: logger.warning(f"Échec envoi email vérification pour {data.email}") - + logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") - + return { "success": True, "message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.", "user_id": new_user.id, - "email": data.email + "email": data.email, } @router.get("/verify-email") -async def verify_email_get( - token: str, - session: AsyncSession = Depends(get_session) -): +async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): """ ✅ Vérification de l'email via lien cliquable (GET) Utilisé quand l'utilisateur clique sur le lien dans l'email """ - result = await session.execute( - select(User).where(User.verification_token == token) - ) + result = await session.execute(select(User).where(User.verification_token == token)) user = result.scalar_one_or_none() - + if not user: return { "success": False, - "message": "Token de vérification invalide ou déjà utilisé." + "message": "Token de vérification invalide ou déjà utilisé.", } - + # Vérifier l'expiration if user.verification_token_expires < datetime.now(): return { "success": False, "message": "Token expiré. Veuillez demander un nouvel email de vérification.", - "expired": True + "expired": True, } - + # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None await session.commit() - + logger.info(f"✅ Email vérifié: {user.email}") - + return { "success": True, "message": "✅ Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", - "email": user.email + "email": user.email, } @router.post("/verify-email") async def verify_email_post( - data: VerifyEmailRequest, - session: AsyncSession = Depends(get_session) + data: VerifyEmailRequest, session: AsyncSession = Depends(get_session) ): """ ✅ Vérification de l'email via API (POST) @@ -249,31 +239,31 @@ async def verify_email_post( select(User).where(User.verification_token == data.token) ) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token de vérification invalide" + detail="Token de vérification invalide", ) - + # Vérifier l'expiration if user.verification_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token expiré. Demandez un nouvel email de vérification." + detail="Token expiré. Demandez un nouvel email de vérification.", ) - + # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None await session.commit() - + logger.info(f"✅ Email vérifié: {user.email}") - + return { "success": True, - "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter." + "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", } @@ -281,135 +271,134 @@ async def verify_email_post( async def resend_verification( data: ResendVerificationRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 🔄 Renvoyer l'email de vérification """ - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + if not user: # Ne pas révéler si l'utilisateur existe return { "success": True, - "message": "Si cet email existe, un nouveau lien de vérification a été envoyé." + "message": "Si cet email existe, un nouveau lien de vérification a été envoyé.", } - + if user.is_verified: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Ce compte est déjà vérifié" + status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié" ) - + # Générer nouveau token verification_token = generate_verification_token() user.verification_token = verification_token user.verification_token_expires = datetime.now() + timedelta(hours=24) await session.commit() - + # Envoyer email - base_url = str(request.base_url).rstrip('/') - AuthEmailService.send_verification_email( - user.email, - verification_token, - base_url - ) - - return { - "success": True, - "message": "Un nouveau lien de vérification a été envoyé." - } + base_url = str(request.base_url).rstrip("/") + AuthEmailService.send_verification_email(user.email, verification_token, base_url) + + return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."} @router.post("/login", response_model=TokenResponse) async def login( - data: LoginRequest, - request: Request, - session: AsyncSession = Depends(get_session) + data: LoginRequest, request: Request, session: AsyncSession = Depends(get_session) ): """ 🔐 Connexion utilisateur - + Retourne access_token (30min) et refresh_token (7 jours) """ ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") - + # Rate limiting is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) if not is_allowed: raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=error_msg + status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg ) - + # Charger l'utilisateur - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + # Vérifications if not user or not verify_password(data.password, user.hashed_password): - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Identifiants incorrects") - + await log_login_attempt( + session, + data.email.lower(), + ip, + user_agent, + False, + "Identifiants incorrects", + ) + # Incrémenter compteur échecs if user: user.failed_login_attempts += 1 - + # Verrouiller après 5 échecs if user.failed_login_attempts >= 5: user.locked_until = datetime.now() + timedelta(minutes=15) await session.commit() raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes." + detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes.", ) - + await session.commit() - + raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Email ou mot de passe incorrect" + detail="Email ou mot de passe incorrect", ) - + # Vérifier statut compte if not user.is_active: - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte désactivé") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte désactivé" + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte désactivé" ) - + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" + ) + if not user.is_verified: - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Email non vérifié") + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Email non vérifié" + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception." + detail="Email non vérifié. Consultez votre boîte de réception.", ) - + # Vérifier verrouillage if user.locked_until and user.locked_until > datetime.now(): - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte verrouillé") + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte verrouillé" + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé" + detail="Compte temporairement verrouillé", ) - + # ✅ CONNEXION RÉUSSIE - + # Réinitialiser compteur échecs user.failed_login_attempts = 0 user.locked_until = None user.last_login = datetime.now() - + # Créer tokens - access_token = create_access_token({"sub": user.id, "email": user.email, "role": user.role}) + access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) refresh_token_jwt = create_refresh_token(user.id) - + # Stocker refresh token en DB (hashé) refresh_token_record = RefreshToken( id=str(uuid.uuid4()), @@ -418,28 +407,27 @@ async def login( device_info=user_agent[:500], ip_address=ip, expires_at=datetime.now() + timedelta(days=7), - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(refresh_token_record) await session.commit() - + # Logger succès await log_login_attempt(session, data.email.lower(), ip, user_agent, True) - + logger.info(f"✅ Connexion réussie: {user.email}") - + return TokenResponse( access_token=access_token, refresh_token=refresh_token_jwt, - expires_in=86400 # 30 minutes + expires_in=86400, # 30 minutes ) @router.post("/refresh", response_model=TokenResponse) async def refresh_access_token( - data: RefreshTokenRequest, - session: AsyncSession = Depends(get_session) + data: RefreshTokenRequest, session: AsyncSession = Depends(get_session) ): """ 🔄 Renouvellement du access_token via refresh_token @@ -448,61 +436,55 @@ async def refresh_access_token( payload = decode_token(data.refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token invalide" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide" ) - + user_id = payload.get("sub") token_hash = hash_token(data.refresh_token) - + # Vérifier en DB result = await session.execute( select(RefreshToken).where( RefreshToken.user_id == user_id, RefreshToken.token_hash == token_hash, - RefreshToken.is_revoked == False + RefreshToken.is_revoked == False, ) ) token_record = result.scalar_one_or_none() - + if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token révoqué ou introuvable" + detail="Refresh token révoqué ou introuvable", ) - + # Vérifier expiration if token_record.expires_at < datetime.now(): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token expiré" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré" ) - + # Charger utilisateur - result = await session.execute( - select(User).where(User.id == user_id) - ) + result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() - + if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Utilisateur introuvable ou désactivé" + detail="Utilisateur introuvable ou désactivé", ) - + # Générer nouveau access token - new_access_token = create_access_token({ - "sub": user.id, - "email": user.email, - "role": user.role - }) - + new_access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) + logger.info(f"🔄 Token rafraîchi: {user.email}") - + return TokenResponse( access_token=new_access_token, refresh_token=data.refresh_token, # Refresh token reste le même - expires_in=86400 + expires_in=86400, ) @@ -510,79 +492,71 @@ async def refresh_access_token( async def forgot_password( data: ForgotPasswordRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 🔑 Demande de réinitialisation de mot de passe """ - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + # Ne pas révéler si l'utilisateur existe if not user: return { "success": True, - "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } - + # Générer token de reset reset_token = generate_reset_token() user.reset_token = reset_token user.reset_token_expires = datetime.now() + timedelta(hours=1) await session.commit() - + # Envoyer email - frontend_url = settings.frontend_url if hasattr(settings, 'frontend_url') else str(request.base_url).rstrip('/') - AuthEmailService.send_password_reset_email( - user.email, - reset_token, - frontend_url + frontend_url = ( + settings.frontend_url + if hasattr(settings, "frontend_url") + else str(request.base_url).rstrip("/") ) - + AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url) + logger.info(f"📧 Reset password demandé: {user.email}") - + return { "success": True, - "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } @router.post("/reset-password") async def reset_password( - data: ResetPasswordRequest, - session: AsyncSession = Depends(get_session) + data: ResetPasswordRequest, session: AsyncSession = Depends(get_session) ): """ 🔐 Réinitialisation du mot de passe avec token """ - result = await session.execute( - select(User).where(User.reset_token == data.token) - ) + result = await session.execute(select(User).where(User.reset_token == data.token)) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token de réinitialisation invalide" + detail="Token de réinitialisation invalide", ) - + # Vérifier expiration if user.reset_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token expiré. Demandez un nouveau lien de réinitialisation." + detail="Token expiré. Demandez un nouveau lien de réinitialisation.", ) - + # Valider nouveau mot de passe is_valid, error_msg = validate_password_strength(data.new_password) if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_msg - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + # Mettre à jour user.hashed_password = hash_password(data.new_password) user.reset_token = None @@ -590,15 +564,15 @@ async def reset_password( user.failed_login_attempts = 0 user.locked_until = None await session.commit() - + # Envoyer notification AuthEmailService.send_password_changed_notification(user.email) - + logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") - + return { "success": True, - "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter." + "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.", } @@ -606,32 +580,28 @@ async def reset_password( async def logout( data: RefreshTokenRequest, session: AsyncSession = Depends(get_session), - user: User = Depends(get_current_user) + user: User = Depends(get_current_user), ): """ 🚪 Déconnexion (révocation du refresh token) """ token_hash = hash_token(data.refresh_token) - + result = await session.execute( select(RefreshToken).where( - RefreshToken.user_id == user.id, - RefreshToken.token_hash == token_hash + RefreshToken.user_id == user.id, RefreshToken.token_hash == token_hash ) ) token_record = result.scalar_one_or_none() - + if token_record: token_record.is_revoked = True token_record.revoked_at = datetime.now() await session.commit() - + logger.info(f"👋 Déconnexion: {user.email}") - - return { - "success": True, - "message": "Déconnexion réussie" - } + + return {"success": True, "message": "Déconnexion réussie"} @router.get("/me") @@ -647,5 +617,5 @@ async def get_current_user_info(user: User = Depends(get_current_user)): "role": user.role, "is_verified": user.is_verified, "created_at": user.created_at.isoformat(), - "last_login": user.last_login.isoformat() if user.last_login else None - } \ No newline at end of file + "last_login": user.last_login.isoformat() if user.last_login else None, + } diff --git a/security/auth.py b/security/auth.py index 9c5009d..7fc182c 100644 --- a/security/auth.py +++ b/security/auth.py @@ -45,24 +45,20 @@ def hash_token(token: str) -> str: def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: """ Crée un JWT access token - + Args: data: Payload (doit contenir 'sub' = user_id) expires_delta: Durée de validité personnalisée """ to_encode = data.copy() - + if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - - to_encode.update({ - "exp": expire, - "iat": datetime.utcnow(), - "type": "access" - }) - + + to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt @@ -70,20 +66,20 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) - def create_refresh_token(user_id: str) -> str: """ Crée un refresh token (JWT long terme) - + Returns: Token JWT non hashé (à hasher avant stockage DB) """ expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) - + to_encode = { "sub": user_id, "exp": expire, "iat": datetime.utcnow(), "type": "refresh", - "jti": secrets.token_urlsafe(16) # Unique ID + "jti": secrets.token_urlsafe(16), # Unique ID } - + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt @@ -91,7 +87,7 @@ def create_refresh_token(user_id: str) -> str: def decode_token(token: str) -> Optional[Dict]: """ Décode et valide un JWT - + Returns: Payload si valide, None sinon """ @@ -108,24 +104,24 @@ def decode_token(token: str) -> Optional[Dict]: def validate_password_strength(password: str) -> tuple[bool, str]: """ Valide la robustesse d'un mot de passe - + Returns: (is_valid, error_message) """ if len(password) < 8: return False, "Le mot de passe doit contenir au moins 8 caractères" - + if not any(c.isupper() for c in password): return False, "Le mot de passe doit contenir au moins une majuscule" - + if not any(c.islower() for c in password): return False, "Le mot de passe doit contenir au moins une minuscule" - + if not any(c.isdigit() for c in password): return False, "Le mot de passe doit contenir au moins un chiffre" - + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" if not any(c in special_chars for c in password): return False, "Le mot de passe doit contenir au moins un caractère spécial" - - return True, "" \ No newline at end of file + + return True, "" diff --git a/services/email_service.py b/services/email_service.py index 44152df..7bb7661 100644 --- a/services/email_service.py +++ b/services/email_service.py @@ -9,46 +9,48 @@ logger = logging.getLogger(__name__) class AuthEmailService: """Service d'envoi d'emails pour l'authentification""" - + @staticmethod def _send_email(to: str, subject: str, html_body: str) -> bool: """Envoi SMTP générique""" try: msg = MIMEMultipart() - msg['From'] = settings.smtp_from - msg['To'] = to - msg['Subject'] = subject - - msg.attach(MIMEText(html_body, 'html')) - - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: + msg["From"] = settings.smtp_from + msg["To"] = to + msg["Subject"] = subject + + msg.attach(MIMEText(html_body, "html")) + + with smtplib.SMTP( + settings.smtp_host, settings.smtp_port, timeout=30 + ) as server: if settings.smtp_use_tls: server.starttls() - + if settings.smtp_user and settings.smtp_password: server.login(settings.smtp_user, settings.smtp_password) - + server.send_message(msg) - + logger.info(f"✅ Email envoyé: {subject} → {to}") return True - + except Exception as e: logger.error(f"❌ Erreur envoi email: {e}") return False - + @staticmethod def send_verification_email(email: str, token: str, base_url: str) -> bool: """ Envoie l'email de vérification avec lien de confirmation - + Args: email: Email du destinataire token: Token de vérification base_url: URL de base de l'API (ex: https://api.votredomaine.com) """ verification_link = f"{base_url}/auth/verify-email?token={token}" - + html_body = f""" @@ -103,25 +105,23 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "🔐 Vérifiez votre adresse email - Sage Dataven", - html_body + email, "🔐 Vérifiez votre adresse email - Sage Dataven", html_body ) - + @staticmethod def send_password_reset_email(email: str, token: str, base_url: str) -> bool: """ Envoie l'email de réinitialisation de mot de passe - + Args: email: Email du destinataire token: Token de reset base_url: URL de base du frontend """ reset_link = f"{base_url}/reset?token={token}" - + html_body = f""" @@ -176,13 +176,11 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "🔐 Réinitialisation de votre mot de passe - Sage Dataven", - html_body + email, "🔐 Réinitialisation de votre mot de passe - Sage Dataven", html_body ) - + @staticmethod def send_password_changed_notification(email: str) -> bool: """Notification après changement de mot de passe réussi""" @@ -218,9 +216,7 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "✅ Votre mot de passe a été modifié - Sage Dataven", - html_body - ) \ No newline at end of file + email, "✅ Votre mot de passe a été modifié - Sage Dataven", html_body + ) From 732ccd2fd446b19e94dbadc7160c5b89e30a5fda Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 10:35:20 +0300 Subject: [PATCH 31/62] feat(api): extend client response model with detailed fields --- api.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index c5ec086..4a26f93 100644 --- a/api.py +++ b/api.py @@ -120,6 +120,51 @@ class ClientResponse(BaseModel): telephone: Optional[str] = None +class ClientDetails(ClientResponse): + type: int + qualite: str + est_prospect: bool + est_fournisseur: bool + est_actif: bool + est_en_sommeil: bool + + civilite: Optional[str] = None + nom: Optional[str] = None + prenom: Optional[str] = None + nom_complet: Optional[str] = None + contact: Optional[str] = None + + complement: Optional[str] = None + region: Optional[str] = None + pays: Optional[str] = None + + portable: Optional[str] = None + telecopie: Optional[str] = None + site_web: Optional[str] = None + + siret: Optional[str] = None + siren: Optional[str] = None + tva_intra: Optional[str] = None + code_naf: Optional[str] = None + forme_juridique: Optional[str] = None + + secteur: Optional[str] = None + effectif: Optional[int] = None + ca_annuel: Optional[float] = None + commercial_code: Optional[str] = None + commercial_nom: Optional[str] = None + + categorie_tarifaire: Optional[int] = None + categorie_comptable: Optional[int] = None + + encours_autorise: Optional[float] = None + assurance_credit: Optional[float] = None + compte_general: Optional[str] = None + + date_creation: Optional[str] = None + date_modification: Optional[str] = None + + class ArticleResponse(BaseModel): reference: str designation: str @@ -752,12 +797,12 @@ app.include_router(auth_router) # ===================================================== # ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) # ===================================================== -@app.get("/clients", response_model=List[ClientResponse], tags=["Clients"]) +@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def rechercher_clients(query: Optional[str] = Query(None)): """🔍 Recherche clients via gateway Windows""" try: clients = sage_client.lister_clients(filtre=query or "") - return [ClientResponse(**c) for c in clients] + return [ClientDetails(**c) for c in clients] except Exception as e: logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) From 60a9d909558cad99c3c19dcddd261d3d92210514 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 10:44:01 +0300 Subject: [PATCH 32/62] refactor(models): make client response fields optional --- api.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/api.py b/api.py index 4a26f93..0176513 100644 --- a/api.py +++ b/api.py @@ -111,8 +111,8 @@ class StatutEmail(str, Enum): # MODÈLES PYDANTIC # ===================================================== class ClientResponse(BaseModel): - numero: str - intitule: str + numero: Optional[str] = None + intitule: Optional[str] = None adresse: Optional[str] = None code_postal: Optional[str] = None ville: Optional[str] = None @@ -121,12 +121,12 @@ class ClientResponse(BaseModel): class ClientDetails(ClientResponse): - type: int - qualite: str - est_prospect: bool - est_fournisseur: bool - est_actif: bool - est_en_sommeil: bool + type: Optional[int] = None + qualite: Optional[str] = None + est_prospect: Optional[bool] = None + est_fournisseur: Optional[bool] = None + est_actif: Optional[bool] = None + est_en_sommeil: Optional[bool] = None civilite: Optional[str] = None nom: Optional[str] = None From 8f4c4f97a7f0894b7f4a5a4826621600fac60b18 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 11:20:01 +0300 Subject: [PATCH 33/62] refactor(models): improve client models structure and documentation --- api.py | 214 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 152 insertions(+), 62 deletions(-) diff --git a/api.py b/api.py index 0176513..9fdeade 100644 --- a/api.py +++ b/api.py @@ -111,59 +111,125 @@ class StatutEmail(str, Enum): # MODÈLES PYDANTIC # ===================================================== class ClientResponse(BaseModel): + """Modèle de réponse client simplifié (pour listes)""" numero: Optional[str] = None intitule: Optional[str] = None adresse: Optional[str] = None code_postal: Optional[str] = None ville: Optional[str] = None email: Optional[str] = None - telephone: Optional[str] = None + telephone: Optional[str] = None # Téléphone principal (fixe ou mobile) -class ClientDetails(ClientResponse): - type: Optional[int] = None - qualite: Optional[str] = None - est_prospect: Optional[bool] = None - est_fournisseur: Optional[bool] = None - est_actif: Optional[bool] = None - est_en_sommeil: Optional[bool] = None - - civilite: Optional[str] = None - nom: Optional[str] = None - prenom: Optional[str] = None - nom_complet: Optional[str] = None - contact: Optional[str] = None - - complement: Optional[str] = None - region: Optional[str] = None - pays: Optional[str] = None - - portable: Optional[str] = None - telecopie: Optional[str] = None - site_web: Optional[str] = None - - siret: Optional[str] = None - siren: Optional[str] = None - tva_intra: Optional[str] = None - code_naf: Optional[str] = None - forme_juridique: Optional[str] = None - - secteur: Optional[str] = None - effectif: Optional[int] = None - ca_annuel: Optional[float] = None - commercial_code: Optional[str] = None - commercial_nom: Optional[str] = None - - categorie_tarifaire: Optional[int] = None - categorie_comptable: Optional[int] = None - - encours_autorise: Optional[float] = None - assurance_credit: Optional[float] = None - compte_general: Optional[str] = None - - date_creation: Optional[str] = None - date_modification: Optional[str] = None - +class ClientDetails(BaseModel): + """Modèle de réponse client complet (pour GET /clients/{code})""" + + # === IDENTIFICATION === + numero: Optional[str] = Field(None, description="Code client (CT_Num)") + intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") + + # === TYPE DE TIERS === + type_tiers: Optional[str] = Field( + None, + description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'" + ) + qualite: Optional[str] = Field( + None, + description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)" + ) + est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") + est_fournisseur: Optional[bool] = Field(None, description="True si fournisseur (CT_Qualite=2 ou 3)") + + # === TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) === + forme_juridique: Optional[str] = Field( + None, + description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier" + ) + est_entreprise: Optional[bool] = Field( + None, + description="True si entreprise (forme_juridique renseignée)" + ) + est_particulier: Optional[bool] = Field( + None, + description="True si particulier (pas de forme juridique)" + ) + + # === STATUT === + est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") + est_en_sommeil: Optional[bool] = Field(None, description="True si en sommeil (CT_Sommeil=1)") + + # === IDENTITÉ (POUR PARTICULIERS) === + civilite: Optional[str] = Field(None, description="M., Mme, Mlle (CT_Civilite)") + nom: Optional[str] = Field(None, description="Nom de famille (CT_Nom)") + prenom: Optional[str] = Field(None, description="Prénom (CT_Prenom)") + nom_complet: Optional[str] = Field( + None, + description="Nom complet formaté : 'Civilité Prénom Nom'" + ) + + # === CONTACT === + contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") + + # === ADRESSE === + adresse: Optional[str] = Field(None, description="Adresse ligne 1") + complement: Optional[str] = Field(None, description="Complément d'adresse") + code_postal: Optional[str] = Field(None, description="Code postal") + ville: Optional[str] = Field(None, description="Ville") + region: Optional[str] = Field(None, description="Région/État") + pays: Optional[str] = Field(None, description="Pays") + + # === TÉLÉCOMMUNICATIONS === + telephone: Optional[str] = Field(None, description="Téléphone fixe") + portable: Optional[str] = Field(None, description="Téléphone mobile") + telecopie: Optional[str] = Field(None, description="Fax") + email: Optional[str] = Field(None, description="Email principal") + site_web: Optional[str] = Field(None, description="Site web") + + # === INFORMATIONS JURIDIQUES (ENTREPRISES) === + siret: Optional[str] = Field(None, description="N° SIRET (14 chiffres)") + siren: Optional[str] = Field(None, description="N° SIREN (9 chiffres)") + tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire") + code_naf: Optional[str] = Field(None, description="Code NAF/APE") + + # === INFORMATIONS COMMERCIALES === + secteur: Optional[str] = Field(None, description="Secteur d'activité") + effectif: Optional[int] = Field(None, description="Nombre d'employés") + ca_annuel: Optional[float] = Field(None, description="Chiffre d'affaires annuel") + commercial_code: Optional[str] = Field(None, description="Code du commercial rattaché") + commercial_nom: Optional[str] = Field(None, description="Nom du commercial") + + # === CATÉGORIES === + categorie_tarifaire: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") + categorie_comptable: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") + + # === INFORMATIONS FINANCIÈRES === + encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé") + assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit") + compte_general: Optional[str] = Field(None, description="Compte général principal") + + # === DATES === + date_creation: Optional[str] = Field(None, description="Date de création") + date_modification: Optional[str] = Field(None, description="Date de dernière modification") + + class Config: + json_schema_extra = { + "example": { + "numero": "CLI000001", + "intitule": "SARL EXEMPLE", + "type_tiers": "client", + "qualite": "CLI", + "est_entreprise": True, + "forme_juridique": "SARL", + "adresse": "123 Rue de la Paix", + "code_postal": "75001", + "ville": "Paris", + "telephone": "0123456789", + "portable": "0612345678", + "email": "contact@exemple.fr", + "siret": "12345678901234", + "tva_intra": "FR12345678901" + } + } class ArticleResponse(BaseModel): reference: str @@ -229,22 +295,47 @@ class BaremeRemiseResponse(BaseModel): class ClientCreateAPIRequest(BaseModel): - intitule: str = Field(..., min_length=1, description="Raison sociale ou Nom") - compte_collectif: str = Field("411000", description="Compte Comptable (ex: 411000)") - num: Optional[str] = Field(None, description="Code client souhaité (optionnel)") - adresse: Optional[str] = None - code_postal: Optional[str] = None - ville: Optional[str] = None - pays: Optional[str] = None + """Modèle pour création d'un nouveau client""" + + intitule: str = Field(..., min_length=1, max_length=69, description="Raison sociale ou Nom complet") + compte_collectif: str = Field("411000", description="Compte comptable (411000 par défaut)") + num: Optional[str] = Field(None, max_length=17, description="Code client souhaité (auto si vide)") + + # Adresse + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + + # Contact email: Optional[EmailStr] = None - telephone: Optional[str] = None - siret: Optional[str] = None - tva_intra: Optional[str] = None - + telephone: Optional[str] = Field(None, max_length=21, description="Téléphone fixe") + portable: Optional[str] = Field(None, max_length=21, description="Téléphone mobile") + + # Juridique + forme_juridique: Optional[str] = Field(None, max_length=50, description="SARL, SA, SAS, EI, etc.") + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "SARL NOUVELLE ENTREPRISE", + "forme_juridique": "SARL", + "adresse": "10 Avenue des Champs", + "code_postal": "75008", + "ville": "Paris", + "telephone": "0123456789", + "portable": "0612345678", + "email": "contact@nouvelle-entreprise.fr", + "siret": "12345678901234", + "tva_intra": "FR12345678901" + } + } class ClientUpdateRequest(BaseModel): """Modèle pour modification d'un client existant""" - + intitule: Optional[str] = Field(None, min_length=1, max_length=69) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) @@ -252,18 +343,17 @@ class ClientUpdateRequest(BaseModel): pays: Optional[str] = Field(None, max_length=35) email: Optional[EmailStr] = None telephone: Optional[str] = Field(None, max_length=21) + portable: Optional[str] = Field(None, max_length=21) + forme_juridique: Optional[str] = Field(None, max_length=50) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { - "intitule": "SARL TEST MODIFIÉ", - "adresse": "456 Avenue des Champs", - "code_postal": "75008", - "ville": "Paris", "email": "nouveau@email.fr", "telephone": "0198765432", + "portable": "0687654321" } } From 1c53135b62e5bbdf129ee3df9bb87d94c240cbdc Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 15:52:07 +0300 Subject: [PATCH 34/62] feat(api): enrich ArticleResponse model with additional fields --- api.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/api.py b/api.py index 9fdeade..cbee1eb 100644 --- a/api.py +++ b/api.py @@ -232,10 +232,65 @@ class ClientDetails(BaseModel): } class ArticleResponse(BaseModel): - reference: str - designation: str - prix_vente: float - stock_reel: float + """ + Modèle de réponse pour un article Sage + + ✅ ENRICHI avec tous les champs disponibles + """ + # === IDENTIFICATION === + reference: str = Field(..., description="Référence article (AR_Ref)") + designation: str = Field(..., description="Désignation principale (AR_Design)") + designation_complementaire: Optional[str] = Field(None, description="Désignation complémentaire") + + # === CODE EAN / CODE-BARRES === + code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") + code_barre: Optional[str] = Field(None, description="Code-barres (alias)") + + # === PRIX === + prix_vente: float = Field(..., description="Prix de vente HT") + prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") + prix_revient: Optional[float] = Field(None, description="Prix de revient") + + # === STOCK === + stock_reel: float = Field(..., description="Stock réel") + stock_mini: Optional[float] = Field(None, description="Stock minimum") + stock_maxi: Optional[float] = Field(None, description="Stock maximum") + stock_reserve: Optional[float] = Field(None, description="Stock réservé (en commande)") + stock_commande: Optional[float] = Field(None, description="Stock en commande fournisseur") + stock_disponible: Optional[float] = Field(None, description="Stock disponible (réel - réservé)") + + # === DESCRIPTIONS === + description: Optional[str] = Field(None, description="Description détaillée / Commentaire") + + # === CLASSIFICATION === + type_article: Optional[int] = Field(None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)") + type_article_libelle: Optional[str] = Field(None, description="Libellé du type") + famille_code: Optional[str] = Field(None, description="Code famille") + famille_libelle: Optional[str] = Field(None, description="Libellé famille") + + # === FOURNISSEUR PRINCIPAL === + fournisseur_principal: Optional[str] = Field(None, description="Code fournisseur principal") + fournisseur_nom: Optional[str] = Field(None, description="Nom fournisseur principal") + + # === UNITÉS === + unite_vente: Optional[str] = Field(None, description="Unité de vente") + unite_achat: Optional[str] = Field(None, description="Unité d'achat") + + # === CARACTÉRISTIQUES PHYSIQUES === + poids: Optional[float] = Field(None, description="Poids (kg)") + volume: Optional[float] = Field(None, description="Volume (m³)") + + # === STATUT === + est_actif: bool = Field(True, description="Article actif") + en_sommeil: bool = Field(False, description="Article en sommeil") + + # === TVA === + tva_code: Optional[str] = Field(None, description="Code TVA") + tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)") + + # === DATES === + date_creation: Optional[str] = Field(None, description="Date de création") + date_modification: Optional[str] = Field(None, description="Date de dernière modification") class LigneDevis(BaseModel): @@ -2889,13 +2944,11 @@ async def lire_prospect(code: str): async def rechercher_fournisseurs(query: Optional[str] = Query(None)): """ 🔍 Recherche fournisseurs via gateway Windows - ✅ CORRECTION : Appel direct sans cache """ try: - # ✅ APPEL DIRECT vers Windows (pas de cache) fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") - logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés depuis Windows") + logger.info(f"✅ {len(fournisseurs)} fournisseurs") if len(fournisseurs) == 0: logger.warning("⚠️ Aucun fournisseur retourné - vérifier la gateway Windows") From 44354ec9bdd3b3fb48fc9e6a095e241d8d91eb4e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 15:55:47 +0300 Subject: [PATCH 35/62] refactor(api): remove debug endpoints before production release --- api.py | 318 --------------------------------------------------------- 1 file changed, 318 deletions(-) diff --git a/api.py b/api.py index cbee1eb..cb8c80f 100644 --- a/api.py +++ b/api.py @@ -3793,324 +3793,6 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) raise HTTPException(500, str(e)) -@app.get("/debug/users/{user_id}", response_model=UserResponse, tags=["Debug"]) -async def lire_utilisateur_debug( - user_id: str, session: AsyncSession = Depends(get_session) -): - """ - 👤 **ROUTE DEBUG** - Détails d'un utilisateur par ID - - ⚠️ Non protégée - à sécuriser en production - """ - from database import User - from sqlalchemy import select - - try: - query = select(User).where(User.id == user_id) - result = await session.execute(query) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException(404, f"Utilisateur {user_id} introuvable") - - return UserResponse( - id=user.id, - email=user.email, - nom=user.nom, - prenom=user.prenom, - role=user.role, - is_verified=user.is_verified, - is_active=user.is_active, - created_at=user.created_at.isoformat() if user.created_at else "", - last_login=user.last_login.isoformat() if user.last_login else None, - failed_login_attempts=user.failed_login_attempts or 0, - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"❌ Erreur lecture utilisateur: {e}") - raise HTTPException(500, str(e)) - - -@app.get("/debug/database/check", tags=["Debug"]) -async def verifier_integrite_database(session: AsyncSession = Depends(get_session)): - """ - 🔍 Vérification de l'intégrité de la base de données - - Retourne des statistiques détaillées sur toutes les tables - """ - from database import User, RefreshToken, LoginAttempt, EmailLog, SignatureLog - from sqlalchemy import func, text - - try: - diagnostics = {} - - # === TABLE USERS === - # Compter tous les users - total_users = await session.execute(select(func.count(User.id))) - diagnostics["users"] = {"total": total_users.scalar(), "details": []} - - # Lister tous les users avec détails - all_users = await session.execute(select(User)) - users_list = all_users.scalars().all() - - for u in users_list: - diagnostics["users"]["details"].append( - { - "id": u.id, - "email": u.email, - "nom": f"{u.prenom} {u.nom}", - "role": u.role, - "is_active": u.is_active, - "is_verified": u.is_verified, - "created_at": u.created_at.isoformat() if u.created_at else None, - "has_reset_token": u.reset_token is not None, - "has_verification_token": u.verification_token is not None, - } - ) - - # === TABLE REFRESH_TOKENS === - total_tokens = await session.execute(select(func.count(RefreshToken.id))) - diagnostics["refresh_tokens"] = {"total": total_tokens.scalar()} - - # === TABLE LOGIN_ATTEMPTS === - total_attempts = await session.execute(select(func.count(LoginAttempt.id))) - diagnostics["login_attempts"] = {"total": total_attempts.scalar()} - - # === TABLE EMAIL_LOGS === - total_emails = await session.execute(select(func.count(EmailLog.id))) - diagnostics["email_logs"] = {"total": total_emails.scalar()} - - # === TABLE SIGNATURE_LOGS === - total_signatures = await session.execute(select(func.count(SignatureLog.id))) - diagnostics["signature_logs"] = {"total": total_signatures.scalar()} - - # === VÉRIFIER LES FICHIERS SQLITE === - import os - - db_file = "sage_dataven.db" - diagnostics["database_file"] = { - "exists": os.path.exists(db_file), - "size_bytes": os.path.getsize(db_file) if os.path.exists(db_file) else 0, - "path": os.path.abspath(db_file), - } - - # === TESTER UNE REQUÊTE RAW SQL === - try: - raw_count = await session.execute(text("SELECT COUNT(*) FROM users")) - diagnostics["raw_sql_check"] = { - "users_count": raw_count.scalar(), - "status": "✅ Connexion DB OK", - } - except Exception as e: - diagnostics["raw_sql_check"] = {"status": "❌ Erreur", "error": str(e)} - - return { - "success": True, - "timestamp": datetime.now().isoformat(), - "diagnostics": diagnostics, - } - - except Exception as e: - logger.error(f"❌ Erreur diagnostic DB: {e}", exc_info=True) - raise HTTPException(500, f"Erreur diagnostic: {str(e)}") - - -@app.post("/debug/database/test-user-persistence", tags=["Debug"]) -async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_session)): - """ - 🧪 Test de création/lecture/modification d'un utilisateur de test - - Crée un utilisateur de test, le modifie, et vérifie la persistance - """ - import uuid - from database import User - from security.auth import hash_password - - try: - test_email = f"test_{uuid.uuid4().hex[:8]}@example.com" - - # === ÉTAPE 1: CRÉATION === - test_user = User( - id=str(uuid.uuid4()), - email=test_email, - hashed_password=hash_password("TestPassword123!"), - nom="Test", - prenom="User", - role="user", - is_verified=True, - is_active=True, - created_at=datetime.now(), - ) - - session.add(test_user) - await session.flush() - user_id = test_user.id - await session.commit() - - logger.info(f"✅ ÉTAPE 1: User créé - {user_id}") - - # === ÉTAPE 2: LECTURE === - result = await session.execute(select(User).where(User.id == user_id)) - loaded_user = result.scalar_one_or_none() - - if not loaded_user: - return { - "success": False, - "error": "❌ User introuvable après création !", - "step": "LECTURE", - } - - logger.info(f"✅ ÉTAPE 2: User chargé - {loaded_user.email}") - - # === ÉTAPE 3: MODIFICATION (simulate reset password) === - loaded_user.hashed_password = hash_password("NewPassword456!") - loaded_user.reset_token = None - loaded_user.reset_token_expires = None - - session.add(loaded_user) - await session.flush() - await session.commit() - await session.refresh(loaded_user) - - logger.info(f"✅ ÉTAPE 3: User modifié") - - # === ÉTAPE 4: RE-LECTURE === - result2 = await session.execute(select(User).where(User.id == user_id)) - reloaded_user = result2.scalar_one_or_none() - - if not reloaded_user: - return { - "success": False, - "error": "❌ User DISPARU après modification !", - "step": "RE-LECTURE", - "user_id": user_id, - } - - logger.info(f"✅ ÉTAPE 4: User re-chargé - {reloaded_user.email}") - - # === ÉTAPE 5: SUPPRESSION DU TEST === - await session.delete(reloaded_user) - await session.commit() - - logger.info(f"✅ ÉTAPE 5: User test supprimé") - - return { - "success": True, - "message": "✅ Tous les tests de persistance sont OK", - "test_user_id": user_id, - "test_email": test_email, - "steps_completed": [ - "1. Création", - "2. Lecture", - "3. Modification (reset password simulé)", - "4. Re-lecture (vérification persistance)", - "5. Suppression (cleanup)", - ], - } - - except Exception as e: - logger.error(f"❌ Erreur test persistance: {e}", exc_info=True) - - # Rollback en cas d'erreur - await session.rollback() - - return { - "success": False, - "error": str(e), - "traceback": str(e.__class__.__name__), - } - - -@app.get("/debug/fournisseurs/cache", tags=["Debug"]) -async def debug_cache_fournisseurs(): - """ - 🔍 Debug : État du cache côté VPS Linux - """ - try: - # Appeler la gateway Windows pour récupérer l'info cache - cache_info = sage_client.get_cache_info() - - # Tenter de lister les fournisseurs - try: - fournisseurs = sage_client.lister_fournisseurs(filtre="") - nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 - exemple = fournisseurs[:3] if fournisseurs else [] - except Exception as e: - nb_fournisseurs = -1 - exemple = [] - error = str(e) - - return { - "success": True, - "cache_info_windows": cache_info, - "test_liste_fournisseurs": { - "nb_fournisseurs": nb_fournisseurs, - "exemples": exemple, - "erreur": error if nb_fournisseurs == -1 else None, - }, - "diagnostic": { - "gateway_accessible": cache_info is not None, - "cache_fournisseurs_existe": ( - "fournisseurs" in cache_info if cache_info else False - ), - "probleme_probable": ( - "Cache fournisseurs non initialisé côté Windows" - if cache_info and "fournisseurs" not in cache_info - else ( - "OK" - if nb_fournisseurs > 0 - else "Erreur lors de la récupération" - ) - ), - }, - } - - except Exception as e: - logger.error(f"❌ Erreur debug cache: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -@app.post("/debug/fournisseurs/force-refresh", tags=["Debug"]) -async def force_refresh_fournisseurs(): - """ - 🔄 Force le refresh du cache fournisseurs côté Windows - """ - try: - # Appeler la gateway Windows pour forcer le refresh - resultat = sage_client.refresh_cache() - - # Attendre 2 secondes - import time - - time.sleep(2) - - # Récupérer le cache info après refresh - cache_info = sage_client.get_cache_info() - - # Tester la liste - fournisseurs = sage_client.lister_fournisseurs(filtre="") - nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 - - return { - "success": True, - "refresh_result": resultat, - "cache_apres_refresh": cache_info, - "nb_fournisseurs_maintenant": nb_fournisseurs, - "exemples": fournisseurs[:3] if fournisseurs else [], - "message": ( - f"✅ Refresh OK : {nb_fournisseurs} fournisseurs disponibles" - if nb_fournisseurs > 0 - else "❌ Problème : aucun fournisseur après refresh" - ), - } - - except Exception as e: - logger.error(f"❌ Erreur force refresh: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - # ===================================================== # LANCEMENT # ===================================================== From 428093306adbbca87a32026bdac75b633104557d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 10 Dec 2025 17:01:49 +0300 Subject: [PATCH 36/62] feat(articles): add CRUD operations for articles management --- api.py | 145 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 53 ++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/api.py b/api.py index cb8c80f..03f22e7 100644 --- a/api.py +++ b/api.py @@ -771,7 +771,32 @@ class FactureUpdateRequest(BaseModel): } } +class ArticleCreateRequest(BaseModel): + """Schéma pour création d'article""" + reference: str = Field(..., max_length=18, description="Référence article") + designation: str = Field(..., max_length=69, description="Désignation") + famille: Optional[str] = Field(None, max_length=18, 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, max_length=13, description="Code-barres") + unite_vente: Optional[str] = Field("UN", max_length=4, description="Unité") + tva_code: Optional[str] = Field(None, max_length=5, description="Code TVA") + description: Optional[str] = Field(None, description="Description") + +class ArticleUpdateRequest(BaseModel): + """Schéma pour modification d'article""" + designation: Optional[str] = Field(None, max_length=69) + prix_vente: Optional[float] = Field(None, ge=0) + prix_achat: Optional[float] = Field(None, ge=0) + stock_reel: Optional[float] = Field(None, ge=0, description="⚠️ Critique pour erreur 2881") + stock_mini: Optional[float] = Field(None, ge=0) + code_ean: Optional[str] = Field(None, max_length=13) + description: Optional[str] = Field(None) + + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1061,7 +1086,127 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) +@router.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) +def creer_article(article: ArticleCreateDTO): + """ + ➕ Création d'un article dans Sage + + **Usage**: Créer un article avec stock pour éviter l'erreur 2881 + + **Erreurs possibles**: + - 400: Article existe déjà ou données invalides + - 500: Erreur Sage + """ + try: + resultat = sage_client.creer_article(article.dict(exclude_none=True)) + + logger.info(f"✅ Article créé: {resultat.get('reference')}") + + return { + "message": "Article créé avec succès", + "article": resultat + } + + except ValueError as e: + logger.warning(f"Erreur métier création article: {e}") + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) + + except Exception as e: + logger.error(f"Erreur création article: {e}") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + +@router.put("/articles/{reference}", tags=["Articles"]) +def modifier_article(reference: str, article: ArticleUpdateDTO): + """ + ✏️ Modification d'un article dans Sage + + **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 + + **Example** - Résoudre l'erreur "L'état du stock ne permet pas de créer la ligne": +```bash + curl -X PUT "http://api.example.com/api/articles/ART001" \ + -H "Content-Type: application/json" \ + -d '{"stock_reel": 100.0}' +``` + + **Erreurs possibles**: + - 404: Article introuvable + - 400: Données invalides + - 500: Erreur Sage + """ + try: + # Filtrer les champs None + article_data = article.dict(exclude_none=True) + + if not article_data: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Aucun champ à modifier" + ) + + resultat = sage_client.modifier_article(reference, article_data) + + logger.info(f"✅ Article {reference} modifié: {list(article_data.keys())}") + + return { + "message": f"Article {reference} modifié avec succès", + "article": resultat, + "champs_modifies": list(article_data.keys()) + } + + except ValueError as e: + logger.warning(f"Erreur métier modification article: {e}") + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) + + except Exception as e: + logger.error(f"Erreur modification article: {e}") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + + +@router.get("/articles/{reference}", tags=["Articles"]) +def lire_article(reference: str): + """ + 📄 Lecture d'un article par référence + + Retourne toutes les informations incluant le stock actuel + """ + try: + article = sage_client.lire_article(reference) + + if not article: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + f"Article {reference} introuvable" + ) + + return {"article": article} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture article: {e}") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + + +@router.get("/articles/all") +def lister_articles(filtre: str = ""): + """ + 📋 Liste tous les articles avec filtre optionnel + """ + try: + articles = sage_client.lister_articles(filtre) + + return { + "articles": articles, + "total": len(articles) + } + + except Exception as e: + logger.error(f"Erreur liste articles: {e}") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + + @app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"]) async def creer_devis(devis: DevisRequest): """📝 Création de devis via gateway Windows""" diff --git a/sage_client.py b/sage_client.py index d03e009..9c1b2c4 100644 --- a/sage_client.py +++ b/sage_client.py @@ -611,5 +611,58 @@ class SageGatewayClient: raise + def creer_article(self, article_data: Dict) -> Dict: + """ + ➕ Création d'un article + + Args: + article_data: Dictionnaire contenant: + - reference (str, obligatoire): Référence article + - designation (str, obligatoire): Désignation + - prix_vente (float, optionnel): Prix vente HT + - stock_reel (float, optionnel): Stock initial + - ... (voir ArticleCreateRequest dans main.py) + + Returns: + Article créé + + Example: + >>> article = sage_client.creer_article({ + ... "reference": "ART001", + ... "designation": "Article test", + ... "prix_vente": 10.0, + ... "stock_reel": 100.0 + ... }) + """ + return self._post("/sage/articles/create", article_data).get("data", {}) + + + def modifier_article(self, reference: str, article_data: Dict) -> Dict: + """ + ✏️ Modification d'un article + + **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 + + Args: + reference: Référence de l'article à modifier + article_data: Dictionnaire contenant les champs à modifier: + - stock_reel (float, optionnel): Nouveau stock + - prix_vente (float, optionnel): Nouveau prix + - ... (seuls les champs présents seront mis à jour) + + Returns: + Article modifié + + Example - Résoudre erreur de stock: + >>> # L'erreur 2881 indique un stock insuffisant + >>> sage_client.modifier_article("ART001", { + ... "stock_reel": 100.0 # Augmenter le stock + ... }) + """ + return self._post( + "/sage/articles/update", + {"reference": reference, "article_data": article_data} + ).get("data", {}) + # Instance globale sage_client = SageGatewayClient() From a133172a0bb160ba9da14f586c4a8d4ebcfc15bd Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 10 Dec 2025 17:04:59 +0300 Subject: [PATCH 37/62] refactor(api): change router to app for article endpoints --- api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api.py b/api.py index 03f22e7..604f2ee 100644 --- a/api.py +++ b/api.py @@ -1086,7 +1086,7 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) -@router.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) +@app.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) def creer_article(article: ArticleCreateDTO): """ ➕ Création d'un article dans Sage @@ -1116,7 +1116,7 @@ def creer_article(article: ArticleCreateDTO): raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) -@router.put("/articles/{reference}", tags=["Articles"]) +@app.put("/articles/{reference}", tags=["Articles"]) def modifier_article(reference: str, article: ArticleUpdateDTO): """ ✏️ Modification d'un article dans Sage @@ -1164,7 +1164,7 @@ def modifier_article(reference: str, article: ArticleUpdateDTO): raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) -@router.get("/articles/{reference}", tags=["Articles"]) +@app.get("/articles/{reference}", tags=["Articles"]) def lire_article(reference: str): """ 📄 Lecture d'un article par référence @@ -1189,7 +1189,7 @@ def lire_article(reference: str): raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) -@router.get("/articles/all") +@app.get("/articles/all") def lister_articles(filtre: str = ""): """ 📋 Liste tous les articles avec filtre optionnel From 44675f69ace53a6be7471d469ffd6a00011e22a3 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 10 Dec 2025 17:06:39 +0300 Subject: [PATCH 38/62] refactor(api): rename DTO classes to Request for clarity --- api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 604f2ee..7f0f342 100644 --- a/api.py +++ b/api.py @@ -1087,7 +1087,7 @@ async def rechercher_articles(query: Optional[str] = Query(None)): raise HTTPException(500, str(e)) @app.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) -def creer_article(article: ArticleCreateDTO): +def creer_article(article: ArticleCreateRequest): """ ➕ Création d'un article dans Sage @@ -1117,7 +1117,7 @@ def creer_article(article: ArticleCreateDTO): @app.put("/articles/{reference}", tags=["Articles"]) -def modifier_article(reference: str, article: ArticleUpdateDTO): +def modifier_article(reference: str, article: ArticleUpdateRequest): """ ✏️ Modification d'un article dans Sage From 963118641b861f47f66dbe46f66b033df7b8e778 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 11 Dec 2025 11:49:17 +0300 Subject: [PATCH 39/62] fix: change exclude_none to exclude_unset in article creation/update --- api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 7f0f342..4bfea6b 100644 --- a/api.py +++ b/api.py @@ -1098,7 +1098,7 @@ def creer_article(article: ArticleCreateRequest): - 500: Erreur Sage """ try: - resultat = sage_client.creer_article(article.dict(exclude_none=True)) + resultat = sage_client.creer_article(article.dict(exclude_unset=True)) logger.info(f"✅ Article créé: {resultat.get('reference')}") @@ -1137,7 +1137,7 @@ def modifier_article(reference: str, article: ArticleUpdateRequest): """ try: # Filtrer les champs None - article_data = article.dict(exclude_none=True) + article_data = article.dict(exclude_unset=True) if not article_data: raise HTTPException( From e56159268f89dab2602268fdabc3758c857f0715 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 11 Dec 2025 12:01:54 +0300 Subject: [PATCH 40/62] feat(articles): enhance article endpoints with async support and validation --- api.py | 201 ++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 150 insertions(+), 51 deletions(-) diff --git a/api.py b/api.py index 4bfea6b..02e90d9 100644 --- a/api.py +++ b/api.py @@ -1086,108 +1086,207 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) -@app.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) -def creer_article(article: ArticleCreateRequest): +@app.post( + "/articles", + response_model=ArticleResponse, + status_code=status.HTTP_201_CREATED, + tags=["Articles"] +) +async def creer_article(article: ArticleCreateRequest): """ - ➕ Création d'un article dans Sage + ➕ Création d'un nouvel article dans Sage - **Usage**: Créer un article avec stock pour éviter l'erreur 2881 + **Usage typique:** Créer un article avec stock pour éviter l'erreur 2881 - **Erreurs possibles**: + **Champs obligatoires:** + - `reference` (max 18 caractères) : Référence unique de l'article + - `designation` (max 69 caractères) : Désignation de l'article + + **Champs optionnels mais recommandés:** + - `stock_reel` : Stock initial (important pour éviter erreurs de transformation) + - `prix_vente` : Prix de vente HT + - `unite_vente` : Unité de vente (défaut: "UN") + + **Erreurs possibles:** - 400: Article existe déjà ou données invalides - - 500: Erreur Sage + - 500: Erreur Sage (problème de connexion, champs mal formatés, etc.) + + **Exemple:** + ```json + { + "reference": "ART001", + "designation": "Article de test", + "prix_vente": 10.50, + "stock_reel": 100.0, + "stock_mini": 10.0, + "unite_vente": "UN", + "tva_code": "C20" + } + ``` """ try: - resultat = sage_client.creer_article(article.dict(exclude_unset=True)) + # Validation des données + if not article.reference or not article.designation: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Les champs 'reference' et 'designation' sont obligatoires" + ) - logger.info(f"✅ Article créé: {resultat.get('reference')}") + # ⚠️ CORRECTION: Ne pas utiliser exclude_none=True car on veut garder + # les valeurs par défaut (comme unite_vente="UN") + article_data = article.dict(exclude_unset=True) - return { - "message": "Article créé avec succès", - "article": resultat - } + logger.info(f"📝 Création article: {article.reference} - {article.designation}") + + # Appel à la gateway Windows + resultat = sage_client.creer_article(article_data) + + logger.info(f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})") + + return ArticleResponse(**resultat) except ValueError as e: - logger.warning(f"Erreur métier création article: {e}") - raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) + # Erreur métier (ex: article existe déjà) + logger.warning(f"⚠️ Erreur métier création article: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + except HTTPException: + raise except Exception as e: - logger.error(f"Erreur création article: {e}") - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + # Erreur technique Sage + logger.error(f"❌ Erreur technique création article: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de l'article: {str(e)}" + ) -@app.put("/articles/{reference}", tags=["Articles"]) -def modifier_article(reference: str, article: ArticleUpdateRequest): +@app.put("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) +async def modifier_article( + reference: str = Path(..., description="Référence de l'article à modifier"), + article: ArticleUpdateRequest = Body(...) +): """ - ✏️ Modification d'un article dans Sage + ✏️ Modification complète d'un article existant - **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 + **Usage critique:** Augmenter le stock pour résoudre l'erreur 2881 - **Example** - Résoudre l'erreur "L'état du stock ne permet pas de créer la ligne": -```bash - curl -X PUT "http://api.example.com/api/articles/ART001" \ - -H "Content-Type: application/json" \ - -d '{"stock_reel": 100.0}' -``` + **Erreur 2881 - "L'état du stock ne permet pas de créer la ligne"** - **Erreurs possibles**: + Cette erreur survient lors de la transformation de documents (devis → commande → facture) + lorsque le stock de l'article est insuffisant. + + **Solution:** Augmenter le `stock_reel` de l'article + + **Exemple - Résoudre l'erreur 2881:** + ```json + { + "stock_reel": 100.0 + } + ``` + + **Autres modifications possibles:** + - Prix de vente/achat + - Stock minimum + - Code EAN + - Description + + **Erreurs possibles:** - 404: Article introuvable - - 400: Données invalides + - 400: Aucun champ à modifier ou données invalides - 500: Erreur Sage + + **Note:** Seuls les champs fournis seront modifiés, les autres restent inchangés """ try: - # Filtrer les champs None + # ✅ CORRECTION: Utiliser exclude_unset=True au lieu de exclude_none=True + # Cela permet de distinguer entre: + # - Champ non fourni (exclu) + # - Champ fourni avec valeur None (inclus pour reset) article_data = article.dict(exclude_unset=True) if not article_data: raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Aucun champ à modifier" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour." ) + logger.info(f"📝 Modification article {reference}: {list(article_data.keys())}") + + # Appel à la gateway Windows resultat = sage_client.modifier_article(reference, article_data) - logger.info(f"✅ Article {reference} modifié: {list(article_data.keys())}") + # Log spécial pour modification de stock (important pour erreur 2881) + if "stock_reel" in article_data: + logger.info( + f"📦 Stock {reference} modifié: {article_data['stock_reel']} " + f"(peut résoudre erreur 2881)" + ) - return { - "message": f"Article {reference} modifié avec succès", - "article": resultat, - "champs_modifies": list(article_data.keys()) - } + logger.info(f"✅ Article {reference} modifié ({len(article_data)} champs)") + + return ArticleResponse(**resultat) except ValueError as e: - logger.warning(f"Erreur métier modification article: {e}") - raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) + # Erreur métier (ex: article introuvable) + logger.warning(f"⚠️ Erreur métier modification article: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + + except HTTPException: + raise except Exception as e: - logger.error(f"Erreur modification article: {e}") - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + # Erreur technique Sage + logger.error(f"❌ Erreur technique modification article: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la modification de l'article: {str(e)}" + ) -@app.get("/articles/{reference}", tags=["Articles"]) -def lire_article(reference: str): +@app.get("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) +async def lire_article(reference: str = Path(..., description="Référence de l'article")): """ - 📄 Lecture d'un article par référence + 📄 Lecture d'un article spécifique par référence - Retourne toutes les informations incluant le stock actuel + **Retourne:** + - Toutes les informations de l'article + - Stock actuel (réel, réservé, disponible) + - Prix de vente et d'achat + - Famille, fournisseur principal + - Caractéristiques physiques (poids, volume) + + **Source:** Cache mémoire (instantané) """ try: article = sage_client.lire_article(reference) if not article: + logger.warning(f"⚠️ Article {reference} introuvable") raise HTTPException( - status.HTTP_404_NOT_FOUND, - f"Article {reference} introuvable" + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Article {reference} introuvable" ) - return {"article": article} + logger.info(f"✅ Article {reference} lu: {article.get('designation', '')}") + + return ArticleResponse(**article) except HTTPException: raise except Exception as e: - logger.error(f"Erreur lecture article: {e}") - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) - + logger.error(f"❌ Erreur lecture article {reference}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture de l'article: {str(e)}" + ) @app.get("/articles/all") def lister_articles(filtre: str = ""): From 5bed8c0cfe4d2c000af385f0a2fbc007506ff3a5 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 11 Dec 2025 12:04:07 +0300 Subject: [PATCH 41/62] refactor(api): add Body import from fastapi for request handling --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 02e90d9..152ed6f 100644 --- a/api.py +++ b/api.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, Path, Query, Depends, status +from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, EmailStr, validator, field_validator From 0faec998179660ef75791b1562ec64241b3ee705 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 09:23:03 +0300 Subject: [PATCH 42/62] refactor(api): introduce TypeDocumentSQL enum and update document reading methods --- api.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/api.py b/api.py index 152ed6f..99a1b2f 100644 --- a/api.py +++ b/api.py @@ -89,6 +89,15 @@ class TypeDocument(int, Enum): BON_AVOIR = settings.SAGE_TYPE_BON_AVOIR FACTURE = settings.SAGE_TYPE_FACTURE +class TypeDocumentSQL(int, Enum): + DEVIS = settings.SAGE_TYPE_DEVIS + BON_COMMANDE = 1 + PREPARATION = 2 + BON_LIVRAISON =3 + BON_RETOUR = 4 + BON_AVOIR = 5 + FACTURE = 6 + class StatutSignature(str, Enum): EN_ATTENTE = "EN_ATTENTE" @@ -1887,7 +1896,7 @@ async def changer_statut_devis( async def lire_commande(id: str): """📄 Lecture d'une commande avec ses lignes""" try: - commande = sage_client.lire_document(id, TypeDocument.BON_COMMANDE) + commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande: raise HTTPException(404, f"Commande {id} introuvable") return commande @@ -2538,7 +2547,7 @@ async def lire_facture_detail(numero: str): Facture complète avec lignes, client, totaux, etc. """ try: - facture = sage_client.lire_document(numero, TypeDocument.FACTURE) + facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {numero} introuvable") @@ -3334,7 +3343,7 @@ async def lister_avoirs( async def lire_avoir(numero: str): """📄 Lecture d'un avoir avec ses lignes""" try: - avoir = sage_client.lire_avoir(numero) + avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR) if not avoir: raise HTTPException(404, f"Avoir {numero} introuvable") return avoir @@ -3530,7 +3539,7 @@ async def lister_livraisons( async def lire_livraison(numero: str): """📄 Lecture d'une livraison avec ses lignes""" try: - livraison = sage_client.lire_livraison(numero) + livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) if not livraison: raise HTTPException(404, f"Livraison {numero} introuvable") return livraison From 42b3164f798391f01d1bdb543d85df19ddd2a5de Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 11:14:16 +0300 Subject: [PATCH 43/62] refactor(sage_client): remove section comments and redundant docstrings --- api.py | 1264 ++++++++++++++++++++---------------------------- sage_client.py | 308 ++---------- 2 files changed, 561 insertions(+), 1011 deletions(-) diff --git a/api.py b/api.py index 99a1b2f..dd7bd98 100644 --- a/api.py +++ b/api.py @@ -89,11 +89,12 @@ class TypeDocument(int, Enum): BON_AVOIR = settings.SAGE_TYPE_BON_AVOIR FACTURE = settings.SAGE_TYPE_FACTURE + class TypeDocumentSQL(int, Enum): DEVIS = settings.SAGE_TYPE_DEVIS BON_COMMANDE = 1 PREPARATION = 2 - BON_LIVRAISON =3 + BON_LIVRAISON = 3 BON_RETOUR = 4 BON_AVOIR = 5 FACTURE = 6 @@ -121,6 +122,7 @@ class StatutEmail(str, Enum): # ===================================================== class ClientResponse(BaseModel): """Modèle de réponse client simplifié (pour listes)""" + numero: Optional[str] = None intitule: Optional[str] = None adresse: Optional[str] = None @@ -132,53 +134,60 @@ class ClientResponse(BaseModel): class ClientDetails(BaseModel): """Modèle de réponse client complet (pour GET /clients/{code})""" - + # === IDENTIFICATION === numero: Optional[str] = Field(None, description="Code client (CT_Num)") - intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") - + intitule: Optional[str] = Field( + None, description="Raison sociale ou Nom complet (CT_Intitule)" + ) + # === TYPE DE TIERS === type_tiers: Optional[str] = Field( - None, - description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'" + None, + description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'", ) qualite: Optional[str] = Field( - None, - description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)" + None, + description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)", ) - est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") - est_fournisseur: Optional[bool] = Field(None, description="True si fournisseur (CT_Qualite=2 ou 3)") - + est_prospect: Optional[bool] = Field( + None, description="True si prospect (CT_Prospect=1)" + ) + est_fournisseur: Optional[bool] = Field( + None, description="True si fournisseur (CT_Qualite=2 ou 3)" + ) + # === TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) === forme_juridique: Optional[str] = Field( - None, - description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier" + None, + description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier", ) est_entreprise: Optional[bool] = Field( - None, - description="True si entreprise (forme_juridique renseignée)" + None, description="True si entreprise (forme_juridique renseignée)" ) est_particulier: Optional[bool] = Field( - None, - description="True si particulier (pas de forme juridique)" + None, description="True si particulier (pas de forme juridique)" ) - + # === STATUT === est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") - est_en_sommeil: Optional[bool] = Field(None, description="True si en sommeil (CT_Sommeil=1)") - + est_en_sommeil: Optional[bool] = Field( + None, description="True si en sommeil (CT_Sommeil=1)" + ) + # === IDENTITÉ (POUR PARTICULIERS) === civilite: Optional[str] = Field(None, description="M., Mme, Mlle (CT_Civilite)") nom: Optional[str] = Field(None, description="Nom de famille (CT_Nom)") prenom: Optional[str] = Field(None, description="Prénom (CT_Prenom)") nom_complet: Optional[str] = Field( - None, - description="Nom complet formaté : 'Civilité Prénom Nom'" + None, description="Nom complet formaté : 'Civilité Prénom Nom'" ) - + # === CONTACT === - contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") - + contact: Optional[str] = Field( + None, description="Nom du contact principal (CT_Contact)" + ) + # === ADRESSE === adresse: Optional[str] = Field(None, description="Adresse ligne 1") complement: Optional[str] = Field(None, description="Complément d'adresse") @@ -186,40 +195,52 @@ class ClientDetails(BaseModel): ville: Optional[str] = Field(None, description="Ville") region: Optional[str] = Field(None, description="Région/État") pays: Optional[str] = Field(None, description="Pays") - + # === TÉLÉCOMMUNICATIONS === telephone: Optional[str] = Field(None, description="Téléphone fixe") portable: Optional[str] = Field(None, description="Téléphone mobile") telecopie: Optional[str] = Field(None, description="Fax") email: Optional[str] = Field(None, description="Email principal") site_web: Optional[str] = Field(None, description="Site web") - + # === INFORMATIONS JURIDIQUES (ENTREPRISES) === siret: Optional[str] = Field(None, description="N° SIRET (14 chiffres)") siren: Optional[str] = Field(None, description="N° SIREN (9 chiffres)") tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire") code_naf: Optional[str] = Field(None, description="Code NAF/APE") - + # === INFORMATIONS COMMERCIALES === secteur: Optional[str] = Field(None, description="Secteur d'activité") effectif: Optional[int] = Field(None, description="Nombre d'employés") ca_annuel: Optional[float] = Field(None, description="Chiffre d'affaires annuel") - commercial_code: Optional[str] = Field(None, description="Code du commercial rattaché") + commercial_code: Optional[str] = Field( + None, description="Code du commercial rattaché" + ) commercial_nom: Optional[str] = Field(None, description="Nom du commercial") - + # === CATÉGORIES === - categorie_tarifaire: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") - categorie_comptable: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") - + categorie_tarifaire: Optional[int] = Field( + None, description="Catégorie tarifaire (N_CatTarif)" + ) + categorie_comptable: Optional[int] = Field( + None, description="Catégorie comptable (N_CatCompta)" + ) + # === INFORMATIONS FINANCIÈRES === - encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé") - assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit") + encours_autorise: Optional[float] = Field( + None, description="Encours maximum autorisé" + ) + assurance_credit: Optional[float] = Field( + None, description="Montant assurance crédit" + ) compte_general: Optional[str] = Field(None, description="Compte général principal") - + # === DATES === date_creation: Optional[str] = Field(None, description="Date de création") - date_modification: Optional[str] = Field(None, description="Date de dernière modification") - + date_modification: Optional[str] = Field( + None, description="Date de dernière modification" + ) + class Config: json_schema_extra = { "example": { @@ -236,70 +257,90 @@ class ClientDetails(BaseModel): "portable": "0612345678", "email": "contact@exemple.fr", "siret": "12345678901234", - "tva_intra": "FR12345678901" + "tva_intra": "FR12345678901", } } + class ArticleResponse(BaseModel): """ Modèle de réponse pour un article Sage - + ✅ ENRICHI avec tous les champs disponibles """ + # === IDENTIFICATION === reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") - designation_complementaire: Optional[str] = Field(None, description="Désignation complémentaire") - + designation_complementaire: Optional[str] = Field( + None, description="Désignation complémentaire" + ) + # === CODE EAN / CODE-BARRES === code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") code_barre: Optional[str] = Field(None, description="Code-barres (alias)") - + # === PRIX === prix_vente: float = Field(..., description="Prix de vente HT") prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") prix_revient: Optional[float] = Field(None, description="Prix de revient") - + # === STOCK === stock_reel: float = Field(..., description="Stock réel") stock_mini: Optional[float] = Field(None, description="Stock minimum") stock_maxi: Optional[float] = Field(None, description="Stock maximum") - stock_reserve: Optional[float] = Field(None, description="Stock réservé (en commande)") - stock_commande: Optional[float] = Field(None, description="Stock en commande fournisseur") - stock_disponible: Optional[float] = Field(None, description="Stock disponible (réel - réservé)") - + stock_reserve: Optional[float] = Field( + None, description="Stock réservé (en commande)" + ) + stock_commande: Optional[float] = Field( + None, description="Stock en commande fournisseur" + ) + stock_disponible: Optional[float] = Field( + None, description="Stock disponible (réel - réservé)" + ) + # === DESCRIPTIONS === - description: Optional[str] = Field(None, description="Description détaillée / Commentaire") - + description: Optional[str] = Field( + None, description="Description détaillée / Commentaire" + ) + # === CLASSIFICATION === - type_article: Optional[int] = Field(None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)") + type_article: Optional[int] = Field( + None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)" + ) type_article_libelle: Optional[str] = Field(None, description="Libellé du type") famille_code: Optional[str] = Field(None, description="Code famille") famille_libelle: Optional[str] = Field(None, description="Libellé famille") - + # === FOURNISSEUR PRINCIPAL === - fournisseur_principal: Optional[str] = Field(None, description="Code fournisseur principal") - fournisseur_nom: Optional[str] = Field(None, description="Nom fournisseur principal") - + fournisseur_principal: Optional[str] = Field( + None, description="Code fournisseur principal" + ) + fournisseur_nom: Optional[str] = Field( + None, description="Nom fournisseur principal" + ) + # === UNITÉS === unite_vente: Optional[str] = Field(None, description="Unité de vente") unite_achat: Optional[str] = Field(None, description="Unité d'achat") - + # === CARACTÉRISTIQUES PHYSIQUES === poids: Optional[float] = Field(None, description="Poids (kg)") volume: Optional[float] = Field(None, description="Volume (m³)") - + # === STATUT === est_actif: bool = Field(True, description="Article actif") en_sommeil: bool = Field(False, description="Article en sommeil") - + # === TVA === tva_code: Optional[str] = Field(None, description="Code TVA") tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)") - + # === DATES === date_creation: Optional[str] = Field(None, description="Date de création") - date_modification: Optional[str] = Field(None, description="Date de dernière modification") + date_modification: Optional[str] = Field( + None, description="Date de dernière modification" + ) class LigneDevis(BaseModel): @@ -360,27 +401,35 @@ class BaremeRemiseResponse(BaseModel): class ClientCreateAPIRequest(BaseModel): """Modèle pour création d'un nouveau client""" - - intitule: str = Field(..., min_length=1, max_length=69, description="Raison sociale ou Nom complet") - compte_collectif: str = Field("411000", description="Compte comptable (411000 par défaut)") - num: Optional[str] = Field(None, max_length=17, description="Code client souhaité (auto si vide)") - + + intitule: str = Field( + ..., min_length=1, max_length=69, description="Raison sociale ou Nom complet" + ) + compte_collectif: str = Field( + "411000", description="Compte comptable (411000 par défaut)" + ) + num: Optional[str] = Field( + None, max_length=17, description="Code client souhaité (auto si vide)" + ) + # Adresse adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) ville: Optional[str] = Field(None, max_length=35) pays: Optional[str] = Field(None, max_length=35) - + # Contact email: Optional[EmailStr] = None telephone: Optional[str] = Field(None, max_length=21, description="Téléphone fixe") portable: Optional[str] = Field(None, max_length=21, description="Téléphone mobile") - + # Juridique - forme_juridique: Optional[str] = Field(None, max_length=50, description="SARL, SA, SAS, EI, etc.") + forme_juridique: Optional[str] = Field( + None, max_length=50, description="SARL, SA, SAS, EI, etc." + ) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { @@ -393,13 +442,14 @@ class ClientCreateAPIRequest(BaseModel): "portable": "0612345678", "email": "contact@nouvelle-entreprise.fr", "siret": "12345678901234", - "tva_intra": "FR12345678901" + "tva_intra": "FR12345678901", } } + class ClientUpdateRequest(BaseModel): """Modèle pour modification d'un client existant""" - + intitule: Optional[str] = Field(None, min_length=1, max_length=69) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) @@ -411,13 +461,13 @@ class ClientUpdateRequest(BaseModel): forme_juridique: Optional[str] = Field(None, max_length=50) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { "email": "nouveau@email.fr", "telephone": "0198765432", - "portable": "0687654321" + "portable": "0687654321", } } @@ -780,8 +830,10 @@ class FactureUpdateRequest(BaseModel): } } + class ArticleCreateRequest(BaseModel): """Schéma pour création d'article""" + reference: str = Field(..., max_length=18, description="Référence article") designation: str = Field(..., max_length=69, description="Désignation") famille: Optional[str] = Field(None, max_length=18, description="Code famille") @@ -797,18 +849,145 @@ class ArticleCreateRequest(BaseModel): class ArticleUpdateRequest(BaseModel): """Schéma pour modification d'article""" + designation: Optional[str] = Field(None, max_length=69) prix_vente: Optional[float] = Field(None, ge=0) prix_achat: Optional[float] = Field(None, ge=0) - stock_reel: Optional[float] = Field(None, ge=0, description="⚠️ Critique pour erreur 2881") + stock_reel: Optional[float] = Field( + None, ge=0, description="⚠️ Critique pour erreur 2881" + ) stock_mini: Optional[float] = Field(None, ge=0) code_ean: Optional[str] = Field(None, max_length=13) description: Optional[str] = Field(None) + + +class FamilleCreateRequest(BaseModel): + """Schéma pour création de famille d'articles""" + + code: str = Field(..., max_length=18, description="Code famille (max 18 car)") + intitule: str = Field(..., max_length=69, description="Intitulé (max 69 car)") + type: int = Field(0, ge=0, le=1, description="0=Détail, 1=Total") + compte_achat: Optional[str] = Field( + None, max_length=13, description="Compte général achat (ex: 607000)" + ) + compte_vente: Optional[str] = Field( + None, max_length=13, description="Compte général vente (ex: 707000)" + ) + + class Config: + json_schema_extra = { + "example": { + "code": "PRODLAIT", + "intitule": "Produits laitiers", + "type": 0, + "compte_achat": "607000", + "compte_vente": "707000", + } + } + + +class FamilleResponse(BaseModel): + """Modèle de réponse pour une famille d'articles""" + + code: str = Field(..., description="Code famille") + intitule: str = Field(..., description="Intitulé") + type: int = Field(..., description="Type (0=Détail, 1=Total)") + type_libelle: str = Field(..., description="Libellé du type") + est_total: bool = Field(..., description="True si type Total") + compte_achat: Optional[str] = Field(None, description="Compte général achat") + compte_vente: Optional[str] = Field(None, description="Compte général vente") + unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") + coef: Optional[float] = Field(None, description="Coefficient") + + class Config: + json_schema_extra = { + "example": { + "code": "ZDIVERS", + "intitule": "Frais et accessoires", + "type": 0, + "type_libelle": "Détail", + "est_total": False, + "compte_achat": "607000", + "compte_vente": "707000", + "unite_vente": "U", + "coef": 2.0, + } + } + +class MouvementStockLigneRequest(BaseModel): + """Ligne de mouvement de stock""" + article_ref: str = Field(..., description="Référence de l'article") + quantite: float = Field(..., gt=0, description="Quantité (>0)") + depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')") + prix_unitaire: Optional[float] = Field(None, ge=0, description="Prix unitaire (optionnel)") + commentaire: Optional[str] = Field(None, description="Commentaire ligne") + + +class EntreeStockRequest(BaseModel): + """Création d'un bon d'entrée en stock""" + date_entree: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") + lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + commentaire: Optional[str] = Field(None, description="Commentaire général") + + class Config: + json_schema_extra = { + "example": { + "date_entree": "2025-01-15", + "reference": "REC-2025-001", + "depot_code": "01", + "lignes": [ + { + "article_ref": "ART001", + "quantite": 50, + "depot_code": "01", + "prix_unitaire": 10.50, + "commentaire": "Réception fournisseur" + } + ], + "commentaire": "Réception livraison fournisseur XYZ" + } + } + + +class SortieStockRequest(BaseModel): + """Création d'un bon de sortie de stock""" + date_sortie: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") + lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + commentaire: Optional[str] = Field(None, description="Commentaire général") + + class Config: + json_schema_extra = { + "example": { + "date_sortie": "2025-01-15", + "reference": "SOR-2025-001", + "depot_code": "01", + "lignes": [ + { + "article_ref": "ART001", + "quantite": 10, + "depot_code": "01", + "commentaire": "Utilisation interne" + } + ], + "commentaire": "Consommation atelier" + } + } + + +class MouvementStockResponse(BaseModel): + """Réponse pour un mouvement de stock""" + numero: str = Field(..., description="Numéro du mouvement") + type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") + type_libelle: str = Field(..., description="Libellé du type") + date: str = Field(..., description="Date du mouvement") + reference: Optional[str] = Field(None, description="Référence externe") + nb_lignes: int = Field(..., description="Nombre de lignes") -# ===================================================== -# SERVICES EXTERNES (Universign) -# ===================================================== async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, nom: str ) -> Dict: @@ -923,17 +1102,12 @@ async def universign_statut(transaction_id: str) -> Dict: logger.error(f"Erreur statut Universign: {e}") return {"statut": "ERREUR", "error": str(e)} - -# ===================================================== -# CYCLE DE VIE -# ===================================================== @asynccontextmanager async def lifespan(app: FastAPI): # Init base de données await init_db() logger.info("✅ Base de données initialisée") - # ✅ CORRECTION: Injecter session_factory ET sage_client dans email_queue email_queue.session_factory = async_session_factory email_queue.sage_client = sage_client @@ -973,12 +1147,8 @@ app.add_middleware( app.include_router(auth_router) -# ===================================================== -# ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) -# ===================================================== @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def rechercher_clients(query: Optional[str] = Query(None)): - """🔍 Recherche clients via gateway Windows""" try: clients = sage_client.lister_clients(filtre=query or "") return [ClientDetails(**c) for c in clients] @@ -989,15 +1159,6 @@ async def rechercher_clients(query: Optional[str] = Query(None)): @app.get("/clients/{code}", tags=["Clients"]) async def lire_client_detail(code: str): - """ - 📄 Lecture détaillée d'un client par son code - - Args: - code: Code du client (ex: "CLI000001", "SARL", etc.) - - Returns: - Toutes les informations du client - """ try: client = sage_client.lire_client(code) @@ -1019,23 +1180,6 @@ async def modifier_client( client_update: ClientUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un client existant - - Args: - code: Code du client à modifier - client_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - - Returns: - Client modifié avec ses nouvelles valeurs - - Example: - PUT /clients/SARL - { - "email": "nouveau@email.fr", - "telephone": "0198765432" - } - """ try: # Appel à la gateway Windows resultat = sage_client.modifier_client( @@ -1064,9 +1208,6 @@ async def modifier_client( async def ajouter_client( client: ClientCreateAPIRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'un nouveau client dans Sage 100c - """ try: nouveau_client = sage_client.creer_client(client.dict()) @@ -1087,7 +1228,6 @@ async def ajouter_client( @app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): - """🔍 Recherche articles via gateway Windows""" try: articles = sage_client.lister_articles(filtre=query or "") return [ArticleResponse(**a) for a in articles] @@ -1095,229 +1235,141 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) + @app.post( "/articles", response_model=ArticleResponse, status_code=status.HTTP_201_CREATED, - tags=["Articles"] + tags=["Articles"], ) async def creer_article(article: ArticleCreateRequest): - """ - ➕ Création d'un nouvel article dans Sage - - **Usage typique:** Créer un article avec stock pour éviter l'erreur 2881 - - **Champs obligatoires:** - - `reference` (max 18 caractères) : Référence unique de l'article - - `designation` (max 69 caractères) : Désignation de l'article - - **Champs optionnels mais recommandés:** - - `stock_reel` : Stock initial (important pour éviter erreurs de transformation) - - `prix_vente` : Prix de vente HT - - `unite_vente` : Unité de vente (défaut: "UN") - - **Erreurs possibles:** - - 400: Article existe déjà ou données invalides - - 500: Erreur Sage (problème de connexion, champs mal formatés, etc.) - - **Exemple:** - ```json - { - "reference": "ART001", - "designation": "Article de test", - "prix_vente": 10.50, - "stock_reel": 100.0, - "stock_mini": 10.0, - "unite_vente": "UN", - "tva_code": "C20" - } - ``` - """ try: # Validation des données if not article.reference or not article.designation: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Les champs 'reference' et 'designation' sont obligatoires" + detail="Les champs 'reference' et 'designation' sont obligatoires", ) - - # ⚠️ CORRECTION: Ne pas utiliser exclude_none=True car on veut garder - # les valeurs par défaut (comme unite_vente="UN") + article_data = article.dict(exclude_unset=True) - + logger.info(f"📝 Création article: {article.reference} - {article.designation}") - + # Appel à la gateway Windows resultat = sage_client.creer_article(article_data) - - logger.info(f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})") - + + logger.info( + f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" + ) + return ArticleResponse(**resultat) - + except ValueError as e: # Erreur métier (ex: article existe déjà) logger.warning(f"⚠️ Erreur métier création article: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except HTTPException: raise - + except Exception as e: # Erreur technique Sage logger.error(f"❌ Erreur technique création article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la création de l'article: {str(e)}" + detail=f"Erreur lors de la création de l'article: {str(e)}", ) @app.put("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), - article: ArticleUpdateRequest = Body(...) + article: ArticleUpdateRequest = Body(...), ): - """ - ✏️ Modification complète d'un article existant - - **Usage critique:** Augmenter le stock pour résoudre l'erreur 2881 - - **Erreur 2881 - "L'état du stock ne permet pas de créer la ligne"** - - Cette erreur survient lors de la transformation de documents (devis → commande → facture) - lorsque le stock de l'article est insuffisant. - - **Solution:** Augmenter le `stock_reel` de l'article - - **Exemple - Résoudre l'erreur 2881:** - ```json - { - "stock_reel": 100.0 - } - ``` - - **Autres modifications possibles:** - - Prix de vente/achat - - Stock minimum - - Code EAN - - Description - - **Erreurs possibles:** - - 404: Article introuvable - - 400: Aucun champ à modifier ou données invalides - - 500: Erreur Sage - - **Note:** Seuls les champs fournis seront modifiés, les autres restent inchangés - """ try: - # ✅ CORRECTION: Utiliser exclude_unset=True au lieu de exclude_none=True - # Cela permet de distinguer entre: - # - Champ non fourni (exclu) - # - Champ fourni avec valeur None (inclus pour reset) article_data = article.dict(exclude_unset=True) - + if not article_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour." + detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour.", ) - + logger.info(f"📝 Modification article {reference}: {list(article_data.keys())}") - + # Appel à la gateway Windows resultat = sage_client.modifier_article(reference, article_data) - + # Log spécial pour modification de stock (important pour erreur 2881) if "stock_reel" in article_data: logger.info( f"📦 Stock {reference} modifié: {article_data['stock_reel']} " f"(peut résoudre erreur 2881)" ) - + logger.info(f"✅ Article {reference} modifié ({len(article_data)} champs)") - + return ArticleResponse(**resultat) - + except ValueError as e: # Erreur métier (ex: article introuvable) logger.warning(f"⚠️ Erreur métier modification article: {e}") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except HTTPException: raise - + except Exception as e: # Erreur technique Sage logger.error(f"❌ Erreur technique modification article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la modification de l'article: {str(e)}" + detail=f"Erreur lors de la modification de l'article: {str(e)}", ) @app.get("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) -async def lire_article(reference: str = Path(..., description="Référence de l'article")): - """ - 📄 Lecture d'un article spécifique par référence - - **Retourne:** - - Toutes les informations de l'article - - Stock actuel (réel, réservé, disponible) - - Prix de vente et d'achat - - Famille, fournisseur principal - - Caractéristiques physiques (poids, volume) - - **Source:** Cache mémoire (instantané) - """ +async def lire_article( + reference: str = Path(..., description="Référence de l'article") +): try: article = sage_client.lire_article(reference) - + if not article: logger.warning(f"⚠️ Article {reference} introuvable") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Article {reference} introuvable" + detail=f"Article {reference} introuvable", ) - + logger.info(f"✅ Article {reference} lu: {article.get('designation', '')}") - + return ArticleResponse(**article) - + except HTTPException: raise except Exception as e: logger.error(f"❌ Erreur lecture article {reference}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la lecture de l'article: {str(e)}" + detail=f"Erreur lors de la lecture de l'article: {str(e)}", ) + @app.get("/articles/all") def lister_articles(filtre: str = ""): - """ - 📋 Liste tous les articles avec filtre optionnel - """ try: articles = sage_client.lister_articles(filtre) - - return { - "articles": articles, - "total": len(articles) - } - + + return {"articles": articles, "total": len(articles)} + except Exception as e: logger.error(f"Erreur liste articles: {e}") raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) - - + + @app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"]) async def creer_devis(devis: DevisRequest): - """📝 Création de devis via gateway Windows""" try: # Préparer les données pour la gateway devis_data = { @@ -1359,26 +1411,6 @@ async def modifier_devis( devis_update: DevisUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un devis existant - - **Champs modifiables:** - - `date_devis`: Nouvelle date du devis - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut (0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé) - - **Note importante:** - - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Pour ajouter/supprimer une ligne, il faut envoyer TOUTES les lignes - - Un devis transformé (statut=5) ne peut plus être modifié - - Args: - id: Numéro du devis à modifier - devis_update: Champs à mettre à jour - - Returns: - Devis modifié avec ses nouvelles valeurs - """ try: # Vérifier que le devis existe devis_existant = sage_client.lire_devis(id) @@ -1433,27 +1465,6 @@ async def modifier_devis( async def creer_commande( commande: CommandeCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'une nouvelle commande (Bon de commande) - - **Workflow typique:** - 1. Création d'un devis → transformation en commande (automatique) - 2. OU création directe d'une commande (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_commande`: Date de la commande (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de commande client) - - Args: - commande: Données de la commande à créer - - Returns: - Commande créée avec son numéro et ses totaux - """ try: # Vérifier que le client existe client = sage_client.lire_client(commande.client_id) @@ -1510,29 +1521,6 @@ async def modifier_commande( commande_update: CommandeUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'une commande existante - - **Champs modifiables:** - - `date_commande`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions:** - - Une commande transformée (statut=5) ne peut plus être modifiée - - Une commande annulée (statut=6) ne peut plus être modifiée - - **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Args: - id: Numéro de la commande à modifier - commande_update: Champs à mettre à jour - - Returns: - Commande modifiée avec ses nouvelles valeurs - """ try: # Vérifier que la commande existe commande_existante = sage_client.lire_document( @@ -1605,16 +1593,6 @@ async def lister_devis( True, description="Inclure les lignes de chaque devis" ), ): - """ - 📋 Liste tous les devis via gateway Windows - - Args: - limit: Nombre maximum de devis à retourner - statut: Filtre par statut (0=Devis, 2=Accepté, 5=Transformé, etc.) - inclure_lignes: Si True, charge les lignes de chaque devis (par défaut: True) - - ✅ AMÉLIORATION: Les lignes sont maintenant incluses par défaut - """ try: devis_list = sage_client.lister_devis( limit=limit, statut=statut, inclure_lignes=inclure_lignes @@ -1628,18 +1606,6 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): - """ - 📄 Lecture d'un devis via gateway Windows - - Returns: - Devis complet avec: - - Toutes les informations standards - - lignes: Lignes du devis - - a_deja_ete_transforme: ✅ Booléen indiquant si le devis a été transformé - - documents_cibles: ✅ Liste des documents créés depuis ce devis - - ✅ ENRICHI: Inclut maintenant l'information de transformation - """ try: devis = sage_client.lire_devis(id) @@ -1665,7 +1631,6 @@ async def lire_devis(id: str): @app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): - """📄 Téléchargement PDF (généré via email_queue)""" try: # Générer PDF en appelant la méthode de email_queue # qui elle-même appellera sage_client pour récupérer les données @@ -1689,31 +1654,6 @@ async def telecharger_document_pdf( ), numero: str = Path(..., description="Numéro du document"), ): - """ - 📄 Téléchargement PDF d'un document (route généralisée) - - **Types de documents supportés:** - - `0`: Devis - - `10`: Bon de commande - - `30`: Bon de livraison - - `60`: Facture - - `50`: Bon d'avoir - - **Exemple d'utilisation:** - - `GET /documents/0/DE00001/pdf` → PDF du devis DE00001 - - `GET /documents/60/FA00001/pdf` → PDF de la facture FA00001 - - **Retour:** - - Fichier PDF prêt à télécharger - - Nom de fichier formaté selon le type de document - - Args: - type_doc: Type de document Sage (0-60) - numero: Numéro du document - - Returns: - StreamingResponse avec le PDF - """ try: # Mapping des types vers les libellés types_labels = { @@ -1771,7 +1711,6 @@ async def telecharger_document_pdf( async def envoyer_devis_email( id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) ): - """📧 Envoi devis par email""" try: # Vérifier que le devis existe devis = sage_client.lire_devis(id) @@ -1828,26 +1767,6 @@ async def changer_statut_devis( ..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé" ), ): - """ - 📊 Changement de statut d'un devis - - **Statuts possibles:** - - 0: Brouillon - - 2: Accepté/Validé - - 5: Transformé (automatique lors d'une transformation) - - 6: Annulé - - **Restrictions:** - - Un devis transformé (5) ne peut plus changer de statut - - Un devis annulé (6) ne peut plus changer de statut - - Args: - id: Numéro du devis - nouveau_statut: Nouveau statut (0-6) - - Returns: - Confirmation du changement avec ancien et nouveau statut - """ try: # Vérifier que le devis existe devis_existant = sage_client.lire_devis(id) @@ -1886,15 +1805,8 @@ async def changer_statut_devis( logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) - -# ===================================================== -# ENDPOINTS - Commandes (WORKFLOW SANS RESSAISIE) -# ===================================================== - - @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): - """📄 Lecture d'une commande avec ses lignes""" try: commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande: @@ -1911,12 +1823,6 @@ async def lire_commande(id: str): async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """ - 📋 Liste toutes les commandes via gateway Windows - - ✅ CORRECTION : Utilise sage_client.lister_commandes() qui existe déjà - Le filtrage sur type 10 est fait côté Windows dans main.py - """ try: commandes = sage_client.lister_commandes(limit=limit, statut=statut) return commandes @@ -1928,11 +1834,6 @@ async def lister_commandes( @app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Devis → Commande - ✅ CORRECTION : Utilise les VRAIS types Sage (0 → 10) - ✅ Met à jour le statut du devis source à 5 (Transformé) - """ try: # Étape 1: Transformation resultat = sage_client.transformer_document( @@ -1985,10 +1886,6 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi @app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Commande → Facture - ✅ Utilise les VRAIS types Sage (10 → 60) - """ try: resultat = sage_client.transformer_document( numero_source=id, @@ -2033,7 +1930,6 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses async def envoyer_signature( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): - """✍️ Envoi document pour signature Universign""" try: # Générer PDF pdf_bytes = email_queue._generate_pdf(demande.doc_id, demande.type_doc) @@ -2084,7 +1980,6 @@ async def envoyer_signature( @app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): - """🔍 Récupération du statut de signature en temps réel""" # Chercher dans la DB locale try: async with async_session_factory() as session: @@ -2116,7 +2011,6 @@ async def lister_signatures( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), ): - """📋 Liste toutes les demandes de signature""" query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc()) if statut: @@ -2152,7 +2046,6 @@ async def lister_signatures( async def statut_signature_detail( transaction_id: str, session: AsyncSession = Depends(get_session) ): - """🔍 Récupération du statut détaillé d'une signature""" query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id) result = await session.execute(query) signature_log = result.scalar_one_or_none() @@ -2204,7 +2097,6 @@ async def statut_signature_detail( @app.post("/signatures/refresh-all", tags=["Signatures"]) async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): - """🔄 Rafraîchit TOUS les statuts des signatures en attente""" query = select(SignatureLog).where( SignatureLog.statut.in_( [StatutSignatureEnum.EN_ATTENTE, StatutSignatureEnum.ENVOYE] @@ -2255,7 +2147,6 @@ async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_sess async def envoyer_devis_signature( id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session) ): - """✏️ Envoi d'un devis pour signature électronique""" try: # Vérifier devis via gateway Windows devis = sage_client.lire_devis(id) @@ -2307,11 +2198,6 @@ async def envoyer_devis_signature( raise HTTPException(500, str(e)) -# ============================================ -# US-A4 - ENVOI EMAILS EN LOT -# ============================================ - - class EmailBatchRequest(BaseModel): destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100) sujet: str = Field(..., min_length=1, max_length=500) @@ -2324,7 +2210,6 @@ class EmailBatchRequest(BaseModel): async def envoyer_emails_lot( batch: EmailBatchRequest, session: AsyncSession = Depends(get_session) ): - """📧 US-A4: Envoi groupé via email_queue""" resultats = [] for destinataire in batch.destinataires: @@ -2368,12 +2253,6 @@ async def envoyer_emails_lot( "details": resultats, } - -# ===================================================== -# ENDPOINTS - US-A5 -# ===================================================== - - @app.post( "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] ) @@ -2381,11 +2260,7 @@ async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), ): - """ - 💰 US-A5: Validation remise via barème client Sage - """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) remise_max = sage_client.lire_remise_max_client(client_id) autorisee = remise_pourcentage <= remise_max @@ -2411,14 +2286,10 @@ async def valider_remise( raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A6 (RELANCE DEVIS) -# ===================================================== @app.post("/devis/{id}/relancer-signature", tags=["Devis"]) async def relancer_devis_signature( id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): - """📧 Relance devis via Universign""" try: # Lire devis via gateway devis = sage_client.lire_devis(id) @@ -2487,7 +2358,6 @@ class ContactClientResponse(BaseModel): @app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis(id: str): - """👤 US-A6: Récupération du contact client associé au devis""" try: # Lire devis via gateway Windows devis = sage_client.lire_devis(id) @@ -2520,12 +2390,6 @@ async def recuperer_contact_devis(id: str): async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """ - 📋 Liste toutes les factures via gateway Windows - - ✅ CORRECTION : Utilise sage_client.lister_factures() qui existe déjà - Le filtrage sur type 60 est fait côté Windows dans main.py - """ try: factures = sage_client.lister_factures(limit=limit, statut=statut) return factures @@ -2537,15 +2401,6 @@ async def lister_factures( @app.get("/factures/{numero}", tags=["Factures"]) async def lire_facture_detail(numero: str): - """ - 📄 Lecture détaillée d'une facture avec ses lignes - - Args: - numero: Numéro de la facture (ex: "FA000001") - - Returns: - Facture complète avec lignes, client, totaux, etc. - """ try: facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE) @@ -2570,32 +2425,6 @@ class RelanceFactureRequest(BaseModel): async def creer_facture( facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'une facture - - **Workflow typique:** - 1. Commande → Livraison → Facture (transformations successives) - 2. OU création directe d'une facture (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_facture`: Date de la facture (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de commande client) - - **Notes importantes:** - - Les factures peuvent avoir des champs obligatoires supplémentaires selon la configuration Sage - - Le statut initial est généralement 2 (Accepté/Validé) - - Les factures sont soumises aux règles de numérotation strictes - - Args: - facture: Données de la facture à créer - - Returns: - Facture créée avec son numéro et ses totaux - """ try: # Vérifier que le client existe client = sage_client.lire_client(facture.client_id) @@ -2652,31 +2481,6 @@ async def modifier_facture( facture_update: FactureUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'une facture existante - - **Champs modifiables:** - - `date_facture`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions IMPORTANTES:** - - Une facture transformée (statut=5) ne peut plus être modifiée - - Une facture annulée (statut=6) ne peut plus être modifiée - - ⚠️ **ATTENTION**: Les factures comptabilisées peuvent être verrouillées par Sage - - Certaines factures peuvent être en lecture seule selon les droits utilisateur - - **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Args: - id: Numéro de la facture à modifier - facture_update: Champs à mettre à jour - - Returns: - Facture modifiée avec ses nouvelles valeurs - """ try: # Vérifier que la facture existe facture_existante = sage_client.lire_document(id, settings.SAGE_TYPE_FACTURE) @@ -2739,7 +2543,6 @@ async def modifier_facture( raise HTTPException(500, str(e)) -# Templates email (si pas déjà définis) templates_email_db = { "relance_facture": { "id": "relance_facture", @@ -2769,7 +2572,6 @@ async def relancer_facture( relance: RelanceFactureRequest, session: AsyncSession = Depends(get_session), ): - """💸 US-A7: Relance facture en un clic""" try: # Lire facture via gateway Windows facture = sage_client.lire_document(id, TypeDocument.FACTURE) @@ -2818,7 +2620,6 @@ async def relancer_facture( # Enqueue email_queue.enqueue(email_log.id) - # ✅ MAJ champ libre via gateway Windows sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) await session.commit() @@ -2837,12 +2638,6 @@ async def relancer_facture( logger.error(f"Erreur relance facture: {e}") raise HTTPException(500, str(e)) - -# ============================================ -# US-A9 - JOURNAL DES E-MAILS -# ============================================ - - @app.get("/emails/logs", tags=["Emails"]) async def journal_emails( statut: Optional[StatutEmail] = Query(None), @@ -2850,7 +2645,6 @@ async def journal_emails( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), ): - """📋 US-A9: Journal des e-mails envoyés""" query = select(EmailLog) if statut: @@ -2885,7 +2679,6 @@ async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), ): - """📥 US-A9: Export CSV des logs d'envoi""" query = select(EmailLog) if statut: query = query.where(EmailLog.statut == StatutEmailEnum[statut.value]) @@ -2940,12 +2733,6 @@ async def exporter_logs_csv( }, ) - -# ============================================ -# Devis0 - MODÈLES D'E-MAILS -# ============================================ - - class TemplateEmail(BaseModel): id: Optional[str] = None nom: str @@ -2962,7 +2749,6 @@ class TemplatePreviewRequest(BaseModel): @app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) async def lister_templates(): - """📧 Emails: Liste tous les templates d'emails""" return [TemplateEmail(**template) for template in templates_email_db.values()] @@ -2970,7 +2756,6 @@ async def lister_templates(): "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def lire_template(template_id: str): - """📖 Lecture d'un template par ID""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -2979,7 +2764,6 @@ async def lire_template(template_id: str): @app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) async def creer_template(template: TemplateEmail): - """➕ Création d'un nouveau template""" template_id = str(uuid.uuid4()) templates_email_db[template_id] = { @@ -2999,7 +2783,6 @@ async def creer_template(template: TemplateEmail): "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def modifier_template(template_id: str, template: TemplateEmail): - """✏️ Modification d'un template existant""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -3022,7 +2805,6 @@ async def modifier_template(template_id: str, template: TemplateEmail): @app.delete("/templates/emails/{template_id}", tags=["Emails"]) async def supprimer_template(template_id: str): - """🗑️ Suppression d'un template""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -3038,7 +2820,6 @@ async def supprimer_template(template_id: str): @app.post("/templates/emails/preview", tags=["Emails"]) async def previsualiser_email(preview: TemplatePreviewRequest): - """👁️ US-A10: Prévisualisation email avec fusion variables""" if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") @@ -3080,7 +2861,6 @@ async def previsualiser_email(preview: TemplatePreviewRequest): # ===================================================== @app.get("/health", tags=["System"]) async def health_check(): - """🏥 Health check""" gateway_health = sage_client.health() return { @@ -3097,7 +2877,6 @@ async def health_check(): @app.get("/", tags=["System"]) async def root(): - """🏠 Page d'accueil""" return { "api": "Sage 100c Dataven - VPS Linux", "version": "2.0.0", @@ -3113,11 +2892,7 @@ async def root(): @app.get("/admin/cache/info", tags=["Admin"]) async def info_cache(): - """ - 📊 Informations sur l'état du cache Windows - """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) cache_info = sage_client.get_cache_info() return cache_info @@ -3126,34 +2901,8 @@ async def info_cache(): raise HTTPException(500, str(e)) -# 🔧 CORRECTION ADMIN: Cache refresh (doit appeler Windows) -@app.post("/admin/cache/refresh", tags=["Admin"]) -async def forcer_actualisation(): - """ - 🔄 Force l'actualisation du cache Windows - """ - try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) - resultat = sage_client.refresh_cache() - cache_info = sage_client.get_cache_info() - - return { - "success": True, - "message": "Cache actualisé sur Windows Server", - "info": cache_info, - } - - except Exception as e: - logger.error(f"Erreur refresh cache: {e}") - raise HTTPException(500, str(e)) - - -# ✅ RESTE INCHANGÉ: admin/queue/status (local au VPS) @app.get("/admin/queue/status", tags=["Admin"]) async def statut_queue(): - """ - 📊 Statut de la queue d'emails (local VPS) - """ return { "queue_size": email_queue.queue.qsize(), "workers": len(email_queue.workers), @@ -3166,7 +2915,6 @@ async def statut_queue(): # ===================================================== @app.get("/prospects", tags=["Prospects"]) async def rechercher_prospects(query: Optional[str] = Query(None)): - """🔍 Recherche prospects via gateway Windows""" try: prospects = sage_client.lister_prospects(filtre=query or "") return prospects @@ -3177,7 +2925,6 @@ async def rechercher_prospects(query: Optional[str] = Query(None)): @app.get("/prospects/{code}", tags=["Prospects"]) async def lire_prospect(code: str): - """📄 Lecture d'un prospect par code""" try: prospect = sage_client.lire_prospect(code) if not prospect: @@ -3195,9 +2942,6 @@ async def lire_prospect(code: str): # ===================================================== @app.get("/fournisseurs", tags=["Fournisseurs"]) async def rechercher_fournisseurs(query: Optional[str] = Query(None)): - """ - 🔍 Recherche fournisseurs via gateway Windows - """ try: fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") @@ -3218,26 +2962,6 @@ async def ajouter_fournisseur( fournisseur: FournisseurCreateAPIRequest, session: AsyncSession = Depends(get_session), ): - """ - ➕ Création d'un nouveau fournisseur dans Sage 100c - - **Champs obligatoires:** - - `intitule`: Raison sociale (max 69 caractères) - - **Champs optionnels:** - - `compte_collectif`: Compte comptable (défaut: 401000) - - `num`: Code fournisseur personnalisé (auto-généré si vide) - - `adresse`, `code_postal`, `ville`, `pays` - - `email`, `telephone` - - `siret`, `tva_intra` - - **Retour:** - - Fournisseur créé avec son numéro définitif - - **Erreurs possibles:** - - 400: Fournisseur existe déjà (doublon) - - 500: Erreur technique Sage - """ try: # Appel à la gateway Windows via sage_client nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) @@ -3267,23 +2991,6 @@ async def modifier_fournisseur( fournisseur_update: FournisseurUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un fournisseur existant - - Args: - code: Code du fournisseur à modifier - fournisseur_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - - Returns: - Fournisseur modifié avec ses nouvelles valeurs - - Example: - PUT /fournisseurs/DUPONT - { - "email": "nouveau@email.fr", - "telephone": "0198765432" - } - """ try: # Appel à la gateway Windows resultat = sage_client.modifier_fournisseur( @@ -3310,7 +3017,6 @@ async def modifier_fournisseur( @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): - """📄 Lecture d'un fournisseur par code""" try: fournisseur = sage_client.lire_fournisseur(code) if not fournisseur: @@ -3330,7 +3036,6 @@ async def lire_fournisseur(code: str): async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """📋 Liste tous les avoirs via gateway Windows""" try: avoirs = sage_client.lister_avoirs(limit=limit, statut=statut) return avoirs @@ -3341,7 +3046,6 @@ async def lister_avoirs( @app.get("/avoirs/{numero}", tags=["Avoirs"]) async def lire_avoir(numero: str): - """📄 Lecture d'un avoir avec ses lignes""" try: avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR) if not avoir: @@ -3358,29 +3062,6 @@ async def lire_avoir(numero: str): async def creer_avoir( avoir: AvoirCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'un avoir (Bon d'avoir) - - **Workflow typique:** - 1. Retour marchandise → création d'un avoir - 2. Geste commercial → création directe d'un avoir (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_avoir`: Date de l'avoir (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de retour) - - **Note:** Les montants des avoirs sont généralement négatifs (crédits) - - Args: - avoir: Données de l'avoir à créer - - Returns: - Avoir créé avec son numéro et ses totaux - """ try: # Vérifier que le client existe client = sage_client.lire_client(avoir.client_id) @@ -3435,29 +3116,6 @@ async def modifier_avoir( avoir_update: AvoirUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un avoir existant - - **Champs modifiables:** - - `date_avoir`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions:** - - Un avoir transformé (statut=5) ne peut plus être modifié - - Un avoir annulé (statut=6) ne peut plus être modifié - - **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Args: - id: Numéro de l'avoir à modifier - avoir_update: Champs à mettre à jour - - Returns: - Avoir modifié avec ses nouvelles valeurs - """ try: # Vérifier que l'avoir existe avoir_existant = sage_client.lire_avoir(id) @@ -3526,7 +3184,6 @@ async def modifier_avoir( async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """📋 Liste tous les bons de livraison via gateway Windows""" try: livraisons = sage_client.lister_livraisons(limit=limit, statut=statut) return livraisons @@ -3537,7 +3194,6 @@ async def lister_livraisons( @app.get("/livraisons/{numero}", tags=["Livraisons"]) async def lire_livraison(numero: str): - """📄 Lecture d'une livraison avec ses lignes""" try: livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) if not livraison: @@ -3554,21 +3210,6 @@ async def lire_livraison(numero: str): async def creer_livraison( livraison: LivraisonCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'une nouvelle livraison (Bon de livraison) - - **Workflow typique:** - 1. Création d'une commande → transformation en livraison (automatique) - 2. OU création directe d'une livraison (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_livraison`: Date de la livraison (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de commande client) - """ try: # Vérifier que le client existe client = sage_client.lire_client(livraison.client_id) @@ -3627,19 +3268,6 @@ async def modifier_livraison( livraison_update: LivraisonUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'une livraison existante - - **Champs modifiables:** - - `date_livraison`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions:** - - Une livraison transformée (statut=5) ne peut plus être modifiée - - Une livraison annulée (statut=6) ne peut plus être modifiée - """ try: # Vérifier que la livraison existe livraison_existante = sage_client.lire_livraison(id) @@ -3704,10 +3332,6 @@ async def modifier_livraison( @app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"]) async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Livraison → Facture - ✅ Utilise les VRAIS types Sage (30 → 60) - """ try: resultat = sage_client.transformer_document( numero_source=id, @@ -3749,26 +3373,6 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session) ): - """ - 🔧 Transformation Devis → Facture (DIRECT, sans commande) - - ✅ Utilise les VRAIS types Sage (0 → 60) - ✅ Met à jour le statut du devis source à 5 (Transformé) - - **Workflow raccourci** : Permet de facturer directement depuis un devis - sans passer par la création d'une commande. - - **Cas d'usage** : - - Prestations de services facturées directement - - Petites commandes sans besoin de suivi intermédiaire - - Ventes au comptoir - - Args: - id: Numéro du devis source - - Returns: - Informations de la facture créée - """ try: # Étape 1: Vérifier que le devis n'a pas déjà été transformé devis_existant = sage_client.lire_devis(id) @@ -3840,30 +3444,6 @@ async def devis_vers_facture_direct( async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session) ): - """ - 🔧 Transformation Commande → Bon de livraison - - ✅ Utilise les VRAIS types Sage (10 → 30) - - **Workflow typique** : Après validation d'une commande, génère - le bon de livraison pour préparer l'expédition. - - **Cas d'usage** : - - Préparation d'une expédition - - Génération du bordereau de livraison - - Suivi logistique - - **Workflow complet** : - 1. Devis → Commande (via `/workflow/devis/{id}/to-commande`) - 2. **Commande → Livraison** (cette route) - 3. Livraison → Facture (via `/workflow/livraison/{id}/to-facture`) - - Args: - id: Numéro de la commande source - - Returns: - Informations du bon de livraison créé - """ try: # Étape 1: Vérifier que la commande existe commande_existante = sage_client.lire_document( @@ -3930,6 +3510,231 @@ async def commande_vers_livraison( raise HTTPException(500, str(e)) +@app.get( + "/familles", + response_model=List[FamilleResponse], + tags=["Familles"], + summary="Liste toutes les familles d'articles", +) +async def lister_familles( + filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé") +): + try: + familles = sage_client.lister_familles(filtre or "") + + logger.info(f"✅ {len(familles)} famille(s) retournée(s)") + + return [FamilleResponse(**f) for f in familles] + + except Exception as e: + logger.error(f"❌ Erreur liste familles: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la récupération des familles: {str(e)}", + ) + + +@app.get( + "/familles/{code}", + response_model=FamilleResponse, + tags=["Familles"], + summary="Lecture d'une famille par son code", +) +async def lire_famille( + code: str = Path(..., description="Code de la famille (ex: ZDIVERS)") +): + try: + famille = sage_client.lire_famille(code) + + if not famille: + logger.warning(f"⚠️ Famille {code} introuvable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Famille {code} introuvable", + ) + + logger.info(f"✅ Famille {code} lue: {famille.get('intitule', '')}") + + return FamilleResponse(**famille) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur lecture famille {code}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture de la famille: {str(e)}", + ) + + +@app.post( + "/familles", + response_model=FamilleResponse, + status_code=status.HTTP_201_CREATED, + tags=["Familles"], + summary="Création d'une famille d'articles", +) +async def creer_famille(famille: FamilleCreateRequest): + try: + # Validation des données + if not famille.code or not famille.intitule: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Les champs 'code' et 'intitule' sont obligatoires", + ) + + famille_data = famille.dict() + + logger.info(f"📦 Création famille: {famille.code} - {famille.intitule}") + + # Appel à la gateway Windows + resultat = sage_client.creer_famille(famille_data) + + logger.info(f"✅ Famille créée: {resultat.get('code')}") + + return FamilleResponse(**resultat) + + except ValueError as e: + # Erreur métier (ex: famille existe déjà) + logger.warning(f"⚠️ Erreur métier création famille: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + # Erreur technique Sage + logger.error(f"❌ Erreur technique création famille: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de la famille: {str(e)}", + ) + + +@app.post( + "/stock/entree", + response_model=MouvementStockResponse, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock" +) +async def creer_entree_stock(entree: EntreeStockRequest): + try: + # Préparer les données + entree_data = entree.dict() + + logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") + + # Appel à la gateway Windows + resultat = sage_client.creer_entree_stock(entree_data) + + logger.info(f"✅ Entrée stock créée: {resultat.get('numero')}") + + return MouvementStockResponse(**resultat) + + except ValueError as e: + logger.warning(f"⚠️ Erreur métier entrée stock: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + except Exception as e: + logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de l'entrée: {str(e)}" + ) + + +@app.post( + "/stock/sortie", + response_model=MouvementStockResponse, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="SORTIE DE STOCK : Retire des articles du stock" +) +async def creer_sortie_stock(sortie: SortieStockRequest): + try: + # Préparer les données + sortie_data = sortie.dict() + + logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") + + # Appel à la gateway Windows + resultat = sage_client.creer_sortie_stock(sortie_data) + + logger.info(f"✅ Sortie stock créée: {resultat.get('numero')}") + + return MouvementStockResponse(**resultat) + + except ValueError as e: + logger.warning(f"⚠️ Erreur métier sortie stock: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + except Exception as e: + logger.error(f"❌ Erreur technique sortie stock: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de la sortie: {str(e)}" + ) + + +@app.get( + "/stock/mouvement/{numero}", + response_model=MouvementStockResponse, + tags=["Stock"], + summary="Lecture d'un mouvement de stock" +) +async def lire_mouvement_stock( + numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)") +): + try: + mouvement = sage_client.lire_mouvement_stock(numero) + + if not mouvement: + logger.warning(f"⚠️ Mouvement {numero} introuvable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mouvement de stock {numero} introuvable" + ) + + logger.info(f"✅ Mouvement {numero} lu") + + return MouvementStockResponse(**mouvement) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur lecture mouvement {numero}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture du mouvement: {str(e)}" + ) + + +@app.get( + "/familles/stats/global", + tags=["Familles"], + summary="Statistiques sur les familles", +) +async def statistiques_familles(): + try: + stats = sage_client.get_stats_familles() + + return {"success": True, "data": stats} + + except Exception as e: + logger.error(f"❌ Erreur stats familles: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la récupération des statistiques: {str(e)}", + ) + + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( session: AsyncSession = Depends(get_session), @@ -3937,20 +3742,6 @@ async def lister_utilisateurs_debug( role: Optional[str] = Query(None), verified_only: bool = Query(False), ): - """ - 🔓 **ROUTE DEBUG** - Liste tous les utilisateurs inscrits - - ⚠️ **ATTENTION**: Cette route n'est PAS protégée par authentification. - À utiliser uniquement en développement ou à sécuriser en production. - - Args: - limit: Nombre maximum d'utilisateurs à retourner - role: Filtrer par rôle (user, admin, commercial) - verified_only: Afficher uniquement les utilisateurs vérifiés - - Returns: - Liste des utilisateurs avec leurs informations (mot de passe masqué) - """ from database import User from sqlalchemy import select @@ -4003,11 +3794,6 @@ async def lister_utilisateurs_debug( @app.get("/debug/users/stats", tags=["Debug"]) async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)): - """ - 📊 **ROUTE DEBUG** - Statistiques sur les utilisateurs - - ⚠️ Non protégée - à sécuriser en production - """ from database import User from sqlalchemy import select, func diff --git a/sage_client.py b/sage_client.py index 9c1b2c4..6b9325c 100644 --- a/sage_client.py +++ b/sage_client.py @@ -63,9 +63,6 @@ class SageGatewayClient: raise time.sleep(2**attempt) - # ===================================================== - # CLIENTS - # ===================================================== def lister_clients(self, filtre: str = "") -> List[Dict]: """Liste tous les clients avec filtre optionnel""" return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) @@ -74,9 +71,6 @@ class SageGatewayClient: """Lecture d'un client par code""" return self._post("/sage/clients/get", {"code": code}).get("data") - # ===================================================== - # ARTICLES - # ===================================================== def lister_articles(self, filtre: str = "") -> List[Dict]: """Liste tous les articles avec filtre optionnel""" return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) @@ -85,9 +79,6 @@ class SageGatewayClient: """Lecture d'un article par référence""" return self._post("/sage/articles/get", {"code": ref}).get("data") - # ===================================================== - # DEVIS (US-A1) - # ===================================================== def creer_devis(self, devis_data: Dict) -> Dict: """Création d'un devis""" return self._post("/sage/devis/create", devis_data).get("data", {}) @@ -102,18 +93,12 @@ class SageGatewayClient: statut: Optional[int] = None, inclure_lignes: bool = True, ) -> List[Dict]: - """ - ✅ Liste tous les devis avec filtres - """ payload = {"limit": limit, "inclure_lignes": inclure_lignes} if statut is not None: payload["statut"] = statut return self._post("/sage/devis/list", payload).get("data", []) def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: - """ - ✅ CORRECTION: Utilise query params au lieu du body - """ try: r = requests.post( f"{self.url}/sage/devis/statut", @@ -130,9 +115,6 @@ class SageGatewayClient: logger.error(f"❌ Erreur changement statut: {e}") raise - # ===================================================== - # DOCUMENTS GÉNÉRIQUES - # ===================================================== def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: """Lecture d'un document générique""" return self._post( @@ -142,9 +124,6 @@ class SageGatewayClient: def transformer_document( self, numero_source: str, type_source: int, type_cible: int ) -> Dict: - """ - ✅ CORRECTION: Utilise query params pour la transformation - """ try: r = requests.post( f"{self.url}/sage/documents/transform", @@ -177,15 +156,9 @@ class SageGatewayClient: ) return resp.get("success", False) - # ===================================================== - # COMMANDES (US-A2) - # ===================================================== def lister_commandes( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """ - Utilise l'endpoint /sage/commandes/list qui filtre déjà sur type 10 - """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut @@ -194,10 +167,6 @@ class SageGatewayClient: def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """ - ✅ Liste toutes les factures - Utilise l'endpoint /sage/factures/list qui filtre déjà sur type 60 - """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut @@ -210,24 +179,15 @@ class SageGatewayClient: ) return resp.get("success", False) - # ===================================================== - # CONTACTS (US-A6) - # ===================================================== def lire_contact_client(self, code_client: str) -> Optional[Dict]: """Lecture du contact principal d'un client""" return self._post("/sage/contact/read", {"code": code_client}).get("data") - # ===================================================== - # REMISES (US-A5) - # ===================================================== def lire_remise_max_client(self, code_client: str) -> float: """Récupère la remise max autorisée pour un client""" result = self._post("/sage/client/remise-max", {"code": code_client}) return result.get("data", {}).get("remise_max", 10.0) - # ===================================================== - # GÉNÉRATION PDF (pour email_queue) - # ===================================================== def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: """Génère le PDF d'un document via la gateway Windows""" try: @@ -253,9 +213,6 @@ class SageGatewayClient: logger.error(f"Erreur génération PDF: {e}") raise - # ===================================================== - # PROSPECTS - # ===================================================== def lister_prospects(self, filtre: str = "") -> List[Dict]: """Liste tous les prospects avec filtre optionnel""" return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", []) @@ -264,9 +221,6 @@ class SageGatewayClient: """Lecture d'un prospect par code""" return self._post("/sage/prospects/get", {"code": code}).get("data") - # ===================================================== - # FOURNISSEURS - # ===================================================== def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: """Liste tous les fournisseurs avec filtre optionnel""" return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", []) @@ -276,37 +230,14 @@ class SageGatewayClient: return self._post("/sage/fournisseurs/get", {"code": code}).get("data") def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: - """ - Envoie la requête de création de fournisseur à la gateway Windows. - - Args: - fournisseur_data: Dict contenant intitule, compte_collectif, etc. - - Returns: - Fournisseur créé avec son numéro définitif - """ return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: - """ - ✏️ Modification d'un fournisseur existant - - Args: - code: Code du fournisseur à modifier - fournisseur_data: Dictionnaire contenant les champs à modifier - (seuls les champs présents seront mis à jour) - - Returns: - Fournisseur modifié - """ return self._post( "/sage/fournisseurs/update", {"code": code, "fournisseur_data": fournisseur_data}, ).get("data", {}) - # ===================================================== - # AVOIRS - # ===================================================== def lister_avoirs( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: @@ -320,9 +251,6 @@ class SageGatewayClient: """Lecture d'un avoir avec ses lignes""" return self._post("/sage/avoirs/get", {"code": numero}).get("data") - # ===================================================== - # LIVRAISONS - # ===================================================== def lister_livraisons( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: @@ -359,206 +287,52 @@ class SageGatewayClient: return {"status": "down"} def creer_client(self, client_data: Dict) -> Dict: - """ - Envoie la requête de création de client à la gateway Windows. - :param client_data: Dict contenant intitule, compte_collectif, etc. - """ - # On appelle la route définie dans main.py return self._post("/sage/clients/create", client_data).get("data", {}) def modifier_client(self, code: str, client_data: Dict) -> Dict: - """ - ✏️ Modification d'un client existant - - Args: - code: Code du client à modifier - client_data: Dictionnaire contenant les champs à modifier - (seuls les champs présents seront mis à jour) - - Returns: - Client modifié - """ return self._post( "/sage/clients/update", {"code": code, "client_data": client_data} ).get("data", {}) def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: - """ - ✏️ Modification d'un devis existant - - Args: - numero: Numéro du devis à modifier - devis_data: Dictionnaire contenant les champs à modifier: - - date_devis (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - Returns: - Devis modifié avec totaux recalculés - """ return self._post( "/sage/devis/update", {"numero": numero, "devis_data": devis_data} ).get("data", {}) def creer_commande(self, commande_data: Dict) -> Dict: - """ - ➕ Création d'une nouvelle commande (Bon de commande) - - Args: - commande_data: Dictionnaire contenant: - - client_id (str): Code du client - - date_commande (str, optional): Date au format ISO - - reference (str, optional): Référence externe - - lignes (List[Dict]): Liste des lignes avec: - - article_code (str) - - quantite (float) - - prix_unitaire_ht (float, optional) - - remise_pourcentage (float, optional) - - Returns: - Commande créée avec son numéro et ses totaux - """ return self._post("/sage/commandes/create", commande_data).get("data", {}) def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: - """ - ✏️ Modification d'une commande existante - - Args: - numero: Numéro de la commande à modifier - commande_data: Dictionnaire contenant les champs à modifier: - - date_commande (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - reference (str, optional): Nouvelle référence - - Returns: - Commande modifiée avec totaux recalculés - """ return self._post( "/sage/commandes/update", {"numero": numero, "commande_data": commande_data} ).get("data", {}) def creer_livraison(self, livraison_data: Dict) -> Dict: - """ - ➕ Création d'une nouvelle livraison (Bon de livraison) - """ return self._post("/sage/livraisons/create", livraison_data).get("data", {}) def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: - """ - ✏️ Modification d'une livraison existante - """ return self._post( "/sage/livraisons/update", {"numero": numero, "livraison_data": livraison_data}, ).get("data", {}) def creer_avoir(self, avoir_data: Dict) -> Dict: - """ - ➕ Création d'un avoir (Bon d'avoir) - - Args: - avoir_data: Dictionnaire contenant: - - client_id (str): Code du client - - date_avoir (str, optional): Date au format ISO - - reference (str, optional): Référence externe - - lignes (List[Dict]): Liste des lignes avec: - - article_code (str) - - quantite (float) - - prix_unitaire_ht (float, optional) - - remise_pourcentage (float, optional) - - Returns: - Avoir créé avec son numéro et ses totaux - """ return self._post("/sage/avoirs/create", avoir_data).get("data", {}) def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: - """ - ✏️ Modification d'un avoir existant - - Args: - numero: Numéro de l'avoir à modifier - avoir_data: Dictionnaire contenant les champs à modifier: - - date_avoir (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - reference (str, optional): Nouvelle référence - - Returns: - Avoir modifié avec totaux recalculés - """ return self._post( "/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data} ).get("data", {}) def creer_facture(self, facture_data: Dict) -> Dict: - """ - ➕ Création d'une facture - - Args: - facture_data: Dictionnaire contenant: - - client_id (str): Code du client - - date_facture (str, optional): Date au format ISO - - reference (str, optional): Référence externe - - lignes (List[Dict]): Liste des lignes avec: - - article_code (str) - - quantite (float) - - prix_unitaire_ht (float, optional) - - remise_pourcentage (float, optional) - - Returns: - Facture créée avec son numéro et ses totaux - """ return self._post("/sage/factures/create", facture_data).get("data", {}) def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: - """ - ✏️ Modification d'une facture existante - - Args: - numero: Numéro de la facture à modifier - facture_data: Dictionnaire contenant les champs à modifier: - - date_facture (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - reference (str, optional): Nouvelle référence - - Returns: - Facture modifiée avec totaux recalculés - """ return self._post( "/sage/factures/update", {"numero": numero, "facture_data": facture_data} ).get("data", {}) def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: - """ - 🆕 Génère le PDF d'un document via la gateway Windows (route généralisée) - - **Cette méthode remplace les appels spécifiques par type de document** - - Args: - doc_id: Numéro du document (ex: "DE00001", "FA00001") - type_doc: Type de document Sage: - - 0: Devis - - 10: Bon de commande - - 30: Bon de livraison - - 60: Facture - - 50: Bon d'avoir - - Returns: - bytes: Contenu du PDF (binaire) - - Raises: - ValueError: Si le PDF retourné est vide - RuntimeError: Si erreur de communication avec la gateway - - Example: - >>> pdf_bytes = sage_client.generer_pdf_document("DE00001", 0) - >>> with open("devis.pdf", "wb") as f: - ... f.write(pdf_bytes) - """ try: logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") @@ -610,59 +384,49 @@ class SageGatewayClient: logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) raise - def creer_article(self, article_data: Dict) -> Dict: - """ - ➕ Création d'un article - - Args: - article_data: Dictionnaire contenant: - - reference (str, obligatoire): Référence article - - designation (str, obligatoire): Désignation - - prix_vente (float, optionnel): Prix vente HT - - stock_reel (float, optionnel): Stock initial - - ... (voir ArticleCreateRequest dans main.py) - - Returns: - Article créé - - Example: - >>> article = sage_client.creer_article({ - ... "reference": "ART001", - ... "designation": "Article test", - ... "prix_vente": 10.0, - ... "stock_reel": 100.0 - ... }) - """ return self._post("/sage/articles/create", article_data).get("data", {}) - def modifier_article(self, reference: str, article_data: Dict) -> Dict: - """ - ✏️ Modification d'un article - - **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 - - Args: - reference: Référence de l'article à modifier - article_data: Dictionnaire contenant les champs à modifier: - - stock_reel (float, optionnel): Nouveau stock - - prix_vente (float, optionnel): Nouveau prix - - ... (seuls les champs présents seront mis à jour) - - Returns: - Article modifié - - Example - Résoudre erreur de stock: - >>> # L'erreur 2881 indique un stock insuffisant - >>> sage_client.modifier_article("ART001", { - ... "stock_reel": 100.0 # Augmenter le stock - ... }) - """ return self._post( "/sage/articles/update", - {"reference": reference, "article_data": article_data} + {"reference": reference, "article_data": article_data}, ).get("data", {}) + def lister_familles(self, filtre: str = "") -> List[Dict]: + return self._get("/sage/familles", params={"filtre": filtre}).get("data", []) + + def lire_famille(self, code: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/familles/{code}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture famille {code}: {e}") + return None + + def creer_famille(self, famille_data: Dict) -> Dict: + return self._post("/sage/familles/create", famille_data).get("data", {}) + + def get_stats_familles(self) -> Dict: + return self._get("/sage/familles/stats").get("data", {}) + + + def creer_entree_stock(self, entree_data: Dict) -> Dict: + return self._post("/sage/stock/entree", entree_data).get("data", {}) + + + def creer_sortie_stock(self, sortie_data: Dict) -> Dict: + return self._post("/sage/stock/sortie", sortie_data).get("data", {}) + + + def lire_mouvement_stock(self, numero: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/stock/mouvement/{numero}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture mouvement {numero}: {e}") + return None + + # Instance globale sage_client = SageGatewayClient() From bf4b00ed855b6118d394e26c9c3262a64a53055d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 11:34:45 +0300 Subject: [PATCH 44/62] fix(api): ensure date fields are properly formatted before processing --- api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api.py b/api.py index dd7bd98..8bfd78b 100644 --- a/api.py +++ b/api.py @@ -3622,6 +3622,8 @@ async def creer_entree_stock(entree: EntreeStockRequest): try: # Préparer les données entree_data = entree.dict() + if entree_data.get("date_entree"): + entree_data["date_entree"] = entree_data["date_entree"].isoformat() logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") @@ -3658,6 +3660,8 @@ async def creer_sortie_stock(sortie: SortieStockRequest): try: # Préparer les données sortie_data = sortie.dict() + if sortie_data.get("date_sortie"): + sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") From 737e34067916d69c4e242731f889d9f9e67382a9 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 12:33:05 +0300 Subject: [PATCH 45/62] refactor(api): wrap client and article responses in success object --- api.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 8bfd78b..15df3b4 100644 --- a/api.py +++ b/api.py @@ -1151,7 +1151,9 @@ app.include_router(auth_router) async def rechercher_clients(query: Optional[str] = Query(None)): try: clients = sage_client.lister_clients(filtre=query or "") - return [ClientDetails(**c) for c in clients] + clients_data = [ClientDetails(**c) for c in clients] + return {"success": True, "data": clients_data} + except Exception as e: logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) @@ -1230,7 +1232,8 @@ async def ajouter_client( async def rechercher_articles(query: Optional[str] = Query(None)): try: articles = sage_client.lister_articles(filtre=query or "") - return [ArticleResponse(**a) for a in articles] + articles_data = [ArticleResponse(**a) for a in articles] + return {"success": True, "data": articles_data} except Exception as e: logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) From 421f4d24dc59fb4e6fbdffbf5ea6eb8774e83257 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 12:35:29 +0300 Subject: [PATCH 46/62] refactor(api): simplify client and article search endpoints --- api.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/api.py b/api.py index 15df3b4..8bfd78b 100644 --- a/api.py +++ b/api.py @@ -1151,9 +1151,7 @@ app.include_router(auth_router) async def rechercher_clients(query: Optional[str] = Query(None)): try: clients = sage_client.lister_clients(filtre=query or "") - clients_data = [ClientDetails(**c) for c in clients] - return {"success": True, "data": clients_data} - + return [ClientDetails(**c) for c in clients] except Exception as e: logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) @@ -1232,8 +1230,7 @@ async def ajouter_client( async def rechercher_articles(query: Optional[str] = Query(None)): try: articles = sage_client.lister_articles(filtre=query or "") - articles_data = [ArticleResponse(**a) for a in articles] - return {"success": True, "data": articles_data} + return [ArticleResponse(**a) for a in articles] except Exception as e: logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) From 62e347969c171a58c840dec723cec3cf4ad4cf48 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 14:55:13 +0300 Subject: [PATCH 47/62] feat(stock): enhance stock movement models with lot tracking and min/max stock --- api.py | 154 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 107 insertions(+), 47 deletions(-) diff --git a/api.py b/api.py index 8bfd78b..9cfe688 100644 --- a/api.py +++ b/api.py @@ -914,23 +914,76 @@ class FamilleResponse(BaseModel): } } + class MouvementStockLigneRequest(BaseModel): - """Ligne de mouvement de stock""" article_ref: str = Field(..., description="Référence de l'article") quantite: float = Field(..., gt=0, description="Quantité (>0)") depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')") - prix_unitaire: Optional[float] = Field(None, ge=0, description="Prix unitaire (optionnel)") + prix_unitaire: Optional[float] = Field( + None, ge=0, description="Prix unitaire (optionnel)" + ) commentaire: Optional[str] = Field(None, description="Commentaire ligne") + numero_lot: Optional[str] = Field( + None, description="Numéro de lot (pour FIFO/LIFO)" + ) + stock_mini: Optional[float] = Field( + None, + ge=0, + description="""Stock minimum à définir pour cet article. + Si fourni, met à jour AS_QteMini dans F_ARTSTOCK. + Laisser None pour ne pas modifier.""", + ) + stock_maxi: Optional[float] = Field( + None, + ge=0, + description="""Stock maximum à définir pour cet article. + Doit être > stock_mini si les deux sont fournis.""", + ) + + class Config: + schema_extra = { + "example": { + "article_ref": "ARTS-001", + "quantite": 50.0, + "depot_code": "01", + "prix_unitaire": 100.0, + "commentaire": "Réapprovisionnement", + "numero_lot": "LOT20241217", + "stock_mini": 10.0, + "stock_maxi": 200.0, + } + } + + @validator("stock_maxi") + def validate_stock_maxi(cls, v, values): + """Valide que stock_maxi > stock_mini si les deux sont fournis""" + if ( + v is not None + and "stock_mini" in values + and values["stock_mini"] is not None + ): + if v <= values["stock_mini"]: + raise ValueError( + "stock_maxi doit être strictement supérieur à stock_mini" + ) + return v class EntreeStockRequest(BaseModel): """Création d'un bon d'entrée en stock""" - date_entree: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + + date_entree: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) reference: Optional[str] = Field(None, description="Référence externe") - depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") - lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigneRequest] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) commentaire: Optional[str] = Field(None, description="Commentaire général") - + class Config: json_schema_extra = { "example": { @@ -943,22 +996,29 @@ class EntreeStockRequest(BaseModel): "quantite": 50, "depot_code": "01", "prix_unitaire": 10.50, - "commentaire": "Réception fournisseur" + "commentaire": "Réception fournisseur", } ], - "commentaire": "Réception livraison fournisseur XYZ" + "commentaire": "Réception livraison fournisseur XYZ", } } class SortieStockRequest(BaseModel): """Création d'un bon de sortie de stock""" - date_sortie: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + + date_sortie: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) reference: Optional[str] = Field(None, description="Référence externe") - depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") - lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigneRequest] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) commentaire: Optional[str] = Field(None, description="Commentaire général") - + class Config: json_schema_extra = { "example": { @@ -970,24 +1030,25 @@ class SortieStockRequest(BaseModel): "article_ref": "ART001", "quantite": 10, "depot_code": "01", - "commentaire": "Utilisation interne" + "commentaire": "Utilisation interne", } ], - "commentaire": "Consommation atelier" + "commentaire": "Consommation atelier", } } class MouvementStockResponse(BaseModel): """Réponse pour un mouvement de stock""" + numero: str = Field(..., description="Numéro du mouvement") type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") type_libelle: str = Field(..., description="Libellé du type") date: str = Field(..., description="Date du mouvement") reference: Optional[str] = Field(None, description="Référence externe") nb_lignes: int = Field(..., description="Nombre de lignes") - - + + async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, nom: str ) -> Dict: @@ -1102,6 +1163,7 @@ async def universign_statut(transaction_id: str) -> Dict: logger.error(f"Erreur statut Universign: {e}") return {"statut": "ERREUR", "error": str(e)} + @asynccontextmanager async def lifespan(app: FastAPI): # Init base de données @@ -1805,6 +1867,7 @@ async def changer_statut_devis( logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) + @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): try: @@ -2253,6 +2316,7 @@ async def envoyer_emails_lot( "details": resultats, } + @app.post( "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] ) @@ -2638,6 +2702,7 @@ async def relancer_facture( logger.error(f"Erreur relance facture: {e}") raise HTTPException(500, str(e)) + @app.get("/emails/logs", tags=["Emails"]) async def journal_emails( statut: Optional[StatutEmail] = Query(None), @@ -2733,6 +2798,7 @@ async def exporter_logs_csv( }, ) + class TemplateEmail(BaseModel): id: Optional[str] = None nom: str @@ -3616,7 +3682,7 @@ async def creer_famille(famille: FamilleCreateRequest): response_model=MouvementStockResponse, status_code=status.HTTP_201_CREATED, tags=["Stock"], - summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock" + summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock", ) async def creer_entree_stock(entree: EntreeStockRequest): try: @@ -3624,28 +3690,25 @@ async def creer_entree_stock(entree: EntreeStockRequest): entree_data = entree.dict() if entree_data.get("date_entree"): entree_data["date_entree"] = entree_data["date_entree"].isoformat() - + logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") - + # Appel à la gateway Windows resultat = sage_client.creer_entree_stock(entree_data) - + logger.info(f"✅ Entrée stock créée: {resultat.get('numero')}") - + return MouvementStockResponse(**resultat) - + except ValueError as e: logger.warning(f"⚠️ Erreur métier entrée stock: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la création de l'entrée: {str(e)}" + detail=f"Erreur lors de la création de l'entrée: {str(e)}", ) @@ -3654,7 +3717,7 @@ async def creer_entree_stock(entree: EntreeStockRequest): response_model=MouvementStockResponse, status_code=status.HTTP_201_CREATED, tags=["Stock"], - summary="SORTIE DE STOCK : Retire des articles du stock" + summary="SORTIE DE STOCK : Retire des articles du stock", ) async def creer_sortie_stock(sortie: SortieStockRequest): try: @@ -3662,28 +3725,25 @@ async def creer_sortie_stock(sortie: SortieStockRequest): sortie_data = sortie.dict() if sortie_data.get("date_sortie"): sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() - + logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") - + # Appel à la gateway Windows resultat = sage_client.creer_sortie_stock(sortie_data) - + logger.info(f"✅ Sortie stock créée: {resultat.get('numero')}") - + return MouvementStockResponse(**resultat) - + except ValueError as e: logger.warning(f"⚠️ Erreur métier sortie stock: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: logger.error(f"❌ Erreur technique sortie stock: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la création de la sortie: {str(e)}" + detail=f"Erreur lors de la création de la sortie: {str(e)}", ) @@ -3691,32 +3751,32 @@ async def creer_sortie_stock(sortie: SortieStockRequest): "/stock/mouvement/{numero}", response_model=MouvementStockResponse, tags=["Stock"], - summary="Lecture d'un mouvement de stock" + summary="Lecture d'un mouvement de stock", ) async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)") ): try: mouvement = sage_client.lire_mouvement_stock(numero) - + if not mouvement: logger.warning(f"⚠️ Mouvement {numero} introuvable") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Mouvement de stock {numero} introuvable" + detail=f"Mouvement de stock {numero} introuvable", ) - + logger.info(f"✅ Mouvement {numero} lu") - + return MouvementStockResponse(**mouvement) - + except HTTPException: raise except Exception as e: logger.error(f"❌ Erreur lecture mouvement {numero}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la lecture du mouvement: {str(e)}" + detail=f"Erreur lors de la lecture du mouvement: {str(e)}", ) From 388618603bdd9e0bb5d6066c6aee18626314bfc6 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 16:22:43 +0300 Subject: [PATCH 48/62] feat: add article_ref field to MouvementStockResponse --- api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api.py b/api.py index 9cfe688..7a1086f 100644 --- a/api.py +++ b/api.py @@ -1041,6 +1041,7 @@ class SortieStockRequest(BaseModel): class MouvementStockResponse(BaseModel): """Réponse pour un mouvement de stock""" + article_ref: str = Field(..., description="Numéro d'article") numero: str = Field(..., description="Numéro du mouvement") type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") type_libelle: str = Field(..., description="Libellé du type") From daf96f71ebf394e673a6d87cbb8dbd5cb33c1a01 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 17:25:27 +0300 Subject: [PATCH 49/62] feat: add PDF document generation and model listing functionality --- api.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/api.py b/api.py index 7a1086f..166f5af 100644 --- a/api.py +++ b/api.py @@ -3896,7 +3896,60 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) logger.error(f"❌ Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) +@app.get("/modeles", tags=["PDF"]) +async def get_modeles_disponibles(): + """Liste tous les modèles PDF disponibles""" + try: + modeles = sage_client.lister_modeles_disponibles() + return modeles + except Exception as e: + logger.error(f"Erreur listage modèles: {e}") + raise HTTPException(500, str(e)) + +@app.get("/documents/{numero}/pdf", tags=["Documents"]) +async def get_document_pdf( + numero: str, + type_doc: int = Query(..., description="0=devis, 60=facture, etc."), + modele: str = Query(None, description="Nom du modèle (ex: 'Facture client logo.bgc')"), + download: bool = Query(False, description="Télécharger au lieu d'afficher") +): + """ + 📄 Génère et retourne le PDF d'un document + + Exemples: + - GET /documents/DE00001/pdf?type_doc=0 + - GET /documents/FA00123/pdf?type_doc=60&modele=Facture client logo.bgc + - GET /documents/FA00123/pdf?type_doc=60&download=true + """ + try: + # Récupérer le PDF (en bytes) + pdf_bytes = sage_client.generer_pdf_document( + numero=numero, + type_doc=type_doc, + modele=modele, + base64_encode=False # On veut les bytes bruts + ) + + # Retourner le PDF + from fastapi.responses import Response + + disposition = "attachment" if download else "inline" + filename = f"{numero}.pdf" + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'{disposition}; filename="{filename}"' + } + ) + + except Exception as e: + logger.error(f"Erreur génération PDF: {e}") + raise HTTPException(500, str(e)) + + # ===================================================== # LANCEMENT # ===================================================== diff --git a/sage_client.py b/sage_client.py index 6b9325c..7aa034a 100644 --- a/sage_client.py +++ b/sage_client.py @@ -428,5 +428,60 @@ class SageGatewayClient: return None -# Instance globale + def lister_modeles_disponibles(self) -> Dict: + """Liste les modèles Crystal Reports disponibles""" + try: + r = requests.get( + f"{self.url}/sage/modeles/list", + headers=self.headers, + timeout=30 + ) + r.raise_for_status() + return r.json().get("data", {}) + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur listage modèles: {e}") + raise + + + def generer_pdf_document( + self, + numero: str, + type_doc: int, + modele: str = None, + base64_encode: bool = True + ) -> Union[bytes, str, Dict]: + """ + Génère un PDF d'un document Sage + + Returns: + Dict: Avec pdf_base64 si base64_encode=True + bytes: Contenu PDF brut si base64_encode=False + """ + try: + params = { + "type_doc": type_doc, + "base64_encode": base64_encode + } + + if modele: + params["modele"] = modele + + r = requests.get( + f"{self.url}/sage/documents/{numero}/pdf", + params=params, + headers=self.headers, + timeout=60 # PDF peut prendre du temps + ) + r.raise_for_status() + + if base64_encode: + return r.json().get("data", {}) + else: + return r.content + + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur génération PDF: {e}") + raise + + sage_client = SageGatewayClient() From 4cdaea2051b8c3b0489c0e3c6f5dcca9c082036d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 17:26:13 +0300 Subject: [PATCH 50/62] refactor(api): update endpoint tags for better consistency --- api.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/api.py b/api.py index 166f5af..e2e6c03 100644 --- a/api.py +++ b/api.py @@ -3896,7 +3896,7 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) logger.error(f"❌ Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) -@app.get("/modeles", tags=["PDF"]) +@app.get("/modeles", tags=["PDF Sage-Like"]) async def get_modeles_disponibles(): """Liste tous les modèles PDF disponibles""" try: @@ -3907,21 +3907,13 @@ async def get_modeles_disponibles(): raise HTTPException(500, str(e)) -@app.get("/documents/{numero}/pdf", tags=["Documents"]) +@app.get("/documents/{numero}/pdf", tags=["PDF Sage-Like"]) async def get_document_pdf( numero: str, type_doc: int = Query(..., description="0=devis, 60=facture, etc."), modele: str = Query(None, description="Nom du modèle (ex: 'Facture client logo.bgc')"), download: bool = Query(False, description="Télécharger au lieu d'afficher") ): - """ - 📄 Génère et retourne le PDF d'un document - - Exemples: - - GET /documents/DE00001/pdf?type_doc=0 - - GET /documents/FA00123/pdf?type_doc=60&modele=Facture client logo.bgc - - GET /documents/FA00123/pdf?type_doc=60&download=true - """ try: # Récupérer le PDF (en bytes) pdf_bytes = sage_client.generer_pdf_document( From 85da047440ca34a9cd44bd9898c4f9adf32f3375 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 17:28:38 +0300 Subject: [PATCH 51/62] Added missing import --- sage_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sage_client.py b/sage_client.py index 7aa034a..64208ae 100644 --- a/sage_client.py +++ b/sage_client.py @@ -1,5 +1,5 @@ import requests -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from config import settings import logging From de6739e3f5b8d2f1eddbb81708becae5602fe5e4 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 18:21:01 +0300 Subject: [PATCH 52/62] fix: make est_total field optional in FamilleResponse model --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index e2e6c03..c447eb5 100644 --- a/api.py +++ b/api.py @@ -893,7 +893,7 @@ class FamilleResponse(BaseModel): intitule: str = Field(..., description="Intitulé") type: int = Field(..., description="Type (0=Détail, 1=Total)") type_libelle: str = Field(..., description="Libellé du type") - est_total: bool = Field(..., description="True si type Total") + est_total: Optional[bool] = Field(None, description="True si type Total") compte_achat: Optional[str] = Field(None, description="Compte général achat") compte_vente: Optional[str] = Field(None, description="Compte général vente") unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") From 4c53477efedbac537922f3662cc55b5b155d8068 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 18 Dec 2025 04:28:26 +0300 Subject: [PATCH 53/62] refactor(api): replace hardcoded document type with enum value --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index c447eb5..e87f713 100644 --- a/api.py +++ b/api.py @@ -3514,7 +3514,7 @@ async def commande_vers_livraison( try: # Étape 1: Vérifier que la commande existe commande_existante = sage_client.lire_document( - id, settings.SAGE_TYPE_BON_COMMANDE + id, TypeDocumentSQL.BON_COMMANDE ) if not commande_existante: From 282ffe4898879ad3d2a99084387f300c830dc93e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 18 Dec 2025 10:09:48 +0300 Subject: [PATCH 54/62] feat(signature): add email templates and tracking for signature workflow --- api.py | 749 ++++++++++++++++++++++++++++++++++++++++++--- database/models.py | 1 + 2 files changed, 702 insertions(+), 48 deletions(-) diff --git a/api.py b/api.py index e87f713..b4ea354 100644 --- a/api.py +++ b/api.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body +from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, EmailStr, validator, field_validator @@ -1050,34 +1050,389 @@ class MouvementStockResponse(BaseModel): nb_lignes: int = Field(..., description="Nombre de lignes") -async def universign_envoyer( - doc_id: str, pdf_bytes: bytes, email: str, nom: str -) -> Dict: - """Envoi signature via API Universign""" - import requests + +templates_signature_email = { + "demande_signature": { + "id": "demande_signature", + "nom": "Demande de Signature Électronique", + "sujet": "📝 Signature requise - {{TYPE_DOC}} {{NUMERO}}", + "corps_html": """ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ 📝 Signature Électronique Requise +

+
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

+ Nous vous invitons à signer électroniquement le document suivant : +

+ + + + + + +
+ + + + + + + + + + + + + + + + + +
Type de document{{TYPE_DOC}}
Numéro{{NUMERO}}
Date{{DATE}}
Montant TTC{{MONTANT_TTC}} €
+
+ +

+ Cliquez sur le bouton ci-dessous pour accéder au document et apposer votre signature électronique sécurisée : +

+ + + + + + +
+ + ✍️ Signer le document + +
+ + + + + + +
+

+ ⏰ Important : Ce lien de signature est valable pendant 30 jours. + Nous vous recommandons de signer ce document dès que possible. +

+
+ +

+ 🔒 Signature électronique sécurisée
+ Votre signature est protégée par notre partenaire de confiance Universign, + certifié eIDAS et conforme au RGPD. Votre identité sera vérifiée et le document sera + horodaté de manière infalsifiable. +

+
+

+ Vous avez des questions ? Contactez-nous à {{CONTACT_EMAIL}} +

+

+ Cet email a été envoyé automatiquement par le système Sage 100c Dataven.
+ Si vous avez reçu cet email par erreur, veuillez nous en informer. +

+
+
+ + + """, + "variables_disponibles": [ + "NOM_SIGNATAIRE", + "TYPE_DOC", + "NUMERO", + "DATE", + "MONTANT_TTC", + "SIGNER_URL", + "CONTACT_EMAIL" + ] + }, + + "signature_confirmee": { + "id": "signature_confirmee", + "nom": "Confirmation de Signature", + "sujet": "✅ Document signé - {{TYPE_DOC}} {{NUMERO}}", + "corps_html": """ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ ✅ Document Signé avec Succès +

+
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

+ Nous confirmons la signature électronique du document suivant : +

+ + + + + + +
+ + + + + + + + + + + + + +
Document{{TYPE_DOC}} {{NUMERO}}
Signé le{{DATE_SIGNATURE}}
ID Transaction{{TRANSACTION_ID}}
+
+ +

+ Le document signé a été automatiquement archivé et est disponible dans votre espace client. + Un certificat de signature électronique conforme eIDAS a été généré. +

+ + + + + +
+

+ 🔐 Signature certifiée : Ce document a été signé avec une signature + électronique qualifiée, ayant la même valeur juridique qu'une signature manuscrite + conformément au règlement eIDAS. +

+
+ +

+ Merci pour votre confiance. Notre équipe reste à votre disposition pour toute question. +

+
+

+ Contact : {{CONTACT_EMAIL}} +

+

+ Sage 100c Dataven - Système de signature électronique sécurisée +

+
+
+ + + """, + "variables_disponibles": [ + "NOM_SIGNATAIRE", + "TYPE_DOC", + "NUMERO", + "DATE_SIGNATURE", + "TRANSACTION_ID", + "CONTACT_EMAIL" + ] + }, + + "relance_signature": { + "id": "relance_signature", + "nom": "Relance Signature en Attente", + "sujet": "⏰ Rappel - Signature en attente {{TYPE_DOC}} {{NUMERO}}", + "corps_html": """ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ ⏰ Signature en Attente +

+
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

+ Nous vous avons envoyé il y a {{NB_JOURS}} jours un document à signer électroniquement. + Nous constatons que celui-ci n'a pas encore été signé. +

+ + + + + + +
+

+ Document en attente : {{TYPE_DOC}} {{NUMERO}} +

+

+ ⏳ Le lien de signature expirera dans {{JOURS_RESTANTS}} jours +

+
+ +

+ Pour éviter tout retard dans le traitement de votre dossier, nous vous invitons à signer ce document dès maintenant : +

+ + + + + + +
+ + ✍️ Signer maintenant + +
+ +

+ Si vous rencontrez des difficultés ou avez des questions, n'hésitez pas à nous contacter. +

+
+

+ Contact : {{CONTACT_EMAIL}} +

+

+ Sage 100c Dataven - Relance automatique +

+
+
+ + + """, + "variables_disponibles": [ + "NOM_SIGNATAIRE", + "TYPE_DOC", + "NUMERO", + "NB_JOURS", + "JOURS_RESTANTS", + "SIGNER_URL", + "CONTACT_EMAIL" + ] + } +} + + +async def universign_envoyer_avec_email( + doc_id: str, + pdf_bytes: bytes, + email: str, + nom: str, + doc_data: Dict, # Données du document (type, montant, date, etc.) + session: AsyncSession +) -> Dict: + import requests + try: api_key = settings.universign_api_key api_url = settings.universign_api_url auth = (api_key, "") - # Étape 1: Créer transaction response = requests.post( f"{api_url}/transactions", auth=auth, - json={"name": f"Devis {doc_id}", "language": "fr"}, + json={ + "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", + "language": "fr" + }, timeout=30, ) response.raise_for_status() transaction_id = response.json().get("id") + + logger.info(f"✅ Transaction Universign créée: {transaction_id}") - # Étape 2: Upload PDF - files = {"file": (f"Devis_{doc_id}.pdf", pdf_bytes, "application/pdf")} + files = {"file": (f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", pdf_bytes, "application/pdf")} response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=30) response.raise_for_status() file_id = response.json().get("id") - - # Étape 3: Ajouter document + response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, @@ -1087,7 +1442,6 @@ async def universign_envoyer( response.raise_for_status() document_id = response.json().get("id") - # Étape 4: Créer champ signature response = requests.post( f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", auth=auth, @@ -1096,8 +1450,7 @@ async def universign_envoyer( ) response.raise_for_status() field_id = response.json().get("id") - - # Étape 5: Assigner signataire + response = requests.post( f"{api_url}/transactions/{transaction_id}/signatures", auth=auth, @@ -1106,31 +1459,90 @@ async def universign_envoyer( ) response.raise_for_status() - # Étape 6: Démarrer transaction response = requests.post( - f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 + f"{api_url}/transactions/{transaction_id}/start", + auth=auth, + timeout=30 ) response.raise_for_status() - + final_data = response.json() signer_url = ( final_data.get("actions", [{}])[0].get("url", "") if final_data.get("actions") else "" ) + + if not signer_url: + raise ValueError("URL de signature non retournée par Universign") + + logger.info(f"✅ Signature Universign démarrée: {transaction_id}") - logger.info(f"✅ Signature Universign envoyée: {transaction_id}") - + template = templates_signature_email["demande_signature"] + + # Préparer les variables + type_labels = { + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir" + } + + variables = { + "NOM_SIGNATAIRE": nom, + "TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"), + "NUMERO": doc_id, + "DATE": doc_data.get("date", datetime.now().strftime("%d/%m/%Y")), + "MONTANT_TTC": f"{doc_data.get('montant_ttc', 0):.2f}", + "SIGNER_URL": signer_url, + "CONTACT_EMAIL": settings.smtp_from + } + + # Remplacer les variables dans le template + sujet = template["sujet"] + corps = template["corps_html"] + + for var, valeur in variables.items(): + sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) + corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) + + # Créer log email + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=email, + sujet=sujet, + corps_html=corps, + document_ids=doc_id, + type_document=doc_data.get("type_doc"), + statut=StatutEmailEnum.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + await session.flush() + + # Enqueue l'email + email_queue.enqueue(email_log.id) + + logger.info(f"📧 Email de signature envoyé en file: {email}") + return { "transaction_id": transaction_id, "signer_url": signer_url, "statut": "ENVOYE", + "email_log_id": email_log.id, + "email_sent": True } - + except Exception as e: - logger.error(f"❌ Erreur Universign: {e}") - return {"error": str(e), "statut": "ERREUR"} - + logger.error(f"❌ Erreur Universign+Email: {e}") + return { + "error": str(e), + "statut": "ERREUR", + "email_sent": False + } async def universign_statut(transaction_id: str) -> Dict: """Récupération statut signature""" @@ -1986,26 +2398,58 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) +def normaliser_type_doc(type_doc: int) -> int: + TYPES_AUTORISES = {0, 10, 30, 50, 60} -# ===================================================== -# ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE) -# ===================================================== -@app.post("/signature/universign/send", tags=["Signatures"]) -async def envoyer_signature( - demande: SignatureRequest, session: AsyncSession = Depends(get_session) -): - try: - # Générer PDF - pdf_bytes = email_queue._generate_pdf(demande.doc_id, demande.type_doc) - - # Envoi Universign - resultat = await universign_envoyer( - demande.doc_id, pdf_bytes, demande.email_signataire, demande.nom_signataire + if type_doc not in TYPES_AUTORISES: + raise ValueError( + f"type_doc invalide ({type_doc}). Valeurs autorisées : {sorted(TYPES_AUTORISES)}" ) + return type_doc if type_doc == 0 else type_doc // 10 + + +@app.post("/signature/universign/send", tags=["Signatures"]) +async def envoyer_signature_optimise( + demande: SignatureRequest, + session: AsyncSession = Depends(get_session) +): + try: + # Récupérer le document depuis Sage + doc = sage_client.lire_document(demande.doc_id, normaliser_type_doc(demande.type_doc)) + if not doc: + raise HTTPException(404, f"Document {demande.doc_id} introuvable") + + # Générer PDF + pdf_bytes = email_queue._generate_pdf(demande.doc_id, normaliser_type_doc(demande.type_doc)) + + # Préparer les données du document pour l'email + doc_data = { + "type_doc": demande.type_doc, + "type_label": { + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir" + }.get(demande.type_doc, "Document"), + "montant_ttc": doc.get("total_ttc", 0), + "date": doc.get("date", datetime.now().strftime("%d/%m/%Y")) + } + + # Envoi Universign + Email automatique + resultat = await universign_envoyer_avec_email( + doc_id=demande.doc_id, + pdf_bytes=pdf_bytes, + email=demande.email_signataire, + nom=demande.nom_signataire, + doc_data=doc_data, + session=session + ) + if "error" in resultat: raise HTTPException(500, resultat["error"]) - + # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), @@ -2018,29 +2462,238 @@ async def envoyer_signature( statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), ) - + session.add(signature_log) await session.commit() - - # MAJ champ libre Sage via gateway Windows + + # MAJ champ libre Sage sage_client.mettre_a_jour_champ_libre( - demande.doc_id, demande.type_doc, "UniversignID", resultat["transaction_id"] + demande.doc_id, + demande.type_doc, + "UniversignID", + resultat["transaction_id"] ) - - logger.info(f"✅ Signature envoyée: {demande.doc_id}") - + + logger.info(f"✅ Signature envoyée: {demande.doc_id} (Email: {resultat['email_sent']})") + return { "success": True, "transaction_id": resultat["transaction_id"], "signer_url": resultat["signer_url"], + "email_sent": resultat["email_sent"], + "email_log_id": resultat.get("email_log_id"), + "message": f"Demande de signature envoyée à {demande.email_signataire}" } - + except HTTPException: raise except Exception as e: - logger.error(f"Erreur signature: {e}") + logger.error(f"❌ Erreur signature: {e}") raise HTTPException(500, str(e)) + + +@app.post("/webhooks/universign", tags=["Signatures"]) +async def webhook_universign( + request: Request, + session: AsyncSession = Depends(get_session) +): + try: + payload = await request.json() + + event_type = payload.get("event") + transaction_id = payload.get("transaction_id") + + if not transaction_id: + logger.warning("⚠️ Webhook sans transaction_id") + return {"status": "ignored"} + + # Chercher la signature dans la DB + query = select(SignatureLog).where( + SignatureLog.transaction_id == transaction_id + ) + result = await session.execute(query) + signature_log = result.scalar_one_or_none() + + if not signature_log: + logger.warning(f"⚠️ Transaction {transaction_id} introuvable en DB") + return {"status": "not_found"} + + # ============================================= + # TRAITER L'EVENT SELON LE TYPE + # ============================================= + + if event_type == "transaction.completed": + # ✅ SIGNATURE RÉUSSIE + signature_log.statut = StatutSignatureEnum.SIGNE + signature_log.date_signature = datetime.now() + + logger.info(f"✅ Signature confirmée: {signature_log.document_id}") + + # ENVOYER EMAIL DE CONFIRMATION + template = templates_signature_email["signature_confirmee"] + + type_labels = { + 0: "Devis", 10: "Commande", 30: "Bon de Livraison", + 60: "Facture", 50: "Avoir" + } + + variables = { + "NOM_SIGNATAIRE": signature_log.nom_signataire, + "TYPE_DOC": type_labels.get(signature_log.type_document, "Document"), + "NUMERO": signature_log.document_id, + "DATE_SIGNATURE": datetime.now().strftime("%d/%m/%Y à %H:%M"), + "TRANSACTION_ID": transaction_id, + "CONTACT_EMAIL": settings.smtp_from + } + + sujet = template["sujet"] + corps = template["corps_html"] + + for var, valeur in variables.items(): + sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) + corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) + + # Créer email de confirmation + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=signature_log.email_signataire, + sujet=sujet, + corps_html=corps, + document_ids=signature_log.document_id, + type_document=signature_log.type_document, + statut=StatutEmailEnum.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + email_queue.enqueue(email_log.id) + + logger.info(f"📧 Email de confirmation envoyé: {signature_log.email_signataire}") + + elif event_type == "transaction.refused": + # ❌ SIGNATURE REFUSÉE + signature_log.statut = StatutSignatureEnum.REFUSE + logger.warning(f"❌ Signature refusée: {signature_log.document_id}") + + elif event_type == "transaction.expired": + # ⏰ TRANSACTION EXPIRÉE + signature_log.statut = StatutSignatureEnum.EXPIRE + logger.warning(f"⏰ Transaction expirée: {signature_log.document_id}") + + await session.commit() + + return { + "status": "processed", + "event": event_type, + "transaction_id": transaction_id + } + + except Exception as e: + logger.error(f"❌ Erreur webhook Universign: {e}") + return {"status": "error", "message": str(e)} +@app.get("/admin/signatures/relances-auto", tags=["Admin"]) +async def relancer_signatures_automatique( + session: AsyncSession = Depends(get_session) +): + try: + from datetime import timedelta + + # Chercher signatures en attente depuis > 7 jours + date_limite = datetime.now() - timedelta(days=7) + + query = select(SignatureLog).where( + SignatureLog.statut.in_([ + StatutSignatureEnum.EN_ATTENTE, + StatutSignatureEnum.ENVOYE + ]), + SignatureLog.date_envoi < date_limite, + SignatureLog.nb_relances < 3 # Max 3 relances + ) + + result = await session.execute(query) + signatures_a_relancer = result.scalars().all() + + nb_relances = 0 + + for signature in signatures_a_relancer: + try: + # Calculer jours écoulés + nb_jours = (datetime.now() - signature.date_envoi).days + jours_restants = 30 - nb_jours # Lien expire après 30 jours + + if jours_restants <= 0: + # Transaction expirée + signature.statut = StatutSignatureEnum.EXPIRE + continue + + # Préparer email de relance + template = templates_signature_email["relance_signature"] + + type_labels = { + 0: "Devis", 10: "Commande", 30: "Bon de Livraison", + 60: "Facture", 50: "Avoir" + } + + variables = { + "NOM_SIGNATAIRE": signature.nom_signataire, + "TYPE_DOC": type_labels.get(signature.type_document, "Document"), + "NUMERO": signature.document_id, + "NB_JOURS": str(nb_jours), + "JOURS_RESTANTS": str(jours_restants), + "SIGNER_URL": signature.signer_url, + "CONTACT_EMAIL": settings.smtp_from + } + + sujet = template["sujet"] + corps = template["corps_html"] + + for var, valeur in variables.items(): + sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) + corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) + + # Créer email de relance + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=signature.email_signataire, + sujet=sujet, + corps_html=corps, + document_ids=signature.document_id, + type_document=signature.type_document, + statut=StatutEmailEnum.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + email_queue.enqueue(email_log.id) + + # Incrémenter compteur de relances + signature.est_relance = True + signature.nb_relances = (signature.nb_relances or 0) + 1 + + nb_relances += 1 + + logger.info(f"📧 Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)") + + except Exception as e: + logger.error(f"❌ Erreur relance signature {signature.id}: {e}") + continue + + await session.commit() + + return { + "success": True, + "signatures_verifiees": len(signatures_a_relancer), + "relances_envoyees": nb_relances, + "message": f"{nb_relances} email(s) de relance envoyé(s)" + } + + except Exception as e: + logger.error(f"❌ Erreur relances automatiques: {e}") + raise HTTPException(500, str(e)) + @app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): diff --git a/database/models.py b/database/models.py index ff7c224..da8c7b2 100644 --- a/database/models.py +++ b/database/models.py @@ -124,6 +124,7 @@ class SignatureLog(Base): # Relances est_relance = Column(Boolean, default=False) nb_relances = Column(Integer, default=0) + derniere_relance = Column(DateTime, nullable=True) # Métadonnées raison_refus = Column(Text, nullable=True) From 5cb9015ab51468fb886455070433b77a327e02a1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 18 Dec 2025 10:59:40 +0300 Subject: [PATCH 55/62] style(api): improve email template readability and clean up code formatting --- api.py | 306 +++++++++++++++++++++++++++++---------------------------- 1 file changed, 156 insertions(+), 150 deletions(-) diff --git a/api.py b/api.py index b4ea354..82e61fe 100644 --- a/api.py +++ b/api.py @@ -1050,8 +1050,6 @@ class MouvementStockResponse(BaseModel): nb_lignes: int = Field(..., description="Nombre de lignes") - - templates_signature_email = { "demande_signature": { "id": "demande_signature", @@ -1073,7 +1071,7 @@ templates_signature_email = { -

+

📝 Signature Électronique Requise

@@ -1124,7 +1122,7 @@ templates_signature_email = { @@ -1179,10 +1177,9 @@ templates_signature_email = { "DATE", "MONTANT_TTC", "SIGNER_URL", - "CONTACT_EMAIL" - ] + "CONTACT_EMAIL", + ], }, - "signature_confirmee": { "id": "signature_confirmee", "nom": "Confirmation de Signature", @@ -1290,10 +1287,9 @@ templates_signature_email = { "NUMERO", "DATE_SIGNATURE", "TRANSACTION_ID", - "CONTACT_EMAIL" - ] + "CONTACT_EMAIL", + ], }, - "relance_signature": { "id": "relance_signature", "nom": "Relance Signature en Attente", @@ -1393,22 +1389,22 @@ templates_signature_email = { "NB_JOURS", "JOURS_RESTANTS", "SIGNER_URL", - "CONTACT_EMAIL" - ] - } + "CONTACT_EMAIL", + ], + }, } async def universign_envoyer_avec_email( - doc_id: str, - pdf_bytes: bytes, - email: str, + doc_id: str, + pdf_bytes: bytes, + email: str, nom: str, doc_data: Dict, # Données du document (type, montant, date, etc.) - session: AsyncSession + session: AsyncSession, ) -> Dict: import requests - + try: api_key = settings.universign_api_key api_url = settings.universign_api_url @@ -1419,20 +1415,26 @@ async def universign_envoyer_avec_email( auth=auth, json={ "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", - "language": "fr" + "language": "fr", }, timeout=30, ) response.raise_for_status() transaction_id = response.json().get("id") - + logger.info(f"✅ Transaction Universign créée: {transaction_id}") - files = {"file": (f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", pdf_bytes, "application/pdf")} + files = { + "file": ( + f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", + pdf_bytes, + "application/pdf", + ) + } response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=30) response.raise_for_status() file_id = response.json().get("id") - + response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, @@ -1450,7 +1452,7 @@ async def universign_envoyer_avec_email( ) response.raise_for_status() field_id = response.json().get("id") - + response = requests.post( f"{api_url}/transactions/{transaction_id}/signatures", auth=auth, @@ -1460,35 +1462,33 @@ async def universign_envoyer_avec_email( response.raise_for_status() response = requests.post( - f"{api_url}/transactions/{transaction_id}/start", - auth=auth, - timeout=30 + f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 ) response.raise_for_status() - + final_data = response.json() signer_url = ( final_data.get("actions", [{}])[0].get("url", "") if final_data.get("actions") else "" ) - + if not signer_url: raise ValueError("URL de signature non retournée par Universign") - + logger.info(f"✅ Signature Universign démarrée: {transaction_id}") template = templates_signature_email["demande_signature"] - + # Préparer les variables type_labels = { 0: "Devis", - 10: "Commande", + 10: "Commande", 30: "Bon de Livraison", 60: "Facture", - 50: "Avoir" + 50: "Avoir", } - + variables = { "NOM_SIGNATAIRE": nom, "TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"), @@ -1496,17 +1496,17 @@ async def universign_envoyer_avec_email( "DATE": doc_data.get("date", datetime.now().strftime("%d/%m/%Y")), "MONTANT_TTC": f"{doc_data.get('montant_ttc', 0):.2f}", "SIGNER_URL": signer_url, - "CONTACT_EMAIL": settings.smtp_from + "CONTACT_EMAIL": settings.smtp_from, } - + # Remplacer les variables dans le template sujet = template["sujet"] corps = template["corps_html"] - + for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - + # Créer log email email_log = EmailLog( id=str(uuid.uuid4()), @@ -1519,30 +1519,27 @@ async def universign_envoyer_avec_email( date_creation=datetime.now(), nb_tentatives=0, ) - + session.add(email_log) await session.flush() - + # Enqueue l'email email_queue.enqueue(email_log.id) - + logger.info(f"📧 Email de signature envoyé en file: {email}") - + return { "transaction_id": transaction_id, "signer_url": signer_url, "statut": "ENVOYE", "email_log_id": email_log.id, - "email_sent": True + "email_sent": True, } - + except Exception as e: logger.error(f"❌ Erreur Universign+Email: {e}") - return { - "error": str(e), - "statut": "ERREUR", - "email_sent": False - } + return {"error": str(e), "statut": "ERREUR", "email_sent": False} + async def universign_statut(transaction_id: str) -> Dict: """Récupération statut signature""" @@ -2398,6 +2395,7 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) + def normaliser_type_doc(type_doc: int) -> int: TYPES_AUTORISES = {0, 10, 30, 50, 60} @@ -2411,32 +2409,35 @@ def normaliser_type_doc(type_doc: int) -> int: @app.post("/signature/universign/send", tags=["Signatures"]) async def envoyer_signature_optimise( - demande: SignatureRequest, - session: AsyncSession = Depends(get_session) + demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): try: # Récupérer le document depuis Sage - doc = sage_client.lire_document(demande.doc_id, normaliser_type_doc(demande.type_doc)) + doc = sage_client.lire_document( + demande.doc_id, normaliser_type_doc(demande.type_doc) + ) if not doc: raise HTTPException(404, f"Document {demande.doc_id} introuvable") - + # Générer PDF - pdf_bytes = email_queue._generate_pdf(demande.doc_id, normaliser_type_doc(demande.type_doc)) - + pdf_bytes = email_queue._generate_pdf( + demande.doc_id, normaliser_type_doc(demande.type_doc) + ) + # Préparer les données du document pour l'email doc_data = { "type_doc": demande.type_doc, "type_label": { 0: "Devis", 10: "Commande", - 30: "Bon de Livraison", + 30: "Bon de Livraison", 60: "Facture", - 50: "Avoir" + 50: "Avoir", }.get(demande.type_doc, "Document"), "montant_ttc": doc.get("total_ttc", 0), - "date": doc.get("date", datetime.now().strftime("%d/%m/%Y")) + "date": doc.get("date", datetime.now().strftime("%d/%m/%Y")), } - + # Envoi Universign + Email automatique resultat = await universign_envoyer_avec_email( doc_id=demande.doc_id, @@ -2444,12 +2445,12 @@ async def envoyer_signature_optimise( email=demande.email_signataire, nom=demande.nom_signataire, doc_data=doc_data, - session=session + session=session, ) - + if "error" in resultat: raise HTTPException(500, resultat["error"]) - + # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), @@ -2462,97 +2463,98 @@ async def envoyer_signature_optimise( statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), ) - + session.add(signature_log) await session.commit() - + # MAJ champ libre Sage sage_client.mettre_a_jour_champ_libre( - demande.doc_id, - demande.type_doc, - "UniversignID", - resultat["transaction_id"] + demande.doc_id, demande.type_doc, "UniversignID", resultat["transaction_id"] ) - - logger.info(f"✅ Signature envoyée: {demande.doc_id} (Email: {resultat['email_sent']})") - + + logger.info( + f"✅ Signature envoyée: {demande.doc_id} (Email: {resultat['email_sent']})" + ) + return { "success": True, "transaction_id": resultat["transaction_id"], "signer_url": resultat["signer_url"], "email_sent": resultat["email_sent"], "email_log_id": resultat.get("email_log_id"), - "message": f"Demande de signature envoyée à {demande.email_signataire}" + "message": f"Demande de signature envoyée à {demande.email_signataire}", } - + except HTTPException: raise except Exception as e: logger.error(f"❌ Erreur signature: {e}") raise HTTPException(500, str(e)) - - + + @app.post("/webhooks/universign", tags=["Signatures"]) async def webhook_universign( - request: Request, - session: AsyncSession = Depends(get_session) + request: Request, session: AsyncSession = Depends(get_session) ): try: payload = await request.json() - + event_type = payload.get("event") transaction_id = payload.get("transaction_id") - + if not transaction_id: logger.warning("⚠️ Webhook sans transaction_id") return {"status": "ignored"} - + # Chercher la signature dans la DB query = select(SignatureLog).where( SignatureLog.transaction_id == transaction_id ) result = await session.execute(query) signature_log = result.scalar_one_or_none() - + if not signature_log: logger.warning(f"⚠️ Transaction {transaction_id} introuvable en DB") return {"status": "not_found"} - + # ============================================= # TRAITER L'EVENT SELON LE TYPE # ============================================= - + if event_type == "transaction.completed": # ✅ SIGNATURE RÉUSSIE signature_log.statut = StatutSignatureEnum.SIGNE signature_log.date_signature = datetime.now() - + logger.info(f"✅ Signature confirmée: {signature_log.document_id}") - + # ENVOYER EMAIL DE CONFIRMATION template = templates_signature_email["signature_confirmee"] - + type_labels = { - 0: "Devis", 10: "Commande", 30: "Bon de Livraison", - 60: "Facture", 50: "Avoir" + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", } - + variables = { "NOM_SIGNATAIRE": signature_log.nom_signataire, "TYPE_DOC": type_labels.get(signature_log.type_document, "Document"), "NUMERO": signature_log.document_id, "DATE_SIGNATURE": datetime.now().strftime("%d/%m/%Y à %H:%M"), "TRANSACTION_ID": transaction_id, - "CONTACT_EMAIL": settings.smtp_from + "CONTACT_EMAIL": settings.smtp_from, } - + sujet = template["sujet"] corps = template["corps_html"] - + for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - + # Créer email de confirmation email_log = EmailLog( id=str(uuid.uuid4()), @@ -2565,77 +2567,80 @@ async def webhook_universign( date_creation=datetime.now(), nb_tentatives=0, ) - + session.add(email_log) email_queue.enqueue(email_log.id) - - logger.info(f"📧 Email de confirmation envoyé: {signature_log.email_signataire}") - + + logger.info( + f"📧 Email de confirmation envoyé: {signature_log.email_signataire}" + ) + elif event_type == "transaction.refused": # ❌ SIGNATURE REFUSÉE signature_log.statut = StatutSignatureEnum.REFUSE logger.warning(f"❌ Signature refusée: {signature_log.document_id}") - + elif event_type == "transaction.expired": # ⏰ TRANSACTION EXPIRÉE signature_log.statut = StatutSignatureEnum.EXPIRE logger.warning(f"⏰ Transaction expirée: {signature_log.document_id}") - + await session.commit() - + return { "status": "processed", "event": event_type, - "transaction_id": transaction_id + "transaction_id": transaction_id, } - + except Exception as e: logger.error(f"❌ Erreur webhook Universign: {e}") return {"status": "error", "message": str(e)} + @app.get("/admin/signatures/relances-auto", tags=["Admin"]) -async def relancer_signatures_automatique( - session: AsyncSession = Depends(get_session) -): +async def relancer_signatures_automatique(session: AsyncSession = Depends(get_session)): try: from datetime import timedelta - + # Chercher signatures en attente depuis > 7 jours date_limite = datetime.now() - timedelta(days=7) - + query = select(SignatureLog).where( - SignatureLog.statut.in_([ - StatutSignatureEnum.EN_ATTENTE, - StatutSignatureEnum.ENVOYE - ]), + SignatureLog.statut.in_( + [StatutSignatureEnum.EN_ATTENTE, StatutSignatureEnum.ENVOYE] + ), SignatureLog.date_envoi < date_limite, - SignatureLog.nb_relances < 3 # Max 3 relances + SignatureLog.nb_relances < 3, # Max 3 relances ) - + result = await session.execute(query) signatures_a_relancer = result.scalars().all() - + nb_relances = 0 - + for signature in signatures_a_relancer: try: # Calculer jours écoulés nb_jours = (datetime.now() - signature.date_envoi).days jours_restants = 30 - nb_jours # Lien expire après 30 jours - + if jours_restants <= 0: # Transaction expirée signature.statut = StatutSignatureEnum.EXPIRE continue - + # Préparer email de relance template = templates_signature_email["relance_signature"] - + type_labels = { - 0: "Devis", 10: "Commande", 30: "Bon de Livraison", - 60: "Facture", 50: "Avoir" + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", } - + variables = { "NOM_SIGNATAIRE": signature.nom_signataire, "TYPE_DOC": type_labels.get(signature.type_document, "Document"), @@ -2643,16 +2648,16 @@ async def relancer_signatures_automatique( "NB_JOURS": str(nb_jours), "JOURS_RESTANTS": str(jours_restants), "SIGNER_URL": signature.signer_url, - "CONTACT_EMAIL": settings.smtp_from + "CONTACT_EMAIL": settings.smtp_from, } - + sujet = template["sujet"] corps = template["corps_html"] - + for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - + # Créer email de relance email_log = EmailLog( id=str(uuid.uuid4()), @@ -2665,35 +2670,37 @@ async def relancer_signatures_automatique( date_creation=datetime.now(), nb_tentatives=0, ) - + session.add(email_log) email_queue.enqueue(email_log.id) - + # Incrémenter compteur de relances signature.est_relance = True signature.nb_relances = (signature.nb_relances or 0) + 1 - + nb_relances += 1 - - logger.info(f"📧 Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)") - + + logger.info( + f"📧 Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)" + ) + except Exception as e: logger.error(f"❌ Erreur relance signature {signature.id}: {e}") continue - + await session.commit() - + return { "success": True, "signatures_verifiees": len(signatures_a_relancer), "relances_envoyees": nb_relances, - "message": f"{nb_relances} email(s) de relance envoyé(s)" + "message": f"{nb_relances} email(s) de relance envoyé(s)", } - + except Exception as e: logger.error(f"❌ Erreur relances automatiques: {e}") raise HTTPException(500, str(e)) - + @app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): @@ -4166,9 +4173,7 @@ async def commande_vers_livraison( ): try: # Étape 1: Vérifier que la commande existe - commande_existante = sage_client.lire_document( - id, TypeDocumentSQL.BON_COMMANDE - ) + commande_existante = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande_existante: raise HTTPException(404, f"Commande {id} introuvable") @@ -4549,6 +4554,7 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) logger.error(f"❌ Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) + @app.get("/modeles", tags=["PDF Sage-Like"]) async def get_modeles_disponibles(): """Liste tous les modèles PDF disponibles""" @@ -4564,8 +4570,10 @@ async def get_modeles_disponibles(): async def get_document_pdf( numero: str, type_doc: int = Query(..., description="0=devis, 60=facture, etc."), - modele: str = Query(None, description="Nom du modèle (ex: 'Facture client logo.bgc')"), - download: bool = Query(False, description="Télécharger au lieu d'afficher") + modele: str = Query( + None, description="Nom du modèle (ex: 'Facture client logo.bgc')" + ), + download: bool = Query(False, description="Télécharger au lieu d'afficher"), ): try: # Récupérer le PDF (en bytes) @@ -4573,28 +4581,26 @@ async def get_document_pdf( numero=numero, type_doc=type_doc, modele=modele, - base64_encode=False # On veut les bytes bruts + base64_encode=False, # On veut les bytes bruts ) - + # Retourner le PDF from fastapi.responses import Response - + disposition = "attachment" if download else "inline" filename = f"{numero}.pdf" - + return Response( content=pdf_bytes, media_type="application/pdf", - headers={ - "Content-Disposition": f'{disposition}; filename="{filename}"' - } + headers={"Content-Disposition": f'{disposition}; filename="{filename}"'}, ) - + except Exception as e: logger.error(f"Erreur génération PDF: {e}") raise HTTPException(500, str(e)) - - + + # ===================================================== # LANCEMENT # ===================================================== From d8e3fb4b00f24ed06fab1fedb223b8dfb3361d24 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 19 Dec 2025 12:39:35 +0300 Subject: [PATCH 56/62] refactor(api): remove redundant devis status update logic --- api.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/api.py b/api.py index 82e61fe..829bde1 100644 --- a/api.py +++ b/api.py @@ -2315,16 +2315,6 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10 ) - # Étape 2: Mettre à jour le statut du devis à 5 (Transformé) - try: - sage_client.changer_statut_devis(id, nouveau_statut=5) - logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") - except Exception as e: - logger.warning( - f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}" - ) - # On continue même si la MAJ statut échoue - # Étape 3: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), @@ -4121,16 +4111,6 @@ async def devis_vers_facture_direct( type_cible=settings.SAGE_TYPE_FACTURE, # = 60 ) - # Étape 3: Mettre à jour le statut du devis à 5 (Transformé) - try: - sage_client.changer_statut_devis(id, nouveau_statut=5) - logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") - except Exception as e: - logger.warning( - f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}" - ) - # On continue même si la MAJ statut échoue - # Étape 4: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), From d26a6a0312aacd1bd3a707a80987d39a717a602e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 19 Dec 2025 13:11:26 +0300 Subject: [PATCH 57/62] fix: update devis retrieval to use lire_document method --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 829bde1..6d4f5f9 100644 --- a/api.py +++ b/api.py @@ -2079,7 +2079,7 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): try: - devis = sage_client.lire_devis(id) + devis = sage_client.lire_document(id, 0) if not devis: raise HTTPException(404, f"Devis {id} introuvable") From da4d43dcf7f64ddd5d07e01cdbb6c7088871fe87 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 19 Dec 2025 13:16:56 +0300 Subject: [PATCH 58/62] refactor(api): simplify devis reading by using TypeDocumentSQL enum --- api.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/api.py b/api.py index 6d4f5f9..a3b8523 100644 --- a/api.py +++ b/api.py @@ -2079,19 +2079,11 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): try: - devis = sage_client.lire_document(id, 0) + devis = sage_client.lire_document(id, TypeDocumentSQL.DEVIS) if not devis: raise HTTPException(404, f"Devis {id} introuvable") - # Log informatif - if devis.get("a_deja_ete_transforme"): - docs = devis.get("documents_cibles", []) - logger.info( - f"📊 Devis {id} a été transformé en " - f"{len(docs)} document(s): {[d['numero'] for d in docs]}" - ) - return {"success": True, "data": devis} except HTTPException: From 19ea145bbb0c663d235e5b13637dfc00ea4404f1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 19 Dec 2025 13:23:36 +0300 Subject: [PATCH 59/62] refactor(api): replace hardcoded document types with TypeDocumentSQL enum --- api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api.py b/api.py index a3b8523..14ba956 100644 --- a/api.py +++ b/api.py @@ -1996,7 +1996,7 @@ async def modifier_commande( try: # Vérifier que la commande existe commande_existante = sage_client.lire_document( - id, settings.SAGE_TYPE_BON_COMMANDE + id, TypeDocumentSQL.BON_COMMANDE ) if not commande_existante: @@ -2079,7 +2079,7 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): try: - devis = sage_client.lire_document(id, TypeDocumentSQL.DEVIS) + devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") @@ -3190,7 +3190,7 @@ async def modifier_facture( ): try: # Vérifier que la facture existe - facture_existante = sage_client.lire_document(id, settings.SAGE_TYPE_FACTURE) + facture_existante = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) if not facture_existante: raise HTTPException(404, f"Facture {id} introuvable") @@ -3281,7 +3281,7 @@ async def relancer_facture( ): try: # Lire facture via gateway Windows - facture = sage_client.lire_document(id, TypeDocument.FACTURE) + facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {id} introuvable") From 49341010858d6a813a4b26cc4217ec95ea976f5c Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 09:21:31 +0300 Subject: [PATCH 60/62] feat(api): add optional reference field to DevisRequest --- api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api.py b/api.py index 14ba956..49877b7 100644 --- a/api.py +++ b/api.py @@ -357,6 +357,7 @@ class LigneDevis(BaseModel): class DevisRequest(BaseModel): client_id: str date_devis: Optional[date] = None + reference: Optional[str] = None lignes: List[LigneDevis] From bffca51fcd48be3575d2b86b7b59d69a56e34db3 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 09:46:02 +0300 Subject: [PATCH 61/62] feat(DevisUpdateRequest): add optional reference field to model --- api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api.py b/api.py index 49877b7..248b481 100644 --- a/api.py +++ b/api.py @@ -564,6 +564,7 @@ class DevisUpdateRequest(BaseModel): """Modèle pour modification d'un devis existant""" date_devis: Optional[date] = None + reference: Optional[str] = None lignes: Optional[List[LigneDevis]] = None statut: Optional[int] = Field(None, ge=0, le=6) @@ -571,6 +572,7 @@ class DevisUpdateRequest(BaseModel): json_schema_extra = { "example": { "date_devis": "2024-01-15", + "reference": "DEV-001", "lignes": [ { "article_code": "ART001", From e5fad0ccca8991cfb359c7748822da1a3e0c0f0f Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 09:53:27 +0300 Subject: [PATCH 62/62] feat(devis): add reference field and remove prix_unitaire_ht --- api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api.py b/api.py index 248b481..fb71362 100644 --- a/api.py +++ b/api.py @@ -1850,11 +1850,11 @@ async def creer_devis(devis: DevisRequest): devis_data = { "client_id": devis.client_id, "date_devis": devis.date_devis.isoformat() if devis.date_devis else None, + "reference": devis.reference, "lignes": [ { "article_code": l.article_code, "quantite": l.quantite, - "prix_unitaire_ht": l.prix_unitaire_ht, "remise_pourcentage": l.remise_pourcentage, } for l in devis.lignes @@ -1917,6 +1917,9 @@ async def modifier_devis( if devis_update.statut is not None: update_data["statut"] = devis_update.statut + + if devis_update.reference is not None: + update_data["reference"] = devis_update.reference # Appel à la gateway Windows resultat = sage_client.modifier_devis(id, update_data)
- + ✍️ Signer le document