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 from typing import List, Optional, Dict from datetime import date, datetime from enum import Enum import uvicorn from contextlib import asynccontextmanager import uuid import csv import io import logging from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from routes.auth import router as auth_router from core.dependencies import get_current_user, require_role # Configuration logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[logging.FileHandler("sage_api.log"), logging.StreamHandler()], ) logger = logging.getLogger(__name__) # Imports locaux from config import settings from database import ( init_db, async_session_factory, get_session, EmailLog, StatutEmail as StatutEmailEnum, WorkflowLog, SignatureLog, StatutSignature as StatutSignatureEnum, ) 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 # ===================================================== class TypeDocument(int, Enum): DEVIS = settings.SAGE_TYPE_DEVIS BON_COMMANDE = settings.SAGE_TYPE_BON_COMMANDE PREPARATION = settings.SAGE_TYPE_PREPARATION BON_LIVRAISON = settings.SAGE_TYPE_BON_LIVRAISON BON_RETOUR = settings.SAGE_TYPE_BON_RETOUR BON_AVOIR = settings.SAGE_TYPE_BON_AVOIR FACTURE = settings.SAGE_TYPE_FACTURE class StatutSignature(str, Enum): EN_ATTENTE = "EN_ATTENTE" ENVOYE = "ENVOYE" SIGNE = "SIGNE" REFUSE = "REFUSE" EXPIRE = "EXPIRE" class StatutEmail(str, Enum): EN_ATTENTE = "EN_ATTENTE" EN_COURS = "EN_COURS" ENVOYE = "ENVOYE" OUVERT = "OUVERT" ERREUR = "ERREUR" BOUNCE = "BOUNCE" # ===================================================== # 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 # Téléphone principal (fixe ou mobile) 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 designation: str prix_vente: float stock_reel: float class LigneDevis(BaseModel): 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 DevisRequest(BaseModel): client_id: str date_devis: Optional[date] = None lignes: List[LigneDevis] class DevisResponse(BaseModel): id: str client_id: str date_devis: str montant_total_ht: float montant_total_ttc: float nb_lignes: int class SignatureRequest(BaseModel): doc_id: str type_doc: TypeDocument email_signataire: EmailStr nom_signataire: str class EmailEnvoiRequest(BaseModel): destinataire: EmailStr cc: Optional[List[EmailStr]] = [] cci: Optional[List[EmailStr]] = [] sujet: str corps_html: str document_ids: Optional[List[str]] = None type_document: Optional[TypeDocument] = None class RelanceDevisRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None class BaremeRemiseResponse(BaseModel): client_id: str remise_max_autorisee: float remise_demandee: float autorisee: bool message: str 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)") # 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.") 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) 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) 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": { "email": "nouveau@email.fr", "telephone": "0198765432", "portable": "0687654321" } } 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 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", } } 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", } } 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, } } 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, } } 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, } } 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) # ===================================================== async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, nom: str ) -> Dict: """Envoi signature via API Universign""" 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"}, timeout=30, ) response.raise_for_status() transaction_id = response.json().get("id") # Étape 2: Upload PDF files = {"file": (f"Devis_{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, data={"document": file_id}, timeout=30, ) 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, data={"type": "signature"}, timeout=30, ) 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, data={"signer": email, "field": field_id}, timeout=30, ) response.raise_for_status() # Étape 6: Démarrer transaction response = requests.post( 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 "" ) logger.info(f"✅ Signature Universign envoyée: {transaction_id}") return { "transaction_id": transaction_id, "signer_url": signer_url, "statut": "ENVOYE", } except Exception as e: logger.error(f"❌ Erreur Universign: {e}") return {"error": str(e), "statut": "ERREUR"} async def universign_statut(transaction_id: str) -> Dict: """Récupération statut signature""" import requests try: response = requests.get( f"{settings.universign_api_url}/transactions/{transaction_id}", auth=(settings.universign_api_key, ""), timeout=10, ) if response.status_code == 200: data = response.json() statut_map = { "draft": "EN_ATTENTE", "started": "EN_ATTENTE", "completed": "SIGNE", "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE", } return { "statut": statut_map.get(data.get("state"), "EN_ATTENTE"), "date_signature": data.get("completed_at"), } else: return {"statut": "ERREUR"} except Exception as e: 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 logger.info("✅ sage_client injecté dans email_queue") # Démarrer queue email_queue.start(num_workers=settings.max_email_workers) logger.info(f"✅ Email queue démarrée") yield # Cleanup email_queue.stop() logger.info("👋 Services arrêtés") # ===================================================== # APPLICATION # ===================================================== app = FastAPI( title="API Sage 100c Dataven", version="2.0.0", description="API de gestion commerciale - VPS Linux", lifespan=lifespan, openapi_tags=TAGS_METADATA, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["*"], allow_credentials=True, ) 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] except Exception as e: 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} 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=["Clients"]) 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=["Clients"]) 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", "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""" try: articles = sage_client.lister_articles(filtre=query or "") return [ArticleResponse(**a) for a in articles] except Exception as e: logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, 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 = { "client_id": devis.client_id, "date_devis": devis.date_devis.isoformat() if devis.date_devis else None, "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 ], } # Appel HTTP vers Windows resultat = sage_client.creer_devis(devis_data) logger.info(f"✅ Devis créé: {resultat.get('numero_devis')}") return DevisResponse( id=resultat["numero_devis"], client_id=devis.client_id, date_devis=resultat["date_devis"], montant_total_ht=resultat["total_ht"], montant_total_ttc=resultat["total_ttc"], nb_lignes=resultat["nb_lignes"], ) except Exception as e: logger.error(f"Erreur création devis: {e}") raise HTTPException(500, str(e)) @app.put("/devis/{id}", tags=["Devis"]) 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=["Commandes"]) 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=["Commandes"]) 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=["Devis"]) async def lister_devis( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), inclure_lignes: bool = Query( 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 ) return devis_list except Exception as e: logger.error(f"Erreur liste devis: {e}") raise HTTPException(500, str(e)) @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) 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: raise except Exception as e: logger.error(f"Erreur lecture devis: {e}") raise HTTPException(500, str(e)) @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 pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) return StreamingResponse( iter([pdf_bytes]), media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename=Devis_{id}.pdf"}, ) except Exception as e: 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"), ): """ 📄 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( 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) if not devis: raise HTTPException(404, f"Devis {id} introuvable") # Créer logs email pour chaque destinataire tous_destinataires = [request.destinataire] + request.cc + request.cci email_logs = [] for dest in tous_destinataires: email_log = EmailLog( id=str(uuid.uuid4()), destinataire=dest, sujet=request.sujet, corps_html=request.corps_html, document_ids=id, type_document=TypeDocument.DEVIS, statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) await session.flush() email_queue.enqueue(email_log.id) email_logs.append(email_log.id) await session.commit() logger.info( f"✅ Email devis {id} mis en file: {len(tous_destinataires)} destinataire(s)" ) return { "success": True, "email_log_ids": email_logs, "devis_id": id, "message": f"{len(tous_destinataires)} email(s) en file d'attente", } except HTTPException: raise except Exception as e: logger.error(f"Erreur envoi email: {e}") raise HTTPException(500, str(e)) @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é" ), ): """ 📊 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) 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é: {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}", } except HTTPException: raise except Exception as e: 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, 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=["Commandes"]) 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 except Exception as e: logger.error(f"Erreur liste commandes: {e}") raise HTTPException(500, str(e)) @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( 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, type_source=TypeDocument.DEVIS, document_cible=resultat.get("document_cible", ""), type_cible=TypeDocument.BON_COMMANDE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), succes=True, ) session.add(workflow_log) await session.commit() logger.info( f"✅ Transformation: Devis {id} → Commande {resultat['document_cible']}" ) return { "success": True, "document_source": id, "document_cible": resultat["document_cible"], "nb_lignes": resultat["nb_lignes"], "statut_devis_mis_a_jour": True, } except Exception as e: logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) @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, type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 type_cible=settings.SAGE_TYPE_FACTURE, # = 60 ) workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, type_source=TypeDocument.BON_COMMANDE, 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: Commande {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)) # ===================================================== # ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE) # ===================================================== @app.post("/signature/universign/send", tags=["Signatures"]) 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) # Envoi Universign resultat = await universign_envoyer( demande.doc_id, pdf_bytes, demande.email_signataire, demande.nom_signataire ) if "error" in resultat: raise HTTPException(500, resultat["error"]) # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=demande.doc_id, type_document=demande.type_doc, transaction_id=resultat["transaction_id"], signer_url=resultat["signer_url"], email_signataire=demande.email_signataire, nom_signataire=demande.nom_signataire, statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), ) session.add(signature_log) await session.commit() # MAJ champ libre Sage via gateway Windows sage_client.mettre_a_jour_champ_libre( demande.doc_id, demande.type_doc, "UniversignID", resultat["transaction_id"] ) logger.info(f"✅ Signature envoyée: {demande.doc_id}") return { "success": True, "transaction_id": resultat["transaction_id"], "signer_url": resultat["signer_url"], } except HTTPException: raise except Exception as e: logger.error(f"Erreur signature: {e}") raise HTTPException(500, str(e)) @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: query = select(SignatureLog).where(SignatureLog.document_id == docId) result = await session.execute(query) signature_log = result.scalar_one_or_none() if not signature_log: raise HTTPException(404, "Signature introuvable") # Interroger Universign statut = await universign_statut(signature_log.transaction_id) return { "doc_id": docId, "statut": statut["statut"], "date_signature": statut.get("date_signature"), } except HTTPException: raise except Exception as e: logger.error(f"Erreur statut signature: {e}") raise HTTPException(500, str(e)) @app.get("/signatures", tags=["Signatures"]) async def lister_signatures( statut: Optional[StatutSignature] = Query(None), 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: statut_db = StatutSignatureEnum[statut.value] query = query.where(SignatureLog.statut == statut_db) query = query.limit(limit) result = await session.execute(query) signatures = result.scalars().all() return [ { "id": sig.id, "document_id": sig.document_id, "type_document": sig.type_document.value, "transaction_id": sig.transaction_id, "signer_url": sig.signer_url, "email_signataire": sig.email_signataire, "nom_signataire": sig.nom_signataire, "statut": sig.statut.value, "date_envoi": sig.date_envoi.isoformat() if sig.date_envoi else None, "date_signature": ( sig.date_signature.isoformat() if sig.date_signature else None ), "est_relance": sig.est_relance, "nb_relances": sig.nb_relances or 0, } for sig in signatures ] @app.get("/signatures/{transaction_id}/status", tags=["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() if not signature_log: raise HTTPException(404, f"Transaction {transaction_id} introuvable") # Interroger Universign statut_universign = await universign_statut(transaction_id) if statut_universign.get("statut") != "ERREUR": statut_map = { "EN_ATTENTE": StatutSignatureEnum.EN_ATTENTE, "ENVOYE": StatutSignatureEnum.ENVOYE, "SIGNE": StatutSignatureEnum.SIGNE, "REFUSE": StatutSignatureEnum.REFUSE, "EXPIRE": StatutSignatureEnum.EXPIRE, } nouveau_statut = statut_map.get( statut_universign["statut"], StatutSignatureEnum.EN_ATTENTE ) signature_log.statut = nouveau_statut if statut_universign.get("date_signature"): signature_log.date_signature = datetime.fromisoformat( statut_universign["date_signature"].replace("Z", "+00:00") ) await session.commit() return { "transaction_id": transaction_id, "document_id": signature_log.document_id, "statut": signature_log.statut.value, "email_signataire": signature_log.email_signataire, "date_envoi": ( signature_log.date_envoi.isoformat() if signature_log.date_envoi else None ), "date_signature": ( signature_log.date_signature.isoformat() if signature_log.date_signature else None ), "signer_url": signature_log.signer_url, } @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] ) ) result = await session.execute(query) signatures = result.scalars().all() nb_mises_a_jour = 0 for sig in signatures: try: statut_universign = await universign_statut(sig.transaction_id) if statut_universign.get("statut") != "ERREUR": statut_map = { "SIGNE": StatutSignatureEnum.SIGNE, "REFUSE": StatutSignatureEnum.REFUSE, "EXPIRE": StatutSignatureEnum.EXPIRE, } nouveau = statut_map.get(statut_universign["statut"]) if nouveau and nouveau != sig.statut: sig.statut = nouveau if statut_universign.get("date_signature"): sig.date_signature = datetime.fromisoformat( statut_universign["date_signature"].replace("Z", "+00:00") ) nb_mises_a_jour += 1 except Exception as e: logger.error(f"Erreur refresh signature {sig.transaction_id}: {e}") continue await session.commit() return { "success": True, "nb_signatures_verifiees": len(signatures), "nb_mises_a_jour": nb_mises_a_jour, } @app.post("/devis/{id}/signer", tags=["Devis"]) 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) if not devis: raise HTTPException(404, f"Devis {id} introuvable") # Générer PDF pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) # Envoi Universign resultat = await universign_envoyer( id, pdf_bytes, request.email_signataire, request.nom_signataire ) if "error" in resultat: raise HTTPException(500, f"Erreur Universign: {resultat['error']}") # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=id, type_document=TypeDocument.DEVIS, transaction_id=resultat["transaction_id"], signer_url=resultat["signer_url"], email_signataire=request.email_signataire, nom_signataire=request.nom_signataire, statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), ) session.add(signature_log) await session.commit() # MAJ champ libre Sage via gateway sage_client.mettre_a_jour_champ_libre( id, TypeDocument.DEVIS, "UniversignID", resultat["transaction_id"] ) return { "success": True, "devis_id": id, "transaction_id": resultat["transaction_id"], "signer_url": resultat["signer_url"], } except HTTPException: raise except Exception as e: logger.error(f"Erreur envoi signature: {e}") 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) corps_html: str = Field(..., min_length=1) document_ids: Optional[List[str]] = None type_document: Optional[TypeDocument] = None @app.post("/emails/send-batch", tags=["Emails"]) 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: email_log = EmailLog( id=str(uuid.uuid4()), destinataire=destinataire, sujet=batch.sujet, corps_html=batch.corps_html, document_ids=",".join(batch.document_ids) if batch.document_ids else None, type_document=batch.type_document, statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) await session.flush() email_queue.enqueue(email_log.id) resultats.append( { "destinataire": destinataire, "log_id": email_log.id, "statut": "EN_ATTENTE", } ) await session.commit() nb_documents = len(batch.document_ids) if batch.document_ids else 0 logger.info( f"✅ {len(batch.destinataires)} emails mis en file avec {nb_documents} docs" ) return { "total": len(batch.destinataires), "succes": len(batch.destinataires), "documents_attaches": nb_documents, "details": resultats, } # ===================================================== # ENDPOINTS - US-A5 # ===================================================== @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), ): """ 💰 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 if not autorisee: message = f"⚠️ Remise trop élevée (max autorisé: {remise_max}%)" logger.warning( f"Remise refusée pour {client_id}: {remise_pourcentage}% > {remise_max}%" ) else: message = "✅ Remise autorisée" return BaremeRemiseResponse( client_id=client_id, remise_max_autorisee=remise_max, remise_demandee=remise_pourcentage, autorisee=autorisee, message=message, ) except Exception as e: logger.error(f"Erreur validation remise: {e}") 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) if not devis: raise HTTPException(404, f"Devis {id} introuvable") # Récupérer contact via gateway contact = sage_client.lire_contact_client(devis["client_code"]) if not contact or not contact.get("email"): raise HTTPException(400, "Aucun email trouvé pour ce client") # Générer PDF pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) # Envoi Universign resultat = await universign_envoyer( id, pdf_bytes, contact["email"], contact["nom"] or contact["client_intitule"], ) if "error" in resultat: raise HTTPException(500, resultat["error"]) # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=id, type_document=TypeDocument.DEVIS, transaction_id=resultat["transaction_id"], signer_url=resultat["signer_url"], email_signataire=contact["email"], nom_signataire=contact["nom"] or contact["client_intitule"], statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), est_relance=True, nb_relances=1, ) session.add(signature_log) await session.commit() return { "success": True, "devis_id": id, "transaction_id": resultat["transaction_id"], "message": "Relance signature envoyée", } except HTTPException: raise except Exception as e: logger.error(f"Erreur relance: {e}") raise HTTPException(500, str(e)) class ContactClientResponse(BaseModel): client_code: str client_intitule: str email: Optional[str] nom: Optional[str] telephone: Optional[str] peut_etre_relance: bool @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) if not devis: raise HTTPException(404, f"Devis {id} introuvable") # Lire contact via gateway Windows contact = sage_client.lire_contact_client(devis["client_code"]) if not contact: raise HTTPException( 404, f"Contact introuvable pour client {devis['client_code']}" ) peut_relancer = bool(contact.get("email")) return ContactClientResponse(**contact, peut_etre_relance=peut_relancer) except HTTPException: raise except Exception as e: logger.error(f"Erreur récupération contact: {e}") raise HTTPException(500, str(e)) # ===================================================== # ENDPOINTS - US-A7 # ===================================================== @app.get("/factures", tags=["Factures"]) 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 except Exception as e: logger.error(f"Erreur liste factures: {e}") raise HTTPException(500, str(e)) @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, 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 @app.post("/factures", status_code=201, tags=["Factures"]) 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=["Factures"]) 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 = { "relance_facture": { "id": "relance_facture", "nom": "Relance Facture", "sujet": "Rappel - Facture {{DO_Piece}}", "corps_html": """

Bonjour {{CT_Intitule}},

La facture {{DO_Piece}} du {{DO_Date}} d'un montant de {{DO_TotalTTC}}€ TTC reste impayée.

Merci de régulariser dans les meilleurs délais.

Cordialement,

""", "variables_disponibles": [ "DO_Piece", "DO_Date", "CT_Intitule", "DO_TotalHT", "DO_TotalTTC", ], } } @app.post("/factures/{id}/relancer", tags=["Factures"]) async def relancer_facture( id: str, 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) if not facture: raise HTTPException(404, f"Facture {id} introuvable") # Récupérer contact via gateway Windows contact = sage_client.lire_contact_client(facture["client_code"]) if not contact or not contact.get("email"): raise HTTPException(400, "Aucun email trouvé pour ce client") # Préparer email template = templates_email_db["relance_facture"] variables = { "DO_Piece": facture.get("numero", id), "DO_Date": str(facture.get("date", "")), "CT_Intitule": facture.get("client_intitule", ""), "DO_TotalHT": f"{facture.get('total_ht', 0):.2f}", "DO_TotalTTC": f"{facture.get('total_ttc', 0):.2f}", } sujet = template["sujet"] corps = relance.message_personnalise or template["corps_html"] for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", valeur) corps = corps.replace(f"{{{{{var}}}}}", valeur) # Créer log email email_log = EmailLog( id=str(uuid.uuid4()), destinataire=contact["email"], sujet=sujet, corps_html=corps, document_ids=id, type_document=TypeDocument.FACTURE, statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) await session.flush() # 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() logger.info(f"✅ Relance facture: {id} → {contact['email']}") return { "success": True, "facture_id": id, "email_log_id": email_log.id, "destinataire": contact["email"], } except HTTPException: raise except Exception as e: 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), destinataire: Optional[str] = Query(None), limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), ): """📋 US-A9: Journal des e-mails envoyés""" query = select(EmailLog) if statut: query = query.where(EmailLog.statut == StatutEmailEnum[statut.value]) if destinataire: query = query.where(EmailLog.destinataire.contains(destinataire)) query = query.order_by(EmailLog.date_creation.desc()).limit(limit) result = await session.execute(query) logs = result.scalars().all() return [ { "id": log.id, "destinataire": log.destinataire, "sujet": log.sujet, "statut": log.statut.value, "date_creation": log.date_creation.isoformat(), "date_envoi": log.date_envoi.isoformat() if log.date_envoi else None, "nb_tentatives": log.nb_tentatives, "derniere_erreur": log.derniere_erreur, "document_ids": log.document_ids, } for log in logs ] @app.get("/emails/logs/export", tags=["Emails"]) 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]) query = query.order_by(EmailLog.date_creation.desc()) result = await session.execute(query) logs = result.scalars().all() # Génération CSV output = io.StringIO() writer = csv.writer(output) # En-têtes writer.writerow( [ "ID", "Destinataire", "Sujet", "Statut", "Date Création", "Date Envoi", "Nb Tentatives", "Erreur", "Documents", ] ) # Données for log in logs: writer.writerow( [ log.id, log.destinataire, log.sujet, log.statut.value, log.date_creation.isoformat(), log.date_envoi.isoformat() if log.date_envoi else "", log.nb_tentatives, log.derniere_erreur or "", log.document_ids or "", ] ) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={ "Content-Disposition": f"attachment; filename=emails_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" }, ) # ============================================ # Devis0 - MODÈLES D'E-MAILS # ============================================ class TemplateEmail(BaseModel): id: Optional[str] = None nom: str sujet: str corps_html: str variables_disponibles: List[str] = [] class TemplatePreviewRequest(BaseModel): template_id: str document_id: str type_document: TypeDocument @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()] @app.get( "/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") return TemplateEmail(**templates_email_db[template_id]) @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] = { "id": template_id, "nom": template.nom, "sujet": template.sujet, "corps_html": template.corps_html, "variables_disponibles": template.variables_disponibles, } logger.info(f"Template créé: {template_id}") return TemplateEmail(id=template_id, **template.dict()) @app.put( "/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") # Ne pas modifier les templates système if template_id in ["relance_devis", "relance_facture"]: raise HTTPException(400, "Les templates système ne peuvent pas être modifiés") templates_email_db[template_id] = { "id": template_id, "nom": template.nom, "sujet": template.sujet, "corps_html": template.corps_html, "variables_disponibles": template.variables_disponibles, } logger.info(f"Template modifié: {template_id}") return TemplateEmail(id=template_id, **template.dict()) @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") if template_id in ["relance_devis", "relance_facture"]: raise HTTPException(400, "Les templates système ne peuvent pas être supprimés") del templates_email_db[template_id] logger.info(f"Template supprimé: {template_id}") return {"success": True, "message": f"Template {template_id} supprimé"} @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") template = templates_email_db[preview.template_id] # Lire document via gateway Windows doc = sage_client.lire_document(preview.document_id, preview.type_document) if not doc: raise HTTPException(404, f"Document {preview.document_id} introuvable") # Variables variables = { "DO_Piece": doc.get("numero", preview.document_id), "DO_Date": str(doc.get("date", "")), "CT_Intitule": doc.get("client_intitule", ""), "DO_TotalHT": f"{doc.get('total_ht', 0):.2f}", "DO_TotalTTC": f"{doc.get('total_ttc', 0):.2f}", } # Fusion sujet_preview = template["sujet"] corps_preview = template["corps_html"] for var, valeur in variables.items(): sujet_preview = sujet_preview.replace(f"{{{{{var}}}}}", valeur) corps_preview = corps_preview.replace(f"{{{{{var}}}}}", valeur) return { "template_id": preview.template_id, "document_id": preview.document_id, "sujet": sujet_preview, "corps_html": corps_preview, "variables_utilisees": variables, } # ===================================================== # ENDPOINTS - HEALTH # ===================================================== @app.get("/health", tags=["System"]) async def health_check(): """🏥 Health check""" gateway_health = sage_client.health() return { "status": "healthy", "sage_gateway": gateway_health, "email_queue": { "running": email_queue.running, "workers": len(email_queue.workers), "queue_size": email_queue.queue.qsize(), }, "timestamp": datetime.now().isoformat(), } @app.get("/", tags=["System"]) async def root(): """🏠 Page d'accueil""" return { "api": "Sage 100c Dataven - VPS Linux", "version": "2.0.0", "documentation": "/docs", "health": "/health", } # ===================================================== # ENDPOINTS - ADMIN # ===================================================== @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 except Exception as e: logger.error(f"Erreur info cache: {e}") 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), "running": email_queue.running, } # ===================================================== # 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 ✅ 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") 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), ): """ ➕ 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.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""" 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)) @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 # ===================================================== @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.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=["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, 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.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) ✅ 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=["Workflows"]) 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), 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)) @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 # ===================================================== if __name__ == "__main__": uvicorn.run( "api:app", host=settings.api_host, port=settings.api_port, reload=settings.api_reload, )