From 643250850b6e2ef4e5fc8a725caad78049b62490 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 27 Nov 2025 12:40:23 +0300 Subject: [PATCH 001/199] feat: Add `/devis` endpoint for listing devis and apply minor formatting adjustments. --- api.py | 447 +++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 320 insertions(+), 127 deletions(-) diff --git a/api.py b/api.py index 368edb1..0377673 100644 --- a/api.py +++ b/api.py @@ -17,11 +17,8 @@ from sqlalchemy import select # Configuration logging logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler("sage_api.log"), - logging.StreamHandler() - ] + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("sage_api.log"), logging.StreamHandler()], ) logger = logging.getLogger(__name__) @@ -35,11 +32,12 @@ from database import ( StatutEmail as StatutEmailEnum, WorkflowLog, SignatureLog, - StatutSignature as StatutSignatureEnum + StatutSignature as StatutSignatureEnum, ) from email_queue import email_queue from sage_client import sage_client + # ===================================================== # ENUMS # ===================================================== @@ -51,6 +49,7 @@ class TypeDocument(int, Enum): PREPARATION = 4 FACTURE = 5 + class StatutSignature(str, Enum): EN_ATTENTE = "EN_ATTENTE" ENVOYE = "ENVOYE" @@ -58,6 +57,7 @@ class StatutSignature(str, Enum): REFUSE = "REFUSE" EXPIRE = "EXPIRE" + class StatutEmail(str, Enum): EN_ATTENTE = "EN_ATTENTE" EN_COURS = "EN_COURS" @@ -66,6 +66,7 @@ class StatutEmail(str, Enum): ERREUR = "ERREUR" BOUNCE = "BOUNCE" + # ===================================================== # MODÈLES PYDANTIC # ===================================================== @@ -78,23 +79,27 @@ class ClientResponse(BaseModel): email: Optional[str] = None telephone: Optional[str] = None + 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 + class DevisRequest(BaseModel): client_id: str date_devis: Optional[date] = None lignes: List[LigneDevis] + class DevisResponse(BaseModel): id: str client_id: str @@ -103,12 +108,14 @@ class DevisResponse(BaseModel): 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]] = [] @@ -118,101 +125,108 @@ class EmailEnvoiRequest(BaseModel): document_ids: Optional[List[str]] = None type_document: Optional[TypeDocument] = None + class RelanceDevisRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== -async def universign_envoyer(doc_id: str, pdf_bytes: bytes, email: str, nom: str) -> Dict: +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 + 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')} + 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 + 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 + 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 + 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 + 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 "" - + 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" + "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 + timeout=10, ) - + if response.status_code == 200: data = response.json() statut_map = { @@ -221,19 +235,20 @@ async def universign_statut(transaction_id: str) -> Dict: "completed": "SIGNE", "refused": "REFUSE", "expired": "EXPIRE", - "canceled": "REFUSE" + "canceled": "REFUSE", } return { "statut": statut_map.get(data.get("state"), "EN_ATTENTE"), - "date_signature": data.get("completed_at") + "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 # ===================================================== @@ -242,23 +257,24 @@ async def lifespan(app: FastAPI): # Init base de données await init_db() logger.info("✅ Base de données initialisée") - + # Injecter session_factory dans email_queue email_queue.session_factory = async_session_factory - + # ⚠️ PAS de sage_connector ici (c'est sur Windows !) # email_queue utilisera sage_client pour générer les PDFs via HTTP - + # 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 # ===================================================== @@ -266,7 +282,7 @@ app = FastAPI( title="API Sage 100c Dataven", version="2.0.0", description="API de gestion commerciale - VPS Linux", - lifespan=lifespan + lifespan=lifespan, ) app.add_middleware( @@ -274,9 +290,10 @@ app.add_middleware( allow_origins=settings.cors_origins, allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["*"], - allow_credentials=True + allow_credentials=True, ) + # ===================================================== # ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) # ===================================================== @@ -290,6 +307,7 @@ async def rechercher_clients(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) + @app.get("/articles", response_model=List[ArticleResponse], tags=["US-A1"]) async def rechercher_articles(query: Optional[str] = Query(None)): """🔍 Recherche articles via gateway Windows""" @@ -300,6 +318,7 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) + @app.post("/devis", response_model=DevisResponse, status_code=201, tags=["US-A1"]) async def creer_devis(devis: DevisRequest): """📝 Création de devis via gateway Windows""" @@ -313,30 +332,48 @@ async def creer_devis(devis: DevisRequest): "article_code": l.article_code, "quantite": l.quantite, "prix_unitaire_ht": l.prix_unitaire_ht, - "remise_pourcentage": l.remise_pourcentage + "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"] + nb_lignes=resultat["nb_lignes"], ) - + except Exception as e: logger.error(f"Erreur création devis: {e}") raise HTTPException(500, str(e)) + +@app.get("/devis", tags=["US-A1"]) +async def lister_devis( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + """ + 📋 Liste tous les devis via gateway Windows + """ + try: + # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) + devis_list = sage_client.lister_devis(limit=limit, statut=statut) + return devis_list + + except Exception as e: + logger.error(f"Erreur liste devis: {e}") + raise HTTPException(500, str(e)) + + @app.get("/devis/{id}", tags=["US-A1"]) async def lire_devis(id: str): """📄 Lecture d'un devis via gateway Windows""" @@ -351,6 +388,7 @@ async def lire_devis(id: str): logger.error(f"Erreur lecture devis: {e}") raise HTTPException(500, str(e)) + @app.get("/devis/{id}/pdf", tags=["US-A1"]) async def telecharger_devis_pdf(id: str): """📄 Téléchargement PDF (généré via email_queue)""" @@ -358,21 +396,20 @@ async def telecharger_devis_pdf(id: str): # 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"} + 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.post("/devis/{id}/envoyer", tags=["US-A1"]) async def envoyer_devis_email( - id: str, - request: EmailEnvoiRequest, - session: AsyncSession = Depends(get_session) + id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) ): """📧 Envoi devis par email""" try: @@ -380,11 +417,11 @@ async def envoyer_devis_email( 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()), @@ -395,49 +432,92 @@ async def envoyer_devis_email( type_document=TypeDocument.DEVIS, statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), - nb_tentatives=0 + 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)") - + + 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" + "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=["US-A1"]) +async def changer_statut_devis(id: str, nouveau_statut: int = Query(..., ge=0, le=5)): + """ + 📄 Changement de statut d'un devis via gateway Windows + """ + try: + # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) + resultat = sage_client.changer_statut_devis(id, nouveau_statut) + + logger.info(f"✅ Statut devis {id} changé: {nouveau_statut}") + + return { + "success": True, + "devis_id": id, + "statut_ancien": resultat.get("statut_ancien"), + "statut_nouveau": resultat.get("statut_nouveau"), + "message": "Statut mis à jour avec succès", + } + + except Exception as e: + logger.error(f"Erreur changement statut: {e}") + raise HTTPException(500, str(e)) + + # ===================================================== # ENDPOINTS - US-A2 (WORKFLOW SANS RESSAISIE) # ===================================================== -@app.post("/workflow/devis/{id}/to-commande", tags=["US-A2"]) -async def devis_vers_commande( - id: str, - session: AsyncSession = Depends(get_session) + + +@app.get("/commandes", tags=["US-A2"]) +async def lister_commandes( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): + """ + 📋 Liste toutes les commandes via gateway Windows + """ + try: + # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) + 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=["US-A2"]) +async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): """🔧 Transformation Devis → Commande via gateway Windows""" try: # Appel HTTP vers Windows resultat = sage_client.transformer_document( numero_source=id, type_source=TypeDocument.DEVIS, - type_cible=TypeDocument.COMMANDE + type_cible=TypeDocument.COMMANDE, ) - + # Logger en DB workflow_log = WorkflowLog( id=str(uuid.uuid4()), @@ -447,38 +527,38 @@ async def devis_vers_commande( type_cible=TypeDocument.COMMANDE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), - succes=True + succes=True, ) - + session.add(workflow_log) await session.commit() - - logger.info(f"✅ Transformation: Devis {id} → Commande {resultat['document_cible']}") - + + 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"] + "nb_lignes": resultat["nb_lignes"], } - + except Exception as e: logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) + @app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) -async def commande_vers_facture( - id: str, - session: AsyncSession = Depends(get_session) -): +async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """🔧 Transformation Commande → Facture via gateway Windows""" try: resultat = sage_client.transformer_document( numero_source=id, type_source=TypeDocument.COMMANDE, - type_cible=TypeDocument.FACTURE + type_cible=TypeDocument.FACTURE, ) - + workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, @@ -487,42 +567,39 @@ async def commande_vers_facture( type_cible=TypeDocument.FACTURE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), - succes=True + succes=True, ) - + session.add(workflow_log) await session.commit() - + return resultat - + 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=["US-A3"]) async def envoyer_signature( - demande: SignatureRequest, - session: AsyncSession = Depends(get_session) + 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 + 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()), @@ -533,42 +610,78 @@ async def envoyer_signature( email_signataire=demande.email_signataire, nom_signataire=demande.nom_signataire, statut=StatutSignatureEnum.ENVOYE, - date_envoi=datetime.now() + 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"] + 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"] + "signer_url": resultat["signer_url"], } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur signature: {e}") raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - US-A5 +# ===================================================== + + +@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["US-A5"]) +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=["US-A6"]) async def relancer_devis_signature( - id: str, - relance: RelanceDevisRequest, - session: AsyncSession = Depends(get_session) + id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): """📧 Relance devis via Universign""" try: @@ -576,26 +689,26 @@ async def relancer_devis_signature( 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"] + 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()), @@ -608,25 +721,48 @@ async def relancer_devis_signature( statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), est_relance=True, - nb_relances=1 + 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" + "message": "Relance signature envoyée", } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur relance: {e}") raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - US-A7 +# ===================================================== + + +@app.get("/factures", tags=["US-A7"]) +async def lister_factures( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + """ + 📋 Liste toutes les factures via gateway Windows + """ + try: + # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) + 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)) + + # ===================================================== # ENDPOINTS - HEALTH # ===================================================== @@ -634,18 +770,19 @@ async def relancer_devis_signature( 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() + "queue_size": email_queue.queue.qsize(), }, - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now().isoformat(), } + @app.get("/", tags=["System"]) async def root(): """🏠 Page d'accueil""" @@ -653,9 +790,65 @@ async def root(): "api": "Sage 100c Dataven - VPS Linux", "version": "2.0.0", "documentation": "/docs", - "health": "/health" + "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, + } + + # ===================================================== # LANCEMENT # ===================================================== @@ -664,5 +857,5 @@ if __name__ == "__main__": "api:app", host=settings.api_host, port=settings.api_port, - reload=settings.api_reload - ) \ No newline at end of file + reload=settings.api_reload, + ) From 636e2a96a7bba75f22a272833c7fd945c02e52e7 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 27 Nov 2025 12:41:52 +0300 Subject: [PATCH 002/199] feat: Add functionality to list quotes, orders, and invoices, change quote status, read client discounts, and retrieve cache information. --- sage_client.py | 127 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 21 deletions(-) diff --git a/sage_client.py b/sage_client.py index a0b92fe..0b5cf95 100644 --- a/sage_client.py +++ b/sage_client.py @@ -5,38 +5,63 @@ import logging logger = logging.getLogger(__name__) + class SageGatewayClient: """ Client HTTP pour communiquer avec la gateway Sage Windows """ - + def __init__(self): self.url = settings.sage_gateway_url.rstrip("/") self.headers = { "X-Sage-Token": settings.sage_gateway_token, - "Content-Type": "application/json" + "Content-Type": "application/json", } self.timeout = 30 def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict: """POST avec retry automatique""" import time - + for attempt in range(retries): try: r = requests.post( f"{self.url}{endpoint}", json=data or {}, headers=self.headers, - timeout=self.timeout + timeout=self.timeout, ) r.raise_for_status() return r.json() except requests.exceptions.RequestException as e: if attempt == retries - 1: - logger.error(f"❌ Échec après {retries} tentatives sur {endpoint}: {e}") + logger.error( + f"❌ Échec après {retries} tentatives sur {endpoint}: {e}" + ) raise - time.sleep(2 ** attempt) # Backoff exponentiel + time.sleep(2**attempt) + + def _get(self, endpoint: str, params: dict = None, retries: int = 3) -> dict: + """GET avec retry automatique""" + import time + + for attempt in range(retries): + try: + r = requests.get( + f"{self.url}{endpoint}", + params=params or {}, + headers=self.headers, + timeout=self.timeout, + ) + r.raise_for_status() + return r.json() + except requests.exceptions.RequestException as e: + if attempt == retries - 1: + logger.error( + f"❌ Échec GET après {retries} tentatives sur {endpoint}: {e}" + ) + raise + time.sleep(2**attempt) # === CLIENTS === def lister_clients(self, filtre: str = "") -> List[Dict]: @@ -59,34 +84,93 @@ class SageGatewayClient: def lire_devis(self, numero: str) -> Optional[Dict]: return self._post("/sage/devis/get", {"code": numero}).get("data") + # 🆕 US-A1: Lister devis + def lister_devis( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + """Liste tous les devis avec filtres""" + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/devis/list", payload).get("data", []) + + # 🆕 US-A1: Changer statut devis + def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: + """Change le statut d'un devis""" + return self._post( + "/sage/devis/statut", {"numero": numero, "nouveau_statut": nouveau_statut} + ).get("data", {}) + # === DOCUMENTS === def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: - return self._post("/sage/documents/get", {"numero": numero, "type_doc": type_doc}).get("data") + return self._post( + "/sage/documents/get", {"numero": numero, "type_doc": type_doc} + ).get("data") - def transformer_document(self, numero_source: str, type_source: int, type_cible: int) -> Dict: - return self._post("/sage/documents/transform", { - "numero_source": numero_source, - "type_source": type_source, - "type_cible": type_cible - }).get("data", {}) + def transformer_document( + self, numero_source: str, type_source: int, type_cible: int + ) -> Dict: + return self._post( + "/sage/documents/transform", + { + "numero_source": numero_source, + "type_source": type_source, + "type_cible": type_cible, + }, + ).get("data", {}) - def mettre_a_jour_champ_libre(self, doc_id: str, type_doc: int, nom_champ: str, valeur: str) -> bool: - resp = self._post("/sage/documents/champ-libre", { - "doc_id": doc_id, - "type_doc": type_doc, - "nom_champ": nom_champ, - "valeur": valeur - }) + def mettre_a_jour_champ_libre( + self, doc_id: str, type_doc: int, nom_champ: str, valeur: str + ) -> bool: + resp = self._post( + "/sage/documents/champ-libre", + { + "doc_id": doc_id, + "type_doc": type_doc, + "nom_champ": nom_champ, + "valeur": valeur, + }, + ) return resp.get("success", False) + # 🆕 US-A2: Lister commandes + def lister_commandes( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + """Liste toutes les commandes""" + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/commandes/list", payload).get("data", []) + + # 🆕 US-A7: Lister factures + def lister_factures( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + """Liste toutes les factures""" + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/factures/list", payload).get("data", []) + # === CONTACTS === def lire_contact_client(self, code_client: str) -> Optional[Dict]: return self._post("/sage/contact/read", {"code": code_client}).get("data") + # 🆕 US-A5: Lire remise max client + def lire_remise_max_client(self, code_client: str) -> float: + """Récupère la remise max autorisée pour un client""" + result = self._post("/sage/client/remise-max", {"code": code_client}) + return result.get("data", {}).get("remise_max", 10.0) + # === CACHE === def refresh_cache(self) -> Dict: return self._post("/sage/cache/refresh") + def get_cache_info(self) -> Dict: + """Récupère les infos du cache""" + return self._get("/sage/cache/info").get("data", {}) + # === HEALTH === def health(self) -> dict: try: @@ -95,5 +179,6 @@ class SageGatewayClient: except: return {"status": "down"} + # Instance globale -sage_client = SageGatewayClient() \ No newline at end of file +sage_client = SageGatewayClient() From df6e09af07542c4eba61c48e7d404468f62e30a7 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 27 Nov 2025 13:08:44 +0300 Subject: [PATCH 003/199] feat: Add `BaremeRemiseResponse` model, expand `SageGatewayClient` with methods for document listing, status updates, discount retrieval, and PDF generation, and ignore `.db` files. --- .gitignore | 2 + api.py | 8 ++++ sage_client.py | 103 ++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 5407a98..023d3fc 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ htmlcov/ *~ .build/ dist/ + +*.db diff --git a/api.py b/api.py index 0377673..9cc55fd 100644 --- a/api.py +++ b/api.py @@ -131,6 +131,14 @@ class RelanceDevisRequest(BaseModel): message_personnalise: Optional[str] = None +class BaremeRemiseResponse(BaseModel): + client_id: str + remise_max_autorisee: float + remise_demandee: float + autorisee: bool + message: str + + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== diff --git a/sage_client.py b/sage_client.py index 0b5cf95..a0f4342 100644 --- a/sage_client.py +++ b/sage_client.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) class SageGatewayClient: """ Client HTTP pour communiquer avec la gateway Sage Windows + ✅ VERSION COMPLÈTE avec toutes les routes nécessaires """ def __init__(self): @@ -63,46 +64,59 @@ class SageGatewayClient: raise time.sleep(2**attempt) - # === CLIENTS === + # ===================================================== + # CLIENTS + # ===================================================== def lister_clients(self, filtre: str = "") -> List[Dict]: + """Liste tous les clients avec filtre optionnel""" return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) def lire_client(self, code: str) -> Optional[Dict]: + """Lecture d'un client par code""" return self._post("/sage/clients/get", {"code": code}).get("data") - # === ARTICLES === + # ===================================================== + # ARTICLES + # ===================================================== def lister_articles(self, filtre: str = "") -> List[Dict]: + """Liste tous les articles avec filtre optionnel""" return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) def lire_article(self, ref: str) -> Optional[Dict]: + """Lecture d'un article par référence""" return self._post("/sage/articles/get", {"code": ref}).get("data") - # === DEVIS === + # ===================================================== + # DEVIS (US-A1) + # ===================================================== def creer_devis(self, devis_data: Dict) -> Dict: + """Création d'un devis""" return self._post("/sage/devis/create", devis_data).get("data", {}) def lire_devis(self, numero: str) -> Optional[Dict]: + """Lecture d'un devis""" return self._post("/sage/devis/get", {"code": numero}).get("data") - # 🆕 US-A1: Lister devis def lister_devis( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """Liste tous les devis avec filtres""" + """✅ NOUVEAU: Liste tous les devis avec filtres""" payload = {"limit": limit} if statut is not None: payload["statut"] = statut return self._post("/sage/devis/list", payload).get("data", []) - # 🆕 US-A1: Changer statut devis def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: - """Change le statut d'un devis""" + """✅ NOUVEAU: Change le statut d'un devis""" return self._post( "/sage/devis/statut", {"numero": numero, "nouveau_statut": nouveau_statut} ).get("data", {}) - # === DOCUMENTS === + # ===================================================== + # DOCUMENTS GÉNÉRIQUES + # ===================================================== def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: + """Lecture d'un document générique""" return self._post( "/sage/documents/get", {"numero": numero, "type_doc": type_doc} ).get("data") @@ -110,6 +124,7 @@ class SageGatewayClient: def transformer_document( self, numero_source: str, type_source: int, type_cible: int ) -> Dict: + """Transformation de document (devis → commande → facture)""" return self._post( "/sage/documents/transform", { @@ -122,6 +137,7 @@ class SageGatewayClient: def mettre_a_jour_champ_libre( self, doc_id: str, type_doc: int, nom_champ: str, valeur: str ) -> bool: + """Mise à jour d'un champ libre""" resp = self._post( "/sage/documents/champ-libre", { @@ -133,46 +149,97 @@ class SageGatewayClient: ) return resp.get("success", False) - # 🆕 US-A2: Lister commandes + # ===================================================== + # COMMANDES (US-A2) + # ===================================================== def lister_commandes( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """Liste toutes les commandes""" + """✅ NOUVEAU: Liste toutes les commandes""" payload = {"limit": limit} if statut is not None: payload["statut"] = statut return self._post("/sage/commandes/list", payload).get("data", []) - # 🆕 US-A7: Lister factures + # ===================================================== + # FACTURES (US-A7) + # ===================================================== def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """Liste toutes les factures""" + """✅ NOUVEAU: Liste toutes les factures""" payload = {"limit": limit} if statut is not None: payload["statut"] = statut return self._post("/sage/factures/list", payload).get("data", []) - # === CONTACTS === + def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool: + """✅ NOUVEAU: Met à jour le champ 'Dernière relance' d'une facture""" + resp = self._post( + "/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc} + ) + return resp.get("success", False) + + # ===================================================== + # CONTACTS (US-A6) + # ===================================================== def lire_contact_client(self, code_client: str) -> Optional[Dict]: + """Lecture du contact principal d'un client""" return self._post("/sage/contact/read", {"code": code_client}).get("data") - # 🆕 US-A5: Lire remise max client + # ===================================================== + # REMISES (US-A5) + # ===================================================== def lire_remise_max_client(self, code_client: str) -> float: - """Récupère la remise max autorisée pour un client""" + """✅ NOUVEAU: Récupère la remise max autorisée pour un client""" result = self._post("/sage/client/remise-max", {"code": code_client}) return result.get("data", {}).get("remise_max", 10.0) - # === CACHE === + # ===================================================== + # GÉNÉRATION PDF (pour email_queue) + # ===================================================== + def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: + """✅ NOUVEAU: Génère le PDF d'un document via la gateway Windows""" + try: + r = requests.post( + f"{self.url}/sage/documents/generate-pdf", + json={"doc_id": doc_id, "type_doc": type_doc}, + headers=self.headers, + timeout=60, # Timeout plus long pour génération PDF + ) + r.raise_for_status() + + # Le PDF est retourné en base64 dans la réponse JSON + import base64 + + response_data = r.json() + pdf_base64 = response_data.get("data", {}).get("pdf_base64", "") + + if not pdf_base64: + raise ValueError("PDF vide retourné par la gateway") + + return base64.b64decode(pdf_base64) + + except Exception as e: + logger.error(f"Erreur génération PDF: {e}") + raise + + # ===================================================== + # CACHE (ADMIN) + # ===================================================== def refresh_cache(self) -> Dict: + """Force le rafraîchissement du cache Windows""" return self._post("/sage/cache/refresh") def get_cache_info(self) -> Dict: - """Récupère les infos du cache""" + """Récupère les infos du cache Windows""" return self._get("/sage/cache/info").get("data", {}) - # === HEALTH === + # ===================================================== + # HEALTH + # ===================================================== def health(self) -> dict: + """Health check de la gateway Windows""" try: r = requests.get(f"{self.url}/health", timeout=5) return r.json() From cba39ad7ec14347e71dbc318d068fb3366f77562 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 27 Nov 2025 17:16:51 +0300 Subject: [PATCH 004/199] feat: Add API endpoints for Universign e-signature management, batch email sending, and quote contact retrieval. --- api.py | 663 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 663 insertions(+) diff --git a/api.py b/api.py index 9cc55fd..9b23297 100644 --- a/api.py +++ b/api.py @@ -644,6 +644,293 @@ async def envoyer_signature( raise HTTPException(500, str(e)) +@app.get("/signature/universign/status", tags=["US-A3"]) +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=["US-A3"]) +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=["US-A3"]) +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=["US-A3"]) +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=["US-A3"]) +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=["US-A4"]) +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 # ===================================================== @@ -749,6 +1036,41 @@ async def relancer_devis_signature( 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=["US-A6"]) +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 # ===================================================== @@ -771,6 +1093,347 @@ async def lister_factures( raise HTTPException(500, str(e)) +class RelanceFactureRequest(BaseModel): + doc_id: str + message_personnalise: Optional[str] = None + + +# 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=["US-A7"]) +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=["US-A9"]) +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=["US-A9"]) +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" + }, + ) + + +# ============================================ +# US-A10 - 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=["US-A10"]) +async def lister_templates(): + """📧 US-A10: Liste tous les templates d'emails""" + return [TemplateEmail(**template) for template in templates_email_db.values()] + + +@app.get( + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["US-A10"] +) +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=["US-A10"]) +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=["US-A10"] +) +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=["US-A10"]) +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=["US-A10"]) +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 # ===================================================== From 8dda1191b338ff50d0cf1805ebbee537bb19d2ca Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 27 Nov 2025 17:47:56 +0300 Subject: [PATCH 005/199] feat: enable including devis lines by default when listing devis. --- api.py | 18 +++++++++++++++--- sage_client.py | 16 +++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/api.py b/api.py index 9b23297..b6926e4 100644 --- a/api.py +++ b/api.py @@ -367,14 +367,26 @@ async def creer_devis(devis: DevisRequest): @app.get("/devis", tags=["US-A1"]) async def lister_devis( - limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) + 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: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) - devis_list = sage_client.lister_devis(limit=limit, statut=statut) + devis_list = sage_client.lister_devis( + limit=limit, statut=statut, inclure_lignes=inclure_lignes + ) return devis_list except Exception as e: diff --git a/sage_client.py b/sage_client.py index a0f4342..741794f 100644 --- a/sage_client.py +++ b/sage_client.py @@ -98,10 +98,20 @@ class SageGatewayClient: return self._post("/sage/devis/get", {"code": numero}).get("data") def lister_devis( - self, limit: int = 100, statut: Optional[int] = None + self, + limit: int = 100, + statut: Optional[int] = None, + inclure_lignes: bool = True, ) -> List[Dict]: - """✅ NOUVEAU: Liste tous les devis avec filtres""" - payload = {"limit": limit} + """ + ✅ NOUVEAU: Liste tous les devis avec filtres + + Args: + limit: Nombre max de devis + statut: Filtre par statut (optionnel) + inclure_lignes: Si True, charge les lignes de chaque devis (par défaut: True) + """ + payload = {"limit": limit, "inclure_lignes": inclure_lignes} if statut is not None: payload["statut"] = statut return self._post("/sage/devis/list", payload).get("data", []) From 2a6f462a0b207912e39e046c65abb4ba41281871 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 28 Nov 2025 05:03:18 +0300 Subject: [PATCH 006/199] refactor: modify quote status endpoint path to include ID and remove a related comment. --- api.py | 1 - sage_client.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/api.py b/api.py index b6926e4..ff07614 100644 --- a/api.py +++ b/api.py @@ -487,7 +487,6 @@ async def changer_statut_devis(id: str, nouveau_statut: int = Query(..., ge=0, l 📄 Changement de statut d'un devis via gateway Windows """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) resultat = sage_client.changer_statut_devis(id, nouveau_statut) logger.info(f"✅ Statut devis {id} changé: {nouveau_statut}") diff --git a/sage_client.py b/sage_client.py index 741794f..d50e1c3 100644 --- a/sage_client.py +++ b/sage_client.py @@ -119,7 +119,7 @@ class SageGatewayClient: def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: """✅ NOUVEAU: Change le statut d'un devis""" return self._post( - "/sage/devis/statut", {"numero": numero, "nouveau_statut": nouveau_statut} + "/sage/devis/{id}/statut", {"numero": numero, "nouveau_statut": nouveau_statut} ).get("data", {}) # ===================================================== From b11e161e7ffcf2b0a2f2f365f4eb6bdcb17ac148 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 28 Nov 2025 05:19:59 +0300 Subject: [PATCH 007/199] Revert "refactor: modify quote status endpoint path to include ID and remove a related comment." This reverts commit 2a6f462a0b207912e39e046c65abb4ba41281871. --- api.py | 1 + sage_client.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api.py b/api.py index ff07614..b6926e4 100644 --- a/api.py +++ b/api.py @@ -487,6 +487,7 @@ async def changer_statut_devis(id: str, nouveau_statut: int = Query(..., ge=0, l 📄 Changement de statut d'un devis via gateway Windows """ try: + # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) resultat = sage_client.changer_statut_devis(id, nouveau_statut) logger.info(f"✅ Statut devis {id} changé: {nouveau_statut}") diff --git a/sage_client.py b/sage_client.py index d50e1c3..741794f 100644 --- a/sage_client.py +++ b/sage_client.py @@ -119,7 +119,7 @@ class SageGatewayClient: def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: """✅ NOUVEAU: Change le statut d'un devis""" return self._post( - "/sage/devis/{id}/statut", {"numero": numero, "nouveau_statut": nouveau_statut} + "/sage/devis/statut", {"numero": numero, "nouveau_statut": nouveau_statut} ).get("data", {}) # ===================================================== From c0327f18909534bf2ca5cc675037c2d11c8f9ae3 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 28 Nov 2025 05:39:04 +0300 Subject: [PATCH 008/199] fix: Use query parameters for changing quote status and transforming documents, and remove "NOUVEAU" tags from docstrings. --- sage_client.py | 72 +++++++++++++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/sage_client.py b/sage_client.py index 741794f..dc2b61e 100644 --- a/sage_client.py +++ b/sage_client.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) class SageGatewayClient: """ Client HTTP pour communiquer avec la gateway Sage Windows - ✅ VERSION COMPLÈTE avec toutes les routes nécessaires + ✅ VERSION CORRIGÉE """ def __init__(self): @@ -104,12 +104,7 @@ class SageGatewayClient: inclure_lignes: bool = True, ) -> List[Dict]: """ - ✅ NOUVEAU: Liste tous les devis avec filtres - - Args: - limit: Nombre max de devis - statut: Filtre par statut (optionnel) - inclure_lignes: Si True, charge les lignes de chaque devis (par défaut: True) + ✅ Liste tous les devis avec filtres """ payload = {"limit": limit, "inclure_lignes": inclure_lignes} if statut is not None: @@ -117,10 +112,24 @@ class SageGatewayClient: return self._post("/sage/devis/list", payload).get("data", []) def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: - """✅ NOUVEAU: Change le statut d'un devis""" - return self._post( - "/sage/devis/statut", {"numero": numero, "nouveau_statut": nouveau_statut} - ).get("data", {}) + """ + ✅ CORRECTION: Utilise query params au lieu du body + """ + try: + r = requests.post( + f"{self.url}/sage/devis/statut", + params={ + "numero": numero, + "nouveau_statut": nouveau_statut, + }, # ← QUERY PARAMS + headers=self.headers, + timeout=self.timeout, + ) + r.raise_for_status() + return r.json().get("data", {}) + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur changement statut: {e}") + raise # ===================================================== # DOCUMENTS GÉNÉRIQUES @@ -134,15 +143,25 @@ class SageGatewayClient: def transformer_document( self, numero_source: str, type_source: int, type_cible: int ) -> Dict: - """Transformation de document (devis → commande → facture)""" - return self._post( - "/sage/documents/transform", - { - "numero_source": numero_source, - "type_source": type_source, - "type_cible": type_cible, - }, - ).get("data", {}) + """ + ✅ CORRECTION: Utilise query params pour la transformation + """ + try: + r = requests.post( + f"{self.url}/sage/documents/transform", + params={ # ← QUERY PARAMS + "numero_source": numero_source, + "type_source": type_source, + "type_cible": type_cible, + }, + headers=self.headers, + timeout=60, # Timeout plus long pour transformation + ) + r.raise_for_status() + return r.json().get("data", {}) + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur transformation: {e}") + raise def mettre_a_jour_champ_libre( self, doc_id: str, type_doc: int, nom_champ: str, valeur: str @@ -165,7 +184,7 @@ class SageGatewayClient: def lister_commandes( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """✅ NOUVEAU: Liste toutes les commandes""" + """Liste toutes les commandes""" payload = {"limit": limit} if statut is not None: payload["statut"] = statut @@ -177,14 +196,14 @@ class SageGatewayClient: def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """✅ NOUVEAU: Liste toutes les factures""" + """Liste toutes les factures""" payload = {"limit": limit} if statut is not None: payload["statut"] = statut return self._post("/sage/factures/list", payload).get("data", []) def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool: - """✅ NOUVEAU: Met à jour le champ 'Dernière relance' d'une facture""" + """Met à jour le champ 'Dernière relance' d'une facture""" resp = self._post( "/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc} ) @@ -201,7 +220,7 @@ class SageGatewayClient: # REMISES (US-A5) # ===================================================== def lire_remise_max_client(self, code_client: str) -> float: - """✅ NOUVEAU: Récupère la remise max autorisée pour un client""" + """Récupère la remise max autorisée pour un client""" result = self._post("/sage/client/remise-max", {"code": code_client}) return result.get("data", {}).get("remise_max", 10.0) @@ -209,17 +228,16 @@ class SageGatewayClient: # GÉNÉRATION PDF (pour email_queue) # ===================================================== def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: - """✅ NOUVEAU: Génère le PDF d'un document via la gateway Windows""" + """Génère le PDF d'un document via la gateway Windows""" try: r = requests.post( f"{self.url}/sage/documents/generate-pdf", json={"doc_id": doc_id, "type_doc": type_doc}, headers=self.headers, - timeout=60, # Timeout plus long pour génération PDF + timeout=60, ) r.raise_for_status() - # Le PDF est retourné en base64 dans la réponse JSON import base64 response_data = r.json() From 33843e031a9a70a53e7d2f483a44591d2ae7e344 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 28 Nov 2025 05:54:25 +0300 Subject: [PATCH 009/199] updated devis to command logics transformation --- api.py | 47 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/api.py b/api.py index b6926e4..cd6a2d1 100644 --- a/api.py +++ b/api.py @@ -529,13 +529,17 @@ async def lister_commandes( @app.post("/workflow/devis/{id}/to-commande", tags=["US-A2"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): - """🔧 Transformation Devis → Commande via gateway Windows""" + """ + 🔧 Transformation Devis → Commande via gateway Windows + + ✅ CORRECTION: Envoie les valeurs numériques des enums, pas les noms + """ try: - # Appel HTTP vers Windows + # ✅ CRITIQUE: Utiliser .value pour obtenir l'entier resultat = sage_client.transformer_document( numero_source=id, - type_source=TypeDocument.DEVIS, - type_cible=TypeDocument.COMMANDE, + type_source=TypeDocument.DEVIS.value, # ← .value = 0 + type_cible=TypeDocument.COMMANDE.value, # ← .value = 3 ) # Logger en DB @@ -569,6 +573,41 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi raise HTTPException(500, str(e)) +@app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) +async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): + """ + 🔧 Transformation Commande → Facture via gateway Windows + + ✅ CORRECTION: Envoie les valeurs numériques + """ + try: + resultat = sage_client.transformer_document( + numero_source=id, + type_source=TypeDocument.COMMANDE.value, # ← 3 + type_cible=TypeDocument.FACTURE.value, # ← 5 + ) + + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.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() + + return resultat + + except Exception as e: + logger.error(f"Erreur transformation: {e}") + raise HTTPException(500, str(e)) + + @app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """🔧 Transformation Commande → Facture via gateway Windows""" From b468c963c9563cc16ba5b4a594cfe767d97cefe7 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 28 Nov 2025 08:28:59 +0300 Subject: [PATCH 010/199] refactor: Use explicit Sage document type constants for enums, document listing, and transformation endpoints. --- api.py | 85 ++++++++++++++++++------------------------------------- config.py | 21 +++++++++----- 2 files changed, 42 insertions(+), 64 deletions(-) diff --git a/api.py b/api.py index cd6a2d1..3db53eb 100644 --- a/api.py +++ b/api.py @@ -42,12 +42,13 @@ from sage_client import sage_client # ENUMS # ===================================================== class TypeDocument(int, Enum): - DEVIS = 0 - BON_LIVRAISON = 1 - BON_RETOUR = 2 - COMMANDE = 3 - PREPARATION = 4 - FACTURE = 5 + 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): @@ -515,11 +516,14 @@ async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): """ - 📋 Liste toutes les commandes via gateway Windows + 📋 Liste toutes les commandes + ✅ CORRECTION : Filtre sur le type 10 (BON_COMMANDE) """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) - commandes = sage_client.lister_commandes(limit=limit, statut=statut) + # Le sage_client doit filtrer sur type=10, pas type=3 + commandes = sage_client.lister_documents_par_type( + type_doc=settings.SAGE_TYPE_BON_COMMANDE, limit=limit, statut=statut # = 10 + ) return commandes except Exception as e: @@ -530,25 +534,22 @@ async def lister_commandes( @app.post("/workflow/devis/{id}/to-commande", tags=["US-A2"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): """ - 🔧 Transformation Devis → Commande via gateway Windows - - ✅ CORRECTION: Envoie les valeurs numériques des enums, pas les noms + 🔧 Transformation Devis → Commande + ✅ CORRECTION : Utilise les VRAIS types Sage (0 → 10) """ try: - # ✅ CRITIQUE: Utiliser .value pour obtenir l'entier resultat = sage_client.transformer_document( numero_source=id, - type_source=TypeDocument.DEVIS.value, # ← .value = 0 - type_cible=TypeDocument.COMMANDE.value, # ← .value = 3 + type_source=settings.SAGE_TYPE_DEVIS, # = 0 + type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10 ) - # Logger en DB workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, type_source=TypeDocument.DEVIS, document_cible=resultat.get("document_cible", ""), - type_cible=TypeDocument.COMMANDE, + type_cible=TypeDocument.BON_COMMANDE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), succes=True, @@ -576,52 +577,20 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi @app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """ - 🔧 Transformation Commande → Facture via gateway Windows - - ✅ CORRECTION: Envoie les valeurs numériques + 🔧 Transformation Commande → Facture + ✅ CORRECTION : Utilise les VRAIS types Sage (10 → 60) """ try: resultat = sage_client.transformer_document( numero_source=id, - type_source=TypeDocument.COMMANDE.value, # ← 3 - type_cible=TypeDocument.FACTURE.value, # ← 5 + 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.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() - - return resultat - - except Exception as e: - logger.error(f"Erreur transformation: {e}") - raise HTTPException(500, str(e)) - - -@app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) -async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): - """🔧 Transformation Commande → Facture via gateway Windows""" - try: - resultat = sage_client.transformer_document( - numero_source=id, - type_source=TypeDocument.COMMANDE, - type_cible=TypeDocument.FACTURE, - ) - - workflow_log = WorkflowLog( - id=str(uuid.uuid4()), - document_source=id, - type_source=TypeDocument.COMMANDE, + type_source=TypeDocument.BON_COMMANDE, document_cible=resultat.get("document_cible", ""), type_cible=TypeDocument.FACTURE, nb_lignes=resultat.get("nb_lignes", 0), @@ -1132,11 +1101,13 @@ async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): """ - 📋 Liste toutes les factures via gateway Windows + 📋 Liste toutes les factures + ✅ CORRECTION : Filtre sur le type 60 (FACTURE) """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) - factures = sage_client.lister_factures(limit=limit, statut=statut) + factures = sage_client.lister_documents_par_type( + type_doc=settings.SAGE_TYPE_FACTURE, limit=limit, statut=statut # = 60 + ) return factures except Exception as e: diff --git a/config.py b/config.py index 2ed4336..ef6d08b 100644 --- a/config.py +++ b/config.py @@ -1,14 +1,20 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from typing import List + class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore" + env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) + SAGE_TYPE_DEVIS: int = 0 + SAGE_TYPE_BON_COMMANDE: int = 10 + SAGE_TYPE_PREPARATION: int = 20 + SAGE_TYPE_BON_LIVRAISON: int = 30 + SAGE_TYPE_BON_RETOUR: int = 40 + SAGE_TYPE_BON_AVOIR: int = 50 + SAGE_TYPE_FACTURE: int = 60 + # === Sage Gateway (Windows) === sage_gateway_url: str sage_gateway_token: str @@ -31,13 +37,14 @@ class Settings(BaseSettings): api_host: str api_port: int api_reload: bool = False - + # === Email Queue === max_email_workers: int = 3 max_retry_attempts: int = 3 retry_delay_seconds: int = 60 - + # === CORS === cors_origins: List[str] = ["*"] -settings = Settings() \ No newline at end of file + +settings = Settings() From 3f8238f6740a6d998279b058685b3bb7d3ee99f5 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 28 Nov 2025 08:52:55 +0300 Subject: [PATCH 011/199] refactor: Use specialized Sage client methods for listing commands and invoices, and enhance the document transformation response. --- api.py | 34 +++++++++++++++++++++------------- sage_client.py | 16 ++++++++++------ 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/api.py b/api.py index 3db53eb..4ac4da0 100644 --- a/api.py +++ b/api.py @@ -516,14 +516,13 @@ async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): """ - 📋 Liste toutes les commandes - ✅ CORRECTION : Filtre sur le type 10 (BON_COMMANDE) + 📋 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: - # Le sage_client doit filtrer sur type=10, pas type=3 - commandes = sage_client.lister_documents_par_type( - type_doc=settings.SAGE_TYPE_BON_COMMANDE, limit=limit, statut=statut # = 10 - ) + commandes = sage_client.lister_commandes(limit=limit, statut=statut) return commandes except Exception as e: @@ -578,7 +577,7 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Commande → Facture - ✅ CORRECTION : Utilise les VRAIS types Sage (10 → 60) + ✅ Utilise les VRAIS types Sage (10 → 60) """ try: resultat = sage_client.transformer_document( @@ -601,7 +600,16 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses session.add(workflow_log) await session.commit() - return resultat + 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}") @@ -1101,13 +1109,13 @@ async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): """ - 📋 Liste toutes les factures - ✅ CORRECTION : Filtre sur le type 60 (FACTURE) + 📋 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_documents_par_type( - type_doc=settings.SAGE_TYPE_FACTURE, limit=limit, statut=statut # = 60 - ) + factures = sage_client.lister_factures(limit=limit, statut=statut) return factures except Exception as e: diff --git a/sage_client.py b/sage_client.py index dc2b61e..9ea5265 100644 --- a/sage_client.py +++ b/sage_client.py @@ -9,7 +9,6 @@ logger = logging.getLogger(__name__) class SageGatewayClient: """ Client HTTP pour communiquer avec la gateway Sage Windows - ✅ VERSION CORRIGÉE """ def __init__(self): @@ -121,7 +120,7 @@ class SageGatewayClient: params={ "numero": numero, "nouveau_statut": nouveau_statut, - }, # ← QUERY PARAMS + }, headers=self.headers, timeout=self.timeout, ) @@ -149,13 +148,13 @@ class SageGatewayClient: try: r = requests.post( f"{self.url}/sage/documents/transform", - params={ # ← QUERY PARAMS + params={ "numero_source": numero_source, "type_source": type_source, "type_cible": type_cible, }, headers=self.headers, - timeout=60, # Timeout plus long pour transformation + timeout=60, ) r.raise_for_status() return r.json().get("data", {}) @@ -184,7 +183,9 @@ class SageGatewayClient: def lister_commandes( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """Liste toutes les commandes""" + """ + Utilise l'endpoint /sage/commandes/list qui filtre déjà sur type 10 + """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut @@ -196,7 +197,10 @@ class SageGatewayClient: def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """Liste toutes les factures""" + """ + ✅ Liste toutes les factures + Utilise l'endpoint /sage/factures/list qui filtre déjà sur type 60 + """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut From b2cfb31e40c7f290690fa440cd2d8209c28ef9a9 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 09:09:29 +0300 Subject: [PATCH 012/199] Added authentication logics --- api.py | 7 + config.py | 8 + core/dependencies.py | 122 ++++++++ create_admin.py | 109 +++++++ database/__init__.py | 13 +- database/models.py | 88 +++++- requirements.txt | 5 + routes/auth.py | 608 ++++++++++++++++++++++++++++++++++++++ security/auth.py | 131 ++++++++ services/email_service.py | 226 ++++++++++++++ 10 files changed, 1314 insertions(+), 3 deletions(-) create mode 100644 core/dependencies.py create mode 100644 create_admin.py create mode 100644 routes/auth.py create mode 100644 security/auth.py create mode 100644 services/email_service.py diff --git a/api.py b/api.py index 4ac4da0..cf91dc8 100644 --- a/api.py +++ b/api.py @@ -14,6 +14,10 @@ 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, @@ -303,6 +307,9 @@ app.add_middleware( ) +app.include_router(auth_router) + + # ===================================================== # ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) # ===================================================== diff --git a/config.py b/config.py index ef6d08b..77f37c2 100644 --- a/config.py +++ b/config.py @@ -6,6 +6,12 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) + + # === JWT & Auth === + jwt_secret: str + jwt_algorithm: str + access_token_expire_minutes: int + refresh_token_expire_days: int SAGE_TYPE_DEVIS: int = 0 SAGE_TYPE_BON_COMMANDE: int = 10 @@ -18,6 +24,7 @@ class Settings(BaseSettings): # === Sage Gateway (Windows) === sage_gateway_url: str sage_gateway_token: str + client_url: str = "http://localhost:3000" # === Base de données === database_url: str = "sqlite+aiosqlite:///./sage_dataven.db" @@ -28,6 +35,7 @@ class Settings(BaseSettings): smtp_user: str smtp_password: str smtp_from: str + smtp_use_tls: bool = True # === Universign === universign_api_key: str diff --git a/core/dependencies.py b/core/dependencies.py new file mode 100644 index 0000000..a860f5c --- /dev/null +++ b/core/dependencies.py @@ -0,0 +1,122 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from database import get_session, User +from security.auth import decode_token +from typing import Optional + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session) +) -> User: + """ + Dépendance FastAPI pour extraire l'utilisateur du JWT + + Usage dans un endpoint: + @app.get("/protected") + async def protected_route(user: User = Depends(get_current_user)): + return {"user_id": user.id} + """ + token = credentials.credentials + + # Décoder le token + payload = decode_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide ou expiré", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Vérifier le type + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Type de token incorrect", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Extraire user_id + user_id: str = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token malformé", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Charger l'utilisateur + result = await session.execute( + select(User).where(User.id == user_id) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur introuvable", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Vérifications de sécurité + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte désactivé" + ) + + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email non vérifié. Consultez votre boîte de réception." + ) + + # Vérifier si le compte est verrouillé + if user.locked_until and user.locked_until > datetime.now(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte temporairement verrouillé suite à trop de tentatives échouées" + ) + + return user + + +async def get_current_user_optional( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + session: AsyncSession = Depends(get_session) +) -> Optional[User]: + """ + Version optionnelle - ne lève pas d'erreur si pas de token + Utile pour des endpoints publics avec contenu enrichi si authentifié + """ + if not credentials: + return None + + try: + return await get_current_user(credentials, session) + except HTTPException: + return None + + +def require_role(*allowed_roles: str): + """ + Décorateur pour restreindre l'accès par rôle + + Usage: + @app.get("/admin/users") + async def list_users(user: User = Depends(require_role("admin"))): + ... + """ + async def role_checker(user: User = Depends(get_current_user)) -> User: + if user.role not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}" + ) + return user + + return role_checker \ No newline at end of file diff --git a/create_admin.py b/create_admin.py new file mode 100644 index 0000000..a85b4df --- /dev/null +++ b/create_admin.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Script de création du premier utilisateur administrateur + +Usage: + python create_admin.py +""" + +import asyncio +import sys +from pathlib import Path +import uuid +from datetime import datetime + +sys.path.insert(0, str(Path(__file__).parent)) + +from database import async_session_factory, User +from security.auth import hash_password, validate_password_strength +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def create_admin(): + """Crée un utilisateur admin""" + + print("\n" + "="*60) + print("🔐 Création d'un compte administrateur") + print("="*60 + "\n") + + # Saisie des informations + email = input("Email de l'admin: ").strip().lower() + if not email or '@' not in email: + print("❌ Email invalide") + return False + + prenom = input("Prénom: ").strip() + nom = input("Nom: ").strip() + + if not prenom or not nom: + print("❌ Prénom et nom requis") + return False + + # Mot de passe avec validation + while True: + password = input("Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): ") + is_valid, error_msg = validate_password_strength(password) + + if is_valid: + confirm = input("Confirmez le mot de passe: ") + if password == confirm: + break + else: + print("❌ Les mots de passe ne correspondent pas\n") + else: + print(f"❌ {error_msg}\n") + + # Vérifier si l'email existe déjà + async with async_session_factory() as session: + from sqlalchemy import select + + result = await session.execute( + select(User).where(User.email == email) + ) + existing = result.scalar_one_or_none() + + if existing: + print(f"\n❌ Un utilisateur avec l'email {email} existe déjà") + return False + + # Créer l'admin + admin = User( + id=str(uuid.uuid4()), + email=email, + hashed_password=hash_password(password), + nom=nom, + prenom=prenom, + role="admin", + is_verified=True, # Admin vérifié par défaut + is_active=True, + created_at=datetime.now() + ) + + session.add(admin) + await session.commit() + + print("\n✅ Administrateur créé avec succès!") + print(f"📧 Email: {email}") + print(f"👤 Nom: {prenom} {nom}") + print(f"🔑 Rôle: admin") + print(f"🆔 ID: {admin.id}") + print("\n💡 Vous pouvez maintenant vous connecter à l'API\n") + + return True + + +if __name__ == "__main__": + try: + result = asyncio.run(create_admin()) + sys.exit(0 if result else 1) + except KeyboardInterrupt: + print("\n\n❌ Création annulée") + sys.exit(1) + except Exception as e: + print(f"\n❌ Erreur: {e}") + logger.exception("Détails:") + sys.exit(1) \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py index 1912d5d..0e2957a 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -14,7 +14,11 @@ from database.models import ( CacheMetadata, AuditLog, StatutEmail, - StatutSignature + StatutSignature, + # Nouveaux modèles auth + User, + RefreshToken, + LoginAttempt, ) __all__ = [ @@ -25,7 +29,7 @@ __all__ = [ 'get_session', 'close_db', - # Models + # Models existants 'Base', 'EmailLog', 'SignatureLog', @@ -36,4 +40,9 @@ __all__ = [ # Enums 'StatutEmail', 'StatutSignature', + + # Modèles auth + 'User', + 'RefreshToken', + 'LoginAttempt', ] \ No newline at end of file diff --git a/database/models.py b/database/models.py index f147305..2c260ef 100644 --- a/database/models.py +++ b/database/models.py @@ -201,4 +201,90 @@ class AuditLog(Base): date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) def __repr__(self): - return f"" \ No newline at end of file + return f"" + +# Ajouter ces modèles à la fin de database/models.py + +class User(Base): + """ + Utilisateurs de l'API avec validation email + """ + __tablename__ = "users" + + id = Column(String(36), primary_key=True) + email = Column(String(255), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + + # Profil + nom = Column(String(100), nullable=False) + prenom = Column(String(100), nullable=False) + role = Column(String(50), default="user") # user, admin, commercial + + # Validation email + is_verified = Column(Boolean, default=False) + verification_token = Column(String(255), nullable=True, unique=True, index=True) + verification_token_expires = Column(DateTime, nullable=True) + + # Sécurité + is_active = Column(Boolean, default=True) + failed_login_attempts = Column(Integer, default=0) + locked_until = Column(DateTime, nullable=True) + + # Mot de passe oublié + reset_token = Column(String(255), nullable=True, unique=True, index=True) + reset_token_expires = Column(DateTime, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.now, nullable=False) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + last_login = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class RefreshToken(Base): + """ + Tokens de rafraîchissement JWT + """ + __tablename__ = "refresh_tokens" + + id = Column(String(36), primary_key=True) + user_id = Column(String(36), nullable=False, index=True) + token_hash = Column(String(255), nullable=False, unique=True, index=True) + + # Métadonnées + device_info = Column(String(500), nullable=True) + ip_address = Column(String(45), nullable=True) + + # Expiration + expires_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=datetime.now, nullable=False) + + # Révocation + is_revoked = Column(Boolean, default=False) + revoked_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class LoginAttempt(Base): + """ + Journal des tentatives de connexion (détection bruteforce) + """ + __tablename__ = "login_attempts" + + id = Column(Integer, primary_key=True, autoincrement=True) + + email = Column(String(255), nullable=False, index=True) + ip_address = Column(String(45), nullable=False, index=True) + user_agent = Column(String(500), nullable=True) + + success = Column(Boolean, default=False) + failure_reason = Column(String(255), nullable=True) + + timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6138d38..d1f5fea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,10 +5,15 @@ pydantic-settings reportlab requests msal + python-multipart email-validator python-dotenv +python-jose[cryptography] +passlib[bcrypt] +bcrypt==4.2.0 + sqlalchemy aiosqlite tenacity \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..0e2d6fa --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,608 @@ +# auth/routes.py +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, EmailStr, Field +from datetime import datetime, timedelta +from typing import Optional +import uuid + +from database import get_session, User, RefreshToken, LoginAttempt +from security.auth import ( + hash_password, + verify_password, + validate_password_strength, + create_access_token, + create_refresh_token, + decode_token, + generate_verification_token, + generate_reset_token, + hash_token +) +from auth.email_service import AuthEmailService +from auth.dependencies import get_current_user +from config import settings +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["Authentication"]) + +# === MODÈLES PYDANTIC === + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + nom: str = Field(..., min_length=2, max_length=100) + prenom: str = Field(..., min_length=2, max_length=100) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int = 1800 # 30 minutes en secondes + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str = Field(..., min_length=8) + + +class VerifyEmailRequest(BaseModel): + token: str + + +class ResendVerificationRequest(BaseModel): + email: EmailStr + + +# === UTILITAIRES === + +async def log_login_attempt( + session: AsyncSession, + email: str, + ip: str, + user_agent: str, + success: bool, + failure_reason: Optional[str] = None +): + """Enregistre une tentative de connexion""" + attempt = LoginAttempt( + email=email, + ip_address=ip, + user_agent=user_agent, + success=success, + failure_reason=failure_reason, + timestamp=datetime.now() + ) + session.add(attempt) + await session.commit() + + +async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[bool, str]: + """ + Vérifie si l'utilisateur/IP est rate limité + + Returns: + (is_allowed, error_message) + """ + # Vérifier les tentatives échouées des 15 dernières minutes + time_window = datetime.now() - timedelta(minutes=15) + + result = await session.execute( + select(LoginAttempt) + .where( + LoginAttempt.email == email, + LoginAttempt.success == False, + LoginAttempt.timestamp >= time_window + ) + ) + failed_attempts = result.scalars().all() + + if len(failed_attempts) >= 5: + return False, "Trop de tentatives échouées. Réessayez dans 15 minutes." + + return True, "" + + +# === ENDPOINTS === + +@router.post("/register", status_code=status.HTTP_201_CREATED) +async def register( + data: RegisterRequest, + request: Request, + session: AsyncSession = Depends(get_session) +): + """ + 📝 Inscription d'un nouvel utilisateur + + - Valide le mot de passe + - Crée le compte (non vérifié) + - Envoie email de vérification + """ + # Vérifier si l'email existe déjà + result = await session.execute( + select(User).where(User.email == data.email) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cet email est déjà utilisé" + ) + + # Valider le mot de passe + is_valid, error_msg = validate_password_strength(data.password) + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_msg + ) + + # Générer token de vérification + verification_token = generate_verification_token() + + # Créer l'utilisateur + new_user = User( + id=str(uuid.uuid4()), + email=data.email.lower(), + hashed_password=hash_password(data.password), + nom=data.nom, + prenom=data.prenom, + is_verified=False, + verification_token=verification_token, + verification_token_expires=datetime.now() + timedelta(hours=24), + created_at=datetime.now() + ) + + session.add(new_user) + await session.commit() + + # Envoyer email de vérification + base_url = str(request.base_url).rstrip('/') + email_sent = AuthEmailService.send_verification_email( + data.email, + verification_token, + base_url + ) + + if not email_sent: + logger.warning(f"Échec envoi email vérification pour {data.email}") + + logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") + + return { + "success": True, + "message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.", + "user_id": new_user.id, + "email": data.email + } + + +@router.post("/verify-email") +async def verify_email( + data: VerifyEmailRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✅ Vérification de l'email via token + """ + result = await session.execute( + select(User).where(User.verification_token == data.token) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token de vérification invalide" + ) + + # Vérifier l'expiration + if user.verification_token_expires < datetime.now(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token expiré. Demandez un nouvel email de vérification." + ) + + # Activer le compte + user.is_verified = True + user.verification_token = None + user.verification_token_expires = None + await session.commit() + + logger.info(f"✅ Email vérifié: {user.email}") + + return { + "success": True, + "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter." + } + + +@router.post("/resend-verification") +async def resend_verification( + data: ResendVerificationRequest, + request: Request, + session: AsyncSession = Depends(get_session) +): + """ + 🔄 Renvoyer l'email de vérification + """ + result = await session.execute( + select(User).where(User.email == data.email.lower()) + ) + user = result.scalar_one_or_none() + + if not user: + # Ne pas révéler si l'utilisateur existe + return { + "success": True, + "message": "Si cet email existe, un nouveau lien de vérification a été envoyé." + } + + if user.is_verified: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ce compte est déjà vérifié" + ) + + # Générer nouveau token + verification_token = generate_verification_token() + user.verification_token = verification_token + user.verification_token_expires = datetime.now() + timedelta(hours=24) + await session.commit() + + # Envoyer email + base_url = str(request.base_url).rstrip('/') + AuthEmailService.send_verification_email( + user.email, + verification_token, + base_url + ) + + return { + "success": True, + "message": "Un nouveau lien de vérification a été envoyé." + } + + +@router.post("/login", response_model=TokenResponse) +async def login( + data: LoginRequest, + request: Request, + session: AsyncSession = Depends(get_session) +): + """ + 🔐 Connexion utilisateur + + Retourne access_token (30min) et refresh_token (7 jours) + """ + ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent", "unknown") + + # Rate limiting + is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) + if not is_allowed: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=error_msg + ) + + # Charger l'utilisateur + result = await session.execute( + select(User).where(User.email == data.email.lower()) + ) + user = result.scalar_one_or_none() + + # Vérifications + if not user or not verify_password(data.password, user.hashed_password): + await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Identifiants incorrects") + + # Incrémenter compteur échecs + if user: + user.failed_login_attempts += 1 + + # Verrouiller après 5 échecs + if user.failed_login_attempts >= 5: + user.locked_until = datetime.now() + timedelta(minutes=15) + await session.commit() + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes." + ) + + await session.commit() + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Email ou mot de passe incorrect" + ) + + # Vérifier statut compte + if not user.is_active: + await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte désactivé") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte désactivé" + ) + + if not user.is_verified: + await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Email non vérifié") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email non vérifié. Consultez votre boîte de réception." + ) + + # Vérifier verrouillage + if user.locked_until and user.locked_until > datetime.now(): + await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte verrouillé") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte temporairement verrouillé" + ) + + # ✅ CONNEXION RÉUSSIE + + # Réinitialiser compteur échecs + user.failed_login_attempts = 0 + user.locked_until = None + user.last_login = datetime.now() + + # Créer tokens + access_token = create_access_token({"sub": user.id, "email": user.email, "role": user.role}) + refresh_token_jwt = create_refresh_token(user.id) + + # Stocker refresh token en DB (hashé) + refresh_token_record = RefreshToken( + id=str(uuid.uuid4()), + user_id=user.id, + token_hash=hash_token(refresh_token_jwt), + device_info=user_agent[:500], + ip_address=ip, + expires_at=datetime.now() + timedelta(days=7), + created_at=datetime.now() + ) + + session.add(refresh_token_record) + await session.commit() + + # Logger succès + await log_login_attempt(session, data.email.lower(), ip, user_agent, True) + + logger.info(f"✅ Connexion réussie: {user.email}") + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token_jwt, + expires_in=1800 # 30 minutes + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_access_token( + data: RefreshTokenRequest, + session: AsyncSession = Depends(get_session) +): + """ + 🔄 Renouvellement du access_token via refresh_token + """ + # Décoder le refresh token + payload = decode_token(data.refresh_token) + if not payload or payload.get("type") != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token invalide" + ) + + user_id = payload.get("sub") + token_hash = hash_token(data.refresh_token) + + # Vérifier en DB + result = await session.execute( + select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.token_hash == token_hash, + RefreshToken.is_revoked == False + ) + ) + token_record = result.scalar_one_or_none() + + if not token_record: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token révoqué ou introuvable" + ) + + # Vérifier expiration + if token_record.expires_at < datetime.now(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token expiré" + ) + + # Charger utilisateur + result = await session.execute( + select(User).where(User.id == user_id) + ) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur introuvable ou désactivé" + ) + + # Générer nouveau access token + new_access_token = create_access_token({ + "sub": user.id, + "email": user.email, + "role": user.role + }) + + logger.info(f"🔄 Token rafraîchi: {user.email}") + + return TokenResponse( + access_token=new_access_token, + refresh_token=data.refresh_token, # Refresh token reste le même + expires_in=1800 + ) + + +@router.post("/forgot-password") +async def forgot_password( + data: ForgotPasswordRequest, + request: Request, + session: AsyncSession = Depends(get_session) +): + """ + 🔑 Demande de réinitialisation de mot de passe + """ + result = await session.execute( + select(User).where(User.email == data.email.lower()) + ) + user = result.scalar_one_or_none() + + # Ne pas révéler si l'utilisateur existe + if not user: + return { + "success": True, + "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + } + + # Générer token de reset + reset_token = generate_reset_token() + user.reset_token = reset_token + user.reset_token_expires = datetime.now() + timedelta(hours=1) + await session.commit() + + # Envoyer email + frontend_url = settings.frontend_url if hasattr(settings, 'frontend_url') else str(request.base_url).rstrip('/') + AuthEmailService.send_password_reset_email( + user.email, + reset_token, + frontend_url + ) + + logger.info(f"📧 Reset password demandé: {user.email}") + + return { + "success": True, + "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + } + + +@router.post("/reset-password") +async def reset_password( + data: ResetPasswordRequest, + session: AsyncSession = Depends(get_session) +): + """ + 🔐 Réinitialisation du mot de passe avec token + """ + result = await session.execute( + select(User).where(User.reset_token == data.token) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token de réinitialisation invalide" + ) + + # Vérifier expiration + if user.reset_token_expires < datetime.now(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token expiré. Demandez un nouveau lien de réinitialisation." + ) + + # Valider nouveau mot de passe + is_valid, error_msg = validate_password_strength(data.new_password) + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_msg + ) + + # Mettre à jour + user.hashed_password = hash_password(data.new_password) + user.reset_token = None + user.reset_token_expires = None + user.failed_login_attempts = 0 + user.locked_until = None + await session.commit() + + # Envoyer notification + AuthEmailService.send_password_changed_notification(user.email) + + logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") + + return { + "success": True, + "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter." + } + + +@router.post("/logout") +async def logout( + data: RefreshTokenRequest, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user) +): + """ + 🚪 Déconnexion (révocation du refresh token) + """ + token_hash = hash_token(data.refresh_token) + + result = await session.execute( + select(RefreshToken).where( + RefreshToken.user_id == user.id, + RefreshToken.token_hash == token_hash + ) + ) + token_record = result.scalar_one_or_none() + + if token_record: + token_record.is_revoked = True + token_record.revoked_at = datetime.now() + await session.commit() + + logger.info(f"👋 Déconnexion: {user.email}") + + return { + "success": True, + "message": "Déconnexion réussie" + } + + +@router.get("/me") +async def get_current_user_info(user: User = Depends(get_current_user)): + """ + 👤 Récupération du profil utilisateur + """ + return { + "id": user.id, + "email": user.email, + "nom": user.nom, + "prenom": user.prenom, + "role": user.role, + "is_verified": user.is_verified, + "created_at": user.created_at.isoformat(), + "last_login": user.last_login.isoformat() if user.last_login else None + } \ No newline at end of file diff --git a/security/auth.py b/security/auth.py new file mode 100644 index 0000000..9c5009d --- /dev/null +++ b/security/auth.py @@ -0,0 +1,131 @@ +from passlib.context import CryptContext +from datetime import datetime, timedelta +from typing import Optional, Dict +import jwt +import secrets +import hashlib + +# Configuration +SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" # À remplacer par settings.jwt_secret +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 +REFRESH_TOKEN_EXPIRE_DAYS = 7 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +# === Hachage de mots de passe === +def hash_password(password: str) -> str: + """Hash un mot de passe avec bcrypt""" + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Vérifie un mot de passe contre son hash""" + return pwd_context.verify(plain_password, hashed_password) + + +# === Génération de tokens aléatoires === +def generate_verification_token() -> str: + """Génère un token de vérification email sécurisé""" + return secrets.token_urlsafe(32) + + +def generate_reset_token() -> str: + """Génère un token de réinitialisation mot de passe""" + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + """Hash un refresh token pour stockage en DB""" + return hashlib.sha256(token.encode()).hexdigest() + + +# === JWT Access Token === +def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: + """ + Crée un JWT access token + + Args: + data: Payload (doit contenir 'sub' = user_id) + expires_delta: Durée de validité personnalisée + """ + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({ + "exp": expire, + "iat": datetime.utcnow(), + "type": "access" + }) + + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def create_refresh_token(user_id: str) -> str: + """ + Crée un refresh token (JWT long terme) + + Returns: + Token JWT non hashé (à hasher avant stockage DB) + """ + expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + + to_encode = { + "sub": user_id, + "exp": expire, + "iat": datetime.utcnow(), + "type": "refresh", + "jti": secrets.token_urlsafe(16) # Unique ID + } + + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> Optional[Dict]: + """ + Décode et valide un JWT + + Returns: + Payload si valide, None sinon + """ + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.JWTError: + return None + + +# === Validation mot de passe === +def validate_password_strength(password: str) -> tuple[bool, str]: + """ + Valide la robustesse d'un mot de passe + + Returns: + (is_valid, error_message) + """ + if len(password) < 8: + return False, "Le mot de passe doit contenir au moins 8 caractères" + + if not any(c.isupper() for c in password): + return False, "Le mot de passe doit contenir au moins une majuscule" + + if not any(c.islower() for c in password): + return False, "Le mot de passe doit contenir au moins une minuscule" + + if not any(c.isdigit() for c in password): + return False, "Le mot de passe doit contenir au moins un chiffre" + + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" + if not any(c in special_chars for c in password): + return False, "Le mot de passe doit contenir au moins un caractère spécial" + + return True, "" \ No newline at end of file diff --git a/services/email_service.py b/services/email_service.py new file mode 100644 index 0000000..2640b73 --- /dev/null +++ b/services/email_service.py @@ -0,0 +1,226 @@ +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from config import settings +import logging + +logger = logging.getLogger(__name__) + + +class AuthEmailService: + """Service d'envoi d'emails pour l'authentification""" + + @staticmethod + def _send_email(to: str, subject: str, html_body: str) -> bool: + """Envoi SMTP générique""" + try: + msg = MIMEMultipart() + msg['From'] = settings.smtp_from + msg['To'] = to + msg['Subject'] = subject + + msg.attach(MIMEText(html_body, 'html')) + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: + if settings.smtp_use_tls: + server.starttls() + + if settings.smtp_user and settings.smtp_password: + server.login(settings.smtp_user, settings.smtp_password) + + server.send_message(msg) + + logger.info(f"✅ Email envoyé: {subject} → {to}") + return True + + except Exception as e: + logger.error(f"❌ Erreur envoi email: {e}") + return False + + @staticmethod + def send_verification_email(email: str, token: str, base_url: str) -> bool: + """ + Envoie l'email de vérification avec lien de confirmation + + Args: + email: Email du destinataire + token: Token de vérification + base_url: URL de base de l'API (ex: https://api.votredomaine.com) + """ + verification_link = f"{base_url}/auth/verify-email?token={token}" + + html_body = f""" + + + + + + +
+
+

🎉 Bienvenue sur Sage Dataven

+
+
+

Vérifiez votre adresse email

+

Merci de vous être inscrit ! Pour activer votre compte, veuillez cliquer sur le bouton ci-dessous :

+ + + +

Ou copiez ce lien dans votre navigateur :

+

+ {verification_link} +

+ +

+ ⚠️ Ce lien expire dans 24 heures +

+ +

+ Si vous n'avez pas créé de compte, ignorez cet email. +

+
+ +
+ + + """ + + return AuthEmailService._send_email( + email, + "🔐 Vérifiez votre adresse email - Sage Dataven", + html_body + ) + + @staticmethod + def send_password_reset_email(email: str, token: str, base_url: str) -> bool: + """ + Envoie l'email de réinitialisation de mot de passe + + Args: + email: Email du destinataire + token: Token de reset + base_url: URL de base du frontend (ex: https://app.votredomaine.com) + """ + reset_link = f"{base_url}/reset-password?token={token}" + + html_body = f""" + + + + + + +
+
+

🔑 Réinitialisation de mot de passe

+
+
+

Demande de réinitialisation

+

Vous avez demandé à réinitialiser votre mot de passe. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :

+ + + +

Ou copiez ce lien dans votre navigateur :

+

+ {reset_link} +

+ +

+ ⚠️ Ce lien expire dans 1 heure +

+ +

+ Si vous n'avez pas demandé cette réinitialisation, ignorez cet email. Votre mot de passe actuel reste inchangé. +

+
+ +
+ + + """ + + return AuthEmailService._send_email( + email, + "🔐 Réinitialisation de votre mot de passe - Sage Dataven", + html_body + ) + + @staticmethod + def send_password_changed_notification(email: str) -> bool: + """Notification après changement de mot de passe réussi""" + html_body = """ + + + + + + +
+
+

✅ Mot de passe modifié

+
+
+

Votre mot de passe a été changé avec succès

+

Ce message confirme que le mot de passe de votre compte Sage Dataven a été modifié.

+ +

+ ⚠️ Si vous n'êtes pas à l'origine de ce changement, contactez immédiatement notre support. +

+
+ +
+ + + """ + + return AuthEmailService._send_email( + email, + "✅ Votre mot de passe a été modifié - Sage Dataven", + html_body + ) \ No newline at end of file From 8c98a966302464b237b9e203b69045d4633ca7ea Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 09:33:18 +0300 Subject: [PATCH 013/199] Corrected invalid import --- routes/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 0e2d6fa..90c30d6 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -19,8 +19,8 @@ from security.auth import ( generate_reset_token, hash_token ) -from auth.email_service import AuthEmailService -from auth.dependencies import get_current_user +from services.email_service import AuthEmailService +from core.dependencies import get_current_user from config import settings from datetime import datetime import logging From 4b9adba73965bae5a9aad3f6304c6a37fcc251a3 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 10:21:54 +0300 Subject: [PATCH 014/199] corrected "Method not allowed" error --- routes/auth.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 90c30d6..961f1c3 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -1,4 +1,3 @@ -# auth/routes.py from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -194,13 +193,57 @@ async def register( } +@router.get("/verify-email") +async def verify_email_get( + token: str, + session: AsyncSession = Depends(get_session) +): + """ + ✅ Vérification de l'email via lien cliquable (GET) + Utilisé quand l'utilisateur clique sur le lien dans l'email + """ + result = await session.execute( + select(User).where(User.verification_token == token) + ) + user = result.scalar_one_or_none() + + if not user: + return { + "success": False, + "message": "Token de vérification invalide ou déjà utilisé." + } + + # Vérifier l'expiration + if user.verification_token_expires < datetime.now(): + return { + "success": False, + "message": "Token expiré. Veuillez demander un nouvel email de vérification.", + "expired": True + } + + # Activer le compte + user.is_verified = True + user.verification_token = None + user.verification_token_expires = None + await session.commit() + + logger.info(f"✅ Email vérifié: {user.email}") + + return { + "success": True, + "message": "✅ Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", + "email": user.email + } + + @router.post("/verify-email") -async def verify_email( +async def verify_email_post( data: VerifyEmailRequest, session: AsyncSession = Depends(get_session) ): """ - ✅ Vérification de l'email via token + ✅ Vérification de l'email via API (POST) + Utilisé pour les appels programmatiques depuis le frontend """ result = await session.execute( select(User).where(User.verification_token == data.token) From 5c84d0d75a0b75aaf04e2e390e2d32d87169bde1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 10:56:09 +0300 Subject: [PATCH 015/199] corrected URL --- services/email_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/email_service.py b/services/email_service.py index 2640b73..4ca6c04 100644 --- a/services/email_service.py +++ b/services/email_service.py @@ -118,9 +118,9 @@ class AuthEmailService: Args: email: Email du destinataire token: Token de reset - base_url: URL de base du frontend (ex: https://app.votredomaine.com) + base_url: URL de base du frontend """ - reset_link = f"{base_url}/reset-password?token={token}" + reset_link = f"{base_url}/auth/reset-password?token={token}" html_body = f""" From 26acd747fb9f8da534a161e422873ff8855d63db Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 15:33:53 +0300 Subject: [PATCH 016/199] Trying to correct mismatch --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index 77f37c2..7f06b3e 100644 --- a/config.py +++ b/config.py @@ -24,7 +24,7 @@ class Settings(BaseSettings): # === Sage Gateway (Windows) === sage_gateway_url: str sage_gateway_token: str - client_url: str = "http://localhost:3000" + frontend_url: str = "http://localhost:3000" # === Base de données === database_url: str = "sqlite+aiosqlite:///./sage_dataven.db" From d2c342c6230b437c3fc6ee9238e01cb6a589ec14 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 15:34:59 +0300 Subject: [PATCH 017/199] re-correct to prevent mismatch --- routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/auth.py b/routes/auth.py index 961f1c3..c23a28b 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -534,7 +534,7 @@ async def forgot_password( await session.commit() # Envoyer email - frontend_url = settings.frontend_url if hasattr(settings, 'frontend_url') else str(request.base_url).rstrip('/') + frontend_url = settings.frontend_url AuthEmailService.send_password_reset_email( user.email, reset_token, From e3c67a0caf53922b23069ae565dd3f92f50b1def Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 15:55:18 +0300 Subject: [PATCH 018/199] reverted second mismatching fix changes --- routes/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/auth.py b/routes/auth.py index c23a28b..961f1c3 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -534,7 +534,7 @@ async def forgot_password( await session.commit() # Envoyer email - frontend_url = settings.frontend_url + frontend_url = settings.frontend_url if hasattr(settings, 'frontend_url') else str(request.base_url).rstrip('/') AuthEmailService.send_password_reset_email( user.email, reset_token, From 0a3920d43522e7c2ff3c5ab8baa930ce5efa36af Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 16:03:12 +0300 Subject: [PATCH 019/199] Test --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index 7f06b3e..bc36e64 100644 --- a/config.py +++ b/config.py @@ -24,7 +24,7 @@ class Settings(BaseSettings): # === Sage Gateway (Windows) === sage_gateway_url: str sage_gateway_token: str - frontend_url: str = "http://localhost:3000" + frontend_url: str # === Base de données === database_url: str = "sqlite+aiosqlite:///./sage_dataven.db" From 2e5a41260df126bd02cadbeeccda532b9264d5f1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 19:45:02 +0300 Subject: [PATCH 020/199] corrected frontend URL mismatch --- services/email_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/email_service.py b/services/email_service.py index 4ca6c04..eb07555 100644 --- a/services/email_service.py +++ b/services/email_service.py @@ -120,7 +120,7 @@ class AuthEmailService: token: Token de reset base_url: URL de base du frontend """ - reset_link = f"{base_url}/auth/reset-password?token={token}" + reset_link = f"{base_url}/auth/reset?token={token}" html_body = f""" From 39c3397cd4f0e86705201c5bf3dbae9063b0e88c Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 2 Dec 2025 20:14:24 +0300 Subject: [PATCH 021/199] corrected frontend URL mismatch --- services/email_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/email_service.py b/services/email_service.py index eb07555..44152df 100644 --- a/services/email_service.py +++ b/services/email_service.py @@ -120,7 +120,7 @@ class AuthEmailService: token: Token de reset base_url: URL de base du frontend """ - reset_link = f"{base_url}/auth/reset?token={token}" + reset_link = f"{base_url}/reset?token={token}" html_body = f""" From 5ff01c6c4532ff818ec78e4bdcffe58ccef3ba87 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 3 Dec 2025 11:00:55 +0300 Subject: [PATCH 022/199] Users debug --- api.py | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/api.py b/api.py index cf91dc8..462d6aa 100644 --- a/api.py +++ b/api.py @@ -144,6 +144,32 @@ class BaremeRemiseResponse(BaseModel): message: str +# À ajouter dans api.py après les imports et avant les endpoints existants + +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime + +# ===================================================== +# MODÈLES PYDANTIC POUR USERS +# ===================================================== + +class UserResponse(BaseModel): + """Modèle de réponse pour un utilisateur""" + id: str + email: str + nom: str + prenom: str + role: str + is_verified: bool + is_active: bool + created_at: str + last_login: Optional[str] = None + failed_login_attempts: int = 0 + + class Config: + from_attributes = True + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1557,6 +1583,162 @@ async def statut_queue(): } + +@app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) +async def lister_utilisateurs_debug( + session: AsyncSession = Depends(get_session), + limit: int = Query(100, le=1000), + role: Optional[str] = Query(None), + verified_only: bool = Query(False) +): + """ + 🔓 **ROUTE DEBUG** - Liste tous les utilisateurs inscrits + + ⚠️ **ATTENTION**: Cette route n'est PAS protégée par authentification. + À utiliser uniquement en développement ou à sécuriser en production. + + Args: + limit: Nombre maximum d'utilisateurs à retourner + role: Filtrer par rôle (user, admin, commercial) + verified_only: Afficher uniquement les utilisateurs vérifiés + + Returns: + Liste des utilisateurs avec leurs informations (mot de passe masqué) + """ + from database import User + from sqlalchemy import select + + try: + # Construction de la requête + query = select(User) + + # Filtres optionnels + if role: + query = query.where(User.role == role) + + if verified_only: + query = query.where(User.is_verified == True) + + # Tri par date de création (plus récents en premier) + query = query.order_by(User.created_at.desc()).limit(limit) + + # Exécution + result = await session.execute(query) + users = result.scalars().all() + + # Conversion en réponse + users_response = [] + for user in users: + users_response.append(UserResponse( + id=user.id, + email=user.email, + nom=user.nom, + prenom=user.prenom, + role=user.role, + is_verified=user.is_verified, + is_active=user.is_active, + created_at=user.created_at.isoformat() if user.created_at else "", + last_login=user.last_login.isoformat() if user.last_login else None, + failed_login_attempts=user.failed_login_attempts or 0 + )) + + logger.info(f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)") + + return users_response + + except Exception as e: + logger.error(f"❌ Erreur liste utilisateurs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/debug/users/stats", tags=["Debug"]) +async def statistiques_utilisateurs( + session: AsyncSession = Depends(get_session) +): + """ + 📊 **ROUTE DEBUG** - Statistiques sur les utilisateurs + + ⚠️ Non protégée - à sécuriser en production + """ + from database import User + from sqlalchemy import select, func + + try: + # Total utilisateurs + total_query = select(func.count(User.id)) + total_result = await session.execute(total_query) + total = total_result.scalar() + + # Utilisateurs vérifiés + verified_query = select(func.count(User.id)).where(User.is_verified == True) + verified_result = await session.execute(verified_query) + verified = verified_result.scalar() + + # Utilisateurs actifs + active_query = select(func.count(User.id)).where(User.is_active == True) + active_result = await session.execute(active_query) + active = active_result.scalar() + + # Par rôle + roles_query = select(User.role, func.count(User.id)).group_by(User.role) + roles_result = await session.execute(roles_query) + roles_stats = {role: count for role, count in roles_result.all()} + + return { + "total_utilisateurs": total, + "utilisateurs_verifies": verified, + "utilisateurs_actifs": active, + "utilisateurs_non_verifies": total - verified, + "repartition_roles": roles_stats, + "taux_verification": f"{(verified/total*100):.1f}%" if total > 0 else "0%" + } + + except Exception as e: + logger.error(f"❌ Erreur stats utilisateurs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/debug/users/{user_id}", response_model=UserResponse, tags=["Debug"]) +async def lire_utilisateur_debug( + user_id: str, + session: AsyncSession = Depends(get_session) +): + """ + 👤 **ROUTE DEBUG** - Détails d'un utilisateur par ID + + ⚠️ Non protégée - à sécuriser en production + """ + from database import User + from sqlalchemy import select + + try: + query = select(User).where(User.id == user_id) + result = await session.execute(query) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(404, f"Utilisateur {user_id} introuvable") + + return UserResponse( + id=user.id, + email=user.email, + nom=user.nom, + prenom=user.prenom, + role=user.role, + is_verified=user.is_verified, + is_active=user.is_active, + created_at=user.created_at.isoformat() if user.created_at else "", + last_login=user.last_login.isoformat() if user.last_login else None, + failed_login_attempts=user.failed_login_attempts or 0 + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur lecture utilisateur: {e}") + raise HTTPException(500, str(e)) + + # ===================================================== # LANCEMENT # ===================================================== From a73bdc4d9eee47b5eb518cd8f2d99142061937f4 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 3 Dec 2025 13:54:28 +0300 Subject: [PATCH 023/199] Corrected error on sending document to universign --- api.py | 214 ++++++++++++++++++++++++++++++++++++++++++- core/dependencies.py | 3 +- email_queue.py | 2 +- 3 files changed, 213 insertions(+), 6 deletions(-) diff --git a/api.py b/api.py index 462d6aa..f01767f 100644 --- a/api.py +++ b/api.py @@ -297,11 +297,11 @@ async def lifespan(app: FastAPI): await init_db() logger.info("✅ Base de données initialisée") - # Injecter session_factory dans email_queue + # ✅ CORRECTION: Injecter session_factory ET sage_client dans email_queue email_queue.session_factory = async_session_factory - - # ⚠️ PAS de sage_connector ici (c'est sur Windows !) - # email_queue utilisera sage_client pour générer les PDFs via HTTP + email_queue.sage_client = sage_client + + logger.info("✅ sage_client injecté dans email_queue") # Démarrer queue email_queue.start(num_workers=settings.max_email_workers) @@ -1739,6 +1739,212 @@ async def lire_utilisateur_debug( raise HTTPException(500, str(e)) +# À ajouter dans api.py dans la section Debug + +@app.get("/debug/database/check", tags=["Debug"]) +async def verifier_integrite_database(session: AsyncSession = Depends(get_session)): + """ + 🔍 Vérification de l'intégrité de la base de données + + Retourne des statistiques détaillées sur toutes les tables + """ + from database import User, RefreshToken, LoginAttempt, EmailLog, SignatureLog + from sqlalchemy import func, text + + try: + diagnostics = {} + + # === TABLE USERS === + # Compter tous les users + total_users = await session.execute(select(func.count(User.id))) + diagnostics["users"] = { + "total": total_users.scalar(), + "details": [] + } + + # Lister tous les users avec détails + all_users = await session.execute(select(User)) + users_list = all_users.scalars().all() + + for u in users_list: + diagnostics["users"]["details"].append({ + "id": u.id, + "email": u.email, + "nom": f"{u.prenom} {u.nom}", + "role": u.role, + "is_active": u.is_active, + "is_verified": u.is_verified, + "created_at": u.created_at.isoformat() if u.created_at else None, + "has_reset_token": u.reset_token is not None, + "has_verification_token": u.verification_token is not None, + }) + + # === TABLE REFRESH_TOKENS === + total_tokens = await session.execute(select(func.count(RefreshToken.id))) + diagnostics["refresh_tokens"] = { + "total": total_tokens.scalar() + } + + # === TABLE LOGIN_ATTEMPTS === + total_attempts = await session.execute(select(func.count(LoginAttempt.id))) + diagnostics["login_attempts"] = { + "total": total_attempts.scalar() + } + + # === TABLE EMAIL_LOGS === + total_emails = await session.execute(select(func.count(EmailLog.id))) + diagnostics["email_logs"] = { + "total": total_emails.scalar() + } + + # === TABLE SIGNATURE_LOGS === + total_signatures = await session.execute(select(func.count(SignatureLog.id))) + diagnostics["signature_logs"] = { + "total": total_signatures.scalar() + } + + # === VÉRIFIER LES FICHIERS SQLITE === + import os + db_file = "sage_dataven.db" + diagnostics["database_file"] = { + "exists": os.path.exists(db_file), + "size_bytes": os.path.getsize(db_file) if os.path.exists(db_file) else 0, + "path": os.path.abspath(db_file) + } + + # === TESTER UNE REQUÊTE RAW SQL === + try: + raw_count = await session.execute(text("SELECT COUNT(*) FROM users")) + diagnostics["raw_sql_check"] = { + "users_count": raw_count.scalar(), + "status": "✅ Connexion DB OK" + } + except Exception as e: + diagnostics["raw_sql_check"] = { + "status": "❌ Erreur", + "error": str(e) + } + + return { + "success": True, + "timestamp": datetime.now().isoformat(), + "diagnostics": diagnostics + } + + except Exception as e: + logger.error(f"❌ Erreur diagnostic DB: {e}", exc_info=True) + raise HTTPException(500, f"Erreur diagnostic: {str(e)}") + + +@app.post("/debug/database/test-user-persistence", tags=["Debug"]) +async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_session)): + """ + 🧪 Test de création/lecture/modification d'un utilisateur de test + + Crée un utilisateur de test, le modifie, et vérifie la persistance + """ + import uuid + from database import User + from security.auth import hash_password + + try: + test_email = f"test_{uuid.uuid4().hex[:8]}@example.com" + + # === ÉTAPE 1: CRÉATION === + test_user = User( + id=str(uuid.uuid4()), + email=test_email, + hashed_password=hash_password("TestPassword123!"), + nom="Test", + prenom="User", + role="user", + is_verified=True, + is_active=True, + created_at=datetime.now() + ) + + session.add(test_user) + await session.flush() + user_id = test_user.id + await session.commit() + + logger.info(f"✅ ÉTAPE 1: User créé - {user_id}") + + # === ÉTAPE 2: LECTURE === + result = await session.execute( + select(User).where(User.id == user_id) + ) + loaded_user = result.scalar_one_or_none() + + if not loaded_user: + return { + "success": False, + "error": "❌ User introuvable après création !", + "step": "LECTURE" + } + + logger.info(f"✅ ÉTAPE 2: User chargé - {loaded_user.email}") + + # === ÉTAPE 3: MODIFICATION (simulate reset password) === + loaded_user.hashed_password = hash_password("NewPassword456!") + loaded_user.reset_token = None + loaded_user.reset_token_expires = None + + session.add(loaded_user) + await session.flush() + await session.commit() + await session.refresh(loaded_user) + + logger.info(f"✅ ÉTAPE 3: User modifié") + + # === ÉTAPE 4: RE-LECTURE === + result2 = await session.execute( + select(User).where(User.id == user_id) + ) + reloaded_user = result2.scalar_one_or_none() + + if not reloaded_user: + return { + "success": False, + "error": "❌ User DISPARU après modification !", + "step": "RE-LECTURE", + "user_id": user_id + } + + logger.info(f"✅ ÉTAPE 4: User re-chargé - {reloaded_user.email}") + + # === ÉTAPE 5: SUPPRESSION DU TEST === + await session.delete(reloaded_user) + await session.commit() + + logger.info(f"✅ ÉTAPE 5: User test supprimé") + + return { + "success": True, + "message": "✅ Tous les tests de persistance sont OK", + "test_user_id": user_id, + "test_email": test_email, + "steps_completed": [ + "1. Création", + "2. Lecture", + "3. Modification (reset password simulé)", + "4. Re-lecture (vérification persistance)", + "5. Suppression (cleanup)" + ] + } + + except Exception as e: + logger.error(f"❌ Erreur test persistance: {e}", exc_info=True) + + # Rollback en cas d'erreur + await session.rollback() + + return { + "success": False, + "error": str(e), + "traceback": str(e.__class__.__name__) + } + # ===================================================== # LANCEMENT # ===================================================== diff --git a/core/dependencies.py b/core/dependencies.py index a860f5c..48bb868 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -5,6 +5,7 @@ from sqlalchemy import select from database import get_session, User from security.auth import decode_token from typing import Optional +from datetime import datetime # ✅ AJOUT MANQUANT - C'ÉTAIT LE BUG ! security = HTTPBearer() @@ -75,7 +76,7 @@ async def get_current_user( detail="Email non vérifié. Consultez votre boîte de réception." ) - # Vérifier si le compte est verrouillé + # ✅ FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!) if user.locked_until and user.locked_until > datetime.now(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/email_queue.py b/email_queue.py index 9c8451d..beda62e 100644 --- a/email_queue.py +++ b/email_queue.py @@ -31,7 +31,7 @@ class EmailQueue: self.workers = [] self.running = False self.session_factory = None - self.sage_client = None # Sera injecté depuis api.py + self.sage_client = None def start(self, num_workers: int = 3): """Démarre les workers""" From b4a76579b81936dc262a8cd6a1d5dd6fc4705ff1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 4 Dec 2025 13:47:28 +0300 Subject: [PATCH 024/199] feat: Add API endpoints and SageClient methods for managing prospects, suppliers, credit notes, and delivery notes. --- api.py | 361 ++++++++++++++++++++++++++++++++----------------- sage_client.py | 54 ++++++++ 2 files changed, 288 insertions(+), 127 deletions(-) diff --git a/api.py b/api.py index f01767f..daec94d 100644 --- a/api.py +++ b/api.py @@ -154,8 +154,10 @@ from datetime import datetime # MODÈLES PYDANTIC POUR USERS # ===================================================== + class UserResponse(BaseModel): """Modèle de réponse pour un utilisateur""" + id: str email: str nom: str @@ -166,10 +168,11 @@ class UserResponse(BaseModel): created_at: str last_login: Optional[str] = None failed_login_attempts: int = 0 - + class Config: from_attributes = True + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -300,7 +303,7 @@ async def lifespan(app: FastAPI): # ✅ CORRECTION: Injecter session_factory ET sage_client dans email_queue email_queue.session_factory = async_session_factory email_queue.sage_client = sage_client - + logger.info("✅ sage_client injecté dans email_queue") # Démarrer queue @@ -1583,116 +1586,237 @@ async def statut_queue(): } +# ===================================================== +# ENDPOINTS - PROSPECTS +# ===================================================== +@app.get("/prospects", tags=["Prospects"]) +async def rechercher_prospects(query: Optional[str] = Query(None)): + """🔍 Recherche prospects via gateway Windows""" + try: + prospects = sage_client.lister_prospects(filtre=query or "") + return prospects + except Exception as e: + logger.error(f"Erreur recherche prospects: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/prospects/{code}", tags=["Prospects"]) +async def lire_prospect(code: str): + """📄 Lecture d'un prospect par code""" + try: + prospect = sage_client.lire_prospect(code) + if not prospect: + raise HTTPException(404, f"Prospect {code} introuvable") + return prospect + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture prospect: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - FOURNISSEURS +# ===================================================== +@app.get("/fournisseurs", tags=["Fournisseurs"]) +async def rechercher_fournisseurs(query: Optional[str] = Query(None)): + """🔍 Recherche fournisseurs via gateway Windows""" + try: + fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") + return fournisseurs + except Exception as e: + logger.error(f"Erreur recherche fournisseurs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) +async def lire_fournisseur(code: str): + """📄 Lecture d'un fournisseur par code""" + try: + fournisseur = sage_client.lire_fournisseur(code) + if not fournisseur: + raise HTTPException(404, f"Fournisseur {code} introuvable") + return fournisseur + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture fournisseur: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - AVOIRS +# ===================================================== +@app.get("/avoirs", tags=["Avoirs"]) +async def lister_avoirs( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + """📋 Liste tous les avoirs via gateway Windows""" + try: + avoirs = sage_client.lister_avoirs(limit=limit, statut=statut) + return avoirs + except Exception as e: + logger.error(f"Erreur liste avoirs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/avoirs/{numero}", tags=["Avoirs"]) +async def lire_avoir(numero: str): + """📄 Lecture d'un avoir avec ses lignes""" + try: + avoir = sage_client.lire_avoir(numero) + if not avoir: + raise HTTPException(404, f"Avoir {numero} introuvable") + return avoir + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture avoir: {e}") + raise HTTPException(500, str(e)) + + +# ===================================================== +# ENDPOINTS - LIVRAISONS +# ===================================================== +@app.get("/livraisons", tags=["Livraisons"]) +async def lister_livraisons( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + """📋 Liste tous les bons de livraison via gateway Windows""" + try: + livraisons = sage_client.lister_livraisons(limit=limit, statut=statut) + return livraisons + except Exception as e: + logger.error(f"Erreur liste livraisons: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/livraisons/{numero}", tags=["Livraisons"]) +async def lire_livraison(numero: str): + """📄 Lecture d'une livraison avec ses lignes""" + try: + livraison = sage_client.lire_livraison(numero) + if not livraison: + raise HTTPException(404, f"Livraison {numero} introuvable") + return livraison + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture livraison: {e}") + raise HTTPException(500, str(e)) + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( session: AsyncSession = Depends(get_session), limit: int = Query(100, le=1000), role: Optional[str] = Query(None), - verified_only: bool = Query(False) + verified_only: bool = Query(False), ): """ 🔓 **ROUTE DEBUG** - Liste tous les utilisateurs inscrits - + ⚠️ **ATTENTION**: Cette route n'est PAS protégée par authentification. À utiliser uniquement en développement ou à sécuriser en production. - + Args: limit: Nombre maximum d'utilisateurs à retourner role: Filtrer par rôle (user, admin, commercial) verified_only: Afficher uniquement les utilisateurs vérifiés - + Returns: Liste des utilisateurs avec leurs informations (mot de passe masqué) """ from database import User from sqlalchemy import select - + try: # Construction de la requête query = select(User) - + # Filtres optionnels if role: query = query.where(User.role == role) - + if verified_only: query = query.where(User.is_verified == True) - + # Tri par date de création (plus récents en premier) query = query.order_by(User.created_at.desc()).limit(limit) - + # Exécution result = await session.execute(query) users = result.scalars().all() - + # Conversion en réponse users_response = [] for user in users: - users_response.append(UserResponse( - id=user.id, - email=user.email, - nom=user.nom, - prenom=user.prenom, - role=user.role, - is_verified=user.is_verified, - is_active=user.is_active, - created_at=user.created_at.isoformat() if user.created_at else "", - last_login=user.last_login.isoformat() if user.last_login else None, - failed_login_attempts=user.failed_login_attempts or 0 - )) - - logger.info(f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)") - + users_response.append( + UserResponse( + id=user.id, + email=user.email, + nom=user.nom, + prenom=user.prenom, + role=user.role, + is_verified=user.is_verified, + is_active=user.is_active, + created_at=user.created_at.isoformat() if user.created_at else "", + last_login=user.last_login.isoformat() if user.last_login else None, + failed_login_attempts=user.failed_login_attempts or 0, + ) + ) + + logger.info( + f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)" + ) + return users_response - + except Exception as e: logger.error(f"❌ Erreur liste utilisateurs: {e}") raise HTTPException(500, str(e)) @app.get("/debug/users/stats", tags=["Debug"]) -async def statistiques_utilisateurs( - session: AsyncSession = Depends(get_session) -): +async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)): """ 📊 **ROUTE DEBUG** - Statistiques sur les utilisateurs - + ⚠️ Non protégée - à sécuriser en production """ from database import User from sqlalchemy import select, func - + try: # Total utilisateurs total_query = select(func.count(User.id)) total_result = await session.execute(total_query) total = total_result.scalar() - + # Utilisateurs vérifiés verified_query = select(func.count(User.id)).where(User.is_verified == True) verified_result = await session.execute(verified_query) verified = verified_result.scalar() - + # Utilisateurs actifs active_query = select(func.count(User.id)).where(User.is_active == True) active_result = await session.execute(active_query) active = active_result.scalar() - + # Par rôle roles_query = select(User.role, func.count(User.id)).group_by(User.role) roles_result = await session.execute(roles_query) roles_stats = {role: count for role, count in roles_result.all()} - + return { "total_utilisateurs": total, "utilisateurs_verifies": verified, "utilisateurs_actifs": active, "utilisateurs_non_verifies": total - verified, "repartition_roles": roles_stats, - "taux_verification": f"{(verified/total*100):.1f}%" if total > 0 else "0%" + "taux_verification": f"{(verified/total*100):.1f}%" if total > 0 else "0%", } - + except Exception as e: logger.error(f"❌ Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) @@ -1700,25 +1824,24 @@ async def statistiques_utilisateurs( @app.get("/debug/users/{user_id}", response_model=UserResponse, tags=["Debug"]) async def lire_utilisateur_debug( - user_id: str, - session: AsyncSession = Depends(get_session) + user_id: str, session: AsyncSession = Depends(get_session) ): """ 👤 **ROUTE DEBUG** - Détails d'un utilisateur par ID - + ⚠️ Non protégée - à sécuriser en production """ from database import User from sqlalchemy import select - + try: query = select(User).where(User.id == user_id) result = await session.execute(query) user = result.scalar_one_or_none() - + if not user: raise HTTPException(404, f"Utilisateur {user_id} introuvable") - + return UserResponse( id=user.id, email=user.email, @@ -1729,9 +1852,9 @@ async def lire_utilisateur_debug( is_active=user.is_active, created_at=user.created_at.isoformat() if user.created_at else "", last_login=user.last_login.isoformat() if user.last_login else None, - failed_login_attempts=user.failed_login_attempts or 0 + failed_login_attempts=user.failed_login_attempts or 0, ) - + except HTTPException: raise except Exception as e: @@ -1739,98 +1862,85 @@ async def lire_utilisateur_debug( raise HTTPException(500, str(e)) -# À ajouter dans api.py dans la section Debug - @app.get("/debug/database/check", tags=["Debug"]) async def verifier_integrite_database(session: AsyncSession = Depends(get_session)): """ 🔍 Vérification de l'intégrité de la base de données - + Retourne des statistiques détaillées sur toutes les tables """ from database import User, RefreshToken, LoginAttempt, EmailLog, SignatureLog from sqlalchemy import func, text - + try: diagnostics = {} - + # === TABLE USERS === # Compter tous les users total_users = await session.execute(select(func.count(User.id))) - diagnostics["users"] = { - "total": total_users.scalar(), - "details": [] - } - + diagnostics["users"] = {"total": total_users.scalar(), "details": []} + # Lister tous les users avec détails all_users = await session.execute(select(User)) users_list = all_users.scalars().all() - + for u in users_list: - diagnostics["users"]["details"].append({ - "id": u.id, - "email": u.email, - "nom": f"{u.prenom} {u.nom}", - "role": u.role, - "is_active": u.is_active, - "is_verified": u.is_verified, - "created_at": u.created_at.isoformat() if u.created_at else None, - "has_reset_token": u.reset_token is not None, - "has_verification_token": u.verification_token is not None, - }) - + diagnostics["users"]["details"].append( + { + "id": u.id, + "email": u.email, + "nom": f"{u.prenom} {u.nom}", + "role": u.role, + "is_active": u.is_active, + "is_verified": u.is_verified, + "created_at": u.created_at.isoformat() if u.created_at else None, + "has_reset_token": u.reset_token is not None, + "has_verification_token": u.verification_token is not None, + } + ) + # === TABLE REFRESH_TOKENS === total_tokens = await session.execute(select(func.count(RefreshToken.id))) - diagnostics["refresh_tokens"] = { - "total": total_tokens.scalar() - } - + diagnostics["refresh_tokens"] = {"total": total_tokens.scalar()} + # === TABLE LOGIN_ATTEMPTS === total_attempts = await session.execute(select(func.count(LoginAttempt.id))) - diagnostics["login_attempts"] = { - "total": total_attempts.scalar() - } - + diagnostics["login_attempts"] = {"total": total_attempts.scalar()} + # === TABLE EMAIL_LOGS === total_emails = await session.execute(select(func.count(EmailLog.id))) - diagnostics["email_logs"] = { - "total": total_emails.scalar() - } - + diagnostics["email_logs"] = {"total": total_emails.scalar()} + # === TABLE SIGNATURE_LOGS === total_signatures = await session.execute(select(func.count(SignatureLog.id))) - diagnostics["signature_logs"] = { - "total": total_signatures.scalar() - } - + diagnostics["signature_logs"] = {"total": total_signatures.scalar()} + # === VÉRIFIER LES FICHIERS SQLITE === import os + db_file = "sage_dataven.db" diagnostics["database_file"] = { "exists": os.path.exists(db_file), "size_bytes": os.path.getsize(db_file) if os.path.exists(db_file) else 0, - "path": os.path.abspath(db_file) + "path": os.path.abspath(db_file), } - + # === TESTER UNE REQUÊTE RAW SQL === try: raw_count = await session.execute(text("SELECT COUNT(*) FROM users")) diagnostics["raw_sql_check"] = { "users_count": raw_count.scalar(), - "status": "✅ Connexion DB OK" + "status": "✅ Connexion DB OK", } except Exception as e: - diagnostics["raw_sql_check"] = { - "status": "❌ Erreur", - "error": str(e) - } - + diagnostics["raw_sql_check"] = {"status": "❌ Erreur", "error": str(e)} + return { "success": True, "timestamp": datetime.now().isoformat(), - "diagnostics": diagnostics + "diagnostics": diagnostics, } - + except Exception as e: logger.error(f"❌ Erreur diagnostic DB: {e}", exc_info=True) raise HTTPException(500, f"Erreur diagnostic: {str(e)}") @@ -1840,7 +1950,7 @@ async def verifier_integrite_database(session: AsyncSession = Depends(get_sessio async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_session)): """ 🧪 Test de création/lecture/modification d'un utilisateur de test - + Crée un utilisateur de test, le modifie, et vérifie la persistance """ import uuid @@ -1849,7 +1959,7 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses try: test_email = f"test_{uuid.uuid4().hex[:8]}@example.com" - + # === ÉTAPE 1: CRÉATION === test_user = User( id=str(uuid.uuid4()), @@ -1860,65 +1970,61 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses role="user", is_verified=True, is_active=True, - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(test_user) await session.flush() user_id = test_user.id await session.commit() - + logger.info(f"✅ ÉTAPE 1: User créé - {user_id}") - + # === ÉTAPE 2: LECTURE === - result = await session.execute( - select(User).where(User.id == user_id) - ) + result = await session.execute(select(User).where(User.id == user_id)) loaded_user = result.scalar_one_or_none() - + if not loaded_user: return { "success": False, "error": "❌ User introuvable après création !", - "step": "LECTURE" + "step": "LECTURE", } - + logger.info(f"✅ ÉTAPE 2: User chargé - {loaded_user.email}") - + # === ÉTAPE 3: MODIFICATION (simulate reset password) === loaded_user.hashed_password = hash_password("NewPassword456!") loaded_user.reset_token = None loaded_user.reset_token_expires = None - + session.add(loaded_user) await session.flush() await session.commit() await session.refresh(loaded_user) - + logger.info(f"✅ ÉTAPE 3: User modifié") - + # === ÉTAPE 4: RE-LECTURE === - result2 = await session.execute( - select(User).where(User.id == user_id) - ) + result2 = await session.execute(select(User).where(User.id == user_id)) reloaded_user = result2.scalar_one_or_none() - + if not reloaded_user: return { "success": False, "error": "❌ User DISPARU après modification !", "step": "RE-LECTURE", - "user_id": user_id + "user_id": user_id, } - + logger.info(f"✅ ÉTAPE 4: User re-chargé - {reloaded_user.email}") - + # === ÉTAPE 5: SUPPRESSION DU TEST === await session.delete(reloaded_user) await session.commit() - + logger.info(f"✅ ÉTAPE 5: User test supprimé") - + return { "success": True, "message": "✅ Tous les tests de persistance sont OK", @@ -1929,22 +2035,23 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses "2. Lecture", "3. Modification (reset password simulé)", "4. Re-lecture (vérification persistance)", - "5. Suppression (cleanup)" - ] + "5. Suppression (cleanup)", + ], } - + except Exception as e: logger.error(f"❌ Erreur test persistance: {e}", exc_info=True) - + # Rollback en cas d'erreur await session.rollback() - + return { "success": False, "error": str(e), - "traceback": str(e.__class__.__name__) + "traceback": str(e.__class__.__name__), } + # ===================================================== # LANCEMENT # ===================================================== diff --git a/sage_client.py b/sage_client.py index 9ea5265..bc5568f 100644 --- a/sage_client.py +++ b/sage_client.py @@ -256,6 +256,60 @@ class SageGatewayClient: logger.error(f"Erreur génération PDF: {e}") raise + # ===================================================== + # PROSPECTS + # ===================================================== + def lister_prospects(self, filtre: str = "") -> List[Dict]: + """Liste tous les prospects avec filtre optionnel""" + return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", []) + + def lire_prospect(self, code: str) -> Optional[Dict]: + """Lecture d'un prospect par code""" + return self._post("/sage/prospects/get", {"code": code}).get("data") + + # ===================================================== + # FOURNISSEURS + # ===================================================== + def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: + """Liste tous les fournisseurs avec filtre optionnel""" + return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", []) + + def lire_fournisseur(self, code: str) -> Optional[Dict]: + """Lecture d'un fournisseur par code""" + return self._post("/sage/fournisseurs/get", {"code": code}).get("data") + + # ===================================================== + # AVOIRS + # ===================================================== + def lister_avoirs( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + """Liste tous les avoirs""" + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/avoirs/list", payload).get("data", []) + + def lire_avoir(self, numero: str) -> Optional[Dict]: + """Lecture d'un avoir avec ses lignes""" + return self._post("/sage/avoirs/get", {"code": numero}).get("data") + + # ===================================================== + # LIVRAISONS + # ===================================================== + def lister_livraisons( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + """Liste tous les bons de livraison""" + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/livraisons/list", payload).get("data", []) + + def lire_livraison(self, numero: str) -> Optional[Dict]: + """Lecture d'une livraison avec ses lignes""" + return self._post("/sage/livraisons/get", {"code": numero}).get("data") + # ===================================================== # CACHE (ADMIN) # ===================================================== From 2bf982f60e6df60796b02c4b1d3d822d35b9c304 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 13:34:40 +0300 Subject: [PATCH 025/199] clearing insecable spaces --- api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api.py b/api.py index daec94d..b11a450 100644 --- a/api.py +++ b/api.py @@ -97,6 +97,10 @@ class LigneDevis(BaseModel): quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 + + @validator("article_code", pre=True) + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() class DevisRequest(BaseModel): From 511435d58e0daf893a282cfd2c91cf81d0c4188f Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 14:00:40 +0300 Subject: [PATCH 026/199] moved database in WORKDIR/data --- .env.example | 2 +- api.py | 4 ++-- config.py | 2 +- database/db_config.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 0995196..314aa07 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ SAGE_GATEWAY_URL=http://192.168.1.50:8100 SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f # === Base de données === -DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db +DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db # === SMTP === SMTP_HOST=smtp.office365.com diff --git a/api.py b/api.py index b11a450..6a0dc37 100644 --- a/api.py +++ b/api.py @@ -1,7 +1,7 @@ from fastapi import FastAPI, HTTPException, Query, Depends, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field, EmailStr +from pydantic import BaseModel, Field, EmailStr, validator, field_validator from typing import List, Optional, Dict from datetime import date, datetime from enum import Enum @@ -98,7 +98,7 @@ class LigneDevis(BaseModel): prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - @validator("article_code", pre=True) + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() diff --git a/config.py b/config.py index bc36e64..7e1c020 100644 --- a/config.py +++ b/config.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): frontend_url: str # === Base de données === - database_url: str = "sqlite+aiosqlite:///./sage_dataven.db" + database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db" # === SMTP === smtp_host: str diff --git a/database/db_config.py b/database/db_config.py index 0bbba98..1973799 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -6,7 +6,7 @@ import logging logger = logging.getLogger(__name__) -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./sage_dataven.db") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./data/sage_dataven.db") engine = create_async_engine( DATABASE_URL, From 588ea6c4f41bb8625b7bb0bd06fdf79bfc1eff24 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 14:17:32 +0300 Subject: [PATCH 027/199] chore: update gitignore and docker configuration --- .gitignore | 4 +--- data/sage_dataven.db | Bin 0 -> 147456 bytes docker-compose.yml | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 data/sage_dataven.db diff --git a/.gitignore b/.gitignore index 023d3fc..e1a8191 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,4 @@ htmlcov/ # Docker *~ .build/ -dist/ - -*.db +dist/ \ No newline at end of file diff --git a/data/sage_dataven.db b/data/sage_dataven.db new file mode 100644 index 0000000000000000000000000000000000000000..25506034829bb9e4f108f3f871e9a7db96753c11 GIT binary patch literal 147456 zcmeI5TW{RP6~{%{TCP@-W%PYs^f8akLq9@MG+&`Wzd(UJq;CcC(lf&)xrcj^)hv(|@HgOSXU@zy zXMc0fnHe%lesg=(i(Gxn4;ogaUmiO-rYK{-*7dQmu?zI~HTs+W5$)q?gMKOk0;CJ{nA(Em69?M9X~zs&iJ?Ej`F>-r>rS! z{S=ONzj3alUD1>=uj#mt!q4l3*0kctmu)i+-5@j<ZId4_Z(Lhh-k4vwbje`y z9Bs6>iYU> zZFx=TaoouA>Y=__ySpjcZV(8SX|rP;^baU{HgDXjv9Iva#*JIc8~5~^wR`%!=NJ{^ zNp)7$3`5y}EK%B)z3rL}GDEs7-a5&Ua+yBg7i%)Gh>W>ax59|>V2dDZXO4^SM=8}X zZ9i^C>57To_DIu-153oO*$DLySJ#&%5~+*0_&FBY>Q+;PAynU4yK(zYO`p%5CPqsq zOWL~8FVQ&&bAh+#T9p&3cI%?jdLzws!W>N&0aJHBrOllUU ziezB}^!dUFqovNM+D&rCIKUa(qr76uPCTbCa^@gcIVaCEUQnF(QT;+ zxQ5@1EL#2TC=cPZFN*BEQ|5LQSj{lmzA5grRAJr@sx{perC34Yz@_|WE*A5oV!S(D z5+U7Z=VNPRF0kfIYrLXr*A1mrO(QTQCRRjt3;EzVTryP*KrYU+f$A@10+)8A0+qdR zQq``I4>Nsy2qSVR;uPvYbea)?8WDZj;r;K33fZ)pj$nYI#%(8JNif>=$SE z@}xM%Xq{52IFV0FeSDGumu!r5JmDUd+a9%RW_D8HqfD-%ms@;<@w7A{x1?v+Woh8* zc&pTQA=%}*?0Kt0VY_CzBr8Lj{%P9s5h5cqK&-}y^cF6;ff)z&OtjcO6BHLC8}R1h zA}?f3ztIsE{l~RKFt1PY6d?GuWOqiaTSS{|9I|d#ZqjwDX}g_g5-Mwf zD|=bh2rMt8tvOMX9#H74lbcNaY@c?z%gj<~(7u}~O6tY}Z>#r@^K)o|Tb{TRPI<>f zF|~4$nWuH$sn54lIzCo7*5_kPhj4jR*hD7h-Ny%*>YO3cB%ejqt3#Wyzo*FK@3Sj1 zLGp3=r*W1TdH+F4(P5^4oY{7E;jq*Q%BBia6xVNjm`XH_HQ#EO@CMUditBn+~niQ za(Tb>Re7bPOhm^|PrNh!?YN_Suk0yn%G!v3rJvBf^2)@OE1ELqH68a+_<7xnT+@mp zU$)ISbc4`bkUzBejH)eHmDX7)^B&PS$BWFm{~$C2Hw^tauwB!0^6s6z%QML8>sZQBj?tLy8lwdFOT$8jUetB3k#?e3;%yFnmSrp=CZ z&_AH)*}QS9#=gQw8#it(Z`{*w*6!)^o?}#u^2+!l%`lYx#}cJ&+1sw!ATy-P;;oYm zDVOQX=1c=vZSpW{SuvnFc)}xuDqgX+O3O9>y0!c z9{Ry&TXp|olKzfw#|^g`nMn~~!r5YiFsWIXDw2f_(B}&yjFvj1YB$Ll;{a!DkMfEs zJMo;p$eDv&kpqnV_oh_s;zi}l%QEhmDb|=~QO88>pi7hO^4mOL3tv9VuCBxuvDwC- zh8YXbOi}XPQhUAiBx8Ejm*#!5?mcKyMz^IR;2M52vS{_UqdbJuz9_QuPMO-?1eM={`FjTO)IUHD_Am6;-=#D6MK5 zfgv%mBC=b^2hZV>sbT5Vv?CR$?1hu6c7=SH>ElBfkwX#JP&vSSGN+g2 zL}Ik&%BpsotSNcphn~7D+qgu$oOdeprIJ=i8#W6deJE`zdCRfqREk43{TAEPBmh|kpEDc;8Z{IhI?VKsGuzHi zoVM*{pTqc5H_B#-FlU)!`@v2~`&^?gXE0fsVxq=m`g$gxOe$HPGfNA}8CwRl9k>Iw z#zCpz$GdJIwjmbt6x5TgZI5wtV~>-Oc7;?_v`agm+^(JYS+l(`92mSuR*oNYV7doKR>`~ zHm#TCOlbV!RaL8z^_Tlt&+pjW{Lc-MKbIO$UVTNLS@!p2N#<5~tIA3#p_~dT?cdJB zceD@j-dBr(!o}&NUZy2>x^z7G-HS3ro>Cgn<(eztt(RGdaM=Mnc-vaGUd2ms4k8=U z6-$o0>xsva%`HD0lX1iL-k1zF!#g1_z1Y|Rf zE^?)G+VHkKJ9&U5S4GacFwjExqz-f4Lc`TmZINtGaoe;^Zj@w}Ka=eWce-9> zVczvjXD4t6$^*aN^(dGkIav!H%H8XenN6!|_Ykw4R#{Go=qCf>%DKz#VWMEu_bqHF z9gmvozD>zx(!*4*&eU}*WtT@CvYxISl-2Q$ICQhD7vDfWrIoHSPrB0@Z%)KJqT~TX z{&oXzPGlPVq;F1s)LVfk)!F2{h}SM+C40VhX;n_p!%4h3$sUd*Z%*EwE(t3C&%<~8 z`~OGpDi4oA00ck)1V8`;KmY_l00ck)1VG@h1aSX9EHR7+0T2KI5C8!X009sH0T2KI z5CDOrM*#Q#qqk=87z9871V8`;KmY_l00ck)1V8`;4od+4|G&c$!*~z?0T2KI5C8!X z009sH0T2KI5IA}S@c#ektrjO&)(jql00@8p2!H?xfB*=900@8p2!Oz0 z3E=*JSYj9t0w4eaAOHd&00JNY0w4eaAOHeKj{xrfM{mvGF$jPF2!H?xfB*=900@8p M2!H?x9G1ZU06#Eq0{{R3 literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml index 3787019..e9ee1bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: container_name: vps-sage-api env_file: .env volumes: - # ✅ Monter un DOSSIER entier au lieu d'un fichier - ./data:/app/data ports: - "8000:8000" From df5ed76ec678891731400f041ce2f5236ffc1e95 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 14:44:53 +0300 Subject: [PATCH 028/199] feat(api): add endpoint to read order with its lines --- api.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/api.py b/api.py index 6a0dc37..81e7e3a 100644 --- a/api.py +++ b/api.py @@ -550,6 +550,20 @@ async def changer_statut_devis(id: str, nouveau_statut: int = Query(..., ge=0, l # ENDPOINTS - US-A2 (WORKFLOW SANS RESSAISIE) # ===================================================== +@app.get("/commandes/{id}", tags=["US-A2"]) +async def lire_commande(id: str): + """📄 Lecture d'une commande avec ses lignes""" + try: + commande = sage_client.lire_document(id, TypeDocument.BON_COMMANDE) + if not commande: + raise HTTPException(404, f"Commande {id} introuvable") + return commande + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture commande: {e}") + raise HTTPException(500, str(e)) + @app.get("/commandes", tags=["US-A2"]) async def lister_commandes( From 77dcb21e4a99cfb7957ad332d24a31e154233023 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 18:21:52 +0300 Subject: [PATCH 029/199] Exceeded access token for 24 hours --- routes/auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 961f1c3..771cb38 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -45,7 +45,7 @@ class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" - expires_in: int = 1800 # 30 minutes en secondes + expires_in: int = 86400 # 30 minutes en secondes class RefreshTokenRequest(BaseModel): @@ -432,7 +432,7 @@ async def login( return TokenResponse( access_token=access_token, refresh_token=refresh_token_jwt, - expires_in=1800 # 30 minutes + expires_in=86400 # 30 minutes ) @@ -502,7 +502,7 @@ async def refresh_access_token( return TokenResponse( access_token=new_access_token, refresh_token=data.refresh_token, # Refresh token reste le même - expires_in=1800 + expires_in=86400 ) From 2f9b2fc1a9799cd6cc013a543c2641eaabe91aa0 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 5 Dec 2025 19:11:36 +0300 Subject: [PATCH 030/199] Added create client logics --- api.py | 38 +++++++++++++++++++++++++++++++++++++- sage_client.py | 8 ++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/api.py b/api.py index 81e7e3a..555f6b7 100644 --- a/api.py +++ b/api.py @@ -148,7 +148,19 @@ class BaremeRemiseResponse(BaseModel): message: str -# À ajouter dans api.py après les imports et avant les endpoints existants +class ClientCreateAPIRequest(BaseModel): + intitule: str = Field(..., min_length=1, description="Raison sociale ou Nom") + compte_collectif: str = Field("411000", description="Compte Comptable (ex: 411000)") + num: Optional[str] = Field(None, description="Code client souhaité (optionnel)") + adresse: Optional[str] = None + code_postal: Optional[str] = None + ville: Optional[str] = None + pays: Optional[str] = None + email: Optional[EmailStr] = None + telephone: Optional[str] = None + siret: Optional[str] = None + tva_intra: Optional[str] = None + from pydantic import BaseModel from typing import List, Optional @@ -356,6 +368,30 @@ async def rechercher_clients(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) +@app.post("/clients", status_code=201, tags=["US-A8"]) +async def ajouter_client( + client: ClientCreateAPIRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'un nouveau client dans Sage 100c + """ + try: + nouveau_client = sage_client.creer_client(client.dict()) + + logger.info(f"✅ Client créé via API: {nouveau_client.get('numero')}") + + return { + "success": True, + "message": "Client créé avec succès", + "client": nouveau_client + } + + except Exception as e: + logger.error(f"Erreur lors de la création du client: {e}") + # On renvoie une 400 si c'est une erreur métier (ex: doublon), sinon 500 + status = 400 if "existe déjà" in str(e) else 500 + raise HTTPException(status, str(e)) @app.get("/articles", response_model=List[ArticleResponse], tags=["US-A1"]) async def rechercher_articles(query: Optional[str] = Query(None)): diff --git a/sage_client.py b/sage_client.py index bc5568f..0037c63 100644 --- a/sage_client.py +++ b/sage_client.py @@ -331,6 +331,14 @@ class SageGatewayClient: return r.json() except: return {"status": "down"} + + def creer_client(self, client_data: Dict) -> Dict: + """ + Envoie la requête de création de client à la gateway Windows. + :param client_data: Dict contenant intitule, compte_collectif, etc. + """ + # On appelle la route définie dans main.py + return self._post("/sage/clients/create", client_data).get("data", {}) # Instance globale From 36554b9ebecb800b347f772161b8961e5b203941 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 10:01:18 +0300 Subject: [PATCH 031/199] changed return field from client to data --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 555f6b7..61e9a85 100644 --- a/api.py +++ b/api.py @@ -384,7 +384,7 @@ async def ajouter_client( return { "success": True, "message": "Client créé avec succès", - "client": nouveau_client + "data": nouveau_client } except Exception as e: From 72bd14a44ecb749fda0c74ffdd739a92d3c92ac1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 10:14:58 +0300 Subject: [PATCH 032/199] Updates for GET on client & facture, and PUT on clients --- api.py | 126 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 20 ++++++-- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/api.py b/api.py index 61e9a85..6b5d2cb 100644 --- a/api.py +++ b/api.py @@ -162,6 +162,31 @@ class ClientCreateAPIRequest(BaseModel): tva_intra: Optional[str] = None +class ClientUpdateRequest(BaseModel): + """Modèle pour modification d'un client existant""" + intitule: Optional[str] = Field(None, min_length=1, max_length=69) + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "SARL TEST MODIFIÉ", + "adresse": "456 Avenue des Champs", + "code_postal": "75008", + "ville": "Paris", + "email": "nouveau@email.fr", + "telephone": "0198765432" + } + } + + from pydantic import BaseModel from typing import List, Optional from datetime import datetime @@ -368,6 +393,79 @@ async def rechercher_clients(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) +@app.get("/clients/{code}", tags=["US-A1"]) +async def lire_client_detail(code: str): + """ + 📄 Lecture détaillée d'un client par son code + + Args: + code: Code du client (ex: "CLI000001", "SARL", etc.) + + Returns: + Toutes les informations du client + """ + try: + client = sage_client.lire_client(code) + + if not client: + raise HTTPException(404, f"Client {code} introuvable") + + return { + "success": True, + "data": client + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture client {code}: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/clients/{code}", tags=["US-A1"]) +async def modifier_client( + code: str, + client_update: ClientUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'un client existant + + Args: + code: Code du client à modifier + client_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) + + Returns: + Client modifié avec ses nouvelles valeurs + + Example: + PUT /clients/SARL + { + "email": "nouveau@email.fr", + "telephone": "0198765432" + } + """ + try: + # Appel à la gateway Windows + resultat = sage_client.modifier_client(code, client_update.dict(exclude_none=True)) + + logger.info(f"✅ Client {code} modifié avec succès") + + return { + "success": True, + "message": f"Client {code} modifié avec succès", + "client": resultat + } + + except ValueError as e: + # Erreur métier (client introuvable, etc.) + logger.warning(f"Erreur métier modification client {code}: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + # Erreur technique + logger.error(f"Erreur technique modification client {code}: {e}") + raise HTTPException(500, str(e)) + @app.post("/clients", status_code=201, tags=["US-A8"]) async def ajouter_client( client: ClientCreateAPIRequest, @@ -1213,6 +1311,34 @@ async def lister_factures( raise HTTPException(500, str(e)) +@app.get("/factures/{numero}", tags=["US-A7"]) +async def lire_facture_detail(numero: str): + """ + 📄 Lecture détaillée d'une facture avec ses lignes + + Args: + numero: Numéro de la facture (ex: "FA000001") + + Returns: + Facture complète avec lignes, client, totaux, etc. + """ + try: + facture = sage_client.lire_document(numero, TypeDocument.FACTURE) + + if not facture: + raise HTTPException(404, f"Facture {numero} introuvable") + + return { + "success": True, + "data": facture + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture facture {numero}: {e}") + raise HTTPException(500, str(e)) + class RelanceFactureRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None diff --git a/sage_client.py b/sage_client.py index 0037c63..732fe2d 100644 --- a/sage_client.py +++ b/sage_client.py @@ -191,9 +191,6 @@ class SageGatewayClient: payload["statut"] = statut return self._post("/sage/commandes/list", payload).get("data", []) - # ===================================================== - # FACTURES (US-A7) - # ===================================================== def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: @@ -339,6 +336,23 @@ class SageGatewayClient: """ # On appelle la route définie dans main.py return self._post("/sage/clients/create", client_data).get("data", {}) + + def modifier_client(self, code: str, client_data: Dict) -> Dict: + """ + ✏️ Modification d'un client existant + + Args: + code: Code du client à modifier + client_data: Dictionnaire contenant les champs à modifier + (seuls les champs présents seront mis à jour) + + Returns: + Client modifié + """ + return self._post("/sage/clients/update", { + "code": code, + "client_data": client_data + }).get("data", {}) # Instance globale From 4867f4dc225b3611e2d68f5fef355f9a156c8d5e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 12:15:36 +0300 Subject: [PATCH 033/199] Cache problem --- api.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/api.py b/api.py index 6b5d2cb..f5951f2 100644 --- a/api.py +++ b/api.py @@ -2231,7 +2231,88 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses "traceback": str(e.__class__.__name__), } +@app.get("/debug/fournisseurs/cache", tags=["Debug"]) +async def debug_cache_fournisseurs(): + """ + 🔍 Debug : État du cache côté VPS Linux + """ + try: + # Appeler la gateway Windows pour récupérer l'info cache + cache_info = sage_client.get_cache_info() + + # Tenter de lister les fournisseurs + try: + fournisseurs = sage_client.lister_fournisseurs(filtre="") + nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 + exemple = fournisseurs[:3] if fournisseurs else [] + except Exception as e: + nb_fournisseurs = -1 + exemple = [] + error = str(e) + + return { + "success": True, + "cache_info_windows": cache_info, + "test_liste_fournisseurs": { + "nb_fournisseurs": nb_fournisseurs, + "exemples": exemple, + "erreur": error if nb_fournisseurs == -1 else None + }, + "diagnostic": { + "gateway_accessible": cache_info is not None, + "cache_fournisseurs_existe": "fournisseurs" in cache_info if cache_info else False, + "probleme_probable": ( + "Cache fournisseurs non initialisé côté Windows" + if cache_info and "fournisseurs" not in cache_info + else "OK" if nb_fournisseurs > 0 + else "Erreur lors de la récupération" + ) + } + } + + except Exception as e: + logger.error(f"❌ Erreur debug cache: {e}", exc_info=True) + raise HTTPException(500, str(e)) + +@app.post("/debug/fournisseurs/force-refresh", tags=["Debug"]) +async def force_refresh_fournisseurs(): + """ + 🔄 Force le refresh du cache fournisseurs côté Windows + """ + try: + # Appeler la gateway Windows pour forcer le refresh + resultat = sage_client.refresh_cache() + + # Attendre 2 secondes + import time + time.sleep(2) + + # Récupérer le cache info après refresh + cache_info = sage_client.get_cache_info() + + # Tester la liste + fournisseurs = sage_client.lister_fournisseurs(filtre="") + nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 + + return { + "success": True, + "refresh_result": resultat, + "cache_apres_refresh": cache_info, + "nb_fournisseurs_maintenant": nb_fournisseurs, + "exemples": fournisseurs[:3] if fournisseurs else [], + "message": ( + f"✅ Refresh OK : {nb_fournisseurs} fournisseurs disponibles" + if nb_fournisseurs > 0 + else "❌ Problème : aucun fournisseur après refresh" + ) + } + + except Exception as e: + logger.error(f"❌ Erreur force refresh: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + # ===================================================== # LANCEMENT # ===================================================== From 2c13c086a56b20727a1c9a8873f46a074d87f566 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 12:50:31 +0300 Subject: [PATCH 034/199] Evicted passing through cache for "fournisseurs" --- api.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index f5951f2..48f8ff1 100644 --- a/api.py +++ b/api.py @@ -1800,12 +1800,16 @@ async def lire_prospect(code: str): # ===================================================== @app.get("/fournisseurs", tags=["Fournisseurs"]) async def rechercher_fournisseurs(query: Optional[str] = Query(None)): - """🔍 Recherche fournisseurs via gateway Windows""" + """ + 🔍 Recherche fournisseurs via gateway Windows + ✅ CORRECTION : Utilise maintenant l'endpoint direct (pas de cache) + """ try: fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") + logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés") return fournisseurs except Exception as e: - logger.error(f"Erreur recherche fournisseurs: {e}") + logger.error(f"❌ Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) From 709de0cb2c2afd2763ecd8825a6355c77610a38f Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 14:10:39 +0300 Subject: [PATCH 035/199] Test, again --- api.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 48f8ff1..fbab037 100644 --- a/api.py +++ b/api.py @@ -1802,12 +1802,19 @@ async def lire_prospect(code: str): async def rechercher_fournisseurs(query: Optional[str] = Query(None)): """ 🔍 Recherche fournisseurs via gateway Windows - ✅ CORRECTION : Utilise maintenant l'endpoint direct (pas de cache) + ✅ CORRECTION : Appel direct sans cache """ try: + # ✅ APPEL DIRECT vers Windows (pas de cache) fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") - logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés") + + logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés depuis Windows") + + if len(fournisseurs) == 0: + logger.warning("⚠️ Aucun fournisseur retourné - vérifier la gateway Windows") + return fournisseurs + except Exception as e: logger.error(f"❌ Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) From ba793543861fce0a01289b3b70f46078f5be6e11 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 15:03:23 +0300 Subject: [PATCH 036/199] Create new fournisseur --- api.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 12 ++++++++ 2 files changed, 88 insertions(+) diff --git a/api.py b/api.py index fbab037..16a7acf 100644 --- a/api.py +++ b/api.py @@ -214,6 +214,36 @@ class UserResponse(BaseModel): from_attributes = True +class FournisseurCreateAPIRequest(BaseModel): + intitule: str = Field(..., min_length=1, max_length=69, description="Raison sociale du fournisseur") + compte_collectif: str = Field("401000", description="Compte comptable fournisseur (ex: 401000)") + num: Optional[str] = Field(None, max_length=17, description="Code fournisseur souhaité (optionnel)") + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "ACME SUPPLIES SARL", + "compte_collectif": "401000", + "num": "FOUR001", + "adresse": "15 Rue du Commerce", + "code_postal": "75001", + "ville": "Paris", + "pays": "France", + "email": "contact@acmesupplies.fr", + "telephone": "0145678901", + "siret": "12345678901234", + "tva_intra": "FR12345678901" + } + } + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1819,6 +1849,52 @@ async def rechercher_fournisseurs(query: Optional[str] = Query(None)): logger.error(f"❌ Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) +@app.post("/fournisseurs", status_code=201, tags=["Fournisseurs"]) +async def ajouter_fournisseur( + fournisseur: FournisseurCreateAPIRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'un nouveau fournisseur dans Sage 100c + + **Champs obligatoires:** + - `intitule`: Raison sociale (max 69 caractères) + + **Champs optionnels:** + - `compte_collectif`: Compte comptable (défaut: 401000) + - `num`: Code fournisseur personnalisé (auto-généré si vide) + - `adresse`, `code_postal`, `ville`, `pays` + - `email`, `telephone` + - `siret`, `tva_intra` + + **Retour:** + - Fournisseur créé avec son numéro définitif + + **Erreurs possibles:** + - 400: Fournisseur existe déjà (doublon) + - 500: Erreur technique Sage + """ + try: + # Appel à la gateway Windows via sage_client + nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) + + logger.info(f"✅ Fournisseur créé via API: {nouveau_fournisseur.get('numero')}") + + return { + "success": True, + "message": "Fournisseur créé avec succès", + "data": nouveau_fournisseur + } + + except ValueError as e: + # Erreur métier (doublon, validation) + logger.warning(f"⚠️ Erreur métier création fournisseur: {e}") + raise HTTPException(400, str(e)) + + except Exception as e: + # Erreur technique (COM, connexion) + logger.error(f"❌ Erreur technique création fournisseur: {e}") + raise HTTPException(500, str(e)) @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): diff --git a/sage_client.py b/sage_client.py index 732fe2d..3074119 100644 --- a/sage_client.py +++ b/sage_client.py @@ -275,6 +275,18 @@ class SageGatewayClient: """Lecture d'un fournisseur par code""" return self._post("/sage/fournisseurs/get", {"code": code}).get("data") + def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: + """ + Envoie la requête de création de fournisseur à la gateway Windows. + + Args: + fournisseur_data: Dict contenant intitule, compte_collectif, etc. + + Returns: + Fournisseur créé avec son numéro définitif + """ + return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) + # ===================================================== # AVOIRS # ===================================================== From a5dd81ddfb3204620e1aa9d3adb8693005ab8025 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 15:17:15 +0300 Subject: [PATCH 037/199] Update fournisseur --- api.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 17 +++++++++++++ 2 files changed, 84 insertions(+) diff --git a/api.py b/api.py index 16a7acf..a49060c 100644 --- a/api.py +++ b/api.py @@ -244,6 +244,28 @@ class FournisseurCreateAPIRequest(BaseModel): } } +class FournisseurUpdateRequest(BaseModel): + """Modèle pour modification d'un fournisseur existant""" + intitule: Optional[str] = Field(None, min_length=1, max_length=69) + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "ACME SUPPLIES MODIFIÉ", + "email": "nouveau@acme.fr", + "telephone": "0198765432" + } + } + + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1896,6 +1918,51 @@ async def ajouter_fournisseur( logger.error(f"❌ Erreur technique création fournisseur: {e}") raise HTTPException(500, str(e)) +@app.put("/fournisseurs/{code}", tags=["Fournisseurs"]) +async def modifier_fournisseur( + code: str, + fournisseur_update: FournisseurUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'un fournisseur existant + + Args: + code: Code du fournisseur à modifier + fournisseur_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) + + Returns: + Fournisseur modifié avec ses nouvelles valeurs + + Example: + PUT /fournisseurs/DUPONT + { + "email": "nouveau@email.fr", + "telephone": "0198765432" + } + """ + try: + # Appel à la gateway Windows + resultat = sage_client.modifier_fournisseur(code, fournisseur_update.dict(exclude_none=True)) + + logger.info(f"✅ Fournisseur {code} modifié avec succès") + + return { + "success": True, + "message": f"Fournisseur {code} modifié avec succès", + "fournisseur": resultat + } + + except ValueError as e: + # Erreur métier (fournisseur introuvable, etc.) + logger.warning(f"Erreur métier modification fournisseur {code}: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + # Erreur technique + logger.error(f"Erreur technique modification fournisseur {code}: {e}") + raise HTTPException(500, str(e)) + + @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): """📄 Lecture d'un fournisseur par code""" diff --git a/sage_client.py b/sage_client.py index 3074119..4d5a0c5 100644 --- a/sage_client.py +++ b/sage_client.py @@ -287,6 +287,23 @@ class SageGatewayClient: """ return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) + def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: + """ + ✏️ Modification d'un fournisseur existant + + Args: + code: Code du fournisseur à modifier + fournisseur_data: Dictionnaire contenant les champs à modifier + (seuls les champs présents seront mis à jour) + + Returns: + Fournisseur modifié + """ + return self._post("/sage/fournisseurs/update", { + "code": code, + "fournisseur_data": fournisseur_data + }).get("data", {}) + # ===================================================== # AVOIRS # ===================================================== From 608ba12c50f97fc4802ff7a6b9a629836f163459 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 6 Dec 2025 17:03:12 +0300 Subject: [PATCH 038/199] Update devis, Create and Update Command --- api.py | 331 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 62 +++++++++ 2 files changed, 393 insertions(+) diff --git a/api.py b/api.py index a49060c..dff2e31 100644 --- a/api.py +++ b/api.py @@ -265,6 +265,87 @@ class FournisseurUpdateRequest(BaseModel): } } +class DevisUpdateRequest(BaseModel): + """Modèle pour modification d'un devis existant""" + date_devis: Optional[date] = None + lignes: Optional[List[LigneDevis]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + + class Config: + json_schema_extra = { + "example": { + "date_devis": "2024-01-15", + "lignes": [ + { + "article_code": "ART001", + "quantite": 5.0, + "prix_unitaire_ht": 100.0, + "remise_pourcentage": 10.0 + } + ], + "statut": 2 + } + } + + +class LigneCommande(BaseModel): + """Ligne de commande""" + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + +class CommandeCreateRequest(BaseModel): + """Création d'une commande""" + client_id: str + date_commande: Optional[date] = None + lignes: List[LigneCommande] + reference: Optional[str] = None # Référence externe + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_commande": "2024-01-15", + "reference": "CMD-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0 + } + ] + } + } + + +class CommandeUpdateRequest(BaseModel): + """Modification d'une commande existante""" + date_commande: Optional[date] = None + lignes: Optional[List[LigneCommande]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0 + } + ], + "statut": 2 + } + } + # ===================================================== # SERVICES EXTERNES (Universign) @@ -592,6 +673,256 @@ async def creer_devis(devis: DevisRequest): raise HTTPException(500, str(e)) +@app.put("/devis/{id}", tags=["US-A1"]) +async def modifier_devis( + id: str, + devis_update: DevisUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'un devis existant + + **Champs modifiables:** + - `date_devis`: Nouvelle date du devis + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut (0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé) + + **Note importante:** + - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées + - Pour ajouter/supprimer une ligne, il faut envoyer TOUTES les lignes + - Un devis transformé (statut=5) ne peut plus être modifié + + Args: + id: Numéro du devis à modifier + devis_update: Champs à mettre à jour + + Returns: + Devis modifié avec ses nouvelles valeurs + """ + try: + # Vérifier que le devis existe + devis_existant = sage_client.lire_devis(id) + if not devis_existant: + raise HTTPException(404, f"Devis {id} introuvable") + + # Vérifier qu'il n'est pas déjà transformé + if devis_existant.get("statut") == 5: + raise HTTPException( + 400, + f"Le devis {id} a déjà été transformé et ne peut plus être modifié" + ) + + # Construire les données de mise à jour + update_data = {} + + if devis_update.date_devis: + update_data["date_devis"] = devis_update.date_devis.isoformat() + + if devis_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in devis_update.lignes + ] + + if devis_update.statut is not None: + update_data["statut"] = devis_update.statut + + # Appel à la gateway Windows + resultat = sage_client.modifier_devis(id, update_data) + + logger.info(f"✅ Devis {id} modifié avec succès") + + return { + "success": True, + "message": f"Devis {id} modifié avec succès", + "devis": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification devis {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/commandes", status_code=201, tags=["US-A2"]) +async def creer_commande( + commande: CommandeCreateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'une nouvelle commande (Bon de commande) + + **Workflow typique:** + 1. Création d'un devis → transformation en commande (automatique) + 2. OU création directe d'une commande (cette route) + + **Champs obligatoires:** + - `client_id`: Code du client + - `lignes`: Liste des lignes (min 1) + + **Champs optionnels:** + - `date_commande`: Date de la commande (par défaut: aujourd'hui) + - `reference`: Référence externe (ex: numéro de commande client) + + Args: + commande: Données de la commande à créer + + Returns: + Commande créée avec son numéro et ses totaux + """ + try: + # Vérifier que le client existe + client = sage_client.lire_client(commande.client_id) + if not client: + raise HTTPException(404, f"Client {commande.client_id} introuvable") + + # Préparer les données pour la gateway + commande_data = { + "client_id": commande.client_id, + "date_commande": ( + commande.date_commande.isoformat() + if commande.date_commande + else None + ), + "reference": commande.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in commande.lignes + ], + } + + # Appel à la gateway Windows + resultat = sage_client.creer_commande(commande_data) + + logger.info(f"✅ Commande créée: {resultat.get('numero_commande')}") + + return { + "success": True, + "message": "Commande créée avec succès", + "data": { + "numero_commande": resultat["numero_commande"], + "client_id": commande.client_id, + "date_commande": resultat["date_commande"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": commande.reference + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création commande: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/commandes/{id}", tags=["US-A2"]) +async def modifier_commande( + id: str, + commande_update: CommandeUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'une commande existante + + **Champs modifiables:** + - `date_commande`: Nouvelle date + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut + - `reference`: Référence externe + + **Restrictions:** + - Une commande transformée (statut=5) ne peut plus être modifiée + - Une commande annulée (statut=6) ne peut plus être modifiée + + **Note importante:** + Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées + + Args: + id: Numéro de la commande à modifier + commande_update: Champs à mettre à jour + + Returns: + Commande modifiée avec ses nouvelles valeurs + """ + try: + # Vérifier que la commande existe + commande_existante = sage_client.lire_document( + id, + settings.SAGE_TYPE_BON_COMMANDE + ) + + if not commande_existante: + raise HTTPException(404, f"Commande {id} introuvable") + + # Vérifier le statut + statut_actuel = commande_existante.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"La commande {id} a déjà été transformée et ne peut plus être modifiée" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"La commande {id} est annulée et ne peut plus être modifiée" + ) + + # Construire les données de mise à jour + update_data = {} + + if commande_update.date_commande: + update_data["date_commande"] = commande_update.date_commande.isoformat() + + if commande_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in commande_update.lignes + ] + + if commande_update.statut is not None: + update_data["statut"] = commande_update.statut + + if commande_update.reference is not None: + update_data["reference"] = commande_update.reference + + # Appel à la gateway Windows + resultat = sage_client.modifier_commande(id, update_data) + + logger.info(f"✅ Commande {id} modifiée avec succès") + + return { + "success": True, + "message": f"Commande {id} modifiée avec succès", + "commande": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification commande {id}: {e}") + raise HTTPException(500, str(e)) + + @app.get("/devis", tags=["US-A1"]) async def lister_devis( limit: int = Query(100, le=1000), diff --git a/sage_client.py b/sage_client.py index 4d5a0c5..ed51bf0 100644 --- a/sage_client.py +++ b/sage_client.py @@ -382,6 +382,68 @@ class SageGatewayClient: "code": code, "client_data": client_data }).get("data", {}) + + + def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: + """ + ✏️ Modification d'un devis existant + + Args: + numero: Numéro du devis à modifier + devis_data: Dictionnaire contenant les champs à modifier: + - date_devis (str, optional): Nouvelle date + - lignes (List, optional): Nouvelles lignes + - statut (int, optional): Nouveau statut + + Returns: + Devis modifié avec totaux recalculés + """ + return self._post("/sage/devis/update", { + "numero": numero, + "devis_data": devis_data + }).get("data", {}) + + def creer_commande(self, commande_data: Dict) -> Dict: + """ + ➕ Création d'une nouvelle commande (Bon de commande) + + Args: + commande_data: Dictionnaire contenant: + - client_id (str): Code du client + - date_commande (str, optional): Date au format ISO + - reference (str, optional): Référence externe + - lignes (List[Dict]): Liste des lignes avec: + - article_code (str) + - quantite (float) + - prix_unitaire_ht (float, optional) + - remise_pourcentage (float, optional) + + Returns: + Commande créée avec son numéro et ses totaux + """ + return self._post("/sage/commandes/create", commande_data).get("data", {}) + + + def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: + """ + ✏️ Modification d'une commande existante + + Args: + numero: Numéro de la commande à modifier + commande_data: Dictionnaire contenant les champs à modifier: + - date_commande (str, optional): Nouvelle date + - lignes (List, optional): Nouvelles lignes + - statut (int, optional): Nouveau statut + - reference (str, optional): Nouvelle référence + + Returns: + Commande modifiée avec totaux recalculés + """ + return self._post("/sage/commandes/update", { + "numero": numero, + "commande_data": commande_data + }).get("data", {}) + # Instance globale From b7a8af5ed5c15de14f8a779c4500127c9335d142 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sun, 7 Dec 2025 06:53:21 +0300 Subject: [PATCH 039/199] Better catch for errors --- api.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/api.py b/api.py index dff2e31..2c946c8 100644 --- a/api.py +++ b/api.py @@ -1040,26 +1040,67 @@ async def envoyer_devis_email( @app.put("/devis/{id}/statut", tags=["US-A1"]) -async def changer_statut_devis(id: str, nouveau_statut: int = Query(..., ge=0, le=5)): +async def changer_statut_devis( + id: str, + nouveau_statut: int = Query(..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé") +): """ - 📄 Changement de statut d'un devis via gateway Windows + 📊 Changement de statut d'un devis + + **Statuts possibles:** + - 0: Brouillon + - 2: Accepté/Validé + - 5: Transformé (automatique lors d'une transformation) + - 6: Annulé + + **Restrictions:** + - Un devis transformé (5) ne peut plus changer de statut + - Un devis annulé (6) ne peut plus changer de statut + + Args: + id: Numéro du devis + nouveau_statut: Nouveau statut (0-6) + + Returns: + Confirmation du changement avec ancien et nouveau statut """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) + # Vérifier que le devis existe + devis_existant = sage_client.lire_devis(id) + if not devis_existant: + raise HTTPException(404, f"Devis {id} introuvable") + + statut_actuel = devis_existant.get("statut", 0) + + # Vérifications de cohérence + if statut_actuel == 5: + raise HTTPException( + 400, + f"Le devis {id} a déjà été transformé et ne peut plus changer de statut" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"Le devis {id} est annulé et ne peut plus changer de statut" + ) + resultat = sage_client.changer_statut_devis(id, nouveau_statut) - - logger.info(f"✅ Statut devis {id} changé: {nouveau_statut}") - + + logger.info(f"✅ Statut devis {id} changé: {statut_actuel} → {nouveau_statut}") + return { "success": True, "devis_id": id, - "statut_ancien": resultat.get("statut_ancien"), - "statut_nouveau": resultat.get("statut_nouveau"), - "message": "Statut mis à jour avec succès", + "statut_ancien": resultat.get("statut_ancien", statut_actuel), + "statut_nouveau": resultat.get("statut_nouveau", nouveau_statut), + "message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}" } - + + except HTTPException: + raise except Exception as e: - logger.error(f"Erreur changement statut: {e}") + logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) From f763d70592d106035e0d3a4986c50540858317dc Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sun, 7 Dec 2025 07:16:00 +0300 Subject: [PATCH 040/199] Change devis' statut when transformed into commande --- api.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api.py b/api.py index 2c946c8..1bea9da 100644 --- a/api.py +++ b/api.py @@ -1147,14 +1147,25 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi """ 🔧 Transformation Devis → Commande ✅ CORRECTION : Utilise les VRAIS types Sage (0 → 10) + ✅ Met à jour le statut du devis source à 5 (Transformé) """ try: + # Étape 1: Transformation resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_DEVIS, # = 0 type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10 ) + # Étape 2: Mettre à jour le statut du devis à 5 (Transformé) + try: + sage_client.changer_statut_devis(id, nouveau_statut=5) + logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") + except Exception as e: + logger.warning(f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}") + # On continue même si la MAJ statut échoue + + # Étape 3: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, @@ -1178,6 +1189,7 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi "document_source": id, "document_cible": resultat["document_cible"], "nb_lignes": resultat["nb_lignes"], + "statut_devis_mis_a_jour": True, } except Exception as e: From 35807542a3c4b4e8432c546d99af702035d2d8fc Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sun, 7 Dec 2025 13:36:47 +0300 Subject: [PATCH 041/199] Inclure transformation verification on devis retrieving --- api.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 1bea9da..6908139 100644 --- a/api.py +++ b/api.py @@ -954,12 +954,37 @@ async def lister_devis( @app.get("/devis/{id}", tags=["US-A1"]) async def lire_devis(id: str): - """📄 Lecture d'un devis via gateway Windows""" + """ + 📄 Lecture d'un devis via gateway Windows + + Returns: + Devis complet avec: + - Toutes les informations standards + - lignes: Lignes du devis + - a_deja_ete_transforme: ✅ Booléen indiquant si le devis a été transformé + - documents_cibles: ✅ Liste des documents créés depuis ce devis + + ✅ ENRICHI: Inclut maintenant l'information de transformation + """ try: devis = sage_client.lire_devis(id) + if not devis: raise HTTPException(404, f"Devis {id} introuvable") - return devis + + # Log informatif + if devis.get("a_deja_ete_transforme"): + docs = devis.get("documents_cibles", []) + logger.info( + f"📊 Devis {id} a été transformé en " + f"{len(docs)} document(s): {[d['numero'] for d in docs]}" + ) + + return { + "success": True, + "data": devis + } + except HTTPException: raise except Exception as e: From 204b7920154594064cc6018e6e7dffa1726876be Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 08:47:48 +0300 Subject: [PATCH 042/199] Integrate create and update for livraison --- api.py | 254 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 17 ++++ 2 files changed, 271 insertions(+) diff --git a/api.py b/api.py index 6908139..33a79ad 100644 --- a/api.py +++ b/api.py @@ -347,6 +347,64 @@ class CommandeUpdateRequest(BaseModel): } +class LigneLivraison(BaseModel): + """Ligne de livraison""" + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + +class LivraisonCreateRequest(BaseModel): + """Création d'une livraison""" + client_id: str + date_livraison: Optional[date] = None + lignes: List[LigneLivraison] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_livraison": "2024-01-15", + "reference": "BL-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0 + } + ] + } + } + + +class LivraisonUpdateRequest(BaseModel): + """Modification d'une livraison existante""" + date_livraison: Optional[date] = None + lignes: Optional[List[LigneLivraison]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0 + } + ], + "statut": 2 + } + } + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -2448,6 +2506,202 @@ async def lire_livraison(numero: str): logger.error(f"Erreur lecture livraison: {e}") raise HTTPException(500, str(e)) +@app.post("/livraisons", status_code=201, tags=["Livraisons"]) +async def creer_livraison( + livraison: LivraisonCreateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'une nouvelle livraison (Bon de livraison) + + **Workflow typique:** + 1. Création d'une commande → transformation en livraison (automatique) + 2. OU création directe d'une livraison (cette route) + + **Champs obligatoires:** + - `client_id`: Code du client + - `lignes`: Liste des lignes (min 1) + + **Champs optionnels:** + - `date_livraison`: Date de la livraison (par défaut: aujourd'hui) + - `reference`: Référence externe (ex: numéro de commande client) + """ + try: + # Vérifier que le client existe + client = sage_client.lire_client(livraison.client_id) + if not client: + raise HTTPException(404, f"Client {livraison.client_id} introuvable") + + # Préparer les données pour la gateway + livraison_data = { + "client_id": livraison.client_id, + "date_livraison": ( + livraison.date_livraison.isoformat() + if livraison.date_livraison + else None + ), + "reference": livraison.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in livraison.lignes + ], + } + + # Appel à la gateway Windows + resultat = sage_client.creer_livraison(livraison_data) + + logger.info(f"✅ Livraison créée: {resultat.get('numero_livraison')}") + + return { + "success": True, + "message": "Livraison créée avec succès", + "data": { + "numero_livraison": resultat["numero_livraison"], + "client_id": livraison.client_id, + "date_livraison": resultat["date_livraison"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": livraison.reference + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création livraison: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/livraisons/{id}", tags=["Livraisons"]) +async def modifier_livraison( + id: str, + livraison_update: LivraisonUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'une livraison existante + + **Champs modifiables:** + - `date_livraison`: Nouvelle date + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut + - `reference`: Référence externe + + **Restrictions:** + - Une livraison transformée (statut=5) ne peut plus être modifiée + - Une livraison annulée (statut=6) ne peut plus être modifiée + """ + try: + # Vérifier que la livraison existe + livraison_existante = sage_client.lire_livraison(id) + + if not livraison_existante: + raise HTTPException(404, f"Livraison {id} introuvable") + + # Vérifier le statut + statut_actuel = livraison_existante.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"La livraison {id} a déjà été transformée et ne peut plus être modifiée" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"La livraison {id} est annulée et ne peut plus être modifiée" + ) + + # Construire les données de mise à jour + update_data = {} + + if livraison_update.date_livraison: + update_data["date_livraison"] = livraison_update.date_livraison.isoformat() + + if livraison_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in livraison_update.lignes + ] + + if livraison_update.statut is not None: + update_data["statut"] = livraison_update.statut + + if livraison_update.reference is not None: + update_data["reference"] = livraison_update.reference + + # Appel à la gateway Windows + resultat = sage_client.modifier_livraison(id, update_data) + + logger.info(f"✅ Livraison {id} modifiée avec succès") + + return { + "success": True, + "message": f"Livraison {id} modifiée avec succès", + "livraison": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification livraison {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/workflow/livraison/{id}/to-facture", tags=["US-A2"]) +async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): + """ + 🔧 Transformation Livraison → Facture + ✅ Utilise les VRAIS types Sage (30 → 60) + """ + try: + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 + type_cible=settings.SAGE_TYPE_FACTURE, # = 60 + ) + + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.BON_LIVRAISON, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.FACTURE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"✅ Transformation: Livraison {id} → Facture {resultat['document_cible']}" + ) + + return { + "success": True, + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + } + + except Exception as e: + logger.error(f"Erreur transformation: {e}") + raise HTTPException(500, str(e)) + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( diff --git a/sage_client.py b/sage_client.py index ed51bf0..47944e8 100644 --- a/sage_client.py +++ b/sage_client.py @@ -443,6 +443,23 @@ class SageGatewayClient: "numero": numero, "commande_data": commande_data }).get("data", {}) + + + def creer_livraison(self, livraison_data: Dict) -> Dict: + """ + ➕ Création d'une nouvelle livraison (Bon de livraison) + """ + return self._post("/sage/livraisons/create", livraison_data).get("data", {}) + + + def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: + """ + ✏️ Modification d'une livraison existante + """ + return self._post("/sage/livraisons/update", { + "numero": numero, + "livraison_data": livraison_data + }).get("data", {}) From c15ae79c6a956f85fb3962210207eaa25d28b3d1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 09:18:15 +0300 Subject: [PATCH 043/199] Added create and update for avoir --- api.py | 229 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 41 +++++++++ 2 files changed, 270 insertions(+) diff --git a/api.py b/api.py index 33a79ad..2b944c6 100644 --- a/api.py +++ b/api.py @@ -405,6 +405,64 @@ class LivraisonUpdateRequest(BaseModel): } } +class LigneAvoir(BaseModel): + """Ligne d'avoir""" + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + +class AvoirCreateRequest(BaseModel): + """Création d'un avoir""" + client_id: str + date_avoir: Optional[date] = None + lignes: List[LigneAvoir] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_avoir": "2024-01-15", + "reference": "AV-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 5.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 0.0 + } + ] + } + } + + +class AvoirUpdateRequest(BaseModel): + """Modification d'un avoir existant""" + date_avoir: Optional[date] = None + lignes: Optional[List[LigneAvoir]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 45.0 + } + ], + "statut": 2 + } + } + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -2475,7 +2533,178 @@ async def lire_avoir(numero: str): logger.error(f"Erreur lecture avoir: {e}") raise HTTPException(500, str(e)) +@app.post("/avoirs", status_code=201, tags=["Avoirs"]) +async def creer_avoir( + avoir: AvoirCreateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'un avoir (Bon d'avoir) + + **Workflow typique:** + 1. Retour marchandise → création d'un avoir + 2. Geste commercial → création directe d'un avoir (cette route) + + **Champs obligatoires:** + - `client_id`: Code du client + - `lignes`: Liste des lignes (min 1) + + **Champs optionnels:** + - `date_avoir`: Date de l'avoir (par défaut: aujourd'hui) + - `reference`: Référence externe (ex: numéro de retour) + + **Note:** Les montants des avoirs sont généralement négatifs (crédits) + + Args: + avoir: Données de l'avoir à créer + + Returns: + Avoir créé avec son numéro et ses totaux + """ + try: + # Vérifier que le client existe + client = sage_client.lire_client(avoir.client_id) + if not client: + raise HTTPException(404, f"Client {avoir.client_id} introuvable") + + # Préparer les données pour la gateway + avoir_data = { + "client_id": avoir.client_id, + "date_avoir": ( + avoir.date_avoir.isoformat() + if avoir.date_avoir + else None + ), + "reference": avoir.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in avoir.lignes + ], + } + + # Appel à la gateway Windows + resultat = sage_client.creer_avoir(avoir_data) + + logger.info(f"✅ Avoir créé: {resultat.get('numero_avoir')}") + + return { + "success": True, + "message": "Avoir créé avec succès", + "data": { + "numero_avoir": resultat["numero_avoir"], + "client_id": avoir.client_id, + "date_avoir": resultat["date_avoir"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": avoir.reference + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création avoir: {e}") + raise HTTPException(500, str(e)) + +@app.put("/avoirs/{id}", tags=["Avoirs"]) +async def modifier_avoir( + id: str, + avoir_update: AvoirUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'un avoir existant + + **Champs modifiables:** + - `date_avoir`: Nouvelle date + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut + - `reference`: Référence externe + + **Restrictions:** + - Un avoir transformé (statut=5) ne peut plus être modifié + - Un avoir annulé (statut=6) ne peut plus être modifié + + **Note importante:** + Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées + + Args: + id: Numéro de l'avoir à modifier + avoir_update: Champs à mettre à jour + + Returns: + Avoir modifié avec ses nouvelles valeurs + """ + try: + # Vérifier que l'avoir existe + avoir_existant = sage_client.lire_avoir(id) + + if not avoir_existant: + raise HTTPException(404, f"Avoir {id} introuvable") + + # Vérifier le statut + statut_actuel = avoir_existant.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"L'avoir {id} a déjà été transformé et ne peut plus être modifié" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"L'avoir {id} est annulé et ne peut plus être modifié" + ) + + # Construire les données de mise à jour + update_data = {} + + if avoir_update.date_avoir: + update_data["date_avoir"] = avoir_update.date_avoir.isoformat() + + if avoir_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in avoir_update.lignes + ] + + if avoir_update.statut is not None: + update_data["statut"] = avoir_update.statut + + if avoir_update.reference is not None: + update_data["reference"] = avoir_update.reference + + # Appel à la gateway Windows + resultat = sage_client.modifier_avoir(id, update_data) + + logger.info(f"✅ Avoir {id} modifié avec succès") + + return { + "success": True, + "message": f"Avoir {id} modifié avec succès", + "avoir": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification avoir {id}: {e}") + raise HTTPException(500, str(e)) + + # ===================================================== # ENDPOINTS - LIVRAISONS # ===================================================== diff --git a/sage_client.py b/sage_client.py index 47944e8..6a07591 100644 --- a/sage_client.py +++ b/sage_client.py @@ -460,6 +460,47 @@ class SageGatewayClient: "numero": numero, "livraison_data": livraison_data }).get("data", {}) + + def creer_avoir(self, avoir_data: Dict) -> Dict: + """ + ➕ Création d'un avoir (Bon d'avoir) + + Args: + avoir_data: Dictionnaire contenant: + - client_id (str): Code du client + - date_avoir (str, optional): Date au format ISO + - reference (str, optional): Référence externe + - lignes (List[Dict]): Liste des lignes avec: + - article_code (str) + - quantite (float) + - prix_unitaire_ht (float, optional) + - remise_pourcentage (float, optional) + + Returns: + Avoir créé avec son numéro et ses totaux + """ + return self._post("/sage/avoirs/create", avoir_data).get("data", {}) + + + def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: + """ + ✏️ Modification d'un avoir existant + + Args: + numero: Numéro de l'avoir à modifier + avoir_data: Dictionnaire contenant les champs à modifier: + - date_avoir (str, optional): Nouvelle date + - lignes (List, optional): Nouvelles lignes + - statut (int, optional): Nouveau statut + - reference (str, optional): Nouvelle référence + + Returns: + Avoir modifié avec totaux recalculés + """ + return self._post("/sage/avoirs/update", { + "numero": numero, + "avoir_data": avoir_data + }).get("data", {}) From 57d1f313f4f212a2f462654d94a7d155b9ade4e3 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 09:43:34 +0300 Subject: [PATCH 044/199] feat(factures): add create and update invoice endpoints --- api.py | 238 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 42 +++++++++ 2 files changed, 280 insertions(+) diff --git a/api.py b/api.py index 2b944c6..d81bec6 100644 --- a/api.py +++ b/api.py @@ -462,7 +462,66 @@ class AvoirUpdateRequest(BaseModel): "statut": 2 } } + +class LigneFacture(BaseModel): + """Ligne de facture""" + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + +class FactureCreateRequest(BaseModel): + """Création d'une facture""" + client_id: str + date_facture: Optional[date] = None + lignes: List[LigneFacture] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_facture": "2024-01-15", + "reference": "FA-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0 + } + ] + } + } + + +class FactureUpdateRequest(BaseModel): + """Modification d'une facture existante""" + date_facture: Optional[date] = None + lignes: Optional[List[LigneFacture]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0 + } + ], + "statut": 2 + } + } + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1920,6 +1979,185 @@ class RelanceFactureRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None +@app.post("/factures", status_code=201, tags=["US-A7"]) +async def creer_facture( + facture: FactureCreateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ➕ Création d'une facture + + **Workflow typique:** + 1. Commande → Livraison → Facture (transformations successives) + 2. OU création directe d'une facture (cette route) + + **Champs obligatoires:** + - `client_id`: Code du client + - `lignes`: Liste des lignes (min 1) + + **Champs optionnels:** + - `date_facture`: Date de la facture (par défaut: aujourd'hui) + - `reference`: Référence externe (ex: numéro de commande client) + + **Notes importantes:** + - Les factures peuvent avoir des champs obligatoires supplémentaires selon la configuration Sage + - Le statut initial est généralement 2 (Accepté/Validé) + - Les factures sont soumises aux règles de numérotation strictes + + Args: + facture: Données de la facture à créer + + Returns: + Facture créée avec son numéro et ses totaux + """ + try: + # Vérifier que le client existe + client = sage_client.lire_client(facture.client_id) + if not client: + raise HTTPException(404, f"Client {facture.client_id} introuvable") + + # Préparer les données pour la gateway + facture_data = { + "client_id": facture.client_id, + "date_facture": ( + facture.date_facture.isoformat() + if facture.date_facture + else None + ), + "reference": facture.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in facture.lignes + ], + } + + # Appel à la gateway Windows + resultat = sage_client.creer_facture(facture_data) + + logger.info(f"✅ Facture créée: {resultat.get('numero_facture')}") + + return { + "success": True, + "message": "Facture créée avec succès", + "data": { + "numero_facture": resultat["numero_facture"], + "client_id": facture.client_id, + "date_facture": resultat["date_facture"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": facture.reference + } + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création facture: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/factures/{id}", tags=["US-A7"]) +async def modifier_facture( + id: str, + facture_update: FactureUpdateRequest, + session: AsyncSession = Depends(get_session) +): + """ + ✏️ Modification d'une facture existante + + **Champs modifiables:** + - `date_facture`: Nouvelle date + - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) + - `statut`: Nouveau statut + - `reference`: Référence externe + + **Restrictions IMPORTANTES:** + - Une facture transformée (statut=5) ne peut plus être modifiée + - Une facture annulée (statut=6) ne peut plus être modifiée + - ⚠️ **ATTENTION**: Les factures comptabilisées peuvent être verrouillées par Sage + - Certaines factures peuvent être en lecture seule selon les droits utilisateur + + **Note importante:** + Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées + + Args: + id: Numéro de la facture à modifier + facture_update: Champs à mettre à jour + + Returns: + Facture modifiée avec ses nouvelles valeurs + """ + try: + # Vérifier que la facture existe + facture_existante = sage_client.lire_document( + id, + settings.SAGE_TYPE_FACTURE + ) + + if not facture_existante: + raise HTTPException(404, f"Facture {id} introuvable") + + # Vérifier le statut + statut_actuel = facture_existante.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"La facture {id} a déjà été transformée et ne peut plus être modifiée" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, + f"La facture {id} est annulée et ne peut plus être modifiée" + ) + + # Construire les données de mise à jour + update_data = {} + + if facture_update.date_facture: + update_data["date_facture"] = facture_update.date_facture.isoformat() + + if facture_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in facture_update.lignes + ] + + if facture_update.statut is not None: + update_data["statut"] = facture_update.statut + + if facture_update.reference is not None: + update_data["reference"] = facture_update.reference + + # Appel à la gateway Windows + resultat = sage_client.modifier_facture(id, update_data) + + logger.info(f"✅ Facture {id} modifiée avec succès") + + return { + "success": True, + "message": f"Facture {id} modifiée avec succès", + "facture": resultat + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification facture {id}: {e}") + raise HTTPException(500, str(e)) + # Templates email (si pas déjà définis) templates_email_db = { diff --git a/sage_client.py b/sage_client.py index 6a07591..5f613c6 100644 --- a/sage_client.py +++ b/sage_client.py @@ -501,6 +501,48 @@ class SageGatewayClient: "numero": numero, "avoir_data": avoir_data }).get("data", {}) + + + def creer_facture(self, facture_data: Dict) -> Dict: + """ + ➕ Création d'une facture + + Args: + facture_data: Dictionnaire contenant: + - client_id (str): Code du client + - date_facture (str, optional): Date au format ISO + - reference (str, optional): Référence externe + - lignes (List[Dict]): Liste des lignes avec: + - article_code (str) + - quantite (float) + - prix_unitaire_ht (float, optional) + - remise_pourcentage (float, optional) + + Returns: + Facture créée avec son numéro et ses totaux + """ + return self._post("/sage/factures/create", facture_data).get("data", {}) + + + def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: + """ + ✏️ Modification d'une facture existante + + Args: + numero: Numéro de la facture à modifier + facture_data: Dictionnaire contenant les champs à modifier: + - date_facture (str, optional): Nouvelle date + - lignes (List, optional): Nouvelles lignes + - statut (int, optional): Nouveau statut + - reference (str, optional): Nouvelle référence + + Returns: + Facture modifiée avec totaux recalculés + """ + return self._post("/sage/factures/update", { + "numero": numero, + "facture_data": facture_data + }).get("data", {}) From 5a6a721f167e8c996fdba1f4121685e2a73be4b1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 09:54:39 +0300 Subject: [PATCH 045/199] feat(workflow): add direct quote-to-invoice and order-to-delivery endpoints --- api.py | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/api.py b/api.py index d81bec6..f574f08 100644 --- a/api.py +++ b/api.py @@ -3170,6 +3170,186 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se raise HTTPException(500, str(e)) +@app.post("/workflow/devis/{id}/to-facture", tags=["US-A2"]) +async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get_session)): + """ + 🔧 Transformation Devis → Facture (DIRECT, sans commande) + + ✅ Utilise les VRAIS types Sage (0 → 60) + ✅ Met à jour le statut du devis source à 5 (Transformé) + + **Workflow raccourci** : Permet de facturer directement depuis un devis + sans passer par la création d'une commande. + + **Cas d'usage** : + - Prestations de services facturées directement + - Petites commandes sans besoin de suivi intermédiaire + - Ventes au comptoir + + Args: + id: Numéro du devis source + + Returns: + Informations de la facture créée + """ + try: + # Étape 1: Vérifier que le devis n'a pas déjà été transformé + devis_existant = sage_client.lire_devis(id) + if not devis_existant: + raise HTTPException(404, f"Devis {id} introuvable") + + statut_devis = devis_existant.get("statut", 0) + if statut_devis == 5: + raise HTTPException( + 400, + f"Le devis {id} a déjà été transformé (statut=5). " + f"Vérifiez les documents déjà créés depuis ce devis." + ) + + # Étape 2: Transformation + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_DEVIS, # = 0 + type_cible=settings.SAGE_TYPE_FACTURE, # = 60 + ) + + # Étape 3: Mettre à jour le statut du devis à 5 (Transformé) + try: + sage_client.changer_statut_devis(id, nouveau_statut=5) + logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") + except Exception as e: + logger.warning(f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}") + # On continue même si la MAJ statut échoue + + # Étape 4: Logger la transformation + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.DEVIS, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.FACTURE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"✅ Transformation DIRECTE: Devis {id} → Facture {resultat['document_cible']}" + ) + + return { + "success": True, + "workflow": "devis_to_facture_direct", + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + "statut_devis_mis_a_jour": True, + "message": f"Facture {resultat['document_cible']} créée directement depuis le devis {id}", + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur transformation devis→facture: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/workflow/commande/{id}/to-livraison", tags=["US-A2"]) +async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_session)): + """ + 🔧 Transformation Commande → Bon de livraison + + ✅ Utilise les VRAIS types Sage (10 → 30) + + **Workflow typique** : Après validation d'une commande, génère + le bon de livraison pour préparer l'expédition. + + **Cas d'usage** : + - Préparation d'une expédition + - Génération du bordereau de livraison + - Suivi logistique + + **Workflow complet** : + 1. Devis → Commande (via `/workflow/devis/{id}/to-commande`) + 2. **Commande → Livraison** (cette route) + 3. Livraison → Facture (via `/workflow/livraison/{id}/to-facture`) + + Args: + id: Numéro de la commande source + + Returns: + Informations du bon de livraison créé + """ + try: + # Étape 1: Vérifier que la commande existe + commande_existante = sage_client.lire_document( + id, + settings.SAGE_TYPE_BON_COMMANDE + ) + + if not commande_existante: + raise HTTPException(404, f"Commande {id} introuvable") + + statut_commande = commande_existante.get("statut", 0) + if statut_commande == 5: + raise HTTPException( + 400, + f"La commande {id} a déjà été transformée (statut=5). " + f"Un bon de livraison existe probablement déjà." + ) + + if statut_commande == 6: + raise HTTPException( + 400, + f"La commande {id} est annulée (statut=6) et ne peut pas être transformée." + ) + + # Étape 2: Transformation + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 + type_cible=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 + ) + + # Étape 3: Logger la transformation + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.BON_COMMANDE, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.BON_LIVRAISON, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"✅ Transformation: Commande {id} → Livraison {resultat['document_cible']}" + ) + + return { + "success": True, + "workflow": "commande_to_livraison", + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + "message": f"Bon de livraison {resultat['document_cible']} créé depuis la commande {id}", + "next_step": f"Utilisez /workflow/livraison/{resultat['document_cible']}/to-facture pour créer la facture", + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur transformation commande→livraison: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( session: AsyncSession = Depends(get_session), From fafd5222a6766fd3eb150d0563831aa8937219e0 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 10:58:24 +0300 Subject: [PATCH 046/199] feat(api): add OpenAPI tags metadata and update endpoint tags --- api.py | 156 +++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 112 insertions(+), 44 deletions(-) diff --git a/api.py b/api.py index f574f08..bcc7bbb 100644 --- a/api.py +++ b/api.py @@ -42,6 +42,73 @@ from email_queue import email_queue from sage_client import sage_client +TAGS_METADATA = [ + { + "name": "Clients", + "description": "Gestion des clients (recherche, création, modification)" + }, + { + "name": "Articles", + "description": "Gestion des articles et produits" + }, + { + "name": "Devis", + "description": "Création, consultation et gestion des devis" + }, + { + "name": "Commandes", + "description": "Création, consultation et gestion des commandes" + }, + { + "name": "Livraisons", + "description": "Création, consultation et gestion des bons de livraison" + }, + { + "name": "Factures", + "description": "Création, consultation et gestion des factures" + }, + { + "name": "Avoirs", + "description": "Création, consultation et gestion des avoirs" + }, + { + "name": "Fournisseurs", + "description": "Gestion des fournisseurs" + }, + { + "name": "Prospects", + "description": "Gestion des prospects" + }, + { + "name": "Workflows", + "description": "Transformations de documents (devis→commande, commande→facture, etc.)" + }, + { + "name": "Signatures", + "description": "Signature électronique via Universign" + }, + { + "name": "Emails", + "description": "Envoi d'emails, templates et logs d'envoi" + }, + { + "name": "Validation", + "description": "Validation de données (remises, etc.)" + }, + { + "name": "Admin", + "description": "🔧 Administration système (cache, queue)" + }, + { + "name": "System", + "description": "🏥 Health checks et informations système" + }, + { + "name": "Debug", + "description": "🐛 Routes de debug et diagnostics" + } +] + # ===================================================== # ENUMS # ===================================================== @@ -674,6 +741,7 @@ app = FastAPI( version="2.0.0", description="API de gestion commerciale - VPS Linux", lifespan=lifespan, + openapi_tags=TAGS_METADATA ) app.add_middleware( @@ -691,7 +759,7 @@ app.include_router(auth_router) # ===================================================== # ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) # ===================================================== -@app.get("/clients", response_model=List[ClientResponse], tags=["US-A1"]) +@app.get("/clients", response_model=List[ClientResponse], tags=["Clients"]) async def rechercher_clients(query: Optional[str] = Query(None)): """🔍 Recherche clients via gateway Windows""" try: @@ -701,7 +769,7 @@ async def rechercher_clients(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) -@app.get("/clients/{code}", tags=["US-A1"]) +@app.get("/clients/{code}", tags=["Clients"]) async def lire_client_detail(code: str): """ 📄 Lecture détaillée d'un client par son code @@ -730,7 +798,7 @@ async def lire_client_detail(code: str): raise HTTPException(500, str(e)) -@app.put("/clients/{code}", tags=["US-A1"]) +@app.put("/clients/{code}", tags=["Clients"]) async def modifier_client( code: str, client_update: ClientUpdateRequest, @@ -774,7 +842,7 @@ async def modifier_client( logger.error(f"Erreur technique modification client {code}: {e}") raise HTTPException(500, str(e)) -@app.post("/clients", status_code=201, tags=["US-A8"]) +@app.post("/clients", status_code=201, tags=["Clients"]) async def ajouter_client( client: ClientCreateAPIRequest, session: AsyncSession = Depends(get_session) @@ -799,7 +867,7 @@ async def ajouter_client( status = 400 if "existe déjà" in str(e) else 500 raise HTTPException(status, str(e)) -@app.get("/articles", response_model=List[ArticleResponse], tags=["US-A1"]) +@app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): """🔍 Recherche articles via gateway Windows""" try: @@ -810,7 +878,7 @@ async def rechercher_articles(query: Optional[str] = Query(None)): raise HTTPException(500, str(e)) -@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["US-A1"]) +@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"]) async def creer_devis(devis: DevisRequest): """📝 Création de devis via gateway Windows""" try: @@ -848,7 +916,7 @@ async def creer_devis(devis: DevisRequest): raise HTTPException(500, str(e)) -@app.put("/devis/{id}", tags=["US-A1"]) +@app.put("/devis/{id}", tags=["Devis"]) async def modifier_devis( id: str, devis_update: DevisUpdateRequest, @@ -925,7 +993,7 @@ async def modifier_devis( raise HTTPException(500, str(e)) -@app.post("/commandes", status_code=201, tags=["US-A2"]) +@app.post("/commandes", status_code=201, tags=["Commandes"]) async def creer_commande( commande: CommandeCreateRequest, session: AsyncSession = Depends(get_session) @@ -1003,7 +1071,7 @@ async def creer_commande( raise HTTPException(500, str(e)) -@app.put("/commandes/{id}", tags=["US-A2"]) +@app.put("/commandes/{id}", tags=["Commandes"]) async def modifier_commande( id: str, commande_update: CommandeUpdateRequest, @@ -1098,7 +1166,7 @@ async def modifier_commande( raise HTTPException(500, str(e)) -@app.get("/devis", tags=["US-A1"]) +@app.get("/devis", tags=["Devis"]) async def lister_devis( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), @@ -1127,7 +1195,7 @@ async def lister_devis( raise HTTPException(500, str(e)) -@app.get("/devis/{id}", tags=["US-A1"]) +@app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): """ 📄 Lecture d'un devis via gateway Windows @@ -1167,7 +1235,7 @@ async def lire_devis(id: str): raise HTTPException(500, str(e)) -@app.get("/devis/{id}/pdf", tags=["US-A1"]) +@app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): """📄 Téléchargement PDF (généré via email_queue)""" try: @@ -1185,7 +1253,7 @@ async def telecharger_devis_pdf(id: str): raise HTTPException(500, str(e)) -@app.post("/devis/{id}/envoyer", tags=["US-A1"]) +@app.post("/devis/{id}/envoyer", tags=["Devis"]) async def envoyer_devis_email( id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) ): @@ -1239,7 +1307,7 @@ async def envoyer_devis_email( raise HTTPException(500, str(e)) -@app.put("/devis/{id}/statut", tags=["US-A1"]) +@app.put("/devis/{id}/statut", tags=["Devis"]) async def changer_statut_devis( id: str, nouveau_statut: int = Query(..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé") @@ -1305,10 +1373,10 @@ async def changer_statut_devis( # ===================================================== -# ENDPOINTS - US-A2 (WORKFLOW SANS RESSAISIE) +# ENDPOINTS - Commandes (WORKFLOW SANS RESSAISIE) # ===================================================== -@app.get("/commandes/{id}", tags=["US-A2"]) +@app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): """📄 Lecture d'une commande avec ses lignes""" try: @@ -1323,7 +1391,7 @@ async def lire_commande(id: str): raise HTTPException(500, str(e)) -@app.get("/commandes", tags=["US-A2"]) +@app.get("/commandes", tags=["Commandes"]) async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): @@ -1397,7 +1465,7 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) +@app.post("/workflow/commande/{id}/to-facture", tags=["Commandes"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Commande → Facture @@ -1443,7 +1511,7 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses # ===================================================== # ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE) # ===================================================== -@app.post("/signature/universign/send", tags=["US-A3"]) +@app.post("/signature/universign/send", tags=["Universign"]) async def envoyer_signature( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): @@ -1496,7 +1564,7 @@ async def envoyer_signature( raise HTTPException(500, str(e)) -@app.get("/signature/universign/status", tags=["US-A3"]) +@app.get("/signature/universign/status", tags=["Universign"]) async def statut_signature(docId: str = Query(...)): """🔍 Récupération du statut de signature en temps réel""" # Chercher dans la DB locale @@ -1524,7 +1592,7 @@ async def statut_signature(docId: str = Query(...)): raise HTTPException(500, str(e)) -@app.get("/signatures", tags=["US-A3"]) +@app.get("/signatures", tags=["Universign"]) async def lister_signatures( statut: Optional[StatutSignature] = Query(None), limit: int = Query(100, le=1000), @@ -1562,7 +1630,7 @@ async def lister_signatures( ] -@app.get("/signatures/{transaction_id}/status", tags=["US-A3"]) +@app.get("/signatures/{transaction_id}/status", tags=["Universign"]) async def statut_signature_detail( transaction_id: str, session: AsyncSession = Depends(get_session) ): @@ -1616,7 +1684,7 @@ async def statut_signature_detail( } -@app.post("/signatures/refresh-all", tags=["US-A3"]) +@app.post("/signatures/refresh-all", tags=["Universign"]) async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): """🔄 Rafraîchit TOUS les statuts des signatures en attente""" query = select(SignatureLog).where( @@ -1665,7 +1733,7 @@ async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_sess } -@app.post("/devis/{id}/signer", tags=["US-A3"]) +@app.post("/devis/{id}/signer", tags=["Devis"]) async def envoyer_devis_signature( id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session) ): @@ -1788,7 +1856,7 @@ async def envoyer_emails_lot( # ===================================================== -@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["US-A5"]) +@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Devis"]) async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), @@ -1826,7 +1894,7 @@ async def valider_remise( # ===================================================== # ENDPOINTS - US-A6 (RELANCE DEVIS) # ===================================================== -@app.post("/devis/{id}/relancer-signature", tags=["US-A6"]) +@app.post("/devis/{id}/relancer-signature", tags=["Devis"]) async def relancer_devis_signature( id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): @@ -1897,7 +1965,7 @@ class ContactClientResponse(BaseModel): peut_etre_relance: bool -@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["US-A6"]) +@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis(id: str): """👤 US-A6: Récupération du contact client associé au devis""" try: @@ -1928,7 +1996,7 @@ async def recuperer_contact_devis(id: str): # ===================================================== -@app.get("/factures", tags=["US-A7"]) +@app.get("/factures", tags=["Factures"]) async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): @@ -1947,7 +2015,7 @@ async def lister_factures( raise HTTPException(500, str(e)) -@app.get("/factures/{numero}", tags=["US-A7"]) +@app.get("/factures/{numero}", tags=["Factures"]) async def lire_facture_detail(numero: str): """ 📄 Lecture détaillée d'une facture avec ses lignes @@ -1979,7 +2047,7 @@ class RelanceFactureRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None -@app.post("/factures", status_code=201, tags=["US-A7"]) +@app.post("/factures", status_code=201, tags=["Factures"]) async def creer_facture( facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) @@ -2062,7 +2130,7 @@ async def creer_facture( raise HTTPException(500, str(e)) -@app.put("/factures/{id}", tags=["US-A7"]) +@app.put("/factures/{id}", tags=["Factures"]) async def modifier_facture( id: str, facture_update: FactureUpdateRequest, @@ -2183,7 +2251,7 @@ templates_email_db = { } -@app.post("/factures/{id}/relancer", tags=["US-A7"]) +@app.post("/factures/{id}/relancer", tags=["Factures"]) async def relancer_facture( id: str, relance: RelanceFactureRequest, @@ -2263,7 +2331,7 @@ async def relancer_facture( # ============================================ -@app.get("/emails/logs", tags=["US-A9"]) +@app.get("/emails/logs", tags=["Emails"]) async def journal_emails( statut: Optional[StatutEmail] = Query(None), destinataire: Optional[str] = Query(None), @@ -2300,7 +2368,7 @@ async def journal_emails( ] -@app.get("/emails/logs/export", tags=["US-A9"]) +@app.get("/emails/logs/export", tags=["Emails"]) async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), @@ -2362,7 +2430,7 @@ async def exporter_logs_csv( # ============================================ -# US-A10 - MODÈLES D'E-MAILS +# Devis0 - MODÈLES D'E-MAILS # ============================================ @@ -2380,14 +2448,14 @@ class TemplatePreviewRequest(BaseModel): type_document: TypeDocument -@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["US-A10"]) +@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) async def lister_templates(): - """📧 US-A10: Liste tous les templates d'emails""" + """📧 Emails: Liste tous les templates d'emails""" return [TemplateEmail(**template) for template in templates_email_db.values()] @app.get( - "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["US-A10"] + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def lire_template(template_id: str): """📖 Lecture d'un template par ID""" @@ -2397,7 +2465,7 @@ async def lire_template(template_id: str): return TemplateEmail(**templates_email_db[template_id]) -@app.post("/templates/emails", response_model=TemplateEmail, tags=["US-A10"]) +@app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) async def creer_template(template: TemplateEmail): """➕ Création d'un nouveau template""" template_id = str(uuid.uuid4()) @@ -2416,7 +2484,7 @@ async def creer_template(template: TemplateEmail): @app.put( - "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["US-A10"] + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def modifier_template(template_id: str, template: TemplateEmail): """✏️ Modification d'un template existant""" @@ -2440,7 +2508,7 @@ async def modifier_template(template_id: str, template: TemplateEmail): return TemplateEmail(id=template_id, **template.dict()) -@app.delete("/templates/emails/{template_id}", tags=["US-A10"]) +@app.delete("/templates/emails/{template_id}", tags=["Emails"]) async def supprimer_template(template_id: str): """🗑️ Suppression d'un template""" if template_id not in templates_email_db: @@ -2456,7 +2524,7 @@ async def supprimer_template(template_id: str): return {"success": True, "message": f"Template {template_id} supprimé"} -@app.post("/templates/emails/preview", tags=["US-A10"]) +@app.post("/templates/emails/preview", tags=["Emails"]) async def previsualiser_email(preview: TemplatePreviewRequest): """👁️ US-A10: Prévisualisation email avec fusion variables""" if preview.template_id not in templates_email_db: @@ -3170,7 +3238,7 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se raise HTTPException(500, str(e)) -@app.post("/workflow/devis/{id}/to-facture", tags=["US-A2"]) +@app.post("/workflow/devis/{id}/to-facture", tags=["Devis"]) async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Devis → Facture (DIRECT, sans commande) @@ -3257,7 +3325,7 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-livraison", tags=["US-A2"]) +@app.post("/workflow/commande/{id}/to-livraison", tags=["Commandes"]) async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Commande → Bon de livraison From 14b2758b68ccae990ad1b89ea455df7b561aa9cb Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 11:03:30 +0300 Subject: [PATCH 047/199] refactor(api): update endpoint tags for better organization --- api.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/api.py b/api.py index bcc7bbb..0b37c3a 100644 --- a/api.py +++ b/api.py @@ -1410,7 +1410,7 @@ async def lister_commandes( raise HTTPException(500, str(e)) -@app.post("/workflow/devis/{id}/to-commande", tags=["US-A2"]) +@app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Devis → Commande @@ -1465,7 +1465,7 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-facture", tags=["Commandes"]) +@app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Commande → Facture @@ -1511,7 +1511,7 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses # ===================================================== # ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE) # ===================================================== -@app.post("/signature/universign/send", tags=["Universign"]) +@app.post("/signature/universign/send", tags=["Signatures"]) async def envoyer_signature( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): @@ -1564,7 +1564,7 @@ async def envoyer_signature( raise HTTPException(500, str(e)) -@app.get("/signature/universign/status", tags=["Universign"]) +@app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): """🔍 Récupération du statut de signature en temps réel""" # Chercher dans la DB locale @@ -1592,7 +1592,7 @@ async def statut_signature(docId: str = Query(...)): raise HTTPException(500, str(e)) -@app.get("/signatures", tags=["Universign"]) +@app.get("/signatures", tags=["Signatures"]) async def lister_signatures( statut: Optional[StatutSignature] = Query(None), limit: int = Query(100, le=1000), @@ -1630,7 +1630,7 @@ async def lister_signatures( ] -@app.get("/signatures/{transaction_id}/status", tags=["Universign"]) +@app.get("/signatures/{transaction_id}/status", tags=["Signatures"]) async def statut_signature_detail( transaction_id: str, session: AsyncSession = Depends(get_session) ): @@ -1684,7 +1684,7 @@ async def statut_signature_detail( } -@app.post("/signatures/refresh-all", tags=["Universign"]) +@app.post("/signatures/refresh-all", tags=["Signatures"]) async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): """🔄 Rafraîchit TOUS les statuts des signatures en attente""" query = select(SignatureLog).where( @@ -1802,7 +1802,7 @@ class EmailBatchRequest(BaseModel): type_document: Optional[TypeDocument] = None -@app.post("/emails/send-batch", tags=["US-A4"]) +@app.post("/emails/send-batch", tags=["Emails"]) async def envoyer_emails_lot( batch: EmailBatchRequest, session: AsyncSession = Depends(get_session) ): @@ -1856,7 +1856,7 @@ async def envoyer_emails_lot( # ===================================================== -@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Devis"]) +@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"]) async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), @@ -3195,7 +3195,7 @@ async def modifier_livraison( raise HTTPException(500, str(e)) -@app.post("/workflow/livraison/{id}/to-facture", tags=["US-A2"]) +@app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"]) async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Livraison → Facture @@ -3238,7 +3238,7 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se raise HTTPException(500, str(e)) -@app.post("/workflow/devis/{id}/to-facture", tags=["Devis"]) +@app.post("/workflow/devis/{id}/to-facture", tags=["Workflows"]) async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Devis → Facture (DIRECT, sans commande) @@ -3325,7 +3325,7 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-livraison", tags=["Commandes"]) +@app.post("/workflow/commande/{id}/to-livraison", tags=["Workflows"]) async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_session)): """ 🔧 Transformation Commande → Bon de livraison From a1794ac90f16ea65984134a5dea7f434c671d4d6 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 17:40:12 +0300 Subject: [PATCH 048/199] feat(documents): add generic PDF download endpoint for documents --- api.py | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 81 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/api.py b/api.py index 0b37c3a..d1f7d8e 100644 --- a/api.py +++ b/api.py @@ -1252,6 +1252,88 @@ async def telecharger_devis_pdf(id: str): logger.error(f"Erreur génération PDF: {e}") raise HTTPException(500, str(e)) +@app.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"]) +async def telecharger_document_pdf( + type_doc: int = Query(..., description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)"), + numero: str = Query(..., description="Numéro du document") +): + """ + 📄 Téléchargement PDF d'un document (route généralisée) + + **Types de documents supportés:** + - `0`: Devis + - `10`: Bon de commande + - `30`: Bon de livraison + - `60`: Facture + - `50`: Bon d'avoir + + **Exemple d'utilisation:** + - `GET /documents/0/DE00001/pdf` → PDF du devis DE00001 + - `GET /documents/60/FA00001/pdf` → PDF de la facture FA00001 + + **Retour:** + - Fichier PDF prêt à télécharger + - Nom de fichier formaté selon le type de document + + Args: + type_doc: Type de document Sage (0-60) + numero: Numéro du document + + Returns: + StreamingResponse avec le PDF + """ + try: + # Mapping des types vers les libellés + types_labels = { + 0: "Devis", + 10: "Commande", + 20: "Preparation", + 30: "BonLivraison", + 40: "BonRetour", + 50: "Avoir", + 60: "Facture" + } + + # Vérifier que le type est valide + if type_doc not in types_labels: + raise HTTPException( + 400, + f"Type de document invalide: {type_doc}. " + f"Types valides: {list(types_labels.keys())}" + ) + + label = types_labels[type_doc] + + logger.info(f"📄 Génération PDF: {label} {numero} (type={type_doc})") + + # Appel à sage_client pour générer le PDF + pdf_bytes = sage_client.generer_pdf_document(numero, type_doc) + + if not pdf_bytes: + raise HTTPException( + 500, + f"Le PDF du document {numero} est vide" + ) + + logger.info(f"✅ PDF généré: {len(pdf_bytes)} octets") + + # Nom de fichier formaté + filename = f"{label}_{numero}.pdf" + + return StreamingResponse( + iter([pdf_bytes]), + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)) + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True) + raise HTTPException(500, f"Erreur génération PDF: {str(e)}") @app.post("/devis/{id}/envoyer", tags=["Devis"]) async def envoyer_devis_email( diff --git a/sage_client.py b/sage_client.py index 5f613c6..e45c6eb 100644 --- a/sage_client.py +++ b/sage_client.py @@ -543,6 +543,87 @@ class SageGatewayClient: "numero": numero, "facture_data": facture_data }).get("data", {}) + + def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: + """ + 🆕 Génère le PDF d'un document via la gateway Windows (route généralisée) + + **Cette méthode remplace les appels spécifiques par type de document** + + Args: + doc_id: Numéro du document (ex: "DE00001", "FA00001") + type_doc: Type de document Sage: + - 0: Devis + - 10: Bon de commande + - 30: Bon de livraison + - 60: Facture + - 50: Bon d'avoir + + Returns: + bytes: Contenu du PDF (binaire) + + Raises: + ValueError: Si le PDF retourné est vide + RuntimeError: Si erreur de communication avec la gateway + + Example: + >>> pdf_bytes = sage_client.generer_pdf_document("DE00001", 0) + >>> with open("devis.pdf", "wb") as f: + ... f.write(pdf_bytes) + """ + try: + logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") + + # Appel HTTP vers la gateway Windows + r = requests.post( + f"{self.url}/sage/documents/generate-pdf", + json={ + "doc_id": doc_id, + "type_doc": type_doc + }, + headers=self.headers, + timeout=60 # Timeout élevé pour génération PDF + ) + + r.raise_for_status() + + import base64 + + response_data = r.json() + + # Vérifier que la réponse contient bien le PDF + if not response_data.get("success"): + error_msg = response_data.get("error", "Erreur inconnue") + raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}") + + pdf_base64 = response_data.get("data", {}).get("pdf_base64", "") + + if not pdf_base64: + raise ValueError( + f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})" + ) + + # Décoder le base64 + pdf_bytes = base64.b64decode(pdf_base64) + + logger.info(f"✅ PDF décodé: {len(pdf_bytes)} octets") + + return pdf_bytes + + except requests.exceptions.Timeout: + logger.error(f"⏱️ Timeout génération PDF pour {doc_id}") + raise RuntimeError( + f"Timeout lors de la génération du PDF (>60s). " + f"Le document {doc_id} est peut-être trop volumineux." + ) + + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur HTTP génération PDF: {e}") + raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}") + + except Exception as e: + logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) + raise From 61e787bf360189d0f1003ae91deb466d3e628196 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 17:48:32 +0300 Subject: [PATCH 049/199] refactor(api): change query params to path params in document endpoint --- api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api.py b/api.py index d1f7d8e..778a4c2 100644 --- a/api.py +++ b/api.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, Query, Depends, status +from fastapi import FastAPI, HTTPException, Path, Query, Depends, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, EmailStr, validator, field_validator @@ -1254,8 +1254,8 @@ async def telecharger_devis_pdf(id: str): @app.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"]) async def telecharger_document_pdf( - type_doc: int = Query(..., description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)"), - numero: str = Query(..., description="Numéro du document") + type_doc: int = Path(..., description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)"), + numero: str = Path(..., description="Numéro du document") ): """ 📄 Téléchargement PDF d'un document (route généralisée) From e95e550044330a8cf41f53b486c601a1327f7494 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 17:57:41 +0300 Subject: [PATCH 050/199] style: Reformat method calls and remove unnecessary blank lines for improved code consistency. --- api.py | 894 ++++++++++++++++++++++++------------------------- sage_client.py | 164 ++++----- 2 files changed, 519 insertions(+), 539 deletions(-) diff --git a/api.py b/api.py index 778a4c2..c5ec086 100644 --- a/api.py +++ b/api.py @@ -45,70 +45,38 @@ from sage_client import sage_client TAGS_METADATA = [ { "name": "Clients", - "description": "Gestion des clients (recherche, création, modification)" - }, - { - "name": "Articles", - "description": "Gestion des articles et produits" - }, - { - "name": "Devis", - "description": "Création, consultation et gestion des devis" + "description": "Gestion des clients (recherche, création, modification)", }, + {"name": "Articles", "description": "Gestion des articles et produits"}, + {"name": "Devis", "description": "Création, consultation et gestion des devis"}, { "name": "Commandes", - "description": "Création, consultation et gestion des commandes" + "description": "Création, consultation et gestion des commandes", }, { "name": "Livraisons", - "description": "Création, consultation et gestion des bons de livraison" + "description": "Création, consultation et gestion des bons de livraison", }, { "name": "Factures", - "description": "Création, consultation et gestion des factures" - }, - { - "name": "Avoirs", - "description": "Création, consultation et gestion des avoirs" - }, - { - "name": "Fournisseurs", - "description": "Gestion des fournisseurs" - }, - { - "name": "Prospects", - "description": "Gestion des prospects" + "description": "Création, consultation et gestion des factures", }, + {"name": "Avoirs", "description": "Création, consultation et gestion des avoirs"}, + {"name": "Fournisseurs", "description": "Gestion des fournisseurs"}, + {"name": "Prospects", "description": "Gestion des prospects"}, { "name": "Workflows", - "description": "Transformations de documents (devis→commande, commande→facture, etc.)" + "description": "Transformations de documents (devis→commande, commande→facture, etc.)", }, - { - "name": "Signatures", - "description": "Signature électronique via Universign" - }, - { - "name": "Emails", - "description": "Envoi d'emails, templates et logs d'envoi" - }, - { - "name": "Validation", - "description": "Validation de données (remises, etc.)" - }, - { - "name": "Admin", - "description": "🔧 Administration système (cache, queue)" - }, - { - "name": "System", - "description": "🏥 Health checks et informations système" - }, - { - "name": "Debug", - "description": "🐛 Routes de debug et diagnostics" - } + {"name": "Signatures", "description": "Signature électronique via Universign"}, + {"name": "Emails", "description": "Envoi d'emails, templates et logs d'envoi"}, + {"name": "Validation", "description": "Validation de données (remises, etc.)"}, + {"name": "Admin", "description": "🔧 Administration système (cache, queue)"}, + {"name": "System", "description": "🏥 Health checks et informations système"}, + {"name": "Debug", "description": "🐛 Routes de debug et diagnostics"}, ] + # ===================================================== # ENUMS # ===================================================== @@ -164,7 +132,7 @@ class LigneDevis(BaseModel): quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -227,10 +195,11 @@ class ClientCreateAPIRequest(BaseModel): telephone: Optional[str] = None siret: Optional[str] = None tva_intra: Optional[str] = None - + class ClientUpdateRequest(BaseModel): """Modèle pour modification d'un client existant""" + intitule: Optional[str] = Field(None, min_length=1, max_length=69) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) @@ -240,7 +209,7 @@ class ClientUpdateRequest(BaseModel): telephone: Optional[str] = Field(None, max_length=21) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { @@ -249,10 +218,10 @@ class ClientUpdateRequest(BaseModel): "code_postal": "75008", "ville": "Paris", "email": "nouveau@email.fr", - "telephone": "0198765432" + "telephone": "0198765432", } } - + from pydantic import BaseModel from typing import List, Optional @@ -282,9 +251,15 @@ class UserResponse(BaseModel): class FournisseurCreateAPIRequest(BaseModel): - intitule: str = Field(..., min_length=1, max_length=69, description="Raison sociale du fournisseur") - compte_collectif: str = Field("401000", description="Compte comptable fournisseur (ex: 401000)") - num: Optional[str] = Field(None, max_length=17, description="Code fournisseur souhaité (optionnel)") + intitule: str = Field( + ..., min_length=1, max_length=69, description="Raison sociale du fournisseur" + ) + compte_collectif: str = Field( + "401000", description="Compte comptable fournisseur (ex: 401000)" + ) + num: Optional[str] = Field( + None, max_length=17, description="Code fournisseur souhaité (optionnel)" + ) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) ville: Optional[str] = Field(None, max_length=35) @@ -293,7 +268,7 @@ class FournisseurCreateAPIRequest(BaseModel): telephone: Optional[str] = Field(None, max_length=21) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { @@ -307,12 +282,14 @@ class FournisseurCreateAPIRequest(BaseModel): "email": "contact@acmesupplies.fr", "telephone": "0145678901", "siret": "12345678901234", - "tva_intra": "FR12345678901" + "tva_intra": "FR12345678901", } } - + + class FournisseurUpdateRequest(BaseModel): """Modèle pour modification d'un fournisseur existant""" + intitule: Optional[str] = Field(None, min_length=1, max_length=69) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) @@ -322,22 +299,24 @@ class FournisseurUpdateRequest(BaseModel): telephone: Optional[str] = Field(None, max_length=21) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { "intitule": "ACME SUPPLIES MODIFIÉ", "email": "nouveau@acme.fr", - "telephone": "0198765432" + "telephone": "0198765432", } } - + + class DevisUpdateRequest(BaseModel): """Modèle pour modification d'un devis existant""" + date_devis: Optional[date] = None lignes: Optional[List[LigneDevis]] = None statut: Optional[int] = Field(None, ge=0, le=6) - + class Config: json_schema_extra = { "example": { @@ -347,21 +326,22 @@ class DevisUpdateRequest(BaseModel): "article_code": "ART001", "quantite": 5.0, "prix_unitaire_ht": 100.0, - "remise_pourcentage": 10.0 + "remise_pourcentage": 10.0, } ], - "statut": 2 + "statut": 2, } } class LigneCommande(BaseModel): """Ligne de commande""" + article_code: str quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -369,11 +349,12 @@ class LigneCommande(BaseModel): class CommandeCreateRequest(BaseModel): """Création d'une commande""" + client_id: str date_commande: Optional[date] = None lignes: List[LigneCommande] reference: Optional[str] = None # Référence externe - + class Config: json_schema_extra = { "example": { @@ -385,20 +366,21 @@ class CommandeCreateRequest(BaseModel): "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 50.0, - "remise_pourcentage": 5.0 + "remise_pourcentage": 5.0, } - ] + ], } } class CommandeUpdateRequest(BaseModel): """Modification d'une commande existante""" + date_commande: Optional[date] = None lignes: Optional[List[LigneCommande]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -406,21 +388,22 @@ class CommandeUpdateRequest(BaseModel): { "article_code": "ART001", "quantite": 15.0, - "prix_unitaire_ht": 45.0 + "prix_unitaire_ht": 45.0, } ], - "statut": 2 + "statut": 2, } } class LigneLivraison(BaseModel): """Ligne de livraison""" + article_code: str quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -428,11 +411,12 @@ class LigneLivraison(BaseModel): class LivraisonCreateRequest(BaseModel): """Création d'une livraison""" + client_id: str date_livraison: Optional[date] = None lignes: List[LigneLivraison] reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -444,20 +428,21 @@ class LivraisonCreateRequest(BaseModel): "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 50.0, - "remise_pourcentage": 5.0 + "remise_pourcentage": 5.0, } - ] + ], } } class LivraisonUpdateRequest(BaseModel): """Modification d'une livraison existante""" + date_livraison: Optional[date] = None lignes: Optional[List[LigneLivraison]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -465,20 +450,22 @@ class LivraisonUpdateRequest(BaseModel): { "article_code": "ART001", "quantite": 15.0, - "prix_unitaire_ht": 45.0 + "prix_unitaire_ht": 45.0, } ], - "statut": 2 + "statut": 2, } } + class LigneAvoir(BaseModel): """Ligne d'avoir""" + article_code: str quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -486,11 +473,12 @@ class LigneAvoir(BaseModel): class AvoirCreateRequest(BaseModel): """Création d'un avoir""" + client_id: str date_avoir: Optional[date] = None lignes: List[LigneAvoir] reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -502,20 +490,21 @@ class AvoirCreateRequest(BaseModel): "article_code": "ART001", "quantite": 5.0, "prix_unitaire_ht": 50.0, - "remise_pourcentage": 0.0 + "remise_pourcentage": 0.0, } - ] + ], } } class AvoirUpdateRequest(BaseModel): """Modification d'un avoir existant""" + date_avoir: Optional[date] = None lignes: Optional[List[LigneAvoir]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -523,20 +512,22 @@ class AvoirUpdateRequest(BaseModel): { "article_code": "ART001", "quantite": 10.0, - "prix_unitaire_ht": 45.0 + "prix_unitaire_ht": 45.0, } ], - "statut": 2 + "statut": 2, } } - + + class LigneFacture(BaseModel): """Ligne de facture""" + article_code: str quantite: float prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 - + @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() @@ -544,11 +535,12 @@ class LigneFacture(BaseModel): class FactureCreateRequest(BaseModel): """Création d'une facture""" + client_id: str date_facture: Optional[date] = None lignes: List[LigneFacture] reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -560,20 +552,21 @@ class FactureCreateRequest(BaseModel): "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 50.0, - "remise_pourcentage": 5.0 + "remise_pourcentage": 5.0, } - ] + ], } } class FactureUpdateRequest(BaseModel): """Modification d'une facture existante""" + date_facture: Optional[date] = None lignes: Optional[List[LigneFacture]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None - + class Config: json_schema_extra = { "example": { @@ -581,14 +574,14 @@ class FactureUpdateRequest(BaseModel): { "article_code": "ART001", "quantite": 15.0, - "prix_unitaire_ht": 45.0 + "prix_unitaire_ht": 45.0, } ], - "statut": 2 + "statut": 2, } } - - + + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -741,7 +734,7 @@ app = FastAPI( version="2.0.0", description="API de gestion commerciale - VPS Linux", lifespan=lifespan, - openapi_tags=TAGS_METADATA + openapi_tags=TAGS_METADATA, ) app.add_middleware( @@ -769,28 +762,26 @@ async def rechercher_clients(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) + @app.get("/clients/{code}", tags=["Clients"]) async def lire_client_detail(code: str): """ 📄 Lecture détaillée d'un client par son code - + Args: code: Code du client (ex: "CLI000001", "SARL", etc.) - + Returns: Toutes les informations du client """ try: client = sage_client.lire_client(code) - + if not client: raise HTTPException(404, f"Client {code} introuvable") - - return { - "success": True, - "data": client - } - + + return {"success": True, "data": client} + except HTTPException: raise except Exception as e: @@ -802,18 +793,18 @@ async def lire_client_detail(code: str): async def modifier_client( code: str, client_update: ClientUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'un client existant - + Args: code: Code du client à modifier client_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - + Returns: Client modifié avec ses nouvelles valeurs - + Example: PUT /clients/SARL { @@ -823,16 +814,18 @@ async def modifier_client( """ try: # Appel à la gateway Windows - resultat = sage_client.modifier_client(code, client_update.dict(exclude_none=True)) - + resultat = sage_client.modifier_client( + code, client_update.dict(exclude_none=True) + ) + logger.info(f"✅ Client {code} modifié avec succès") - + return { "success": True, "message": f"Client {code} modifié avec succès", - "client": resultat + "client": resultat, } - + except ValueError as e: # Erreur métier (client introuvable, etc.) logger.warning(f"Erreur métier modification client {code}: {e}") @@ -841,32 +834,33 @@ async def modifier_client( # Erreur technique logger.error(f"Erreur technique modification client {code}: {e}") raise HTTPException(500, str(e)) - + + @app.post("/clients", status_code=201, tags=["Clients"]) async def ajouter_client( - client: ClientCreateAPIRequest, - session: AsyncSession = Depends(get_session) + client: ClientCreateAPIRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'un nouveau client dans Sage 100c """ try: nouveau_client = sage_client.creer_client(client.dict()) - + logger.info(f"✅ Client créé via API: {nouveau_client.get('numero')}") - + return { "success": True, "message": "Client créé avec succès", - "data": nouveau_client + "data": nouveau_client, } - + except Exception as e: logger.error(f"Erreur lors de la création du client: {e}") # On renvoie une 400 si c'est une erreur métier (ex: doublon), sinon 500 status = 400 if "existe déjà" in str(e) else 500 raise HTTPException(status, str(e)) + @app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): """🔍 Recherche articles via gateway Windows""" @@ -920,25 +914,25 @@ async def creer_devis(devis: DevisRequest): async def modifier_devis( id: str, devis_update: DevisUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'un devis existant - + **Champs modifiables:** - `date_devis`: Nouvelle date du devis - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut (0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé) - + **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - Pour ajouter/supprimer une ligne, il faut envoyer TOUTES les lignes - Un devis transformé (statut=5) ne peut plus être modifié - + Args: id: Numéro du devis à modifier devis_update: Champs à mettre à jour - + Returns: Devis modifié avec ses nouvelles valeurs """ @@ -947,20 +941,19 @@ async def modifier_devis( devis_existant = sage_client.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") - + # Vérifier qu'il n'est pas déjà transformé if devis_existant.get("statut") == 5: raise HTTPException( - 400, - f"Le devis {id} a déjà été transformé et ne peut plus être modifié" + 400, f"Le devis {id} a déjà été transformé et ne peut plus être modifié" ) - + # Construire les données de mise à jour update_data = {} - + if devis_update.date_devis: update_data["date_devis"] = devis_update.date_devis.isoformat() - + if devis_update.lignes is not None: update_data["lignes"] = [ { @@ -971,51 +964,50 @@ async def modifier_devis( } for l in devis_update.lignes ] - + if devis_update.statut is not None: update_data["statut"] = devis_update.statut - + # Appel à la gateway Windows resultat = sage_client.modifier_devis(id, update_data) - + logger.info(f"✅ Devis {id} modifié avec succès") - + return { "success": True, "message": f"Devis {id} modifié avec succès", - "devis": resultat + "devis": resultat, } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur modification devis {id}: {e}") raise HTTPException(500, str(e)) - + @app.post("/commandes", status_code=201, tags=["Commandes"]) async def creer_commande( - commande: CommandeCreateRequest, - session: AsyncSession = Depends(get_session) + commande: CommandeCreateRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'une nouvelle commande (Bon de commande) - + **Workflow typique:** 1. Création d'un devis → transformation en commande (automatique) 2. OU création directe d'une commande (cette route) - + **Champs obligatoires:** - `client_id`: Code du client - `lignes`: Liste des lignes (min 1) - + **Champs optionnels:** - `date_commande`: Date de la commande (par défaut: aujourd'hui) - `reference`: Référence externe (ex: numéro de commande client) - + Args: commande: Données de la commande à créer - + Returns: Commande créée avec son numéro et ses totaux """ @@ -1024,14 +1016,12 @@ async def creer_commande( client = sage_client.lire_client(commande.client_id) if not client: raise HTTPException(404, f"Client {commande.client_id} introuvable") - + # Préparer les données pour la gateway commande_data = { "client_id": commande.client_id, "date_commande": ( - commande.date_commande.isoformat() - if commande.date_commande - else None + commande.date_commande.isoformat() if commande.date_commande else None ), "reference": commande.reference, "lignes": [ @@ -1044,12 +1034,12 @@ async def creer_commande( for l in commande.lignes ], } - + # Appel à la gateway Windows resultat = sage_client.creer_commande(commande_data) - + logger.info(f"✅ Commande créée: {resultat.get('numero_commande')}") - + return { "success": True, "message": "Commande créée avec succès", @@ -1060,10 +1050,10 @@ async def creer_commande( "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], - "reference": commande.reference - } + "reference": commande.reference, + }, } - + except HTTPException: raise except Exception as e: @@ -1075,62 +1065,60 @@ async def creer_commande( async def modifier_commande( id: str, commande_update: CommandeUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'une commande existante - + **Champs modifiables:** - `date_commande`: Nouvelle date - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut - `reference`: Référence externe - + **Restrictions:** - Une commande transformée (statut=5) ne peut plus être modifiée - Une commande annulée (statut=6) ne peut plus être modifiée - + **Note importante:** Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - + Args: id: Numéro de la commande à modifier commande_update: Champs à mettre à jour - + Returns: Commande modifiée avec ses nouvelles valeurs """ try: # Vérifier que la commande existe commande_existante = sage_client.lire_document( - id, - settings.SAGE_TYPE_BON_COMMANDE + id, settings.SAGE_TYPE_BON_COMMANDE ) - + if not commande_existante: raise HTTPException(404, f"Commande {id} introuvable") - + # Vérifier le statut statut_actuel = commande_existante.get("statut", 0) - + if statut_actuel == 5: raise HTTPException( 400, - f"La commande {id} a déjà été transformée et ne peut plus être modifiée" + f"La commande {id} a déjà été transformée et ne peut plus être modifiée", ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"La commande {id} est annulée et ne peut plus être modifiée" + 400, f"La commande {id} est annulée et ne peut plus être modifiée" ) - + # Construire les données de mise à jour update_data = {} - + if commande_update.date_commande: update_data["date_commande"] = commande_update.date_commande.isoformat() - + if commande_update.lignes is not None: update_data["lignes"] = [ { @@ -1141,30 +1129,30 @@ async def modifier_commande( } for l in commande_update.lignes ] - + if commande_update.statut is not None: update_data["statut"] = commande_update.statut - + if commande_update.reference is not None: update_data["reference"] = commande_update.reference - + # Appel à la gateway Windows resultat = sage_client.modifier_commande(id, update_data) - + logger.info(f"✅ Commande {id} modifiée avec succès") - + return { "success": True, "message": f"Commande {id} modifiée avec succès", - "commande": resultat + "commande": resultat, } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur modification commande {id}: {e}") raise HTTPException(500, str(e)) - + @app.get("/devis", tags=["Devis"]) async def lister_devis( @@ -1199,22 +1187,22 @@ async def lister_devis( async def lire_devis(id: str): """ 📄 Lecture d'un devis via gateway Windows - + Returns: Devis complet avec: - Toutes les informations standards - lignes: Lignes du devis - a_deja_ete_transforme: ✅ Booléen indiquant si le devis a été transformé - documents_cibles: ✅ Liste des documents créés depuis ce devis - + ✅ ENRICHI: Inclut maintenant l'information de transformation """ try: devis = sage_client.lire_devis(id) - + if not devis: raise HTTPException(404, f"Devis {id} introuvable") - + # Log informatif if devis.get("a_deja_ete_transforme"): docs = devis.get("documents_cibles", []) @@ -1222,12 +1210,9 @@ async def lire_devis(id: str): f"📊 Devis {id} a été transformé en " f"{len(docs)} document(s): {[d['numero'] for d in docs]}" ) - - return { - "success": True, - "data": devis - } - + + return {"success": True, "data": devis} + except HTTPException: raise except Exception as e: @@ -1252,33 +1237,37 @@ async def telecharger_devis_pdf(id: str): logger.error(f"Erreur génération PDF: {e}") raise HTTPException(500, str(e)) + @app.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"]) async def telecharger_document_pdf( - type_doc: int = Path(..., description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)"), - numero: str = Path(..., description="Numéro du document") + type_doc: int = Path( + ..., + description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)", + ), + numero: str = Path(..., description="Numéro du document"), ): """ 📄 Téléchargement PDF d'un document (route généralisée) - + **Types de documents supportés:** - `0`: Devis - `10`: Bon de commande - `30`: Bon de livraison - `60`: Facture - `50`: Bon d'avoir - + **Exemple d'utilisation:** - `GET /documents/0/DE00001/pdf` → PDF du devis DE00001 - `GET /documents/60/FA00001/pdf` → PDF de la facture FA00001 - + **Retour:** - Fichier PDF prêt à télécharger - Nom de fichier formaté selon le type de document - + Args: type_doc: Type de document Sage (0-60) numero: Numéro du document - + Returns: StreamingResponse avec le PDF """ @@ -1291,50 +1280,50 @@ async def telecharger_document_pdf( 30: "BonLivraison", 40: "BonRetour", 50: "Avoir", - 60: "Facture" + 60: "Facture", } - + # Vérifier que le type est valide if type_doc not in types_labels: raise HTTPException( - 400, + 400, f"Type de document invalide: {type_doc}. " - f"Types valides: {list(types_labels.keys())}" + f"Types valides: {list(types_labels.keys())}", ) - + label = types_labels[type_doc] - + logger.info(f"📄 Génération PDF: {label} {numero} (type={type_doc})") - + # Appel à sage_client pour générer le PDF pdf_bytes = sage_client.generer_pdf_document(numero, type_doc) - + if not pdf_bytes: - raise HTTPException( - 500, - f"Le PDF du document {numero} est vide" - ) - + raise HTTPException(500, f"Le PDF du document {numero} est vide") + logger.info(f"✅ PDF généré: {len(pdf_bytes)} octets") - + # Nom de fichier formaté filename = f"{label}_{numero}.pdf" - + return StreamingResponse( iter([pdf_bytes]), media_type="application/pdf", headers={ "Content-Disposition": f"attachment; filename={filename}", - "Content-Length": str(len(pdf_bytes)) - } + "Content-Length": str(len(pdf_bytes)), + }, ) - + except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True) + logger.error( + f"❌ Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True + ) raise HTTPException(500, f"Erreur génération PDF: {str(e)}") + @app.post("/devis/{id}/envoyer", tags=["Devis"]) async def envoyer_devis_email( id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) @@ -1391,26 +1380,28 @@ async def envoyer_devis_email( @app.put("/devis/{id}/statut", tags=["Devis"]) async def changer_statut_devis( - id: str, - nouveau_statut: int = Query(..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé") + id: str, + nouveau_statut: int = Query( + ..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé" + ), ): """ 📊 Changement de statut d'un devis - + **Statuts possibles:** - 0: Brouillon - 2: Accepté/Validé - 5: Transformé (automatique lors d'une transformation) - 6: Annulé - + **Restrictions:** - Un devis transformé (5) ne peut plus changer de statut - Un devis annulé (6) ne peut plus changer de statut - + Args: id: Numéro du devis nouveau_statut: Nouveau statut (0-6) - + Returns: Confirmation du changement avec ancien et nouveau statut """ @@ -1419,34 +1410,33 @@ async def changer_statut_devis( devis_existant = sage_client.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") - + statut_actuel = devis_existant.get("statut", 0) - + # Vérifications de cohérence if statut_actuel == 5: raise HTTPException( 400, - f"Le devis {id} a déjà été transformé et ne peut plus changer de statut" + f"Le devis {id} a déjà été transformé et ne peut plus changer de statut", ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"Le devis {id} est annulé et ne peut plus changer de statut" + 400, f"Le devis {id} est annulé et ne peut plus changer de statut" ) - + resultat = sage_client.changer_statut_devis(id, nouveau_statut) - + logger.info(f"✅ Statut devis {id} changé: {statut_actuel} → {nouveau_statut}") - + return { "success": True, "devis_id": id, "statut_ancien": resultat.get("statut_ancien", statut_actuel), "statut_nouveau": resultat.get("statut_nouveau", nouveau_statut), - "message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}" + "message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}", } - + except HTTPException: raise except Exception as e: @@ -1458,6 +1448,7 @@ async def changer_statut_devis( # ENDPOINTS - Commandes (WORKFLOW SANS RESSAISIE) # ===================================================== + @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): """📄 Lecture d'une commande avec ses lignes""" @@ -1471,7 +1462,7 @@ async def lire_commande(id: str): except Exception as e: logger.error(f"Erreur lecture commande: {e}") raise HTTPException(500, str(e)) - + @app.get("/commandes", tags=["Commandes"]) async def lister_commandes( @@ -1512,7 +1503,9 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi sage_client.changer_statut_devis(id, nouveau_statut=5) logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") except Exception as e: - logger.warning(f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}") + logger.warning( + f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}" + ) # On continue même si la MAJ statut échoue # Étape 3: Logger la transformation @@ -1938,7 +1931,9 @@ async def envoyer_emails_lot( # ===================================================== -@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"]) +@app.post( + "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] +) async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), @@ -2101,62 +2096,60 @@ async def lister_factures( async def lire_facture_detail(numero: str): """ 📄 Lecture détaillée d'une facture avec ses lignes - + Args: numero: Numéro de la facture (ex: "FA000001") - + Returns: Facture complète avec lignes, client, totaux, etc. """ try: facture = sage_client.lire_document(numero, TypeDocument.FACTURE) - + if not facture: raise HTTPException(404, f"Facture {numero} introuvable") - - return { - "success": True, - "data": facture - } - + + return {"success": True, "data": facture} + except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture facture {numero}: {e}") raise HTTPException(500, str(e)) - + + class RelanceFactureRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None + @app.post("/factures", status_code=201, tags=["Factures"]) async def creer_facture( - facture: FactureCreateRequest, - session: AsyncSession = Depends(get_session) + facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'une facture - + **Workflow typique:** 1. Commande → Livraison → Facture (transformations successives) 2. OU création directe d'une facture (cette route) - + **Champs obligatoires:** - `client_id`: Code du client - `lignes`: Liste des lignes (min 1) - + **Champs optionnels:** - `date_facture`: Date de la facture (par défaut: aujourd'hui) - `reference`: Référence externe (ex: numéro de commande client) - + **Notes importantes:** - Les factures peuvent avoir des champs obligatoires supplémentaires selon la configuration Sage - Le statut initial est généralement 2 (Accepté/Validé) - Les factures sont soumises aux règles de numérotation strictes - + Args: facture: Données de la facture à créer - + Returns: Facture créée avec son numéro et ses totaux """ @@ -2165,14 +2158,12 @@ async def creer_facture( client = sage_client.lire_client(facture.client_id) if not client: raise HTTPException(404, f"Client {facture.client_id} introuvable") - + # Préparer les données pour la gateway facture_data = { "client_id": facture.client_id, "date_facture": ( - facture.date_facture.isoformat() - if facture.date_facture - else None + facture.date_facture.isoformat() if facture.date_facture else None ), "reference": facture.reference, "lignes": [ @@ -2185,12 +2176,12 @@ async def creer_facture( for l in facture.lignes ], } - + # Appel à la gateway Windows resultat = sage_client.creer_facture(facture_data) - + logger.info(f"✅ Facture créée: {resultat.get('numero_facture')}") - + return { "success": True, "message": "Facture créée avec succès", @@ -2201,10 +2192,10 @@ async def creer_facture( "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], - "reference": facture.reference - } + "reference": facture.reference, + }, } - + except HTTPException: raise except Exception as e: @@ -2216,64 +2207,60 @@ async def creer_facture( async def modifier_facture( id: str, facture_update: FactureUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'une facture existante - + **Champs modifiables:** - `date_facture`: Nouvelle date - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut - `reference`: Référence externe - + **Restrictions IMPORTANTES:** - Une facture transformée (statut=5) ne peut plus être modifiée - Une facture annulée (statut=6) ne peut plus être modifiée - ⚠️ **ATTENTION**: Les factures comptabilisées peuvent être verrouillées par Sage - Certaines factures peuvent être en lecture seule selon les droits utilisateur - + **Note importante:** Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - + Args: id: Numéro de la facture à modifier facture_update: Champs à mettre à jour - + Returns: Facture modifiée avec ses nouvelles valeurs """ try: # Vérifier que la facture existe - facture_existante = sage_client.lire_document( - id, - settings.SAGE_TYPE_FACTURE - ) - + facture_existante = sage_client.lire_document(id, settings.SAGE_TYPE_FACTURE) + if not facture_existante: raise HTTPException(404, f"Facture {id} introuvable") - + # Vérifier le statut statut_actuel = facture_existante.get("statut", 0) - + if statut_actuel == 5: raise HTTPException( 400, - f"La facture {id} a déjà été transformée et ne peut plus être modifiée" + f"La facture {id} a déjà été transformée et ne peut plus être modifiée", ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"La facture {id} est annulée et ne peut plus être modifiée" + 400, f"La facture {id} est annulée et ne peut plus être modifiée" ) - + # Construire les données de mise à jour update_data = {} - + if facture_update.date_facture: update_data["date_facture"] = facture_update.date_facture.isoformat() - + if facture_update.lignes is not None: update_data["lignes"] = [ { @@ -2284,30 +2271,30 @@ async def modifier_facture( } for l in facture_update.lignes ] - + if facture_update.statut is not None: update_data["statut"] = facture_update.statut - + if facture_update.reference is not None: update_data["reference"] = facture_update.reference - + # Appel à la gateway Windows resultat = sage_client.modifier_facture(id, update_data) - + logger.info(f"✅ Facture {id} modifiée avec succès") - + return { "success": True, "message": f"Facture {id} modifiée avec succès", - "facture": resultat + "facture": resultat, } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur modification facture {id}: {e}") raise HTTPException(500, str(e)) - + # Templates email (si pas déjà définis) templates_email_db = { @@ -2772,39 +2759,40 @@ async def rechercher_fournisseurs(query: Optional[str] = Query(None)): try: # ✅ APPEL DIRECT vers Windows (pas de cache) fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") - + logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés depuis Windows") - + if len(fournisseurs) == 0: logger.warning("⚠️ Aucun fournisseur retourné - vérifier la gateway Windows") - + return fournisseurs - + except Exception as e: logger.error(f"❌ Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) + @app.post("/fournisseurs", status_code=201, tags=["Fournisseurs"]) async def ajouter_fournisseur( fournisseur: FournisseurCreateAPIRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ➕ Création d'un nouveau fournisseur dans Sage 100c - + **Champs obligatoires:** - `intitule`: Raison sociale (max 69 caractères) - + **Champs optionnels:** - `compte_collectif`: Compte comptable (défaut: 401000) - `num`: Code fournisseur personnalisé (auto-généré si vide) - `adresse`, `code_postal`, `ville`, `pays` - `email`, `telephone` - `siret`, `tva_intra` - + **Retour:** - Fournisseur créé avec son numéro définitif - + **Erreurs possibles:** - 400: Fournisseur existe déjà (doublon) - 500: Erreur technique Sage @@ -2812,41 +2800,42 @@ async def ajouter_fournisseur( try: # Appel à la gateway Windows via sage_client nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) - + logger.info(f"✅ Fournisseur créé via API: {nouveau_fournisseur.get('numero')}") - + return { "success": True, "message": "Fournisseur créé avec succès", - "data": nouveau_fournisseur + "data": nouveau_fournisseur, } - + except ValueError as e: # Erreur métier (doublon, validation) logger.warning(f"⚠️ Erreur métier création fournisseur: {e}") raise HTTPException(400, str(e)) - + except Exception as e: # Erreur technique (COM, connexion) logger.error(f"❌ Erreur technique création fournisseur: {e}") raise HTTPException(500, str(e)) + @app.put("/fournisseurs/{code}", tags=["Fournisseurs"]) async def modifier_fournisseur( code: str, fournisseur_update: FournisseurUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'un fournisseur existant - + Args: code: Code du fournisseur à modifier fournisseur_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - + Returns: Fournisseur modifié avec ses nouvelles valeurs - + Example: PUT /fournisseurs/DUPONT { @@ -2856,16 +2845,18 @@ async def modifier_fournisseur( """ try: # Appel à la gateway Windows - resultat = sage_client.modifier_fournisseur(code, fournisseur_update.dict(exclude_none=True)) - + resultat = sage_client.modifier_fournisseur( + code, fournisseur_update.dict(exclude_none=True) + ) + logger.info(f"✅ Fournisseur {code} modifié avec succès") - + return { "success": True, "message": f"Fournisseur {code} modifié avec succès", - "fournisseur": resultat + "fournisseur": resultat, } - + except ValueError as e: # Erreur métier (fournisseur introuvable, etc.) logger.warning(f"Erreur métier modification fournisseur {code}: {e}") @@ -2874,8 +2865,8 @@ async def modifier_fournisseur( # Erreur technique logger.error(f"Erreur technique modification fournisseur {code}: {e}") raise HTTPException(500, str(e)) - - + + @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): """📄 Lecture d'un fournisseur par code""" @@ -2921,31 +2912,31 @@ async def lire_avoir(numero: str): logger.error(f"Erreur lecture avoir: {e}") raise HTTPException(500, str(e)) + @app.post("/avoirs", status_code=201, tags=["Avoirs"]) async def creer_avoir( - avoir: AvoirCreateRequest, - session: AsyncSession = Depends(get_session) + avoir: AvoirCreateRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'un avoir (Bon d'avoir) - + **Workflow typique:** 1. Retour marchandise → création d'un avoir 2. Geste commercial → création directe d'un avoir (cette route) - + **Champs obligatoires:** - `client_id`: Code du client - `lignes`: Liste des lignes (min 1) - + **Champs optionnels:** - `date_avoir`: Date de l'avoir (par défaut: aujourd'hui) - `reference`: Référence externe (ex: numéro de retour) - + **Note:** Les montants des avoirs sont généralement négatifs (crédits) - + Args: avoir: Données de l'avoir à créer - + Returns: Avoir créé avec son numéro et ses totaux """ @@ -2954,15 +2945,11 @@ async def creer_avoir( client = sage_client.lire_client(avoir.client_id) if not client: raise HTTPException(404, f"Client {avoir.client_id} introuvable") - + # Préparer les données pour la gateway avoir_data = { "client_id": avoir.client_id, - "date_avoir": ( - avoir.date_avoir.isoformat() - if avoir.date_avoir - else None - ), + "date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None), "reference": avoir.reference, "lignes": [ { @@ -2974,12 +2961,12 @@ async def creer_avoir( for l in avoir.lignes ], } - + # Appel à la gateway Windows resultat = sage_client.creer_avoir(avoir_data) - + logger.info(f"✅ Avoir créé: {resultat.get('numero_avoir')}") - + return { "success": True, "message": "Avoir créé avec succès", @@ -2990,10 +2977,10 @@ async def creer_avoir( "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], - "reference": avoir.reference - } + "reference": avoir.reference, + }, } - + except HTTPException: raise except Exception as e: @@ -3005,59 +2992,57 @@ async def creer_avoir( async def modifier_avoir( id: str, avoir_update: AvoirUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'un avoir existant - + **Champs modifiables:** - `date_avoir`: Nouvelle date - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut - `reference`: Référence externe - + **Restrictions:** - Un avoir transformé (statut=5) ne peut plus être modifié - Un avoir annulé (statut=6) ne peut plus être modifié - + **Note importante:** Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - + Args: id: Numéro de l'avoir à modifier avoir_update: Champs à mettre à jour - + Returns: Avoir modifié avec ses nouvelles valeurs """ try: # Vérifier que l'avoir existe avoir_existant = sage_client.lire_avoir(id) - + if not avoir_existant: raise HTTPException(404, f"Avoir {id} introuvable") - + # Vérifier le statut statut_actuel = avoir_existant.get("statut", 0) - + if statut_actuel == 5: raise HTTPException( - 400, - f"L'avoir {id} a déjà été transformé et ne peut plus être modifié" + 400, f"L'avoir {id} a déjà été transformé et ne peut plus être modifié" ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"L'avoir {id} est annulé et ne peut plus être modifié" + 400, f"L'avoir {id} est annulé et ne peut plus être modifié" ) - + # Construire les données de mise à jour update_data = {} - + if avoir_update.date_avoir: update_data["date_avoir"] = avoir_update.date_avoir.isoformat() - + if avoir_update.lignes is not None: update_data["lignes"] = [ { @@ -3068,31 +3053,31 @@ async def modifier_avoir( } for l in avoir_update.lignes ] - + if avoir_update.statut is not None: update_data["statut"] = avoir_update.statut - + if avoir_update.reference is not None: update_data["reference"] = avoir_update.reference - + # Appel à la gateway Windows resultat = sage_client.modifier_avoir(id, update_data) - + logger.info(f"✅ Avoir {id} modifié avec succès") - + return { "success": True, "message": f"Avoir {id} modifié avec succès", - "avoir": resultat + "avoir": resultat, } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur modification avoir {id}: {e}") raise HTTPException(500, str(e)) - - + + # ===================================================== # ENDPOINTS - LIVRAISONS # ===================================================== @@ -3123,22 +3108,22 @@ async def lire_livraison(numero: str): logger.error(f"Erreur lecture livraison: {e}") raise HTTPException(500, str(e)) + @app.post("/livraisons", status_code=201, tags=["Livraisons"]) async def creer_livraison( - livraison: LivraisonCreateRequest, - session: AsyncSession = Depends(get_session) + livraison: LivraisonCreateRequest, session: AsyncSession = Depends(get_session) ): """ ➕ Création d'une nouvelle livraison (Bon de livraison) - + **Workflow typique:** 1. Création d'une commande → transformation en livraison (automatique) 2. OU création directe d'une livraison (cette route) - + **Champs obligatoires:** - `client_id`: Code du client - `lignes`: Liste des lignes (min 1) - + **Champs optionnels:** - `date_livraison`: Date de la livraison (par défaut: aujourd'hui) - `reference`: Référence externe (ex: numéro de commande client) @@ -3148,13 +3133,13 @@ async def creer_livraison( client = sage_client.lire_client(livraison.client_id) if not client: raise HTTPException(404, f"Client {livraison.client_id} introuvable") - + # Préparer les données pour la gateway livraison_data = { "client_id": livraison.client_id, "date_livraison": ( - livraison.date_livraison.isoformat() - if livraison.date_livraison + livraison.date_livraison.isoformat() + if livraison.date_livraison else None ), "reference": livraison.reference, @@ -3168,12 +3153,12 @@ async def creer_livraison( for l in livraison.lignes ], } - + # Appel à la gateway Windows resultat = sage_client.creer_livraison(livraison_data) - + logger.info(f"✅ Livraison créée: {resultat.get('numero_livraison')}") - + return { "success": True, "message": "Livraison créée avec succès", @@ -3184,10 +3169,10 @@ async def creer_livraison( "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], - "reference": livraison.reference - } + "reference": livraison.reference, + }, } - + except HTTPException: raise except Exception as e: @@ -3199,17 +3184,17 @@ async def creer_livraison( async def modifier_livraison( id: str, livraison_update: LivraisonUpdateRequest, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ ✏️ Modification d'une livraison existante - + **Champs modifiables:** - `date_livraison`: Nouvelle date - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - `statut`: Nouveau statut - `reference`: Référence externe - + **Restrictions:** - Une livraison transformée (statut=5) ne peut plus être modifiée - Une livraison annulée (statut=6) ne peut plus être modifiée @@ -3217,31 +3202,30 @@ async def modifier_livraison( try: # Vérifier que la livraison existe livraison_existante = sage_client.lire_livraison(id) - + if not livraison_existante: raise HTTPException(404, f"Livraison {id} introuvable") - + # Vérifier le statut statut_actuel = livraison_existante.get("statut", 0) - + if statut_actuel == 5: raise HTTPException( 400, - f"La livraison {id} a déjà été transformée et ne peut plus être modifiée" + f"La livraison {id} a déjà été transformée et ne peut plus être modifiée", ) - + if statut_actuel == 6: raise HTTPException( - 400, - f"La livraison {id} est annulée et ne peut plus être modifiée" + 400, f"La livraison {id} est annulée et ne peut plus être modifiée" ) - + # Construire les données de mise à jour update_data = {} - + if livraison_update.date_livraison: update_data["date_livraison"] = livraison_update.date_livraison.isoformat() - + if livraison_update.lignes is not None: update_data["lignes"] = [ { @@ -3252,24 +3236,24 @@ async def modifier_livraison( } for l in livraison_update.lignes ] - + if livraison_update.statut is not None: update_data["statut"] = livraison_update.statut - + if livraison_update.reference is not None: update_data["reference"] = livraison_update.reference - + # Appel à la gateway Windows resultat = sage_client.modifier_livraison(id, update_data) - + logger.info(f"✅ Livraison {id} modifiée avec succès") - + return { "success": True, "message": f"Livraison {id} modifiée avec succès", - "livraison": resultat + "livraison": resultat, } - + except HTTPException: raise except Exception as e: @@ -3321,24 +3305,26 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se @app.post("/workflow/devis/{id}/to-facture", tags=["Workflows"]) -async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get_session)): +async def devis_vers_facture_direct( + id: str, session: AsyncSession = Depends(get_session) +): """ 🔧 Transformation Devis → Facture (DIRECT, sans commande) - + ✅ Utilise les VRAIS types Sage (0 → 60) ✅ Met à jour le statut du devis source à 5 (Transformé) - + **Workflow raccourci** : Permet de facturer directement depuis un devis sans passer par la création d'une commande. - + **Cas d'usage** : - Prestations de services facturées directement - Petites commandes sans besoin de suivi intermédiaire - Ventes au comptoir - + Args: id: Numéro du devis source - + Returns: Informations de la facture créée """ @@ -3347,15 +3333,15 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get devis_existant = sage_client.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") - + statut_devis = devis_existant.get("statut", 0) if statut_devis == 5: raise HTTPException( - 400, + 400, f"Le devis {id} a déjà été transformé (statut=5). " - f"Vérifiez les documents déjà créés depuis ce devis." + f"Vérifiez les documents déjà créés depuis ce devis.", ) - + # Étape 2: Transformation resultat = sage_client.transformer_document( numero_source=id, @@ -3368,7 +3354,9 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get sage_client.changer_statut_devis(id, nouveau_statut=5) logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") except Exception as e: - logger.warning(f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}") + logger.warning( + f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}" + ) # On continue même si la MAJ statut échoue # Étape 4: Logger la transformation @@ -3408,55 +3396,56 @@ async def devis_vers_facture_direct(id: str, session: AsyncSession = Depends(get @app.post("/workflow/commande/{id}/to-livraison", tags=["Workflows"]) -async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_session)): +async def commande_vers_livraison( + id: str, session: AsyncSession = Depends(get_session) +): """ 🔧 Transformation Commande → Bon de livraison - + ✅ Utilise les VRAIS types Sage (10 → 30) - + **Workflow typique** : Après validation d'une commande, génère le bon de livraison pour préparer l'expédition. - + **Cas d'usage** : - Préparation d'une expédition - Génération du bordereau de livraison - Suivi logistique - + **Workflow complet** : 1. Devis → Commande (via `/workflow/devis/{id}/to-commande`) 2. **Commande → Livraison** (cette route) 3. Livraison → Facture (via `/workflow/livraison/{id}/to-facture`) - + Args: id: Numéro de la commande source - + Returns: Informations du bon de livraison créé """ try: # Étape 1: Vérifier que la commande existe commande_existante = sage_client.lire_document( - id, - settings.SAGE_TYPE_BON_COMMANDE + id, settings.SAGE_TYPE_BON_COMMANDE ) - + if not commande_existante: raise HTTPException(404, f"Commande {id} introuvable") - + statut_commande = commande_existante.get("statut", 0) if statut_commande == 5: raise HTTPException( 400, f"La commande {id} a déjà été transformée (statut=5). " - f"Un bon de livraison existe probablement déjà." + f"Un bon de livraison existe probablement déjà.", ) - + if statut_commande == 6: raise HTTPException( 400, - f"La commande {id} est annulée (statut=6) et ne peut pas être transformée." + f"La commande {id} est annulée (statut=6) et ne peut pas être transformée.", ) - + # Étape 2: Transformation resultat = sage_client.transformer_document( numero_source=id, @@ -3498,7 +3487,7 @@ async def commande_vers_livraison(id: str, session: AsyncSession = Depends(get_s except Exception as e: logger.error(f"❌ Erreur transformation commande→livraison: {e}", exc_info=True) raise HTTPException(500, str(e)) - + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( @@ -3845,6 +3834,7 @@ async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_ses "traceback": str(e.__class__.__name__), } + @app.get("/debug/fournisseurs/cache", tags=["Debug"]) async def debug_cache_fournisseurs(): """ @@ -3853,7 +3843,7 @@ async def debug_cache_fournisseurs(): try: # Appeler la gateway Windows pour récupérer l'info cache cache_info = sage_client.get_cache_info() - + # Tenter de lister les fournisseurs try: fournisseurs = sage_client.lister_fournisseurs(filtre="") @@ -3863,27 +3853,32 @@ async def debug_cache_fournisseurs(): nb_fournisseurs = -1 exemple = [] error = str(e) - + return { "success": True, "cache_info_windows": cache_info, "test_liste_fournisseurs": { "nb_fournisseurs": nb_fournisseurs, "exemples": exemple, - "erreur": error if nb_fournisseurs == -1 else None + "erreur": error if nb_fournisseurs == -1 else None, }, "diagnostic": { "gateway_accessible": cache_info is not None, - "cache_fournisseurs_existe": "fournisseurs" in cache_info if cache_info else False, + "cache_fournisseurs_existe": ( + "fournisseurs" in cache_info if cache_info else False + ), "probleme_probable": ( - "Cache fournisseurs non initialisé côté Windows" - if cache_info and "fournisseurs" not in cache_info - else "OK" if nb_fournisseurs > 0 - else "Erreur lors de la récupération" - ) - } + "Cache fournisseurs non initialisé côté Windows" + if cache_info and "fournisseurs" not in cache_info + else ( + "OK" + if nb_fournisseurs > 0 + else "Erreur lors de la récupération" + ) + ), + }, } - + except Exception as e: logger.error(f"❌ Erreur debug cache: {e}", exc_info=True) raise HTTPException(500, str(e)) @@ -3897,18 +3892,19 @@ async def force_refresh_fournisseurs(): try: # Appeler la gateway Windows pour forcer le refresh resultat = sage_client.refresh_cache() - + # Attendre 2 secondes import time + time.sleep(2) - + # Récupérer le cache info après refresh cache_info = sage_client.get_cache_info() - + # Tester la liste fournisseurs = sage_client.lister_fournisseurs(filtre="") nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 - + return { "success": True, "refresh_result": resultat, @@ -3916,17 +3912,17 @@ async def force_refresh_fournisseurs(): "nb_fournisseurs_maintenant": nb_fournisseurs, "exemples": fournisseurs[:3] if fournisseurs else [], "message": ( - f"✅ Refresh OK : {nb_fournisseurs} fournisseurs disponibles" - if nb_fournisseurs > 0 + f"✅ Refresh OK : {nb_fournisseurs} fournisseurs disponibles" + if nb_fournisseurs > 0 else "❌ Problème : aucun fournisseur après refresh" - ) + ), } - + except Exception as e: logger.error(f"❌ Erreur force refresh: {e}", exc_info=True) raise HTTPException(500, str(e)) - - + + # ===================================================== # LANCEMENT # ===================================================== diff --git a/sage_client.py b/sage_client.py index e45c6eb..d03e009 100644 --- a/sage_client.py +++ b/sage_client.py @@ -278,32 +278,32 @@ class SageGatewayClient: def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: """ Envoie la requête de création de fournisseur à la gateway Windows. - + Args: fournisseur_data: Dict contenant intitule, compte_collectif, etc. - + Returns: Fournisseur créé avec son numéro définitif """ return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) - + def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: """ ✏️ Modification d'un fournisseur existant - + Args: code: Code du fournisseur à modifier fournisseur_data: Dictionnaire contenant les champs à modifier (seuls les champs présents seront mis à jour) - + Returns: Fournisseur modifié """ - return self._post("/sage/fournisseurs/update", { - "code": code, - "fournisseur_data": fournisseur_data - }).get("data", {}) - + return self._post( + "/sage/fournisseurs/update", + {"code": code, "fournisseur_data": fournisseur_data}, + ).get("data", {}) + # ===================================================== # AVOIRS # ===================================================== @@ -357,7 +357,7 @@ class SageGatewayClient: return r.json() except: return {"status": "down"} - + def creer_client(self, client_data: Dict) -> Dict: """ Envoie la requête de création de client à la gateway Windows. @@ -365,48 +365,45 @@ class SageGatewayClient: """ # On appelle la route définie dans main.py return self._post("/sage/clients/create", client_data).get("data", {}) - + def modifier_client(self, code: str, client_data: Dict) -> Dict: """ ✏️ Modification d'un client existant - + Args: code: Code du client à modifier client_data: Dictionnaire contenant les champs à modifier (seuls les champs présents seront mis à jour) - + Returns: Client modifié """ - return self._post("/sage/clients/update", { - "code": code, - "client_data": client_data - }).get("data", {}) - - + return self._post( + "/sage/clients/update", {"code": code, "client_data": client_data} + ).get("data", {}) + def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: """ ✏️ Modification d'un devis existant - + Args: numero: Numéro du devis à modifier devis_data: Dictionnaire contenant les champs à modifier: - date_devis (str, optional): Nouvelle date - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - + Returns: Devis modifié avec totaux recalculés """ - return self._post("/sage/devis/update", { - "numero": numero, - "devis_data": devis_data - }).get("data", {}) - + return self._post( + "/sage/devis/update", {"numero": numero, "devis_data": devis_data} + ).get("data", {}) + def creer_commande(self, commande_data: Dict) -> Dict: """ ➕ Création d'une nouvelle commande (Bon de commande) - + Args: commande_data: Dictionnaire contenant: - client_id (str): Code du client @@ -417,17 +414,16 @@ class SageGatewayClient: - quantite (float) - prix_unitaire_ht (float, optional) - remise_pourcentage (float, optional) - + Returns: Commande créée avec son numéro et ses totaux """ return self._post("/sage/commandes/create", commande_data).get("data", {}) - - + def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: """ ✏️ Modification d'une commande existante - + Args: numero: Numéro de la commande à modifier commande_data: Dictionnaire contenant les champs à modifier: @@ -435,36 +431,33 @@ class SageGatewayClient: - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - reference (str, optional): Nouvelle référence - + Returns: Commande modifiée avec totaux recalculés """ - return self._post("/sage/commandes/update", { - "numero": numero, - "commande_data": commande_data - }).get("data", {}) - - + return self._post( + "/sage/commandes/update", {"numero": numero, "commande_data": commande_data} + ).get("data", {}) + def creer_livraison(self, livraison_data: Dict) -> Dict: """ ➕ Création d'une nouvelle livraison (Bon de livraison) """ return self._post("/sage/livraisons/create", livraison_data).get("data", {}) - def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: """ ✏️ Modification d'une livraison existante """ - return self._post("/sage/livraisons/update", { - "numero": numero, - "livraison_data": livraison_data - }).get("data", {}) - + return self._post( + "/sage/livraisons/update", + {"numero": numero, "livraison_data": livraison_data}, + ).get("data", {}) + def creer_avoir(self, avoir_data: Dict) -> Dict: """ ➕ Création d'un avoir (Bon d'avoir) - + Args: avoir_data: Dictionnaire contenant: - client_id (str): Code du client @@ -475,17 +468,16 @@ class SageGatewayClient: - quantite (float) - prix_unitaire_ht (float, optional) - remise_pourcentage (float, optional) - + Returns: Avoir créé avec son numéro et ses totaux """ return self._post("/sage/avoirs/create", avoir_data).get("data", {}) - def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: """ ✏️ Modification d'un avoir existant - + Args: numero: Numéro de l'avoir à modifier avoir_data: Dictionnaire contenant les champs à modifier: @@ -493,20 +485,18 @@ class SageGatewayClient: - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - reference (str, optional): Nouvelle référence - + Returns: Avoir modifié avec totaux recalculés """ - return self._post("/sage/avoirs/update", { - "numero": numero, - "avoir_data": avoir_data - }).get("data", {}) - - + return self._post( + "/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data} + ).get("data", {}) + def creer_facture(self, facture_data: Dict) -> Dict: """ ➕ Création d'une facture - + Args: facture_data: Dictionnaire contenant: - client_id (str): Code du client @@ -517,17 +507,16 @@ class SageGatewayClient: - quantite (float) - prix_unitaire_ht (float, optional) - remise_pourcentage (float, optional) - + Returns: Facture créée avec son numéro et ses totaux """ return self._post("/sage/factures/create", facture_data).get("data", {}) - def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: """ ✏️ Modification d'une facture existante - + Args: numero: Numéro de la facture à modifier facture_data: Dictionnaire contenant les champs à modifier: @@ -535,21 +524,20 @@ class SageGatewayClient: - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - reference (str, optional): Nouvelle référence - + Returns: Facture modifiée avec totaux recalculés """ - return self._post("/sage/factures/update", { - "numero": numero, - "facture_data": facture_data - }).get("data", {}) - + return self._post( + "/sage/factures/update", {"numero": numero, "facture_data": facture_data} + ).get("data", {}) + def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: """ 🆕 Génère le PDF d'un document via la gateway Windows (route généralisée) - + **Cette méthode remplace les appels spécifiques par type de document** - + Args: doc_id: Numéro du document (ex: "DE00001", "FA00001") type_doc: Type de document Sage: @@ -558,14 +546,14 @@ class SageGatewayClient: - 30: Bon de livraison - 60: Facture - 50: Bon d'avoir - + Returns: bytes: Contenu du PDF (binaire) - + Raises: ValueError: Si le PDF retourné est vide RuntimeError: Si erreur de communication avec la gateway - + Example: >>> pdf_bytes = sage_client.generer_pdf_document("DE00001", 0) >>> with open("devis.pdf", "wb") as f: @@ -573,59 +561,55 @@ class SageGatewayClient: """ try: logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") - + # Appel HTTP vers la gateway Windows r = requests.post( f"{self.url}/sage/documents/generate-pdf", - json={ - "doc_id": doc_id, - "type_doc": type_doc - }, + json={"doc_id": doc_id, "type_doc": type_doc}, headers=self.headers, - timeout=60 # Timeout élevé pour génération PDF + timeout=60, # Timeout élevé pour génération PDF ) - + r.raise_for_status() - + import base64 - + response_data = r.json() - + # Vérifier que la réponse contient bien le PDF if not response_data.get("success"): error_msg = response_data.get("error", "Erreur inconnue") raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}") - + pdf_base64 = response_data.get("data", {}).get("pdf_base64", "") - + if not pdf_base64: raise ValueError( f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})" ) - + # Décoder le base64 pdf_bytes = base64.b64decode(pdf_base64) - + logger.info(f"✅ PDF décodé: {len(pdf_bytes)} octets") - + return pdf_bytes - + except requests.exceptions.Timeout: logger.error(f"⏱️ Timeout génération PDF pour {doc_id}") raise RuntimeError( f"Timeout lors de la génération du PDF (>60s). " f"Le document {doc_id} est peut-être trop volumineux." ) - + except requests.exceptions.RequestException as e: logger.error(f"❌ Erreur HTTP génération PDF: {e}") raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}") - + except Exception as e: logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) raise - # Instance globale sage_client = SageGatewayClient() From 4434f0716fd6911f95a28f8f009d07edd59f76ee Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 18:03:47 +0300 Subject: [PATCH 051/199] feat: implement comprehensive user authentication including registration, login, email verification, password reset, and token management. --- config.py | 4 +- core/dependencies.py | 46 +++-- create_admin.py | 44 ++--- database/__init__.py | 39 ++-- database/db_config.py | 6 +- database/models.py | 137 ++++++++------ email_queue.py | 249 ++++++++++++------------- init_db.py | 22 +-- routes/auth.py | 370 ++++++++++++++++++-------------------- security/auth.py | 38 ++-- services/email_service.py | 60 +++---- 11 files changed, 504 insertions(+), 511 deletions(-) diff --git a/config.py b/config.py index 7e1c020..1b3125e 100644 --- a/config.py +++ b/config.py @@ -6,8 +6,8 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) - - # === JWT & Auth === + + # === JWT & Auth === jwt_secret: str jwt_algorithm: str access_token_expire_minutes: int diff --git a/core/dependencies.py b/core/dependencies.py index 48bb868..7f8a5f9 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -12,18 +12,18 @@ security = HTTPBearer() async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ) -> User: """ Dépendance FastAPI pour extraire l'utilisateur du JWT - + Usage dans un endpoint: @app.get("/protected") async def protected_route(user: User = Depends(get_current_user)): return {"user_id": user.id} """ token = credentials.credentials - + # Décoder le token payload = decode_token(token) if not payload: @@ -32,7 +32,7 @@ async def get_current_user( detail="Token invalide ou expiré", headers={"WWW-Authenticate": "Bearer"}, ) - + # Vérifier le type if payload.get("type") != "access": raise HTTPException( @@ -40,7 +40,7 @@ async def get_current_user( detail="Type de token incorrect", headers={"WWW-Authenticate": "Bearer"}, ) - + # Extraire user_id user_id: str = payload.get("sub") if not user_id: @@ -49,46 +49,43 @@ async def get_current_user( detail="Token malformé", headers={"WWW-Authenticate": "Bearer"}, ) - + # Charger l'utilisateur - result = await session.execute( - select(User).where(User.id == user_id) - ) + result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Utilisateur introuvable", headers={"WWW-Authenticate": "Bearer"}, ) - + # Vérifications de sécurité if not user.is_active: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte désactivé" + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" ) - + if not user.is_verified: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception." + detail="Email non vérifié. Consultez votre boîte de réception.", ) - + # ✅ FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!) if user.locked_until and user.locked_until > datetime.now(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé suite à trop de tentatives échouées" + detail="Compte temporairement verrouillé suite à trop de tentatives échouées", ) - + return user async def get_current_user_optional( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ) -> Optional[User]: """ Version optionnelle - ne lève pas d'erreur si pas de token @@ -96,7 +93,7 @@ async def get_current_user_optional( """ if not credentials: return None - + try: return await get_current_user(credentials, session) except HTTPException: @@ -106,18 +103,19 @@ async def get_current_user_optional( def require_role(*allowed_roles: str): """ Décorateur pour restreindre l'accès par rôle - + Usage: @app.get("/admin/users") async def list_users(user: User = Depends(require_role("admin"))): ... """ + async def role_checker(user: User = Depends(get_current_user)) -> User: if user.role not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}" + detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", ) return user - - return role_checker \ No newline at end of file + + return role_checker diff --git a/create_admin.py b/create_admin.py index a85b4df..41f11b7 100644 --- a/create_admin.py +++ b/create_admin.py @@ -25,29 +25,31 @@ logger = logging.getLogger(__name__) async def create_admin(): """Crée un utilisateur admin""" - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("🔐 Création d'un compte administrateur") - print("="*60 + "\n") - + print("=" * 60 + "\n") + # Saisie des informations email = input("Email de l'admin: ").strip().lower() - if not email or '@' not in email: + if not email or "@" not in email: print("❌ Email invalide") return False - + prenom = input("Prénom: ").strip() nom = input("Nom: ").strip() - + if not prenom or not nom: print("❌ Prénom et nom requis") return False - + # Mot de passe avec validation while True: - password = input("Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): ") + password = input( + "Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): " + ) is_valid, error_msg = validate_password_strength(password) - + if is_valid: confirm = input("Confirmez le mot de passe: ") if password == confirm: @@ -56,20 +58,18 @@ async def create_admin(): print("❌ Les mots de passe ne correspondent pas\n") else: print(f"❌ {error_msg}\n") - + # Vérifier si l'email existe déjà async with async_session_factory() as session: from sqlalchemy import select - - result = await session.execute( - select(User).where(User.email == email) - ) + + result = await session.execute(select(User).where(User.email == email)) existing = result.scalar_one_or_none() - + if existing: print(f"\n❌ Un utilisateur avec l'email {email} existe déjà") return False - + # Créer l'admin admin = User( id=str(uuid.uuid4()), @@ -80,19 +80,19 @@ async def create_admin(): role="admin", is_verified=True, # Admin vérifié par défaut is_active=True, - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(admin) await session.commit() - + print("\n✅ Administrateur créé avec succès!") print(f"📧 Email: {email}") print(f"👤 Nom: {prenom} {nom}") print(f"🔑 Rôle: admin") print(f"🆔 ID: {admin.id}") print("\n💡 Vous pouvez maintenant vous connecter à l'API\n") - + return True @@ -106,4 +106,4 @@ if __name__ == "__main__": except Exception as e: print(f"\n❌ Erreur: {e}") logger.exception("Détails:") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/database/__init__.py b/database/__init__.py index 0e2957a..579c644 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -3,7 +3,7 @@ from database.db_config import ( async_session_factory, init_db, get_session, - close_db + close_db, ) from database.models import ( @@ -23,26 +23,23 @@ from database.models import ( __all__ = [ # Config - 'engine', - 'async_session_factory', - 'init_db', - 'get_session', - 'close_db', - + "engine", + "async_session_factory", + "init_db", + "get_session", + "close_db", # Models existants - 'Base', - 'EmailLog', - 'SignatureLog', - 'WorkflowLog', - 'CacheMetadata', - 'AuditLog', - + "Base", + "EmailLog", + "SignatureLog", + "WorkflowLog", + "CacheMetadata", + "AuditLog", # Enums - 'StatutEmail', - 'StatutSignature', - + "StatutEmail", + "StatutSignature", # Modèles auth - 'User', - 'RefreshToken', - 'LoginAttempt', -] \ No newline at end of file + "User", + "RefreshToken", + "LoginAttempt", +] diff --git a/database/db_config.py b/database/db_config.py index 1973799..f5bc0b4 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -32,10 +32,10 @@ async def init_db(): try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - + logger.info("✅ Base de données initialisée avec succès") logger.info(f"📍 Fichier DB: {DATABASE_URL}") - + except Exception as e: logger.error(f"❌ Erreur initialisation DB: {e}") raise @@ -53,4 +53,4 @@ async def get_session() -> AsyncSession: async def close_db(): """Ferme proprement toutes les connexions""" await engine.dispose() - logger.info("🔌 Connexions DB fermées") \ No newline at end of file + logger.info("🔌 Connexions DB fermées") diff --git a/database/models.py b/database/models.py index 2c260ef..ff7c224 100644 --- a/database/models.py +++ b/database/models.py @@ -1,4 +1,13 @@ -from sqlalchemy import Column, Integer, String, DateTime, Float, Text, Boolean, Enum as SQLEnum +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Float, + Text, + Boolean, + Enum as SQLEnum, +) from sqlalchemy.ext.declarative import declarative_base from datetime import datetime import enum @@ -9,8 +18,10 @@ Base = declarative_base() # Enums # ============================================================================ + class StatutEmail(str, enum.Enum): """Statuts possibles d'un email""" + EN_ATTENTE = "EN_ATTENTE" EN_COURS = "EN_COURS" ENVOYE = "ENVOYE" @@ -18,54 +29,59 @@ class StatutEmail(str, enum.Enum): ERREUR = "ERREUR" BOUNCE = "BOUNCE" + class StatutSignature(str, enum.Enum): """Statuts possibles d'une signature électronique""" + EN_ATTENTE = "EN_ATTENTE" ENVOYE = "ENVOYE" SIGNE = "SIGNE" REFUSE = "REFUSE" EXPIRE = "EXPIRE" + # ============================================================================ # Tables # ============================================================================ + class EmailLog(Base): """ Journal des emails envoyés via l'API Permet le suivi et le retry automatique """ + __tablename__ = "email_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Destinataires destinataire = Column(String(255), nullable=False, index=True) cc = Column(Text, nullable=True) # JSON stringifié cci = Column(Text, nullable=True) # JSON stringifié - + # Contenu sujet = Column(String(500), nullable=False) corps_html = Column(Text, nullable=False) - + # Documents attachés document_ids = Column(Text, nullable=True) # Séparés par virgules type_document = Column(Integer, nullable=True) - + # Statut statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) - + # Tracking temporel date_creation = Column(DateTime, default=datetime.now, nullable=False) date_envoi = Column(DateTime, nullable=True) date_ouverture = Column(DateTime, nullable=True) - + # Retry automatique nb_tentatives = Column(Integer, default=0) derniere_erreur = Column(Text, nullable=True) prochain_retry = Column(DateTime, nullable=True) - + # Métadonnées ip_envoi = Column(String(45), nullable=True) user_agent = Column(String(500), nullable=True) @@ -79,33 +95,36 @@ class SignatureLog(Base): Journal des demandes de signature Universign Permet le suivi du workflow de signature """ + __tablename__ = "signature_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Document Sage associé document_id = Column(String(100), nullable=False, index=True) type_document = Column(Integer, nullable=False) - + # Universign transaction_id = Column(String(100), unique=True, index=True, nullable=True) signer_url = Column(String(500), nullable=True) - + # Signataire email_signataire = Column(String(255), nullable=False, index=True) nom_signataire = Column(String(255), nullable=False) - + # Statut - statut = Column(SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True) + statut = Column( + SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True + ) date_envoi = Column(DateTime, default=datetime.now) date_signature = Column(DateTime, nullable=True) date_refus = Column(DateTime, nullable=True) - + # Relances est_relance = Column(Boolean, default=False) nb_relances = Column(Integer, default=0) - + # Métadonnées raison_refus = Column(Text, nullable=True) ip_signature = Column(String(45), nullable=True) @@ -119,27 +138,28 @@ class WorkflowLog(Base): Journal des transformations de documents (Devis → Commande → Facture) Permet la traçabilité du workflow commercial """ + __tablename__ = "workflow_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Documents document_source = Column(String(100), nullable=False, index=True) type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc. - + document_cible = Column(String(100), nullable=False, index=True) type_cible = Column(Integer, nullable=False) - + # Métadonnées de transformation nb_lignes = Column(Integer, nullable=True) montant_ht = Column(Float, nullable=True) montant_ttc = Column(Float, nullable=True) - + # Tracking date_transformation = Column(DateTime, default=datetime.now, nullable=False) utilisateur = Column(String(100), nullable=True) - + # Résultat succes = Column(Boolean, default=True) erreur = Column(Text, nullable=True) @@ -154,18 +174,21 @@ class CacheMetadata(Base): Métadonnées sur le cache Sage (clients, articles) Permet le monitoring du cache géré par la gateway Windows """ + __tablename__ = "cache_metadata" - + id = Column(Integer, primary_key=True, autoincrement=True) - + # Type de cache - cache_type = Column(String(50), unique=True, nullable=False) # 'clients' ou 'articles' - + cache_type = Column( + String(50), unique=True, nullable=False + ) # 'clients' ou 'articles' + # Statistiques last_refresh = Column(DateTime, default=datetime.now) item_count = Column(Integer, default=0) refresh_duration_ms = Column(Float, nullable=True) - + # Santé last_error = Column(Text, nullable=True) error_count = Column(Integer, default=0) @@ -179,66 +202,72 @@ class AuditLog(Base): Journal d'audit pour la sécurité et la conformité Trace toutes les actions importantes dans l'API """ + __tablename__ = "audit_logs" - + id = Column(Integer, primary_key=True, autoincrement=True) - + # Action - action = Column(String(100), nullable=False, index=True) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. + action = Column( + String(100), nullable=False, index=True + ) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. ressource_type = Column(String(50), nullable=True) # 'devis', 'facture', etc. ressource_id = Column(String(100), nullable=True, index=True) - + # Utilisateur (si authentification ajoutée plus tard) utilisateur = Column(String(100), nullable=True) ip_address = Column(String(45), nullable=True) - + # Résultat succes = Column(Boolean, default=True) details = Column(Text, nullable=True) # JSON stringifié erreur = Column(Text, nullable=True) - + # Timestamp date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) def __repr__(self): return f"" - + + # Ajouter ces modèles à la fin de database/models.py + class User(Base): """ Utilisateurs de l'API avec validation email """ + __tablename__ = "users" - + id = Column(String(36), primary_key=True) email = Column(String(255), unique=True, nullable=False, index=True) hashed_password = Column(String(255), nullable=False) - + # Profil nom = Column(String(100), nullable=False) prenom = Column(String(100), nullable=False) role = Column(String(50), default="user") # user, admin, commercial - + # Validation email is_verified = Column(Boolean, default=False) verification_token = Column(String(255), nullable=True, unique=True, index=True) verification_token_expires = Column(DateTime, nullable=True) - + # Sécurité is_active = Column(Boolean, default=True) failed_login_attempts = Column(Integer, default=0) locked_until = Column(DateTime, nullable=True) - + # Mot de passe oublié reset_token = Column(String(255), nullable=True, unique=True, index=True) reset_token_expires = Column(DateTime, nullable=True) - + # Timestamps created_at = Column(DateTime, default=datetime.now, nullable=False) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) last_login = Column(DateTime, nullable=True) - + def __repr__(self): return f"" @@ -247,24 +276,25 @@ class RefreshToken(Base): """ Tokens de rafraîchissement JWT """ + __tablename__ = "refresh_tokens" - + id = Column(String(36), primary_key=True) user_id = Column(String(36), nullable=False, index=True) token_hash = Column(String(255), nullable=False, unique=True, index=True) - + # Métadonnées device_info = Column(String(500), nullable=True) ip_address = Column(String(45), nullable=True) - + # Expiration expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False) - + # Révocation is_revoked = Column(Boolean, default=False) revoked_at = Column(DateTime, nullable=True) - + def __repr__(self): return f"" @@ -273,18 +303,19 @@ class LoginAttempt(Base): """ Journal des tentatives de connexion (détection bruteforce) """ + __tablename__ = "login_attempts" - + id = Column(Integer, primary_key=True, autoincrement=True) - + email = Column(String(255), nullable=False, index=True) ip_address = Column(String(45), nullable=False, index=True) user_agent = Column(String(500), nullable=True) - + success = Column(Boolean, default=False) failure_reason = Column(String(255), nullable=True) - + timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True) - + def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/email_queue.py b/email_queue.py index beda62e..53d65af 100644 --- a/email_queue.py +++ b/email_queue.py @@ -25,67 +25,65 @@ class EmailQueue: """ Queue d'emails avec workers threadés et retry automatique """ - + def __init__(self): self.queue = queue.Queue() self.workers = [] self.running = False self.session_factory = None self.sage_client = None - + def start(self, num_workers: int = 3): """Démarre les workers""" if self.running: logger.warning("Queue déjà démarrée") return - + self.running = True for i in range(num_workers): worker = threading.Thread( - target=self._worker, - name=f"EmailWorker-{i}", - daemon=True + target=self._worker, name=f"EmailWorker-{i}", daemon=True ) worker.start() self.workers.append(worker) - + logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)") - + def stop(self): """Arrête les workers proprement""" logger.info("🛑 Arrêt de la queue email...") self.running = False - + # Attendre que la queue soit vide (max 30s) try: self.queue.join() logger.info("✅ Queue email arrêtée proprement") except: logger.warning("⚠️ Timeout lors de l'arrêt de la queue") - + def enqueue(self, email_log_id: str): """Ajoute un email dans la queue""" self.queue.put(email_log_id) logger.debug(f"📨 Email {email_log_id} ajouté à la queue") - + def _worker(self): """Worker qui traite les emails dans un thread""" # Créer une event loop pour ce thread loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: while self.running: try: # Récupérer un email de la queue (timeout 1s) email_log_id = self.queue.get(timeout=1) - + # Traiter l'email loop.run_until_complete(self._process_email(email_log_id)) - + # Marquer comme traité self.queue.task_done() - + except queue.Empty: continue except Exception as e: @@ -96,144 +94,147 @@ class EmailQueue: pass finally: loop.close() - + async def _process_email(self, email_log_id: str): """Traite un email avec retry automatique""" from database import EmailLog, StatutEmail from sqlalchemy import select - + if not self.session_factory: logger.error("❌ session_factory non configuré") return - + async with self.session_factory() as session: # Charger l'email log result = await session.execute( select(EmailLog).where(EmailLog.id == email_log_id) ) email_log = result.scalar_one_or_none() - + if not email_log: logger.error(f"❌ Email log {email_log_id} introuvable") return - + # Marquer comme en cours email_log.statut = StatutEmail.EN_COURS email_log.nb_tentatives += 1 await session.commit() - + try: # Envoi avec retry automatique await self._send_with_retry(email_log) - + # Succès email_log.statut = StatutEmail.ENVOYE email_log.date_envoi = datetime.now() email_log.derniere_erreur = None logger.info(f"✅ Email envoyé: {email_log.destinataire}") - + except Exception as e: # Échec email_log.statut = StatutEmail.ERREUR email_log.derniere_erreur = str(e)[:1000] # Limiter la taille - + # Programmer un retry si < max attempts if email_log.nb_tentatives < settings.max_retry_attempts: - delay = settings.retry_delay_seconds * (2 ** (email_log.nb_tentatives - 1)) + delay = settings.retry_delay_seconds * ( + 2 ** (email_log.nb_tentatives - 1) + ) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) - + # Programmer le retry timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer.daemon = True timer.start() - - logger.warning(f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}") + + logger.warning( + f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}" + ) else: logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}") - + await session.commit() - + @retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=4, max=10) + stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10) ) async def _send_with_retry(self, email_log): """Envoi SMTP avec retry Tenacity + génération PDF""" # Préparer le message msg = MIMEMultipart() - msg['From'] = settings.smtp_from - msg['To'] = email_log.destinataire - msg['Subject'] = email_log.sujet - + msg["From"] = settings.smtp_from + msg["To"] = email_log.destinataire + msg["Subject"] = email_log.sujet + # Corps HTML - msg.attach(MIMEText(email_log.corps_html, 'html')) - + msg.attach(MIMEText(email_log.corps_html, "html")) + # 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs if email_log.document_ids: - document_ids = email_log.document_ids.split(',') + document_ids = email_log.document_ids.split(",") type_doc = email_log.type_document - + for doc_id in document_ids: doc_id = doc_id.strip() if not doc_id: continue - + try: # Générer PDF (appel bloquant dans thread séparé) pdf_bytes = await asyncio.to_thread( - self._generate_pdf, - doc_id, - type_doc + self._generate_pdf, doc_id, type_doc ) - + if pdf_bytes: # Attacher PDF part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf") - part['Content-Disposition'] = f'attachment; filename="{doc_id}.pdf"' + part["Content-Disposition"] = ( + f'attachment; filename="{doc_id}.pdf"' + ) msg.attach(part) logger.info(f"📎 PDF attaché: {doc_id}.pdf") - + except Exception as e: logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") # Continuer avec les autres PDFs - + # Envoi SMTP (bloquant mais dans thread séparé) await asyncio.to_thread(self._send_smtp, msg) - + def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: """ Génération PDF via ReportLab + sage_client - + ⚠️ Cette méthode est appelée depuis un thread worker """ from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib.units import cm from io import BytesIO - + if not self.sage_client: logger.error("❌ sage_client non configuré") raise Exception("sage_client non disponible") - + # 📡 Récupérer document depuis gateway Windows via HTTP try: doc = self.sage_client.lire_document(doc_id, type_doc) except Exception as e: logger.error(f"❌ Erreur récupération document {doc_id}: {e}") raise Exception(f"Document {doc_id} inaccessible") - + if not doc: raise Exception(f"Document {doc_id} introuvable") - + # 📄 Créer PDF avec ReportLab buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 - + # === EN-TÊTE === pdf.setFont("Helvetica-Bold", 20) - pdf.drawString(2*cm, height - 3*cm, f"Document N° {doc_id}") - + pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}") + # Type de document type_labels = { 0: "DEVIS", @@ -241,101 +242,105 @@ class EmailQueue: 2: "BON DE RETOUR", 3: "COMMANDE", 4: "PRÉPARATION", - 5: "FACTURE" + 5: "FACTURE", } type_label = type_labels.get(type_doc, "DOCUMENT") - + pdf.setFont("Helvetica", 12) - pdf.drawString(2*cm, height - 4*cm, f"Type: {type_label}") - + pdf.drawString(2 * cm, height - 4 * cm, f"Type: {type_label}") + # === INFORMATIONS CLIENT === - y = height - 5*cm + y = height - 5 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "CLIENT") - - y -= 0.8*cm + pdf.drawString(2 * cm, y, "CLIENT") + + y -= 0.8 * cm pdf.setFont("Helvetica", 11) - pdf.drawString(2*cm, y, f"Code: {doc.get('client_code', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Nom: {doc.get('client_intitule', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Date: {doc.get('date', '')}") - + pdf.drawString(2 * cm, y, f"Code: {doc.get('client_code', '')}") + y -= 0.6 * cm + pdf.drawString(2 * cm, y, f"Nom: {doc.get('client_intitule', '')}") + y -= 0.6 * cm + pdf.drawString(2 * cm, y, f"Date: {doc.get('date', '')}") + # === LIGNES === - y -= 1.5*cm + y -= 1.5 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "ARTICLES") - - y -= 1*cm + pdf.drawString(2 * cm, y, "ARTICLES") + + y -= 1 * cm pdf.setFont("Helvetica-Bold", 10) - pdf.drawString(2*cm, y, "Désignation") - pdf.drawString(10*cm, y, "Qté") - pdf.drawString(12*cm, y, "Prix Unit.") - pdf.drawString(15*cm, y, "Total HT") - - y -= 0.5*cm - pdf.line(2*cm, y, width - 2*cm, y) - - y -= 0.7*cm + pdf.drawString(2 * cm, y, "Désignation") + pdf.drawString(10 * cm, y, "Qté") + pdf.drawString(12 * cm, y, "Prix Unit.") + pdf.drawString(15 * cm, y, "Total HT") + + y -= 0.5 * cm + pdf.line(2 * cm, y, width - 2 * cm, y) + + y -= 0.7 * cm pdf.setFont("Helvetica", 9) - - for ligne in doc.get('lignes', []): + + for ligne in doc.get("lignes", []): # Nouvelle page si nécessaire - if y < 3*cm: + if y < 3 * cm: pdf.showPage() - y = height - 3*cm + y = height - 3 * cm pdf.setFont("Helvetica", 9) - - designation = ligne.get('designation', '')[:50] - pdf.drawString(2*cm, y, designation) - pdf.drawString(10*cm, y, str(ligne.get('quantite', 0))) - pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€") - pdf.drawString(15*cm, y, f"{ligne.get('montant_ht', 0):.2f}€") - y -= 0.6*cm - + + designation = ligne.get("designation", "")[:50] + pdf.drawString(2 * cm, y, designation) + pdf.drawString(10 * cm, y, str(ligne.get("quantite", 0))) + pdf.drawString(12 * cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€") + pdf.drawString(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}€") + y -= 0.6 * cm + # === TOTAUX === - y -= 1*cm - pdf.line(12*cm, y, width - 2*cm, y) - - y -= 0.8*cm + y -= 1 * cm + pdf.line(12 * cm, y, width - 2 * cm, y) + + y -= 0.8 * cm pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(12*cm, y, "Total HT:") - pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}€") - - y -= 0.6*cm - pdf.drawString(12*cm, y, "TVA (20%):") - tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0) - pdf.drawString(15*cm, y, f"{tva:.2f}€") - - y -= 0.6*cm + pdf.drawString(12 * cm, y, "Total HT:") + pdf.drawString(15 * cm, y, f"{doc.get('total_ht', 0):.2f}€") + + y -= 0.6 * cm + pdf.drawString(12 * cm, y, "TVA (20%):") + tva = doc.get("total_ttc", 0) - doc.get("total_ht", 0) + pdf.drawString(15 * cm, y, f"{tva:.2f}€") + + y -= 0.6 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(12*cm, y, "Total TTC:") - pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}€") - + pdf.drawString(12 * cm, y, "Total TTC:") + pdf.drawString(15 * cm, y, f"{doc.get('total_ttc', 0):.2f}€") + # === PIED DE PAGE === pdf.setFont("Helvetica", 8) - pdf.drawString(2*cm, 2*cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}") - pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven") - + pdf.drawString( + 2 * cm, 2 * cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}" + ) + pdf.drawString(2 * cm, 1.5 * cm, "Sage 100c - API Dataven") + # Finaliser pdf.save() buffer.seek(0) - + logger.info(f"✅ PDF généré: {doc_id}.pdf") return buffer.read() - + def _send_smtp(self, msg): """Envoi SMTP bloquant (appelé depuis asyncio.to_thread)""" try: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: + with smtplib.SMTP( + settings.smtp_host, settings.smtp_port, timeout=30 + ) as server: if settings.smtp_use_tls: server.starttls() - + if settings.smtp_user and settings.smtp_password: server.login(settings.smtp_user, settings.smtp_password) - + server.send_message(msg) - + except smtplib.SMTPException as e: raise Exception(f"Erreur SMTP: {str(e)}") except Exception as e: @@ -343,4 +348,4 @@ class EmailQueue: # Instance globale -email_queue = EmailQueue() \ No newline at end of file +email_queue = EmailQueue() diff --git a/init_db.py b/init_db.py index b59d822..7f5c174 100644 --- a/init_db.py +++ b/init_db.py @@ -23,35 +23,35 @@ logger = logging.getLogger(__name__) async def main(): """Crée toutes les tables dans sage_dataven.db""" - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("🚀 Initialisation de la base de données Sage Dataven") - print("="*60 + "\n") - + print("=" * 60 + "\n") + try: # Créer les tables await init_db() - + print("\n✅ Base de données créée avec succès!") print(f"📍 Fichier: sage_dataven.db") - + print("\n📊 Tables créées:") print(" ├─ email_logs (Journalisation emails)") print(" ├─ signature_logs (Suivi signatures Universign)") print(" ├─ workflow_logs (Transformations documents)") print(" ├─ cache_metadata (Métadonnées cache)") print(" └─ audit_logs (Journal d'audit)") - + print("\n📝 Prochaines étapes:") print(" 1. Configurer le fichier .env avec vos credentials") print(" 2. Lancer la gateway Windows sur la machine Sage") print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000") print(" 4. Ou avec Docker: docker-compose up -d") print(" 5. Tester: http://votre-vps:8000/docs") - - print("\n" + "="*60 + "\n") + + print("\n" + "=" * 60 + "\n") return True - + except Exception as e: print(f"\n❌ Erreur lors de l'initialisation: {e}") logger.exception("Détails de l'erreur:") @@ -60,4 +60,4 @@ async def main(): if __name__ == "__main__": result = asyncio.run(main()) - sys.exit(0 if result else 1) \ No newline at end of file + sys.exit(0 if result else 1) diff --git a/routes/auth.py b/routes/auth.py index 771cb38..3d682e0 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -16,7 +16,7 @@ from security.auth import ( decode_token, generate_verification_token, generate_reset_token, - hash_token + hash_token, ) from services.email_service import AuthEmailService from core.dependencies import get_current_user @@ -29,6 +29,7 @@ router = APIRouter(prefix="/auth", tags=["Authentication"]) # === MODÈLES PYDANTIC === + class RegisterRequest(BaseModel): email: EmailStr password: str = Field(..., min_length=8) @@ -71,13 +72,14 @@ class ResendVerificationRequest(BaseModel): # === UTILITAIRES === + async def log_login_attempt( session: AsyncSession, email: str, ip: str, user_agent: str, success: bool, - failure_reason: Optional[str] = None + failure_reason: Optional[str] = None, ): """Enregistre une tentative de connexion""" attempt = LoginAttempt( @@ -86,76 +88,72 @@ async def log_login_attempt( user_agent=user_agent, success=success, failure_reason=failure_reason, - timestamp=datetime.now() + timestamp=datetime.now(), ) session.add(attempt) await session.commit() -async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[bool, str]: +async def check_rate_limit( + session: AsyncSession, email: str, ip: str +) -> tuple[bool, str]: """ Vérifie si l'utilisateur/IP est rate limité - + Returns: (is_allowed, error_message) """ # Vérifier les tentatives échouées des 15 dernières minutes time_window = datetime.now() - timedelta(minutes=15) - + result = await session.execute( - select(LoginAttempt) - .where( + select(LoginAttempt).where( LoginAttempt.email == email, LoginAttempt.success == False, - LoginAttempt.timestamp >= time_window + LoginAttempt.timestamp >= time_window, ) ) failed_attempts = result.scalars().all() - + if len(failed_attempts) >= 5: return False, "Trop de tentatives échouées. Réessayez dans 15 minutes." - + return True, "" # === ENDPOINTS === + @router.post("/register", status_code=status.HTTP_201_CREATED) async def register( data: RegisterRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 📝 Inscription d'un nouvel utilisateur - + - Valide le mot de passe - Crée le compte (non vérifié) - Envoie email de vérification """ # Vérifier si l'email existe déjà - result = await session.execute( - select(User).where(User.email == data.email) - ) + result = await session.execute(select(User).where(User.email == data.email)) existing_user = result.scalar_one_or_none() - + if existing_user: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cet email est déjà utilisé" + status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé" ) - + # Valider le mot de passe is_valid, error_msg = validate_password_strength(data.password) if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_msg - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + # Générer token de vérification verification_token = generate_verification_token() - + # Créer l'utilisateur new_user = User( id=str(uuid.uuid4()), @@ -166,80 +164,72 @@ async def register( is_verified=False, verification_token=verification_token, verification_token_expires=datetime.now() + timedelta(hours=24), - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(new_user) await session.commit() - + # Envoyer email de vérification - base_url = str(request.base_url).rstrip('/') + base_url = str(request.base_url).rstrip("/") email_sent = AuthEmailService.send_verification_email( - data.email, - verification_token, - base_url + data.email, verification_token, base_url ) - + if not email_sent: logger.warning(f"Échec envoi email vérification pour {data.email}") - + logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") - + return { "success": True, "message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.", "user_id": new_user.id, - "email": data.email + "email": data.email, } @router.get("/verify-email") -async def verify_email_get( - token: str, - session: AsyncSession = Depends(get_session) -): +async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): """ ✅ Vérification de l'email via lien cliquable (GET) Utilisé quand l'utilisateur clique sur le lien dans l'email """ - result = await session.execute( - select(User).where(User.verification_token == token) - ) + result = await session.execute(select(User).where(User.verification_token == token)) user = result.scalar_one_or_none() - + if not user: return { "success": False, - "message": "Token de vérification invalide ou déjà utilisé." + "message": "Token de vérification invalide ou déjà utilisé.", } - + # Vérifier l'expiration if user.verification_token_expires < datetime.now(): return { "success": False, "message": "Token expiré. Veuillez demander un nouvel email de vérification.", - "expired": True + "expired": True, } - + # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None await session.commit() - + logger.info(f"✅ Email vérifié: {user.email}") - + return { "success": True, "message": "✅ Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", - "email": user.email + "email": user.email, } @router.post("/verify-email") async def verify_email_post( - data: VerifyEmailRequest, - session: AsyncSession = Depends(get_session) + data: VerifyEmailRequest, session: AsyncSession = Depends(get_session) ): """ ✅ Vérification de l'email via API (POST) @@ -249,31 +239,31 @@ async def verify_email_post( select(User).where(User.verification_token == data.token) ) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token de vérification invalide" + detail="Token de vérification invalide", ) - + # Vérifier l'expiration if user.verification_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token expiré. Demandez un nouvel email de vérification." + detail="Token expiré. Demandez un nouvel email de vérification.", ) - + # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None await session.commit() - + logger.info(f"✅ Email vérifié: {user.email}") - + return { "success": True, - "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter." + "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", } @@ -281,135 +271,134 @@ async def verify_email_post( async def resend_verification( data: ResendVerificationRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 🔄 Renvoyer l'email de vérification """ - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + if not user: # Ne pas révéler si l'utilisateur existe return { "success": True, - "message": "Si cet email existe, un nouveau lien de vérification a été envoyé." + "message": "Si cet email existe, un nouveau lien de vérification a été envoyé.", } - + if user.is_verified: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Ce compte est déjà vérifié" + status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié" ) - + # Générer nouveau token verification_token = generate_verification_token() user.verification_token = verification_token user.verification_token_expires = datetime.now() + timedelta(hours=24) await session.commit() - + # Envoyer email - base_url = str(request.base_url).rstrip('/') - AuthEmailService.send_verification_email( - user.email, - verification_token, - base_url - ) - - return { - "success": True, - "message": "Un nouveau lien de vérification a été envoyé." - } + base_url = str(request.base_url).rstrip("/") + AuthEmailService.send_verification_email(user.email, verification_token, base_url) + + return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."} @router.post("/login", response_model=TokenResponse) async def login( - data: LoginRequest, - request: Request, - session: AsyncSession = Depends(get_session) + data: LoginRequest, request: Request, session: AsyncSession = Depends(get_session) ): """ 🔐 Connexion utilisateur - + Retourne access_token (30min) et refresh_token (7 jours) """ ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") - + # Rate limiting is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) if not is_allowed: raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=error_msg + status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg ) - + # Charger l'utilisateur - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + # Vérifications if not user or not verify_password(data.password, user.hashed_password): - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Identifiants incorrects") - + await log_login_attempt( + session, + data.email.lower(), + ip, + user_agent, + False, + "Identifiants incorrects", + ) + # Incrémenter compteur échecs if user: user.failed_login_attempts += 1 - + # Verrouiller après 5 échecs if user.failed_login_attempts >= 5: user.locked_until = datetime.now() + timedelta(minutes=15) await session.commit() raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes." + detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes.", ) - + await session.commit() - + raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Email ou mot de passe incorrect" + detail="Email ou mot de passe incorrect", ) - + # Vérifier statut compte if not user.is_active: - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte désactivé") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte désactivé" + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte désactivé" ) - + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" + ) + if not user.is_verified: - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Email non vérifié") + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Email non vérifié" + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception." + detail="Email non vérifié. Consultez votre boîte de réception.", ) - + # Vérifier verrouillage if user.locked_until and user.locked_until > datetime.now(): - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte verrouillé") + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte verrouillé" + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé" + detail="Compte temporairement verrouillé", ) - + # ✅ CONNEXION RÉUSSIE - + # Réinitialiser compteur échecs user.failed_login_attempts = 0 user.locked_until = None user.last_login = datetime.now() - + # Créer tokens - access_token = create_access_token({"sub": user.id, "email": user.email, "role": user.role}) + access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) refresh_token_jwt = create_refresh_token(user.id) - + # Stocker refresh token en DB (hashé) refresh_token_record = RefreshToken( id=str(uuid.uuid4()), @@ -418,28 +407,27 @@ async def login( device_info=user_agent[:500], ip_address=ip, expires_at=datetime.now() + timedelta(days=7), - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(refresh_token_record) await session.commit() - + # Logger succès await log_login_attempt(session, data.email.lower(), ip, user_agent, True) - + logger.info(f"✅ Connexion réussie: {user.email}") - + return TokenResponse( access_token=access_token, refresh_token=refresh_token_jwt, - expires_in=86400 # 30 minutes + expires_in=86400, # 30 minutes ) @router.post("/refresh", response_model=TokenResponse) async def refresh_access_token( - data: RefreshTokenRequest, - session: AsyncSession = Depends(get_session) + data: RefreshTokenRequest, session: AsyncSession = Depends(get_session) ): """ 🔄 Renouvellement du access_token via refresh_token @@ -448,61 +436,55 @@ async def refresh_access_token( payload = decode_token(data.refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token invalide" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide" ) - + user_id = payload.get("sub") token_hash = hash_token(data.refresh_token) - + # Vérifier en DB result = await session.execute( select(RefreshToken).where( RefreshToken.user_id == user_id, RefreshToken.token_hash == token_hash, - RefreshToken.is_revoked == False + RefreshToken.is_revoked == False, ) ) token_record = result.scalar_one_or_none() - + if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token révoqué ou introuvable" + detail="Refresh token révoqué ou introuvable", ) - + # Vérifier expiration if token_record.expires_at < datetime.now(): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token expiré" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré" ) - + # Charger utilisateur - result = await session.execute( - select(User).where(User.id == user_id) - ) + result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() - + if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Utilisateur introuvable ou désactivé" + detail="Utilisateur introuvable ou désactivé", ) - + # Générer nouveau access token - new_access_token = create_access_token({ - "sub": user.id, - "email": user.email, - "role": user.role - }) - + new_access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) + logger.info(f"🔄 Token rafraîchi: {user.email}") - + return TokenResponse( access_token=new_access_token, refresh_token=data.refresh_token, # Refresh token reste le même - expires_in=86400 + expires_in=86400, ) @@ -510,79 +492,71 @@ async def refresh_access_token( async def forgot_password( data: ForgotPasswordRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 🔑 Demande de réinitialisation de mot de passe """ - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + # Ne pas révéler si l'utilisateur existe if not user: return { "success": True, - "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } - + # Générer token de reset reset_token = generate_reset_token() user.reset_token = reset_token user.reset_token_expires = datetime.now() + timedelta(hours=1) await session.commit() - + # Envoyer email - frontend_url = settings.frontend_url if hasattr(settings, 'frontend_url') else str(request.base_url).rstrip('/') - AuthEmailService.send_password_reset_email( - user.email, - reset_token, - frontend_url + frontend_url = ( + settings.frontend_url + if hasattr(settings, "frontend_url") + else str(request.base_url).rstrip("/") ) - + AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url) + logger.info(f"📧 Reset password demandé: {user.email}") - + return { "success": True, - "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } @router.post("/reset-password") async def reset_password( - data: ResetPasswordRequest, - session: AsyncSession = Depends(get_session) + data: ResetPasswordRequest, session: AsyncSession = Depends(get_session) ): """ 🔐 Réinitialisation du mot de passe avec token """ - result = await session.execute( - select(User).where(User.reset_token == data.token) - ) + result = await session.execute(select(User).where(User.reset_token == data.token)) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token de réinitialisation invalide" + detail="Token de réinitialisation invalide", ) - + # Vérifier expiration if user.reset_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token expiré. Demandez un nouveau lien de réinitialisation." + detail="Token expiré. Demandez un nouveau lien de réinitialisation.", ) - + # Valider nouveau mot de passe is_valid, error_msg = validate_password_strength(data.new_password) if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_msg - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + # Mettre à jour user.hashed_password = hash_password(data.new_password) user.reset_token = None @@ -590,15 +564,15 @@ async def reset_password( user.failed_login_attempts = 0 user.locked_until = None await session.commit() - + # Envoyer notification AuthEmailService.send_password_changed_notification(user.email) - + logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") - + return { "success": True, - "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter." + "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.", } @@ -606,32 +580,28 @@ async def reset_password( async def logout( data: RefreshTokenRequest, session: AsyncSession = Depends(get_session), - user: User = Depends(get_current_user) + user: User = Depends(get_current_user), ): """ 🚪 Déconnexion (révocation du refresh token) """ token_hash = hash_token(data.refresh_token) - + result = await session.execute( select(RefreshToken).where( - RefreshToken.user_id == user.id, - RefreshToken.token_hash == token_hash + RefreshToken.user_id == user.id, RefreshToken.token_hash == token_hash ) ) token_record = result.scalar_one_or_none() - + if token_record: token_record.is_revoked = True token_record.revoked_at = datetime.now() await session.commit() - + logger.info(f"👋 Déconnexion: {user.email}") - - return { - "success": True, - "message": "Déconnexion réussie" - } + + return {"success": True, "message": "Déconnexion réussie"} @router.get("/me") @@ -647,5 +617,5 @@ async def get_current_user_info(user: User = Depends(get_current_user)): "role": user.role, "is_verified": user.is_verified, "created_at": user.created_at.isoformat(), - "last_login": user.last_login.isoformat() if user.last_login else None - } \ No newline at end of file + "last_login": user.last_login.isoformat() if user.last_login else None, + } diff --git a/security/auth.py b/security/auth.py index 9c5009d..7fc182c 100644 --- a/security/auth.py +++ b/security/auth.py @@ -45,24 +45,20 @@ def hash_token(token: str) -> str: def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: """ Crée un JWT access token - + Args: data: Payload (doit contenir 'sub' = user_id) expires_delta: Durée de validité personnalisée """ to_encode = data.copy() - + if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - - to_encode.update({ - "exp": expire, - "iat": datetime.utcnow(), - "type": "access" - }) - + + to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt @@ -70,20 +66,20 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) - def create_refresh_token(user_id: str) -> str: """ Crée un refresh token (JWT long terme) - + Returns: Token JWT non hashé (à hasher avant stockage DB) """ expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) - + to_encode = { "sub": user_id, "exp": expire, "iat": datetime.utcnow(), "type": "refresh", - "jti": secrets.token_urlsafe(16) # Unique ID + "jti": secrets.token_urlsafe(16), # Unique ID } - + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt @@ -91,7 +87,7 @@ def create_refresh_token(user_id: str) -> str: def decode_token(token: str) -> Optional[Dict]: """ Décode et valide un JWT - + Returns: Payload si valide, None sinon """ @@ -108,24 +104,24 @@ def decode_token(token: str) -> Optional[Dict]: def validate_password_strength(password: str) -> tuple[bool, str]: """ Valide la robustesse d'un mot de passe - + Returns: (is_valid, error_message) """ if len(password) < 8: return False, "Le mot de passe doit contenir au moins 8 caractères" - + if not any(c.isupper() for c in password): return False, "Le mot de passe doit contenir au moins une majuscule" - + if not any(c.islower() for c in password): return False, "Le mot de passe doit contenir au moins une minuscule" - + if not any(c.isdigit() for c in password): return False, "Le mot de passe doit contenir au moins un chiffre" - + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" if not any(c in special_chars for c in password): return False, "Le mot de passe doit contenir au moins un caractère spécial" - - return True, "" \ No newline at end of file + + return True, "" diff --git a/services/email_service.py b/services/email_service.py index 44152df..7bb7661 100644 --- a/services/email_service.py +++ b/services/email_service.py @@ -9,46 +9,48 @@ logger = logging.getLogger(__name__) class AuthEmailService: """Service d'envoi d'emails pour l'authentification""" - + @staticmethod def _send_email(to: str, subject: str, html_body: str) -> bool: """Envoi SMTP générique""" try: msg = MIMEMultipart() - msg['From'] = settings.smtp_from - msg['To'] = to - msg['Subject'] = subject - - msg.attach(MIMEText(html_body, 'html')) - - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: + msg["From"] = settings.smtp_from + msg["To"] = to + msg["Subject"] = subject + + msg.attach(MIMEText(html_body, "html")) + + with smtplib.SMTP( + settings.smtp_host, settings.smtp_port, timeout=30 + ) as server: if settings.smtp_use_tls: server.starttls() - + if settings.smtp_user and settings.smtp_password: server.login(settings.smtp_user, settings.smtp_password) - + server.send_message(msg) - + logger.info(f"✅ Email envoyé: {subject} → {to}") return True - + except Exception as e: logger.error(f"❌ Erreur envoi email: {e}") return False - + @staticmethod def send_verification_email(email: str, token: str, base_url: str) -> bool: """ Envoie l'email de vérification avec lien de confirmation - + Args: email: Email du destinataire token: Token de vérification base_url: URL de base de l'API (ex: https://api.votredomaine.com) """ verification_link = f"{base_url}/auth/verify-email?token={token}" - + html_body = f""" @@ -103,25 +105,23 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "🔐 Vérifiez votre adresse email - Sage Dataven", - html_body + email, "🔐 Vérifiez votre adresse email - Sage Dataven", html_body ) - + @staticmethod def send_password_reset_email(email: str, token: str, base_url: str) -> bool: """ Envoie l'email de réinitialisation de mot de passe - + Args: email: Email du destinataire token: Token de reset base_url: URL de base du frontend """ reset_link = f"{base_url}/reset?token={token}" - + html_body = f""" @@ -176,13 +176,11 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "🔐 Réinitialisation de votre mot de passe - Sage Dataven", - html_body + email, "🔐 Réinitialisation de votre mot de passe - Sage Dataven", html_body ) - + @staticmethod def send_password_changed_notification(email: str) -> bool: """Notification après changement de mot de passe réussi""" @@ -218,9 +216,7 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "✅ Votre mot de passe a été modifié - Sage Dataven", - html_body - ) \ No newline at end of file + email, "✅ Votre mot de passe a été modifié - Sage Dataven", html_body + ) From 732ccd2fd446b19e94dbadc7160c5b89e30a5fda Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 10:35:20 +0300 Subject: [PATCH 052/199] feat(api): extend client response model with detailed fields --- api.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index c5ec086..4a26f93 100644 --- a/api.py +++ b/api.py @@ -120,6 +120,51 @@ class ClientResponse(BaseModel): telephone: Optional[str] = None +class ClientDetails(ClientResponse): + type: int + qualite: str + est_prospect: bool + est_fournisseur: bool + est_actif: bool + est_en_sommeil: bool + + civilite: Optional[str] = None + nom: Optional[str] = None + prenom: Optional[str] = None + nom_complet: Optional[str] = None + contact: Optional[str] = None + + complement: Optional[str] = None + region: Optional[str] = None + pays: Optional[str] = None + + portable: Optional[str] = None + telecopie: Optional[str] = None + site_web: Optional[str] = None + + siret: Optional[str] = None + siren: Optional[str] = None + tva_intra: Optional[str] = None + code_naf: Optional[str] = None + forme_juridique: Optional[str] = None + + secteur: Optional[str] = None + effectif: Optional[int] = None + ca_annuel: Optional[float] = None + commercial_code: Optional[str] = None + commercial_nom: Optional[str] = None + + categorie_tarifaire: Optional[int] = None + categorie_comptable: Optional[int] = None + + encours_autorise: Optional[float] = None + assurance_credit: Optional[float] = None + compte_general: Optional[str] = None + + date_creation: Optional[str] = None + date_modification: Optional[str] = None + + class ArticleResponse(BaseModel): reference: str designation: str @@ -752,12 +797,12 @@ app.include_router(auth_router) # ===================================================== # ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) # ===================================================== -@app.get("/clients", response_model=List[ClientResponse], tags=["Clients"]) +@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def rechercher_clients(query: Optional[str] = Query(None)): """🔍 Recherche clients via gateway Windows""" try: clients = sage_client.lister_clients(filtre=query or "") - return [ClientResponse(**c) for c in clients] + return [ClientDetails(**c) for c in clients] except Exception as e: logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) From 60a9d909558cad99c3c19dcddd261d3d92210514 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 10:44:01 +0300 Subject: [PATCH 053/199] refactor(models): make client response fields optional --- api.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/api.py b/api.py index 4a26f93..0176513 100644 --- a/api.py +++ b/api.py @@ -111,8 +111,8 @@ class StatutEmail(str, Enum): # MODÈLES PYDANTIC # ===================================================== class ClientResponse(BaseModel): - numero: str - intitule: str + numero: Optional[str] = None + intitule: Optional[str] = None adresse: Optional[str] = None code_postal: Optional[str] = None ville: Optional[str] = None @@ -121,12 +121,12 @@ class ClientResponse(BaseModel): class ClientDetails(ClientResponse): - type: int - qualite: str - est_prospect: bool - est_fournisseur: bool - est_actif: bool - est_en_sommeil: bool + type: Optional[int] = None + qualite: Optional[str] = None + est_prospect: Optional[bool] = None + est_fournisseur: Optional[bool] = None + est_actif: Optional[bool] = None + est_en_sommeil: Optional[bool] = None civilite: Optional[str] = None nom: Optional[str] = None From 8f4c4f97a7f0894b7f4a5a4826621600fac60b18 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 11:20:01 +0300 Subject: [PATCH 054/199] refactor(models): improve client models structure and documentation --- api.py | 214 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 152 insertions(+), 62 deletions(-) diff --git a/api.py b/api.py index 0176513..9fdeade 100644 --- a/api.py +++ b/api.py @@ -111,59 +111,125 @@ class StatutEmail(str, Enum): # MODÈLES PYDANTIC # ===================================================== class ClientResponse(BaseModel): + """Modèle de réponse client simplifié (pour listes)""" numero: Optional[str] = None intitule: Optional[str] = None adresse: Optional[str] = None code_postal: Optional[str] = None ville: Optional[str] = None email: Optional[str] = None - telephone: Optional[str] = None + telephone: Optional[str] = None # Téléphone principal (fixe ou mobile) -class ClientDetails(ClientResponse): - type: Optional[int] = None - qualite: Optional[str] = None - est_prospect: Optional[bool] = None - est_fournisseur: Optional[bool] = None - est_actif: Optional[bool] = None - est_en_sommeil: Optional[bool] = None - - civilite: Optional[str] = None - nom: Optional[str] = None - prenom: Optional[str] = None - nom_complet: Optional[str] = None - contact: Optional[str] = None - - complement: Optional[str] = None - region: Optional[str] = None - pays: Optional[str] = None - - portable: Optional[str] = None - telecopie: Optional[str] = None - site_web: Optional[str] = None - - siret: Optional[str] = None - siren: Optional[str] = None - tva_intra: Optional[str] = None - code_naf: Optional[str] = None - forme_juridique: Optional[str] = None - - secteur: Optional[str] = None - effectif: Optional[int] = None - ca_annuel: Optional[float] = None - commercial_code: Optional[str] = None - commercial_nom: Optional[str] = None - - categorie_tarifaire: Optional[int] = None - categorie_comptable: Optional[int] = None - - encours_autorise: Optional[float] = None - assurance_credit: Optional[float] = None - compte_general: Optional[str] = None - - date_creation: Optional[str] = None - date_modification: Optional[str] = None - +class ClientDetails(BaseModel): + """Modèle de réponse client complet (pour GET /clients/{code})""" + + # === IDENTIFICATION === + numero: Optional[str] = Field(None, description="Code client (CT_Num)") + intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") + + # === TYPE DE TIERS === + type_tiers: Optional[str] = Field( + None, + description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'" + ) + qualite: Optional[str] = Field( + None, + description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)" + ) + est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") + est_fournisseur: Optional[bool] = Field(None, description="True si fournisseur (CT_Qualite=2 ou 3)") + + # === TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) === + forme_juridique: Optional[str] = Field( + None, + description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier" + ) + est_entreprise: Optional[bool] = Field( + None, + description="True si entreprise (forme_juridique renseignée)" + ) + est_particulier: Optional[bool] = Field( + None, + description="True si particulier (pas de forme juridique)" + ) + + # === STATUT === + est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") + est_en_sommeil: Optional[bool] = Field(None, description="True si en sommeil (CT_Sommeil=1)") + + # === IDENTITÉ (POUR PARTICULIERS) === + civilite: Optional[str] = Field(None, description="M., Mme, Mlle (CT_Civilite)") + nom: Optional[str] = Field(None, description="Nom de famille (CT_Nom)") + prenom: Optional[str] = Field(None, description="Prénom (CT_Prenom)") + nom_complet: Optional[str] = Field( + None, + description="Nom complet formaté : 'Civilité Prénom Nom'" + ) + + # === CONTACT === + contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") + + # === ADRESSE === + adresse: Optional[str] = Field(None, description="Adresse ligne 1") + complement: Optional[str] = Field(None, description="Complément d'adresse") + code_postal: Optional[str] = Field(None, description="Code postal") + ville: Optional[str] = Field(None, description="Ville") + region: Optional[str] = Field(None, description="Région/État") + pays: Optional[str] = Field(None, description="Pays") + + # === TÉLÉCOMMUNICATIONS === + telephone: Optional[str] = Field(None, description="Téléphone fixe") + portable: Optional[str] = Field(None, description="Téléphone mobile") + telecopie: Optional[str] = Field(None, description="Fax") + email: Optional[str] = Field(None, description="Email principal") + site_web: Optional[str] = Field(None, description="Site web") + + # === INFORMATIONS JURIDIQUES (ENTREPRISES) === + siret: Optional[str] = Field(None, description="N° SIRET (14 chiffres)") + siren: Optional[str] = Field(None, description="N° SIREN (9 chiffres)") + tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire") + code_naf: Optional[str] = Field(None, description="Code NAF/APE") + + # === INFORMATIONS COMMERCIALES === + secteur: Optional[str] = Field(None, description="Secteur d'activité") + effectif: Optional[int] = Field(None, description="Nombre d'employés") + ca_annuel: Optional[float] = Field(None, description="Chiffre d'affaires annuel") + commercial_code: Optional[str] = Field(None, description="Code du commercial rattaché") + commercial_nom: Optional[str] = Field(None, description="Nom du commercial") + + # === CATÉGORIES === + categorie_tarifaire: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") + categorie_comptable: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") + + # === INFORMATIONS FINANCIÈRES === + encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé") + assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit") + compte_general: Optional[str] = Field(None, description="Compte général principal") + + # === DATES === + date_creation: Optional[str] = Field(None, description="Date de création") + date_modification: Optional[str] = Field(None, description="Date de dernière modification") + + class Config: + json_schema_extra = { + "example": { + "numero": "CLI000001", + "intitule": "SARL EXEMPLE", + "type_tiers": "client", + "qualite": "CLI", + "est_entreprise": True, + "forme_juridique": "SARL", + "adresse": "123 Rue de la Paix", + "code_postal": "75001", + "ville": "Paris", + "telephone": "0123456789", + "portable": "0612345678", + "email": "contact@exemple.fr", + "siret": "12345678901234", + "tva_intra": "FR12345678901" + } + } class ArticleResponse(BaseModel): reference: str @@ -229,22 +295,47 @@ class BaremeRemiseResponse(BaseModel): class ClientCreateAPIRequest(BaseModel): - intitule: str = Field(..., min_length=1, description="Raison sociale ou Nom") - compte_collectif: str = Field("411000", description="Compte Comptable (ex: 411000)") - num: Optional[str] = Field(None, description="Code client souhaité (optionnel)") - adresse: Optional[str] = None - code_postal: Optional[str] = None - ville: Optional[str] = None - pays: Optional[str] = None + """Modèle pour création d'un nouveau client""" + + intitule: str = Field(..., min_length=1, max_length=69, description="Raison sociale ou Nom complet") + compte_collectif: str = Field("411000", description="Compte comptable (411000 par défaut)") + num: Optional[str] = Field(None, max_length=17, description="Code client souhaité (auto si vide)") + + # Adresse + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + + # Contact email: Optional[EmailStr] = None - telephone: Optional[str] = None - siret: Optional[str] = None - tva_intra: Optional[str] = None - + telephone: Optional[str] = Field(None, max_length=21, description="Téléphone fixe") + portable: Optional[str] = Field(None, max_length=21, description="Téléphone mobile") + + # Juridique + forme_juridique: Optional[str] = Field(None, max_length=50, description="SARL, SA, SAS, EI, etc.") + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "SARL NOUVELLE ENTREPRISE", + "forme_juridique": "SARL", + "adresse": "10 Avenue des Champs", + "code_postal": "75008", + "ville": "Paris", + "telephone": "0123456789", + "portable": "0612345678", + "email": "contact@nouvelle-entreprise.fr", + "siret": "12345678901234", + "tva_intra": "FR12345678901" + } + } class ClientUpdateRequest(BaseModel): """Modèle pour modification d'un client existant""" - + intitule: Optional[str] = Field(None, min_length=1, max_length=69) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) @@ -252,18 +343,17 @@ class ClientUpdateRequest(BaseModel): pays: Optional[str] = Field(None, max_length=35) email: Optional[EmailStr] = None telephone: Optional[str] = Field(None, max_length=21) + portable: Optional[str] = Field(None, max_length=21) + forme_juridique: Optional[str] = Field(None, max_length=50) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { - "intitule": "SARL TEST MODIFIÉ", - "adresse": "456 Avenue des Champs", - "code_postal": "75008", - "ville": "Paris", "email": "nouveau@email.fr", "telephone": "0198765432", + "portable": "0687654321" } } From 1c53135b62e5bbdf129ee3df9bb87d94c240cbdc Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 15:52:07 +0300 Subject: [PATCH 055/199] feat(api): enrich ArticleResponse model with additional fields --- api.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/api.py b/api.py index 9fdeade..cbee1eb 100644 --- a/api.py +++ b/api.py @@ -232,10 +232,65 @@ class ClientDetails(BaseModel): } class ArticleResponse(BaseModel): - reference: str - designation: str - prix_vente: float - stock_reel: float + """ + Modèle de réponse pour un article Sage + + ✅ ENRICHI avec tous les champs disponibles + """ + # === IDENTIFICATION === + reference: str = Field(..., description="Référence article (AR_Ref)") + designation: str = Field(..., description="Désignation principale (AR_Design)") + designation_complementaire: Optional[str] = Field(None, description="Désignation complémentaire") + + # === CODE EAN / CODE-BARRES === + code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") + code_barre: Optional[str] = Field(None, description="Code-barres (alias)") + + # === PRIX === + prix_vente: float = Field(..., description="Prix de vente HT") + prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") + prix_revient: Optional[float] = Field(None, description="Prix de revient") + + # === STOCK === + stock_reel: float = Field(..., description="Stock réel") + stock_mini: Optional[float] = Field(None, description="Stock minimum") + stock_maxi: Optional[float] = Field(None, description="Stock maximum") + stock_reserve: Optional[float] = Field(None, description="Stock réservé (en commande)") + stock_commande: Optional[float] = Field(None, description="Stock en commande fournisseur") + stock_disponible: Optional[float] = Field(None, description="Stock disponible (réel - réservé)") + + # === DESCRIPTIONS === + description: Optional[str] = Field(None, description="Description détaillée / Commentaire") + + # === CLASSIFICATION === + type_article: Optional[int] = Field(None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)") + type_article_libelle: Optional[str] = Field(None, description="Libellé du type") + famille_code: Optional[str] = Field(None, description="Code famille") + famille_libelle: Optional[str] = Field(None, description="Libellé famille") + + # === FOURNISSEUR PRINCIPAL === + fournisseur_principal: Optional[str] = Field(None, description="Code fournisseur principal") + fournisseur_nom: Optional[str] = Field(None, description="Nom fournisseur principal") + + # === UNITÉS === + unite_vente: Optional[str] = Field(None, description="Unité de vente") + unite_achat: Optional[str] = Field(None, description="Unité d'achat") + + # === CARACTÉRISTIQUES PHYSIQUES === + poids: Optional[float] = Field(None, description="Poids (kg)") + volume: Optional[float] = Field(None, description="Volume (m³)") + + # === STATUT === + est_actif: bool = Field(True, description="Article actif") + en_sommeil: bool = Field(False, description="Article en sommeil") + + # === TVA === + tva_code: Optional[str] = Field(None, description="Code TVA") + tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)") + + # === DATES === + date_creation: Optional[str] = Field(None, description="Date de création") + date_modification: Optional[str] = Field(None, description="Date de dernière modification") class LigneDevis(BaseModel): @@ -2889,13 +2944,11 @@ async def lire_prospect(code: str): async def rechercher_fournisseurs(query: Optional[str] = Query(None)): """ 🔍 Recherche fournisseurs via gateway Windows - ✅ CORRECTION : Appel direct sans cache """ try: - # ✅ APPEL DIRECT vers Windows (pas de cache) fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") - logger.info(f"✅ {len(fournisseurs)} fournisseurs retournés depuis Windows") + logger.info(f"✅ {len(fournisseurs)} fournisseurs") if len(fournisseurs) == 0: logger.warning("⚠️ Aucun fournisseur retourné - vérifier la gateway Windows") From 44354ec9bdd3b3fb48fc9e6a095e241d8d91eb4e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 9 Dec 2025 15:55:47 +0300 Subject: [PATCH 056/199] refactor(api): remove debug endpoints before production release --- api.py | 318 --------------------------------------------------------- 1 file changed, 318 deletions(-) diff --git a/api.py b/api.py index cbee1eb..cb8c80f 100644 --- a/api.py +++ b/api.py @@ -3793,324 +3793,6 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) raise HTTPException(500, str(e)) -@app.get("/debug/users/{user_id}", response_model=UserResponse, tags=["Debug"]) -async def lire_utilisateur_debug( - user_id: str, session: AsyncSession = Depends(get_session) -): - """ - 👤 **ROUTE DEBUG** - Détails d'un utilisateur par ID - - ⚠️ Non protégée - à sécuriser en production - """ - from database import User - from sqlalchemy import select - - try: - query = select(User).where(User.id == user_id) - result = await session.execute(query) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException(404, f"Utilisateur {user_id} introuvable") - - return UserResponse( - id=user.id, - email=user.email, - nom=user.nom, - prenom=user.prenom, - role=user.role, - is_verified=user.is_verified, - is_active=user.is_active, - created_at=user.created_at.isoformat() if user.created_at else "", - last_login=user.last_login.isoformat() if user.last_login else None, - failed_login_attempts=user.failed_login_attempts or 0, - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"❌ Erreur lecture utilisateur: {e}") - raise HTTPException(500, str(e)) - - -@app.get("/debug/database/check", tags=["Debug"]) -async def verifier_integrite_database(session: AsyncSession = Depends(get_session)): - """ - 🔍 Vérification de l'intégrité de la base de données - - Retourne des statistiques détaillées sur toutes les tables - """ - from database import User, RefreshToken, LoginAttempt, EmailLog, SignatureLog - from sqlalchemy import func, text - - try: - diagnostics = {} - - # === TABLE USERS === - # Compter tous les users - total_users = await session.execute(select(func.count(User.id))) - diagnostics["users"] = {"total": total_users.scalar(), "details": []} - - # Lister tous les users avec détails - all_users = await session.execute(select(User)) - users_list = all_users.scalars().all() - - for u in users_list: - diagnostics["users"]["details"].append( - { - "id": u.id, - "email": u.email, - "nom": f"{u.prenom} {u.nom}", - "role": u.role, - "is_active": u.is_active, - "is_verified": u.is_verified, - "created_at": u.created_at.isoformat() if u.created_at else None, - "has_reset_token": u.reset_token is not None, - "has_verification_token": u.verification_token is not None, - } - ) - - # === TABLE REFRESH_TOKENS === - total_tokens = await session.execute(select(func.count(RefreshToken.id))) - diagnostics["refresh_tokens"] = {"total": total_tokens.scalar()} - - # === TABLE LOGIN_ATTEMPTS === - total_attempts = await session.execute(select(func.count(LoginAttempt.id))) - diagnostics["login_attempts"] = {"total": total_attempts.scalar()} - - # === TABLE EMAIL_LOGS === - total_emails = await session.execute(select(func.count(EmailLog.id))) - diagnostics["email_logs"] = {"total": total_emails.scalar()} - - # === TABLE SIGNATURE_LOGS === - total_signatures = await session.execute(select(func.count(SignatureLog.id))) - diagnostics["signature_logs"] = {"total": total_signatures.scalar()} - - # === VÉRIFIER LES FICHIERS SQLITE === - import os - - db_file = "sage_dataven.db" - diagnostics["database_file"] = { - "exists": os.path.exists(db_file), - "size_bytes": os.path.getsize(db_file) if os.path.exists(db_file) else 0, - "path": os.path.abspath(db_file), - } - - # === TESTER UNE REQUÊTE RAW SQL === - try: - raw_count = await session.execute(text("SELECT COUNT(*) FROM users")) - diagnostics["raw_sql_check"] = { - "users_count": raw_count.scalar(), - "status": "✅ Connexion DB OK", - } - except Exception as e: - diagnostics["raw_sql_check"] = {"status": "❌ Erreur", "error": str(e)} - - return { - "success": True, - "timestamp": datetime.now().isoformat(), - "diagnostics": diagnostics, - } - - except Exception as e: - logger.error(f"❌ Erreur diagnostic DB: {e}", exc_info=True) - raise HTTPException(500, f"Erreur diagnostic: {str(e)}") - - -@app.post("/debug/database/test-user-persistence", tags=["Debug"]) -async def tester_persistance_utilisateur(session: AsyncSession = Depends(get_session)): - """ - 🧪 Test de création/lecture/modification d'un utilisateur de test - - Crée un utilisateur de test, le modifie, et vérifie la persistance - """ - import uuid - from database import User - from security.auth import hash_password - - try: - test_email = f"test_{uuid.uuid4().hex[:8]}@example.com" - - # === ÉTAPE 1: CRÉATION === - test_user = User( - id=str(uuid.uuid4()), - email=test_email, - hashed_password=hash_password("TestPassword123!"), - nom="Test", - prenom="User", - role="user", - is_verified=True, - is_active=True, - created_at=datetime.now(), - ) - - session.add(test_user) - await session.flush() - user_id = test_user.id - await session.commit() - - logger.info(f"✅ ÉTAPE 1: User créé - {user_id}") - - # === ÉTAPE 2: LECTURE === - result = await session.execute(select(User).where(User.id == user_id)) - loaded_user = result.scalar_one_or_none() - - if not loaded_user: - return { - "success": False, - "error": "❌ User introuvable après création !", - "step": "LECTURE", - } - - logger.info(f"✅ ÉTAPE 2: User chargé - {loaded_user.email}") - - # === ÉTAPE 3: MODIFICATION (simulate reset password) === - loaded_user.hashed_password = hash_password("NewPassword456!") - loaded_user.reset_token = None - loaded_user.reset_token_expires = None - - session.add(loaded_user) - await session.flush() - await session.commit() - await session.refresh(loaded_user) - - logger.info(f"✅ ÉTAPE 3: User modifié") - - # === ÉTAPE 4: RE-LECTURE === - result2 = await session.execute(select(User).where(User.id == user_id)) - reloaded_user = result2.scalar_one_or_none() - - if not reloaded_user: - return { - "success": False, - "error": "❌ User DISPARU après modification !", - "step": "RE-LECTURE", - "user_id": user_id, - } - - logger.info(f"✅ ÉTAPE 4: User re-chargé - {reloaded_user.email}") - - # === ÉTAPE 5: SUPPRESSION DU TEST === - await session.delete(reloaded_user) - await session.commit() - - logger.info(f"✅ ÉTAPE 5: User test supprimé") - - return { - "success": True, - "message": "✅ Tous les tests de persistance sont OK", - "test_user_id": user_id, - "test_email": test_email, - "steps_completed": [ - "1. Création", - "2. Lecture", - "3. Modification (reset password simulé)", - "4. Re-lecture (vérification persistance)", - "5. Suppression (cleanup)", - ], - } - - except Exception as e: - logger.error(f"❌ Erreur test persistance: {e}", exc_info=True) - - # Rollback en cas d'erreur - await session.rollback() - - return { - "success": False, - "error": str(e), - "traceback": str(e.__class__.__name__), - } - - -@app.get("/debug/fournisseurs/cache", tags=["Debug"]) -async def debug_cache_fournisseurs(): - """ - 🔍 Debug : État du cache côté VPS Linux - """ - try: - # Appeler la gateway Windows pour récupérer l'info cache - cache_info = sage_client.get_cache_info() - - # Tenter de lister les fournisseurs - try: - fournisseurs = sage_client.lister_fournisseurs(filtre="") - nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 - exemple = fournisseurs[:3] if fournisseurs else [] - except Exception as e: - nb_fournisseurs = -1 - exemple = [] - error = str(e) - - return { - "success": True, - "cache_info_windows": cache_info, - "test_liste_fournisseurs": { - "nb_fournisseurs": nb_fournisseurs, - "exemples": exemple, - "erreur": error if nb_fournisseurs == -1 else None, - }, - "diagnostic": { - "gateway_accessible": cache_info is not None, - "cache_fournisseurs_existe": ( - "fournisseurs" in cache_info if cache_info else False - ), - "probleme_probable": ( - "Cache fournisseurs non initialisé côté Windows" - if cache_info and "fournisseurs" not in cache_info - else ( - "OK" - if nb_fournisseurs > 0 - else "Erreur lors de la récupération" - ) - ), - }, - } - - except Exception as e: - logger.error(f"❌ Erreur debug cache: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -@app.post("/debug/fournisseurs/force-refresh", tags=["Debug"]) -async def force_refresh_fournisseurs(): - """ - 🔄 Force le refresh du cache fournisseurs côté Windows - """ - try: - # Appeler la gateway Windows pour forcer le refresh - resultat = sage_client.refresh_cache() - - # Attendre 2 secondes - import time - - time.sleep(2) - - # Récupérer le cache info après refresh - cache_info = sage_client.get_cache_info() - - # Tester la liste - fournisseurs = sage_client.lister_fournisseurs(filtre="") - nb_fournisseurs = len(fournisseurs) if fournisseurs else 0 - - return { - "success": True, - "refresh_result": resultat, - "cache_apres_refresh": cache_info, - "nb_fournisseurs_maintenant": nb_fournisseurs, - "exemples": fournisseurs[:3] if fournisseurs else [], - "message": ( - f"✅ Refresh OK : {nb_fournisseurs} fournisseurs disponibles" - if nb_fournisseurs > 0 - else "❌ Problème : aucun fournisseur après refresh" - ), - } - - except Exception as e: - logger.error(f"❌ Erreur force refresh: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - # ===================================================== # LANCEMENT # ===================================================== From 428093306adbbca87a32026bdac75b633104557d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 10 Dec 2025 17:01:49 +0300 Subject: [PATCH 057/199] feat(articles): add CRUD operations for articles management --- api.py | 145 +++++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 53 ++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/api.py b/api.py index cb8c80f..03f22e7 100644 --- a/api.py +++ b/api.py @@ -771,7 +771,32 @@ class FactureUpdateRequest(BaseModel): } } +class ArticleCreateRequest(BaseModel): + """Schéma pour création d'article""" + reference: str = Field(..., max_length=18, description="Référence article") + designation: str = Field(..., max_length=69, description="Désignation") + famille: Optional[str] = Field(None, max_length=18, description="Code famille") + prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT") + prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT") + stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial") + stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum") + code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres") + unite_vente: Optional[str] = Field("UN", max_length=4, description="Unité") + tva_code: Optional[str] = Field(None, max_length=5, description="Code TVA") + description: Optional[str] = Field(None, description="Description") + +class ArticleUpdateRequest(BaseModel): + """Schéma pour modification d'article""" + designation: Optional[str] = Field(None, max_length=69) + prix_vente: Optional[float] = Field(None, ge=0) + prix_achat: Optional[float] = Field(None, ge=0) + stock_reel: Optional[float] = Field(None, ge=0, description="⚠️ Critique pour erreur 2881") + stock_mini: Optional[float] = Field(None, ge=0) + code_ean: Optional[str] = Field(None, max_length=13) + description: Optional[str] = Field(None) + + # ===================================================== # SERVICES EXTERNES (Universign) # ===================================================== @@ -1061,7 +1086,127 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) +@router.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) +def creer_article(article: ArticleCreateDTO): + """ + ➕ Création d'un article dans Sage + + **Usage**: Créer un article avec stock pour éviter l'erreur 2881 + + **Erreurs possibles**: + - 400: Article existe déjà ou données invalides + - 500: Erreur Sage + """ + try: + resultat = sage_client.creer_article(article.dict(exclude_none=True)) + + logger.info(f"✅ Article créé: {resultat.get('reference')}") + + return { + "message": "Article créé avec succès", + "article": resultat + } + + except ValueError as e: + logger.warning(f"Erreur métier création article: {e}") + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) + + except Exception as e: + logger.error(f"Erreur création article: {e}") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + +@router.put("/articles/{reference}", tags=["Articles"]) +def modifier_article(reference: str, article: ArticleUpdateDTO): + """ + ✏️ Modification d'un article dans Sage + + **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 + + **Example** - Résoudre l'erreur "L'état du stock ne permet pas de créer la ligne": +```bash + curl -X PUT "http://api.example.com/api/articles/ART001" \ + -H "Content-Type: application/json" \ + -d '{"stock_reel": 100.0}' +``` + + **Erreurs possibles**: + - 404: Article introuvable + - 400: Données invalides + - 500: Erreur Sage + """ + try: + # Filtrer les champs None + article_data = article.dict(exclude_none=True) + + if not article_data: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Aucun champ à modifier" + ) + + resultat = sage_client.modifier_article(reference, article_data) + + logger.info(f"✅ Article {reference} modifié: {list(article_data.keys())}") + + return { + "message": f"Article {reference} modifié avec succès", + "article": resultat, + "champs_modifies": list(article_data.keys()) + } + + except ValueError as e: + logger.warning(f"Erreur métier modification article: {e}") + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) + + except Exception as e: + logger.error(f"Erreur modification article: {e}") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + + +@router.get("/articles/{reference}", tags=["Articles"]) +def lire_article(reference: str): + """ + 📄 Lecture d'un article par référence + + Retourne toutes les informations incluant le stock actuel + """ + try: + article = sage_client.lire_article(reference) + + if not article: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + f"Article {reference} introuvable" + ) + + return {"article": article} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture article: {e}") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + + +@router.get("/articles/all") +def lister_articles(filtre: str = ""): + """ + 📋 Liste tous les articles avec filtre optionnel + """ + try: + articles = sage_client.lister_articles(filtre) + + return { + "articles": articles, + "total": len(articles) + } + + except Exception as e: + logger.error(f"Erreur liste articles: {e}") + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + + @app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"]) async def creer_devis(devis: DevisRequest): """📝 Création de devis via gateway Windows""" diff --git a/sage_client.py b/sage_client.py index d03e009..9c1b2c4 100644 --- a/sage_client.py +++ b/sage_client.py @@ -611,5 +611,58 @@ class SageGatewayClient: raise + def creer_article(self, article_data: Dict) -> Dict: + """ + ➕ Création d'un article + + Args: + article_data: Dictionnaire contenant: + - reference (str, obligatoire): Référence article + - designation (str, obligatoire): Désignation + - prix_vente (float, optionnel): Prix vente HT + - stock_reel (float, optionnel): Stock initial + - ... (voir ArticleCreateRequest dans main.py) + + Returns: + Article créé + + Example: + >>> article = sage_client.creer_article({ + ... "reference": "ART001", + ... "designation": "Article test", + ... "prix_vente": 10.0, + ... "stock_reel": 100.0 + ... }) + """ + return self._post("/sage/articles/create", article_data).get("data", {}) + + + def modifier_article(self, reference: str, article_data: Dict) -> Dict: + """ + ✏️ Modification d'un article + + **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 + + Args: + reference: Référence de l'article à modifier + article_data: Dictionnaire contenant les champs à modifier: + - stock_reel (float, optionnel): Nouveau stock + - prix_vente (float, optionnel): Nouveau prix + - ... (seuls les champs présents seront mis à jour) + + Returns: + Article modifié + + Example - Résoudre erreur de stock: + >>> # L'erreur 2881 indique un stock insuffisant + >>> sage_client.modifier_article("ART001", { + ... "stock_reel": 100.0 # Augmenter le stock + ... }) + """ + return self._post( + "/sage/articles/update", + {"reference": reference, "article_data": article_data} + ).get("data", {}) + # Instance globale sage_client = SageGatewayClient() From a133172a0bb160ba9da14f586c4a8d4ebcfc15bd Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 10 Dec 2025 17:04:59 +0300 Subject: [PATCH 058/199] refactor(api): change router to app for article endpoints --- api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api.py b/api.py index 03f22e7..604f2ee 100644 --- a/api.py +++ b/api.py @@ -1086,7 +1086,7 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) -@router.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) +@app.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) def creer_article(article: ArticleCreateDTO): """ ➕ Création d'un article dans Sage @@ -1116,7 +1116,7 @@ def creer_article(article: ArticleCreateDTO): raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) -@router.put("/articles/{reference}", tags=["Articles"]) +@app.put("/articles/{reference}", tags=["Articles"]) def modifier_article(reference: str, article: ArticleUpdateDTO): """ ✏️ Modification d'un article dans Sage @@ -1164,7 +1164,7 @@ def modifier_article(reference: str, article: ArticleUpdateDTO): raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) -@router.get("/articles/{reference}", tags=["Articles"]) +@app.get("/articles/{reference}", tags=["Articles"]) def lire_article(reference: str): """ 📄 Lecture d'un article par référence @@ -1189,7 +1189,7 @@ def lire_article(reference: str): raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) -@router.get("/articles/all") +@app.get("/articles/all") def lister_articles(filtre: str = ""): """ 📋 Liste tous les articles avec filtre optionnel From 44675f69ace53a6be7471d469ffd6a00011e22a3 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 10 Dec 2025 17:06:39 +0300 Subject: [PATCH 059/199] refactor(api): rename DTO classes to Request for clarity --- api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 604f2ee..7f0f342 100644 --- a/api.py +++ b/api.py @@ -1087,7 +1087,7 @@ async def rechercher_articles(query: Optional[str] = Query(None)): raise HTTPException(500, str(e)) @app.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) -def creer_article(article: ArticleCreateDTO): +def creer_article(article: ArticleCreateRequest): """ ➕ Création d'un article dans Sage @@ -1117,7 +1117,7 @@ def creer_article(article: ArticleCreateDTO): @app.put("/articles/{reference}", tags=["Articles"]) -def modifier_article(reference: str, article: ArticleUpdateDTO): +def modifier_article(reference: str, article: ArticleUpdateRequest): """ ✏️ Modification d'un article dans Sage From 963118641b861f47f66dbe46f66b033df7b8e778 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 11 Dec 2025 11:49:17 +0300 Subject: [PATCH 060/199] fix: change exclude_none to exclude_unset in article creation/update --- api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 7f0f342..4bfea6b 100644 --- a/api.py +++ b/api.py @@ -1098,7 +1098,7 @@ def creer_article(article: ArticleCreateRequest): - 500: Erreur Sage """ try: - resultat = sage_client.creer_article(article.dict(exclude_none=True)) + resultat = sage_client.creer_article(article.dict(exclude_unset=True)) logger.info(f"✅ Article créé: {resultat.get('reference')}") @@ -1137,7 +1137,7 @@ def modifier_article(reference: str, article: ArticleUpdateRequest): """ try: # Filtrer les champs None - article_data = article.dict(exclude_none=True) + article_data = article.dict(exclude_unset=True) if not article_data: raise HTTPException( From e56159268f89dab2602268fdabc3758c857f0715 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 11 Dec 2025 12:01:54 +0300 Subject: [PATCH 061/199] feat(articles): enhance article endpoints with async support and validation --- api.py | 201 ++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 150 insertions(+), 51 deletions(-) diff --git a/api.py b/api.py index 4bfea6b..02e90d9 100644 --- a/api.py +++ b/api.py @@ -1086,108 +1086,207 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) -@app.post("/articles", status_code=status.HTTP_201_CREATED, tags=["Articles"]) -def creer_article(article: ArticleCreateRequest): +@app.post( + "/articles", + response_model=ArticleResponse, + status_code=status.HTTP_201_CREATED, + tags=["Articles"] +) +async def creer_article(article: ArticleCreateRequest): """ - ➕ Création d'un article dans Sage + ➕ Création d'un nouvel article dans Sage - **Usage**: Créer un article avec stock pour éviter l'erreur 2881 + **Usage typique:** Créer un article avec stock pour éviter l'erreur 2881 - **Erreurs possibles**: + **Champs obligatoires:** + - `reference` (max 18 caractères) : Référence unique de l'article + - `designation` (max 69 caractères) : Désignation de l'article + + **Champs optionnels mais recommandés:** + - `stock_reel` : Stock initial (important pour éviter erreurs de transformation) + - `prix_vente` : Prix de vente HT + - `unite_vente` : Unité de vente (défaut: "UN") + + **Erreurs possibles:** - 400: Article existe déjà ou données invalides - - 500: Erreur Sage + - 500: Erreur Sage (problème de connexion, champs mal formatés, etc.) + + **Exemple:** + ```json + { + "reference": "ART001", + "designation": "Article de test", + "prix_vente": 10.50, + "stock_reel": 100.0, + "stock_mini": 10.0, + "unite_vente": "UN", + "tva_code": "C20" + } + ``` """ try: - resultat = sage_client.creer_article(article.dict(exclude_unset=True)) + # Validation des données + if not article.reference or not article.designation: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Les champs 'reference' et 'designation' sont obligatoires" + ) - logger.info(f"✅ Article créé: {resultat.get('reference')}") + # ⚠️ CORRECTION: Ne pas utiliser exclude_none=True car on veut garder + # les valeurs par défaut (comme unite_vente="UN") + article_data = article.dict(exclude_unset=True) - return { - "message": "Article créé avec succès", - "article": resultat - } + logger.info(f"📝 Création article: {article.reference} - {article.designation}") + + # Appel à la gateway Windows + resultat = sage_client.creer_article(article_data) + + logger.info(f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})") + + return ArticleResponse(**resultat) except ValueError as e: - logger.warning(f"Erreur métier création article: {e}") - raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) + # Erreur métier (ex: article existe déjà) + logger.warning(f"⚠️ Erreur métier création article: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + except HTTPException: + raise except Exception as e: - logger.error(f"Erreur création article: {e}") - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + # Erreur technique Sage + logger.error(f"❌ Erreur technique création article: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de l'article: {str(e)}" + ) -@app.put("/articles/{reference}", tags=["Articles"]) -def modifier_article(reference: str, article: ArticleUpdateRequest): +@app.put("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) +async def modifier_article( + reference: str = Path(..., description="Référence de l'article à modifier"), + article: ArticleUpdateRequest = Body(...) +): """ - ✏️ Modification d'un article dans Sage + ✏️ Modification complète d'un article existant - **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 + **Usage critique:** Augmenter le stock pour résoudre l'erreur 2881 - **Example** - Résoudre l'erreur "L'état du stock ne permet pas de créer la ligne": -```bash - curl -X PUT "http://api.example.com/api/articles/ART001" \ - -H "Content-Type: application/json" \ - -d '{"stock_reel": 100.0}' -``` + **Erreur 2881 - "L'état du stock ne permet pas de créer la ligne"** - **Erreurs possibles**: + Cette erreur survient lors de la transformation de documents (devis → commande → facture) + lorsque le stock de l'article est insuffisant. + + **Solution:** Augmenter le `stock_reel` de l'article + + **Exemple - Résoudre l'erreur 2881:** + ```json + { + "stock_reel": 100.0 + } + ``` + + **Autres modifications possibles:** + - Prix de vente/achat + - Stock minimum + - Code EAN + - Description + + **Erreurs possibles:** - 404: Article introuvable - - 400: Données invalides + - 400: Aucun champ à modifier ou données invalides - 500: Erreur Sage + + **Note:** Seuls les champs fournis seront modifiés, les autres restent inchangés """ try: - # Filtrer les champs None + # ✅ CORRECTION: Utiliser exclude_unset=True au lieu de exclude_none=True + # Cela permet de distinguer entre: + # - Champ non fourni (exclu) + # - Champ fourni avec valeur None (inclus pour reset) article_data = article.dict(exclude_unset=True) if not article_data: raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Aucun champ à modifier" + status_code=status.HTTP_400_BAD_REQUEST, + detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour." ) + logger.info(f"📝 Modification article {reference}: {list(article_data.keys())}") + + # Appel à la gateway Windows resultat = sage_client.modifier_article(reference, article_data) - logger.info(f"✅ Article {reference} modifié: {list(article_data.keys())}") + # Log spécial pour modification de stock (important pour erreur 2881) + if "stock_reel" in article_data: + logger.info( + f"📦 Stock {reference} modifié: {article_data['stock_reel']} " + f"(peut résoudre erreur 2881)" + ) - return { - "message": f"Article {reference} modifié avec succès", - "article": resultat, - "champs_modifies": list(article_data.keys()) - } + logger.info(f"✅ Article {reference} modifié ({len(article_data)} champs)") + + return ArticleResponse(**resultat) except ValueError as e: - logger.warning(f"Erreur métier modification article: {e}") - raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) + # Erreur métier (ex: article introuvable) + logger.warning(f"⚠️ Erreur métier modification article: {e}") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + + except HTTPException: + raise except Exception as e: - logger.error(f"Erreur modification article: {e}") - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) + # Erreur technique Sage + logger.error(f"❌ Erreur technique modification article: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la modification de l'article: {str(e)}" + ) -@app.get("/articles/{reference}", tags=["Articles"]) -def lire_article(reference: str): +@app.get("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) +async def lire_article(reference: str = Path(..., description="Référence de l'article")): """ - 📄 Lecture d'un article par référence + 📄 Lecture d'un article spécifique par référence - Retourne toutes les informations incluant le stock actuel + **Retourne:** + - Toutes les informations de l'article + - Stock actuel (réel, réservé, disponible) + - Prix de vente et d'achat + - Famille, fournisseur principal + - Caractéristiques physiques (poids, volume) + + **Source:** Cache mémoire (instantané) """ try: article = sage_client.lire_article(reference) if not article: + logger.warning(f"⚠️ Article {reference} introuvable") raise HTTPException( - status.HTTP_404_NOT_FOUND, - f"Article {reference} introuvable" + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Article {reference} introuvable" ) - return {"article": article} + logger.info(f"✅ Article {reference} lu: {article.get('designation', '')}") + + return ArticleResponse(**article) except HTTPException: raise except Exception as e: - logger.error(f"Erreur lecture article: {e}") - raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) - + logger.error(f"❌ Erreur lecture article {reference}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture de l'article: {str(e)}" + ) @app.get("/articles/all") def lister_articles(filtre: str = ""): From 5bed8c0cfe4d2c000af385f0a2fbc007506ff3a5 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 11 Dec 2025 12:04:07 +0300 Subject: [PATCH 062/199] refactor(api): add Body import from fastapi for request handling --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 02e90d9..152ed6f 100644 --- a/api.py +++ b/api.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, Path, Query, Depends, status +from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, EmailStr, validator, field_validator From 0faec998179660ef75791b1562ec64241b3ee705 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 09:23:03 +0300 Subject: [PATCH 063/199] refactor(api): introduce TypeDocumentSQL enum and update document reading methods --- api.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/api.py b/api.py index 152ed6f..99a1b2f 100644 --- a/api.py +++ b/api.py @@ -89,6 +89,15 @@ class TypeDocument(int, Enum): BON_AVOIR = settings.SAGE_TYPE_BON_AVOIR FACTURE = settings.SAGE_TYPE_FACTURE +class TypeDocumentSQL(int, Enum): + DEVIS = settings.SAGE_TYPE_DEVIS + BON_COMMANDE = 1 + PREPARATION = 2 + BON_LIVRAISON =3 + BON_RETOUR = 4 + BON_AVOIR = 5 + FACTURE = 6 + class StatutSignature(str, Enum): EN_ATTENTE = "EN_ATTENTE" @@ -1887,7 +1896,7 @@ async def changer_statut_devis( async def lire_commande(id: str): """📄 Lecture d'une commande avec ses lignes""" try: - commande = sage_client.lire_document(id, TypeDocument.BON_COMMANDE) + commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande: raise HTTPException(404, f"Commande {id} introuvable") return commande @@ -2538,7 +2547,7 @@ async def lire_facture_detail(numero: str): Facture complète avec lignes, client, totaux, etc. """ try: - facture = sage_client.lire_document(numero, TypeDocument.FACTURE) + facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {numero} introuvable") @@ -3334,7 +3343,7 @@ async def lister_avoirs( async def lire_avoir(numero: str): """📄 Lecture d'un avoir avec ses lignes""" try: - avoir = sage_client.lire_avoir(numero) + avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR) if not avoir: raise HTTPException(404, f"Avoir {numero} introuvable") return avoir @@ -3530,7 +3539,7 @@ async def lister_livraisons( async def lire_livraison(numero: str): """📄 Lecture d'une livraison avec ses lignes""" try: - livraison = sage_client.lire_livraison(numero) + livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) if not livraison: raise HTTPException(404, f"Livraison {numero} introuvable") return livraison From 42b3164f798391f01d1bdb543d85df19ddd2a5de Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 11:14:16 +0300 Subject: [PATCH 064/199] refactor(sage_client): remove section comments and redundant docstrings --- api.py | 1264 ++++++++++++++++++++---------------------------- sage_client.py | 308 ++---------- 2 files changed, 561 insertions(+), 1011 deletions(-) diff --git a/api.py b/api.py index 99a1b2f..dd7bd98 100644 --- a/api.py +++ b/api.py @@ -89,11 +89,12 @@ class TypeDocument(int, Enum): BON_AVOIR = settings.SAGE_TYPE_BON_AVOIR FACTURE = settings.SAGE_TYPE_FACTURE + class TypeDocumentSQL(int, Enum): DEVIS = settings.SAGE_TYPE_DEVIS BON_COMMANDE = 1 PREPARATION = 2 - BON_LIVRAISON =3 + BON_LIVRAISON = 3 BON_RETOUR = 4 BON_AVOIR = 5 FACTURE = 6 @@ -121,6 +122,7 @@ class StatutEmail(str, Enum): # ===================================================== class ClientResponse(BaseModel): """Modèle de réponse client simplifié (pour listes)""" + numero: Optional[str] = None intitule: Optional[str] = None adresse: Optional[str] = None @@ -132,53 +134,60 @@ class ClientResponse(BaseModel): class ClientDetails(BaseModel): """Modèle de réponse client complet (pour GET /clients/{code})""" - + # === IDENTIFICATION === numero: Optional[str] = Field(None, description="Code client (CT_Num)") - intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") - + intitule: Optional[str] = Field( + None, description="Raison sociale ou Nom complet (CT_Intitule)" + ) + # === TYPE DE TIERS === type_tiers: Optional[str] = Field( - None, - description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'" + None, + description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'", ) qualite: Optional[str] = Field( - None, - description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)" + None, + description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)", ) - est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") - est_fournisseur: Optional[bool] = Field(None, description="True si fournisseur (CT_Qualite=2 ou 3)") - + est_prospect: Optional[bool] = Field( + None, description="True si prospect (CT_Prospect=1)" + ) + est_fournisseur: Optional[bool] = Field( + None, description="True si fournisseur (CT_Qualite=2 ou 3)" + ) + # === TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) === forme_juridique: Optional[str] = Field( - None, - description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier" + None, + description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier", ) est_entreprise: Optional[bool] = Field( - None, - description="True si entreprise (forme_juridique renseignée)" + None, description="True si entreprise (forme_juridique renseignée)" ) est_particulier: Optional[bool] = Field( - None, - description="True si particulier (pas de forme juridique)" + None, description="True si particulier (pas de forme juridique)" ) - + # === STATUT === est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") - est_en_sommeil: Optional[bool] = Field(None, description="True si en sommeil (CT_Sommeil=1)") - + est_en_sommeil: Optional[bool] = Field( + None, description="True si en sommeil (CT_Sommeil=1)" + ) + # === IDENTITÉ (POUR PARTICULIERS) === civilite: Optional[str] = Field(None, description="M., Mme, Mlle (CT_Civilite)") nom: Optional[str] = Field(None, description="Nom de famille (CT_Nom)") prenom: Optional[str] = Field(None, description="Prénom (CT_Prenom)") nom_complet: Optional[str] = Field( - None, - description="Nom complet formaté : 'Civilité Prénom Nom'" + None, description="Nom complet formaté : 'Civilité Prénom Nom'" ) - + # === CONTACT === - contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") - + contact: Optional[str] = Field( + None, description="Nom du contact principal (CT_Contact)" + ) + # === ADRESSE === adresse: Optional[str] = Field(None, description="Adresse ligne 1") complement: Optional[str] = Field(None, description="Complément d'adresse") @@ -186,40 +195,52 @@ class ClientDetails(BaseModel): ville: Optional[str] = Field(None, description="Ville") region: Optional[str] = Field(None, description="Région/État") pays: Optional[str] = Field(None, description="Pays") - + # === TÉLÉCOMMUNICATIONS === telephone: Optional[str] = Field(None, description="Téléphone fixe") portable: Optional[str] = Field(None, description="Téléphone mobile") telecopie: Optional[str] = Field(None, description="Fax") email: Optional[str] = Field(None, description="Email principal") site_web: Optional[str] = Field(None, description="Site web") - + # === INFORMATIONS JURIDIQUES (ENTREPRISES) === siret: Optional[str] = Field(None, description="N° SIRET (14 chiffres)") siren: Optional[str] = Field(None, description="N° SIREN (9 chiffres)") tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire") code_naf: Optional[str] = Field(None, description="Code NAF/APE") - + # === INFORMATIONS COMMERCIALES === secteur: Optional[str] = Field(None, description="Secteur d'activité") effectif: Optional[int] = Field(None, description="Nombre d'employés") ca_annuel: Optional[float] = Field(None, description="Chiffre d'affaires annuel") - commercial_code: Optional[str] = Field(None, description="Code du commercial rattaché") + commercial_code: Optional[str] = Field( + None, description="Code du commercial rattaché" + ) commercial_nom: Optional[str] = Field(None, description="Nom du commercial") - + # === CATÉGORIES === - categorie_tarifaire: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") - categorie_comptable: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") - + categorie_tarifaire: Optional[int] = Field( + None, description="Catégorie tarifaire (N_CatTarif)" + ) + categorie_comptable: Optional[int] = Field( + None, description="Catégorie comptable (N_CatCompta)" + ) + # === INFORMATIONS FINANCIÈRES === - encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé") - assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit") + encours_autorise: Optional[float] = Field( + None, description="Encours maximum autorisé" + ) + assurance_credit: Optional[float] = Field( + None, description="Montant assurance crédit" + ) compte_general: Optional[str] = Field(None, description="Compte général principal") - + # === DATES === date_creation: Optional[str] = Field(None, description="Date de création") - date_modification: Optional[str] = Field(None, description="Date de dernière modification") - + date_modification: Optional[str] = Field( + None, description="Date de dernière modification" + ) + class Config: json_schema_extra = { "example": { @@ -236,70 +257,90 @@ class ClientDetails(BaseModel): "portable": "0612345678", "email": "contact@exemple.fr", "siret": "12345678901234", - "tva_intra": "FR12345678901" + "tva_intra": "FR12345678901", } } + class ArticleResponse(BaseModel): """ Modèle de réponse pour un article Sage - + ✅ ENRICHI avec tous les champs disponibles """ + # === IDENTIFICATION === reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") - designation_complementaire: Optional[str] = Field(None, description="Désignation complémentaire") - + designation_complementaire: Optional[str] = Field( + None, description="Désignation complémentaire" + ) + # === CODE EAN / CODE-BARRES === code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") code_barre: Optional[str] = Field(None, description="Code-barres (alias)") - + # === PRIX === prix_vente: float = Field(..., description="Prix de vente HT") prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") prix_revient: Optional[float] = Field(None, description="Prix de revient") - + # === STOCK === stock_reel: float = Field(..., description="Stock réel") stock_mini: Optional[float] = Field(None, description="Stock minimum") stock_maxi: Optional[float] = Field(None, description="Stock maximum") - stock_reserve: Optional[float] = Field(None, description="Stock réservé (en commande)") - stock_commande: Optional[float] = Field(None, description="Stock en commande fournisseur") - stock_disponible: Optional[float] = Field(None, description="Stock disponible (réel - réservé)") - + stock_reserve: Optional[float] = Field( + None, description="Stock réservé (en commande)" + ) + stock_commande: Optional[float] = Field( + None, description="Stock en commande fournisseur" + ) + stock_disponible: Optional[float] = Field( + None, description="Stock disponible (réel - réservé)" + ) + # === DESCRIPTIONS === - description: Optional[str] = Field(None, description="Description détaillée / Commentaire") - + description: Optional[str] = Field( + None, description="Description détaillée / Commentaire" + ) + # === CLASSIFICATION === - type_article: Optional[int] = Field(None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)") + type_article: Optional[int] = Field( + None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)" + ) type_article_libelle: Optional[str] = Field(None, description="Libellé du type") famille_code: Optional[str] = Field(None, description="Code famille") famille_libelle: Optional[str] = Field(None, description="Libellé famille") - + # === FOURNISSEUR PRINCIPAL === - fournisseur_principal: Optional[str] = Field(None, description="Code fournisseur principal") - fournisseur_nom: Optional[str] = Field(None, description="Nom fournisseur principal") - + fournisseur_principal: Optional[str] = Field( + None, description="Code fournisseur principal" + ) + fournisseur_nom: Optional[str] = Field( + None, description="Nom fournisseur principal" + ) + # === UNITÉS === unite_vente: Optional[str] = Field(None, description="Unité de vente") unite_achat: Optional[str] = Field(None, description="Unité d'achat") - + # === CARACTÉRISTIQUES PHYSIQUES === poids: Optional[float] = Field(None, description="Poids (kg)") volume: Optional[float] = Field(None, description="Volume (m³)") - + # === STATUT === est_actif: bool = Field(True, description="Article actif") en_sommeil: bool = Field(False, description="Article en sommeil") - + # === TVA === tva_code: Optional[str] = Field(None, description="Code TVA") tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)") - + # === DATES === date_creation: Optional[str] = Field(None, description="Date de création") - date_modification: Optional[str] = Field(None, description="Date de dernière modification") + date_modification: Optional[str] = Field( + None, description="Date de dernière modification" + ) class LigneDevis(BaseModel): @@ -360,27 +401,35 @@ class BaremeRemiseResponse(BaseModel): class ClientCreateAPIRequest(BaseModel): """Modèle pour création d'un nouveau client""" - - intitule: str = Field(..., min_length=1, max_length=69, description="Raison sociale ou Nom complet") - compte_collectif: str = Field("411000", description="Compte comptable (411000 par défaut)") - num: Optional[str] = Field(None, max_length=17, description="Code client souhaité (auto si vide)") - + + intitule: str = Field( + ..., min_length=1, max_length=69, description="Raison sociale ou Nom complet" + ) + compte_collectif: str = Field( + "411000", description="Compte comptable (411000 par défaut)" + ) + num: Optional[str] = Field( + None, max_length=17, description="Code client souhaité (auto si vide)" + ) + # Adresse adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) ville: Optional[str] = Field(None, max_length=35) pays: Optional[str] = Field(None, max_length=35) - + # Contact email: Optional[EmailStr] = None telephone: Optional[str] = Field(None, max_length=21, description="Téléphone fixe") portable: Optional[str] = Field(None, max_length=21, description="Téléphone mobile") - + # Juridique - forme_juridique: Optional[str] = Field(None, max_length=50, description="SARL, SA, SAS, EI, etc.") + forme_juridique: Optional[str] = Field( + None, max_length=50, description="SARL, SA, SAS, EI, etc." + ) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { @@ -393,13 +442,14 @@ class ClientCreateAPIRequest(BaseModel): "portable": "0612345678", "email": "contact@nouvelle-entreprise.fr", "siret": "12345678901234", - "tva_intra": "FR12345678901" + "tva_intra": "FR12345678901", } } + class ClientUpdateRequest(BaseModel): """Modèle pour modification d'un client existant""" - + intitule: Optional[str] = Field(None, min_length=1, max_length=69) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) @@ -411,13 +461,13 @@ class ClientUpdateRequest(BaseModel): forme_juridique: Optional[str] = Field(None, max_length=50) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { "email": "nouveau@email.fr", "telephone": "0198765432", - "portable": "0687654321" + "portable": "0687654321", } } @@ -780,8 +830,10 @@ class FactureUpdateRequest(BaseModel): } } + class ArticleCreateRequest(BaseModel): """Schéma pour création d'article""" + reference: str = Field(..., max_length=18, description="Référence article") designation: str = Field(..., max_length=69, description="Désignation") famille: Optional[str] = Field(None, max_length=18, description="Code famille") @@ -797,18 +849,145 @@ class ArticleCreateRequest(BaseModel): class ArticleUpdateRequest(BaseModel): """Schéma pour modification d'article""" + designation: Optional[str] = Field(None, max_length=69) prix_vente: Optional[float] = Field(None, ge=0) prix_achat: Optional[float] = Field(None, ge=0) - stock_reel: Optional[float] = Field(None, ge=0, description="⚠️ Critique pour erreur 2881") + stock_reel: Optional[float] = Field( + None, ge=0, description="⚠️ Critique pour erreur 2881" + ) stock_mini: Optional[float] = Field(None, ge=0) code_ean: Optional[str] = Field(None, max_length=13) description: Optional[str] = Field(None) + + +class FamilleCreateRequest(BaseModel): + """Schéma pour création de famille d'articles""" + + code: str = Field(..., max_length=18, description="Code famille (max 18 car)") + intitule: str = Field(..., max_length=69, description="Intitulé (max 69 car)") + type: int = Field(0, ge=0, le=1, description="0=Détail, 1=Total") + compte_achat: Optional[str] = Field( + None, max_length=13, description="Compte général achat (ex: 607000)" + ) + compte_vente: Optional[str] = Field( + None, max_length=13, description="Compte général vente (ex: 707000)" + ) + + class Config: + json_schema_extra = { + "example": { + "code": "PRODLAIT", + "intitule": "Produits laitiers", + "type": 0, + "compte_achat": "607000", + "compte_vente": "707000", + } + } + + +class FamilleResponse(BaseModel): + """Modèle de réponse pour une famille d'articles""" + + code: str = Field(..., description="Code famille") + intitule: str = Field(..., description="Intitulé") + type: int = Field(..., description="Type (0=Détail, 1=Total)") + type_libelle: str = Field(..., description="Libellé du type") + est_total: bool = Field(..., description="True si type Total") + compte_achat: Optional[str] = Field(None, description="Compte général achat") + compte_vente: Optional[str] = Field(None, description="Compte général vente") + unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") + coef: Optional[float] = Field(None, description="Coefficient") + + class Config: + json_schema_extra = { + "example": { + "code": "ZDIVERS", + "intitule": "Frais et accessoires", + "type": 0, + "type_libelle": "Détail", + "est_total": False, + "compte_achat": "607000", + "compte_vente": "707000", + "unite_vente": "U", + "coef": 2.0, + } + } + +class MouvementStockLigneRequest(BaseModel): + """Ligne de mouvement de stock""" + article_ref: str = Field(..., description="Référence de l'article") + quantite: float = Field(..., gt=0, description="Quantité (>0)") + depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')") + prix_unitaire: Optional[float] = Field(None, ge=0, description="Prix unitaire (optionnel)") + commentaire: Optional[str] = Field(None, description="Commentaire ligne") + + +class EntreeStockRequest(BaseModel): + """Création d'un bon d'entrée en stock""" + date_entree: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") + lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + commentaire: Optional[str] = Field(None, description="Commentaire général") + + class Config: + json_schema_extra = { + "example": { + "date_entree": "2025-01-15", + "reference": "REC-2025-001", + "depot_code": "01", + "lignes": [ + { + "article_ref": "ART001", + "quantite": 50, + "depot_code": "01", + "prix_unitaire": 10.50, + "commentaire": "Réception fournisseur" + } + ], + "commentaire": "Réception livraison fournisseur XYZ" + } + } + + +class SortieStockRequest(BaseModel): + """Création d'un bon de sortie de stock""" + date_sortie: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") + lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + commentaire: Optional[str] = Field(None, description="Commentaire général") + + class Config: + json_schema_extra = { + "example": { + "date_sortie": "2025-01-15", + "reference": "SOR-2025-001", + "depot_code": "01", + "lignes": [ + { + "article_ref": "ART001", + "quantite": 10, + "depot_code": "01", + "commentaire": "Utilisation interne" + } + ], + "commentaire": "Consommation atelier" + } + } + + +class MouvementStockResponse(BaseModel): + """Réponse pour un mouvement de stock""" + numero: str = Field(..., description="Numéro du mouvement") + type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") + type_libelle: str = Field(..., description="Libellé du type") + date: str = Field(..., description="Date du mouvement") + reference: Optional[str] = Field(None, description="Référence externe") + nb_lignes: int = Field(..., description="Nombre de lignes") -# ===================================================== -# SERVICES EXTERNES (Universign) -# ===================================================== async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, nom: str ) -> Dict: @@ -923,17 +1102,12 @@ async def universign_statut(transaction_id: str) -> Dict: logger.error(f"Erreur statut Universign: {e}") return {"statut": "ERREUR", "error": str(e)} - -# ===================================================== -# CYCLE DE VIE -# ===================================================== @asynccontextmanager async def lifespan(app: FastAPI): # Init base de données await init_db() logger.info("✅ Base de données initialisée") - # ✅ CORRECTION: Injecter session_factory ET sage_client dans email_queue email_queue.session_factory = async_session_factory email_queue.sage_client = sage_client @@ -973,12 +1147,8 @@ app.add_middleware( app.include_router(auth_router) -# ===================================================== -# ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) -# ===================================================== @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def rechercher_clients(query: Optional[str] = Query(None)): - """🔍 Recherche clients via gateway Windows""" try: clients = sage_client.lister_clients(filtre=query or "") return [ClientDetails(**c) for c in clients] @@ -989,15 +1159,6 @@ async def rechercher_clients(query: Optional[str] = Query(None)): @app.get("/clients/{code}", tags=["Clients"]) async def lire_client_detail(code: str): - """ - 📄 Lecture détaillée d'un client par son code - - Args: - code: Code du client (ex: "CLI000001", "SARL", etc.) - - Returns: - Toutes les informations du client - """ try: client = sage_client.lire_client(code) @@ -1019,23 +1180,6 @@ async def modifier_client( client_update: ClientUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un client existant - - Args: - code: Code du client à modifier - client_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - - Returns: - Client modifié avec ses nouvelles valeurs - - Example: - PUT /clients/SARL - { - "email": "nouveau@email.fr", - "telephone": "0198765432" - } - """ try: # Appel à la gateway Windows resultat = sage_client.modifier_client( @@ -1064,9 +1208,6 @@ async def modifier_client( async def ajouter_client( client: ClientCreateAPIRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'un nouveau client dans Sage 100c - """ try: nouveau_client = sage_client.creer_client(client.dict()) @@ -1087,7 +1228,6 @@ async def ajouter_client( @app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): - """🔍 Recherche articles via gateway Windows""" try: articles = sage_client.lister_articles(filtre=query or "") return [ArticleResponse(**a) for a in articles] @@ -1095,229 +1235,141 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) + @app.post( "/articles", response_model=ArticleResponse, status_code=status.HTTP_201_CREATED, - tags=["Articles"] + tags=["Articles"], ) async def creer_article(article: ArticleCreateRequest): - """ - ➕ Création d'un nouvel article dans Sage - - **Usage typique:** Créer un article avec stock pour éviter l'erreur 2881 - - **Champs obligatoires:** - - `reference` (max 18 caractères) : Référence unique de l'article - - `designation` (max 69 caractères) : Désignation de l'article - - **Champs optionnels mais recommandés:** - - `stock_reel` : Stock initial (important pour éviter erreurs de transformation) - - `prix_vente` : Prix de vente HT - - `unite_vente` : Unité de vente (défaut: "UN") - - **Erreurs possibles:** - - 400: Article existe déjà ou données invalides - - 500: Erreur Sage (problème de connexion, champs mal formatés, etc.) - - **Exemple:** - ```json - { - "reference": "ART001", - "designation": "Article de test", - "prix_vente": 10.50, - "stock_reel": 100.0, - "stock_mini": 10.0, - "unite_vente": "UN", - "tva_code": "C20" - } - ``` - """ try: # Validation des données if not article.reference or not article.designation: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Les champs 'reference' et 'designation' sont obligatoires" + detail="Les champs 'reference' et 'designation' sont obligatoires", ) - - # ⚠️ CORRECTION: Ne pas utiliser exclude_none=True car on veut garder - # les valeurs par défaut (comme unite_vente="UN") + article_data = article.dict(exclude_unset=True) - + logger.info(f"📝 Création article: {article.reference} - {article.designation}") - + # Appel à la gateway Windows resultat = sage_client.creer_article(article_data) - - logger.info(f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})") - + + logger.info( + f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" + ) + return ArticleResponse(**resultat) - + except ValueError as e: # Erreur métier (ex: article existe déjà) logger.warning(f"⚠️ Erreur métier création article: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except HTTPException: raise - + except Exception as e: # Erreur technique Sage logger.error(f"❌ Erreur technique création article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la création de l'article: {str(e)}" + detail=f"Erreur lors de la création de l'article: {str(e)}", ) @app.put("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), - article: ArticleUpdateRequest = Body(...) + article: ArticleUpdateRequest = Body(...), ): - """ - ✏️ Modification complète d'un article existant - - **Usage critique:** Augmenter le stock pour résoudre l'erreur 2881 - - **Erreur 2881 - "L'état du stock ne permet pas de créer la ligne"** - - Cette erreur survient lors de la transformation de documents (devis → commande → facture) - lorsque le stock de l'article est insuffisant. - - **Solution:** Augmenter le `stock_reel` de l'article - - **Exemple - Résoudre l'erreur 2881:** - ```json - { - "stock_reel": 100.0 - } - ``` - - **Autres modifications possibles:** - - Prix de vente/achat - - Stock minimum - - Code EAN - - Description - - **Erreurs possibles:** - - 404: Article introuvable - - 400: Aucun champ à modifier ou données invalides - - 500: Erreur Sage - - **Note:** Seuls les champs fournis seront modifiés, les autres restent inchangés - """ try: - # ✅ CORRECTION: Utiliser exclude_unset=True au lieu de exclude_none=True - # Cela permet de distinguer entre: - # - Champ non fourni (exclu) - # - Champ fourni avec valeur None (inclus pour reset) article_data = article.dict(exclude_unset=True) - + if not article_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour." + detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour.", ) - + logger.info(f"📝 Modification article {reference}: {list(article_data.keys())}") - + # Appel à la gateway Windows resultat = sage_client.modifier_article(reference, article_data) - + # Log spécial pour modification de stock (important pour erreur 2881) if "stock_reel" in article_data: logger.info( f"📦 Stock {reference} modifié: {article_data['stock_reel']} " f"(peut résoudre erreur 2881)" ) - + logger.info(f"✅ Article {reference} modifié ({len(article_data)} champs)") - + return ArticleResponse(**resultat) - + except ValueError as e: # Erreur métier (ex: article introuvable) logger.warning(f"⚠️ Erreur métier modification article: {e}") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except HTTPException: raise - + except Exception as e: # Erreur technique Sage logger.error(f"❌ Erreur technique modification article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la modification de l'article: {str(e)}" + detail=f"Erreur lors de la modification de l'article: {str(e)}", ) @app.get("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) -async def lire_article(reference: str = Path(..., description="Référence de l'article")): - """ - 📄 Lecture d'un article spécifique par référence - - **Retourne:** - - Toutes les informations de l'article - - Stock actuel (réel, réservé, disponible) - - Prix de vente et d'achat - - Famille, fournisseur principal - - Caractéristiques physiques (poids, volume) - - **Source:** Cache mémoire (instantané) - """ +async def lire_article( + reference: str = Path(..., description="Référence de l'article") +): try: article = sage_client.lire_article(reference) - + if not article: logger.warning(f"⚠️ Article {reference} introuvable") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Article {reference} introuvable" + detail=f"Article {reference} introuvable", ) - + logger.info(f"✅ Article {reference} lu: {article.get('designation', '')}") - + return ArticleResponse(**article) - + except HTTPException: raise except Exception as e: logger.error(f"❌ Erreur lecture article {reference}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la lecture de l'article: {str(e)}" + detail=f"Erreur lors de la lecture de l'article: {str(e)}", ) + @app.get("/articles/all") def lister_articles(filtre: str = ""): - """ - 📋 Liste tous les articles avec filtre optionnel - """ try: articles = sage_client.lister_articles(filtre) - - return { - "articles": articles, - "total": len(articles) - } - + + return {"articles": articles, "total": len(articles)} + except Exception as e: logger.error(f"Erreur liste articles: {e}") raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) - - + + @app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"]) async def creer_devis(devis: DevisRequest): - """📝 Création de devis via gateway Windows""" try: # Préparer les données pour la gateway devis_data = { @@ -1359,26 +1411,6 @@ async def modifier_devis( devis_update: DevisUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un devis existant - - **Champs modifiables:** - - `date_devis`: Nouvelle date du devis - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut (0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé) - - **Note importante:** - - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Pour ajouter/supprimer une ligne, il faut envoyer TOUTES les lignes - - Un devis transformé (statut=5) ne peut plus être modifié - - Args: - id: Numéro du devis à modifier - devis_update: Champs à mettre à jour - - Returns: - Devis modifié avec ses nouvelles valeurs - """ try: # Vérifier que le devis existe devis_existant = sage_client.lire_devis(id) @@ -1433,27 +1465,6 @@ async def modifier_devis( async def creer_commande( commande: CommandeCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'une nouvelle commande (Bon de commande) - - **Workflow typique:** - 1. Création d'un devis → transformation en commande (automatique) - 2. OU création directe d'une commande (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_commande`: Date de la commande (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de commande client) - - Args: - commande: Données de la commande à créer - - Returns: - Commande créée avec son numéro et ses totaux - """ try: # Vérifier que le client existe client = sage_client.lire_client(commande.client_id) @@ -1510,29 +1521,6 @@ async def modifier_commande( commande_update: CommandeUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'une commande existante - - **Champs modifiables:** - - `date_commande`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions:** - - Une commande transformée (statut=5) ne peut plus être modifiée - - Une commande annulée (statut=6) ne peut plus être modifiée - - **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Args: - id: Numéro de la commande à modifier - commande_update: Champs à mettre à jour - - Returns: - Commande modifiée avec ses nouvelles valeurs - """ try: # Vérifier que la commande existe commande_existante = sage_client.lire_document( @@ -1605,16 +1593,6 @@ async def lister_devis( True, description="Inclure les lignes de chaque devis" ), ): - """ - 📋 Liste tous les devis via gateway Windows - - Args: - limit: Nombre maximum de devis à retourner - statut: Filtre par statut (0=Devis, 2=Accepté, 5=Transformé, etc.) - inclure_lignes: Si True, charge les lignes de chaque devis (par défaut: True) - - ✅ AMÉLIORATION: Les lignes sont maintenant incluses par défaut - """ try: devis_list = sage_client.lister_devis( limit=limit, statut=statut, inclure_lignes=inclure_lignes @@ -1628,18 +1606,6 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): - """ - 📄 Lecture d'un devis via gateway Windows - - Returns: - Devis complet avec: - - Toutes les informations standards - - lignes: Lignes du devis - - a_deja_ete_transforme: ✅ Booléen indiquant si le devis a été transformé - - documents_cibles: ✅ Liste des documents créés depuis ce devis - - ✅ ENRICHI: Inclut maintenant l'information de transformation - """ try: devis = sage_client.lire_devis(id) @@ -1665,7 +1631,6 @@ async def lire_devis(id: str): @app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): - """📄 Téléchargement PDF (généré via email_queue)""" try: # Générer PDF en appelant la méthode de email_queue # qui elle-même appellera sage_client pour récupérer les données @@ -1689,31 +1654,6 @@ async def telecharger_document_pdf( ), numero: str = Path(..., description="Numéro du document"), ): - """ - 📄 Téléchargement PDF d'un document (route généralisée) - - **Types de documents supportés:** - - `0`: Devis - - `10`: Bon de commande - - `30`: Bon de livraison - - `60`: Facture - - `50`: Bon d'avoir - - **Exemple d'utilisation:** - - `GET /documents/0/DE00001/pdf` → PDF du devis DE00001 - - `GET /documents/60/FA00001/pdf` → PDF de la facture FA00001 - - **Retour:** - - Fichier PDF prêt à télécharger - - Nom de fichier formaté selon le type de document - - Args: - type_doc: Type de document Sage (0-60) - numero: Numéro du document - - Returns: - StreamingResponse avec le PDF - """ try: # Mapping des types vers les libellés types_labels = { @@ -1771,7 +1711,6 @@ async def telecharger_document_pdf( async def envoyer_devis_email( id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) ): - """📧 Envoi devis par email""" try: # Vérifier que le devis existe devis = sage_client.lire_devis(id) @@ -1828,26 +1767,6 @@ async def changer_statut_devis( ..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé" ), ): - """ - 📊 Changement de statut d'un devis - - **Statuts possibles:** - - 0: Brouillon - - 2: Accepté/Validé - - 5: Transformé (automatique lors d'une transformation) - - 6: Annulé - - **Restrictions:** - - Un devis transformé (5) ne peut plus changer de statut - - Un devis annulé (6) ne peut plus changer de statut - - Args: - id: Numéro du devis - nouveau_statut: Nouveau statut (0-6) - - Returns: - Confirmation du changement avec ancien et nouveau statut - """ try: # Vérifier que le devis existe devis_existant = sage_client.lire_devis(id) @@ -1886,15 +1805,8 @@ async def changer_statut_devis( logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) - -# ===================================================== -# ENDPOINTS - Commandes (WORKFLOW SANS RESSAISIE) -# ===================================================== - - @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): - """📄 Lecture d'une commande avec ses lignes""" try: commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande: @@ -1911,12 +1823,6 @@ async def lire_commande(id: str): async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """ - 📋 Liste toutes les commandes via gateway Windows - - ✅ CORRECTION : Utilise sage_client.lister_commandes() qui existe déjà - Le filtrage sur type 10 est fait côté Windows dans main.py - """ try: commandes = sage_client.lister_commandes(limit=limit, statut=statut) return commandes @@ -1928,11 +1834,6 @@ async def lister_commandes( @app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Devis → Commande - ✅ CORRECTION : Utilise les VRAIS types Sage (0 → 10) - ✅ Met à jour le statut du devis source à 5 (Transformé) - """ try: # Étape 1: Transformation resultat = sage_client.transformer_document( @@ -1985,10 +1886,6 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi @app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Commande → Facture - ✅ Utilise les VRAIS types Sage (10 → 60) - """ try: resultat = sage_client.transformer_document( numero_source=id, @@ -2033,7 +1930,6 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses async def envoyer_signature( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): - """✍️ Envoi document pour signature Universign""" try: # Générer PDF pdf_bytes = email_queue._generate_pdf(demande.doc_id, demande.type_doc) @@ -2084,7 +1980,6 @@ async def envoyer_signature( @app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): - """🔍 Récupération du statut de signature en temps réel""" # Chercher dans la DB locale try: async with async_session_factory() as session: @@ -2116,7 +2011,6 @@ async def lister_signatures( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), ): - """📋 Liste toutes les demandes de signature""" query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc()) if statut: @@ -2152,7 +2046,6 @@ async def lister_signatures( async def statut_signature_detail( transaction_id: str, session: AsyncSession = Depends(get_session) ): - """🔍 Récupération du statut détaillé d'une signature""" query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id) result = await session.execute(query) signature_log = result.scalar_one_or_none() @@ -2204,7 +2097,6 @@ async def statut_signature_detail( @app.post("/signatures/refresh-all", tags=["Signatures"]) async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): - """🔄 Rafraîchit TOUS les statuts des signatures en attente""" query = select(SignatureLog).where( SignatureLog.statut.in_( [StatutSignatureEnum.EN_ATTENTE, StatutSignatureEnum.ENVOYE] @@ -2255,7 +2147,6 @@ async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_sess async def envoyer_devis_signature( id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session) ): - """✏️ Envoi d'un devis pour signature électronique""" try: # Vérifier devis via gateway Windows devis = sage_client.lire_devis(id) @@ -2307,11 +2198,6 @@ async def envoyer_devis_signature( raise HTTPException(500, str(e)) -# ============================================ -# US-A4 - ENVOI EMAILS EN LOT -# ============================================ - - class EmailBatchRequest(BaseModel): destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100) sujet: str = Field(..., min_length=1, max_length=500) @@ -2324,7 +2210,6 @@ class EmailBatchRequest(BaseModel): async def envoyer_emails_lot( batch: EmailBatchRequest, session: AsyncSession = Depends(get_session) ): - """📧 US-A4: Envoi groupé via email_queue""" resultats = [] for destinataire in batch.destinataires: @@ -2368,12 +2253,6 @@ async def envoyer_emails_lot( "details": resultats, } - -# ===================================================== -# ENDPOINTS - US-A5 -# ===================================================== - - @app.post( "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] ) @@ -2381,11 +2260,7 @@ async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), ): - """ - 💰 US-A5: Validation remise via barème client Sage - """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) remise_max = sage_client.lire_remise_max_client(client_id) autorisee = remise_pourcentage <= remise_max @@ -2411,14 +2286,10 @@ async def valider_remise( raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A6 (RELANCE DEVIS) -# ===================================================== @app.post("/devis/{id}/relancer-signature", tags=["Devis"]) async def relancer_devis_signature( id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): - """📧 Relance devis via Universign""" try: # Lire devis via gateway devis = sage_client.lire_devis(id) @@ -2487,7 +2358,6 @@ class ContactClientResponse(BaseModel): @app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis(id: str): - """👤 US-A6: Récupération du contact client associé au devis""" try: # Lire devis via gateway Windows devis = sage_client.lire_devis(id) @@ -2520,12 +2390,6 @@ async def recuperer_contact_devis(id: str): async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """ - 📋 Liste toutes les factures via gateway Windows - - ✅ CORRECTION : Utilise sage_client.lister_factures() qui existe déjà - Le filtrage sur type 60 est fait côté Windows dans main.py - """ try: factures = sage_client.lister_factures(limit=limit, statut=statut) return factures @@ -2537,15 +2401,6 @@ async def lister_factures( @app.get("/factures/{numero}", tags=["Factures"]) async def lire_facture_detail(numero: str): - """ - 📄 Lecture détaillée d'une facture avec ses lignes - - Args: - numero: Numéro de la facture (ex: "FA000001") - - Returns: - Facture complète avec lignes, client, totaux, etc. - """ try: facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE) @@ -2570,32 +2425,6 @@ class RelanceFactureRequest(BaseModel): async def creer_facture( facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'une facture - - **Workflow typique:** - 1. Commande → Livraison → Facture (transformations successives) - 2. OU création directe d'une facture (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_facture`: Date de la facture (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de commande client) - - **Notes importantes:** - - Les factures peuvent avoir des champs obligatoires supplémentaires selon la configuration Sage - - Le statut initial est généralement 2 (Accepté/Validé) - - Les factures sont soumises aux règles de numérotation strictes - - Args: - facture: Données de la facture à créer - - Returns: - Facture créée avec son numéro et ses totaux - """ try: # Vérifier que le client existe client = sage_client.lire_client(facture.client_id) @@ -2652,31 +2481,6 @@ async def modifier_facture( facture_update: FactureUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'une facture existante - - **Champs modifiables:** - - `date_facture`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions IMPORTANTES:** - - Une facture transformée (statut=5) ne peut plus être modifiée - - Une facture annulée (statut=6) ne peut plus être modifiée - - ⚠️ **ATTENTION**: Les factures comptabilisées peuvent être verrouillées par Sage - - Certaines factures peuvent être en lecture seule selon les droits utilisateur - - **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Args: - id: Numéro de la facture à modifier - facture_update: Champs à mettre à jour - - Returns: - Facture modifiée avec ses nouvelles valeurs - """ try: # Vérifier que la facture existe facture_existante = sage_client.lire_document(id, settings.SAGE_TYPE_FACTURE) @@ -2739,7 +2543,6 @@ async def modifier_facture( raise HTTPException(500, str(e)) -# Templates email (si pas déjà définis) templates_email_db = { "relance_facture": { "id": "relance_facture", @@ -2769,7 +2572,6 @@ async def relancer_facture( relance: RelanceFactureRequest, session: AsyncSession = Depends(get_session), ): - """💸 US-A7: Relance facture en un clic""" try: # Lire facture via gateway Windows facture = sage_client.lire_document(id, TypeDocument.FACTURE) @@ -2818,7 +2620,6 @@ async def relancer_facture( # Enqueue email_queue.enqueue(email_log.id) - # ✅ MAJ champ libre via gateway Windows sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) await session.commit() @@ -2837,12 +2638,6 @@ async def relancer_facture( logger.error(f"Erreur relance facture: {e}") raise HTTPException(500, str(e)) - -# ============================================ -# US-A9 - JOURNAL DES E-MAILS -# ============================================ - - @app.get("/emails/logs", tags=["Emails"]) async def journal_emails( statut: Optional[StatutEmail] = Query(None), @@ -2850,7 +2645,6 @@ async def journal_emails( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), ): - """📋 US-A9: Journal des e-mails envoyés""" query = select(EmailLog) if statut: @@ -2885,7 +2679,6 @@ async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), ): - """📥 US-A9: Export CSV des logs d'envoi""" query = select(EmailLog) if statut: query = query.where(EmailLog.statut == StatutEmailEnum[statut.value]) @@ -2940,12 +2733,6 @@ async def exporter_logs_csv( }, ) - -# ============================================ -# Devis0 - MODÈLES D'E-MAILS -# ============================================ - - class TemplateEmail(BaseModel): id: Optional[str] = None nom: str @@ -2962,7 +2749,6 @@ class TemplatePreviewRequest(BaseModel): @app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) async def lister_templates(): - """📧 Emails: Liste tous les templates d'emails""" return [TemplateEmail(**template) for template in templates_email_db.values()] @@ -2970,7 +2756,6 @@ async def lister_templates(): "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def lire_template(template_id: str): - """📖 Lecture d'un template par ID""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -2979,7 +2764,6 @@ async def lire_template(template_id: str): @app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) async def creer_template(template: TemplateEmail): - """➕ Création d'un nouveau template""" template_id = str(uuid.uuid4()) templates_email_db[template_id] = { @@ -2999,7 +2783,6 @@ async def creer_template(template: TemplateEmail): "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def modifier_template(template_id: str, template: TemplateEmail): - """✏️ Modification d'un template existant""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -3022,7 +2805,6 @@ async def modifier_template(template_id: str, template: TemplateEmail): @app.delete("/templates/emails/{template_id}", tags=["Emails"]) async def supprimer_template(template_id: str): - """🗑️ Suppression d'un template""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -3038,7 +2820,6 @@ async def supprimer_template(template_id: str): @app.post("/templates/emails/preview", tags=["Emails"]) async def previsualiser_email(preview: TemplatePreviewRequest): - """👁️ US-A10: Prévisualisation email avec fusion variables""" if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") @@ -3080,7 +2861,6 @@ async def previsualiser_email(preview: TemplatePreviewRequest): # ===================================================== @app.get("/health", tags=["System"]) async def health_check(): - """🏥 Health check""" gateway_health = sage_client.health() return { @@ -3097,7 +2877,6 @@ async def health_check(): @app.get("/", tags=["System"]) async def root(): - """🏠 Page d'accueil""" return { "api": "Sage 100c Dataven - VPS Linux", "version": "2.0.0", @@ -3113,11 +2892,7 @@ async def root(): @app.get("/admin/cache/info", tags=["Admin"]) async def info_cache(): - """ - 📊 Informations sur l'état du cache Windows - """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) cache_info = sage_client.get_cache_info() return cache_info @@ -3126,34 +2901,8 @@ async def info_cache(): raise HTTPException(500, str(e)) -# 🔧 CORRECTION ADMIN: Cache refresh (doit appeler Windows) -@app.post("/admin/cache/refresh", tags=["Admin"]) -async def forcer_actualisation(): - """ - 🔄 Force l'actualisation du cache Windows - """ - try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) - resultat = sage_client.refresh_cache() - cache_info = sage_client.get_cache_info() - - return { - "success": True, - "message": "Cache actualisé sur Windows Server", - "info": cache_info, - } - - except Exception as e: - logger.error(f"Erreur refresh cache: {e}") - raise HTTPException(500, str(e)) - - -# ✅ RESTE INCHANGÉ: admin/queue/status (local au VPS) @app.get("/admin/queue/status", tags=["Admin"]) async def statut_queue(): - """ - 📊 Statut de la queue d'emails (local VPS) - """ return { "queue_size": email_queue.queue.qsize(), "workers": len(email_queue.workers), @@ -3166,7 +2915,6 @@ async def statut_queue(): # ===================================================== @app.get("/prospects", tags=["Prospects"]) async def rechercher_prospects(query: Optional[str] = Query(None)): - """🔍 Recherche prospects via gateway Windows""" try: prospects = sage_client.lister_prospects(filtre=query or "") return prospects @@ -3177,7 +2925,6 @@ async def rechercher_prospects(query: Optional[str] = Query(None)): @app.get("/prospects/{code}", tags=["Prospects"]) async def lire_prospect(code: str): - """📄 Lecture d'un prospect par code""" try: prospect = sage_client.lire_prospect(code) if not prospect: @@ -3195,9 +2942,6 @@ async def lire_prospect(code: str): # ===================================================== @app.get("/fournisseurs", tags=["Fournisseurs"]) async def rechercher_fournisseurs(query: Optional[str] = Query(None)): - """ - 🔍 Recherche fournisseurs via gateway Windows - """ try: fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") @@ -3218,26 +2962,6 @@ async def ajouter_fournisseur( fournisseur: FournisseurCreateAPIRequest, session: AsyncSession = Depends(get_session), ): - """ - ➕ Création d'un nouveau fournisseur dans Sage 100c - - **Champs obligatoires:** - - `intitule`: Raison sociale (max 69 caractères) - - **Champs optionnels:** - - `compte_collectif`: Compte comptable (défaut: 401000) - - `num`: Code fournisseur personnalisé (auto-généré si vide) - - `adresse`, `code_postal`, `ville`, `pays` - - `email`, `telephone` - - `siret`, `tva_intra` - - **Retour:** - - Fournisseur créé avec son numéro définitif - - **Erreurs possibles:** - - 400: Fournisseur existe déjà (doublon) - - 500: Erreur technique Sage - """ try: # Appel à la gateway Windows via sage_client nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) @@ -3267,23 +2991,6 @@ async def modifier_fournisseur( fournisseur_update: FournisseurUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un fournisseur existant - - Args: - code: Code du fournisseur à modifier - fournisseur_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - - Returns: - Fournisseur modifié avec ses nouvelles valeurs - - Example: - PUT /fournisseurs/DUPONT - { - "email": "nouveau@email.fr", - "telephone": "0198765432" - } - """ try: # Appel à la gateway Windows resultat = sage_client.modifier_fournisseur( @@ -3310,7 +3017,6 @@ async def modifier_fournisseur( @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): - """📄 Lecture d'un fournisseur par code""" try: fournisseur = sage_client.lire_fournisseur(code) if not fournisseur: @@ -3330,7 +3036,6 @@ async def lire_fournisseur(code: str): async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """📋 Liste tous les avoirs via gateway Windows""" try: avoirs = sage_client.lister_avoirs(limit=limit, statut=statut) return avoirs @@ -3341,7 +3046,6 @@ async def lister_avoirs( @app.get("/avoirs/{numero}", tags=["Avoirs"]) async def lire_avoir(numero: str): - """📄 Lecture d'un avoir avec ses lignes""" try: avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR) if not avoir: @@ -3358,29 +3062,6 @@ async def lire_avoir(numero: str): async def creer_avoir( avoir: AvoirCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'un avoir (Bon d'avoir) - - **Workflow typique:** - 1. Retour marchandise → création d'un avoir - 2. Geste commercial → création directe d'un avoir (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_avoir`: Date de l'avoir (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de retour) - - **Note:** Les montants des avoirs sont généralement négatifs (crédits) - - Args: - avoir: Données de l'avoir à créer - - Returns: - Avoir créé avec son numéro et ses totaux - """ try: # Vérifier que le client existe client = sage_client.lire_client(avoir.client_id) @@ -3435,29 +3116,6 @@ async def modifier_avoir( avoir_update: AvoirUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un avoir existant - - **Champs modifiables:** - - `date_avoir`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions:** - - Un avoir transformé (statut=5) ne peut plus être modifié - - Un avoir annulé (statut=6) ne peut plus être modifié - - **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Args: - id: Numéro de l'avoir à modifier - avoir_update: Champs à mettre à jour - - Returns: - Avoir modifié avec ses nouvelles valeurs - """ try: # Vérifier que l'avoir existe avoir_existant = sage_client.lire_avoir(id) @@ -3526,7 +3184,6 @@ async def modifier_avoir( async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """📋 Liste tous les bons de livraison via gateway Windows""" try: livraisons = sage_client.lister_livraisons(limit=limit, statut=statut) return livraisons @@ -3537,7 +3194,6 @@ async def lister_livraisons( @app.get("/livraisons/{numero}", tags=["Livraisons"]) async def lire_livraison(numero: str): - """📄 Lecture d'une livraison avec ses lignes""" try: livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) if not livraison: @@ -3554,21 +3210,6 @@ async def lire_livraison(numero: str): async def creer_livraison( livraison: LivraisonCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'une nouvelle livraison (Bon de livraison) - - **Workflow typique:** - 1. Création d'une commande → transformation en livraison (automatique) - 2. OU création directe d'une livraison (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_livraison`: Date de la livraison (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de commande client) - """ try: # Vérifier que le client existe client = sage_client.lire_client(livraison.client_id) @@ -3627,19 +3268,6 @@ async def modifier_livraison( livraison_update: LivraisonUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'une livraison existante - - **Champs modifiables:** - - `date_livraison`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions:** - - Une livraison transformée (statut=5) ne peut plus être modifiée - - Une livraison annulée (statut=6) ne peut plus être modifiée - """ try: # Vérifier que la livraison existe livraison_existante = sage_client.lire_livraison(id) @@ -3704,10 +3332,6 @@ async def modifier_livraison( @app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"]) async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Livraison → Facture - ✅ Utilise les VRAIS types Sage (30 → 60) - """ try: resultat = sage_client.transformer_document( numero_source=id, @@ -3749,26 +3373,6 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session) ): - """ - 🔧 Transformation Devis → Facture (DIRECT, sans commande) - - ✅ Utilise les VRAIS types Sage (0 → 60) - ✅ Met à jour le statut du devis source à 5 (Transformé) - - **Workflow raccourci** : Permet de facturer directement depuis un devis - sans passer par la création d'une commande. - - **Cas d'usage** : - - Prestations de services facturées directement - - Petites commandes sans besoin de suivi intermédiaire - - Ventes au comptoir - - Args: - id: Numéro du devis source - - Returns: - Informations de la facture créée - """ try: # Étape 1: Vérifier que le devis n'a pas déjà été transformé devis_existant = sage_client.lire_devis(id) @@ -3840,30 +3444,6 @@ async def devis_vers_facture_direct( async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session) ): - """ - 🔧 Transformation Commande → Bon de livraison - - ✅ Utilise les VRAIS types Sage (10 → 30) - - **Workflow typique** : Après validation d'une commande, génère - le bon de livraison pour préparer l'expédition. - - **Cas d'usage** : - - Préparation d'une expédition - - Génération du bordereau de livraison - - Suivi logistique - - **Workflow complet** : - 1. Devis → Commande (via `/workflow/devis/{id}/to-commande`) - 2. **Commande → Livraison** (cette route) - 3. Livraison → Facture (via `/workflow/livraison/{id}/to-facture`) - - Args: - id: Numéro de la commande source - - Returns: - Informations du bon de livraison créé - """ try: # Étape 1: Vérifier que la commande existe commande_existante = sage_client.lire_document( @@ -3930,6 +3510,231 @@ async def commande_vers_livraison( raise HTTPException(500, str(e)) +@app.get( + "/familles", + response_model=List[FamilleResponse], + tags=["Familles"], + summary="Liste toutes les familles d'articles", +) +async def lister_familles( + filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé") +): + try: + familles = sage_client.lister_familles(filtre or "") + + logger.info(f"✅ {len(familles)} famille(s) retournée(s)") + + return [FamilleResponse(**f) for f in familles] + + except Exception as e: + logger.error(f"❌ Erreur liste familles: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la récupération des familles: {str(e)}", + ) + + +@app.get( + "/familles/{code}", + response_model=FamilleResponse, + tags=["Familles"], + summary="Lecture d'une famille par son code", +) +async def lire_famille( + code: str = Path(..., description="Code de la famille (ex: ZDIVERS)") +): + try: + famille = sage_client.lire_famille(code) + + if not famille: + logger.warning(f"⚠️ Famille {code} introuvable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Famille {code} introuvable", + ) + + logger.info(f"✅ Famille {code} lue: {famille.get('intitule', '')}") + + return FamilleResponse(**famille) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur lecture famille {code}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture de la famille: {str(e)}", + ) + + +@app.post( + "/familles", + response_model=FamilleResponse, + status_code=status.HTTP_201_CREATED, + tags=["Familles"], + summary="Création d'une famille d'articles", +) +async def creer_famille(famille: FamilleCreateRequest): + try: + # Validation des données + if not famille.code or not famille.intitule: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Les champs 'code' et 'intitule' sont obligatoires", + ) + + famille_data = famille.dict() + + logger.info(f"📦 Création famille: {famille.code} - {famille.intitule}") + + # Appel à la gateway Windows + resultat = sage_client.creer_famille(famille_data) + + logger.info(f"✅ Famille créée: {resultat.get('code')}") + + return FamilleResponse(**resultat) + + except ValueError as e: + # Erreur métier (ex: famille existe déjà) + logger.warning(f"⚠️ Erreur métier création famille: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + # Erreur technique Sage + logger.error(f"❌ Erreur technique création famille: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de la famille: {str(e)}", + ) + + +@app.post( + "/stock/entree", + response_model=MouvementStockResponse, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock" +) +async def creer_entree_stock(entree: EntreeStockRequest): + try: + # Préparer les données + entree_data = entree.dict() + + logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") + + # Appel à la gateway Windows + resultat = sage_client.creer_entree_stock(entree_data) + + logger.info(f"✅ Entrée stock créée: {resultat.get('numero')}") + + return MouvementStockResponse(**resultat) + + except ValueError as e: + logger.warning(f"⚠️ Erreur métier entrée stock: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + except Exception as e: + logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de l'entrée: {str(e)}" + ) + + +@app.post( + "/stock/sortie", + response_model=MouvementStockResponse, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="SORTIE DE STOCK : Retire des articles du stock" +) +async def creer_sortie_stock(sortie: SortieStockRequest): + try: + # Préparer les données + sortie_data = sortie.dict() + + logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") + + # Appel à la gateway Windows + resultat = sage_client.creer_sortie_stock(sortie_data) + + logger.info(f"✅ Sortie stock créée: {resultat.get('numero')}") + + return MouvementStockResponse(**resultat) + + except ValueError as e: + logger.warning(f"⚠️ Erreur métier sortie stock: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + except Exception as e: + logger.error(f"❌ Erreur technique sortie stock: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de la sortie: {str(e)}" + ) + + +@app.get( + "/stock/mouvement/{numero}", + response_model=MouvementStockResponse, + tags=["Stock"], + summary="Lecture d'un mouvement de stock" +) +async def lire_mouvement_stock( + numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)") +): + try: + mouvement = sage_client.lire_mouvement_stock(numero) + + if not mouvement: + logger.warning(f"⚠️ Mouvement {numero} introuvable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mouvement de stock {numero} introuvable" + ) + + logger.info(f"✅ Mouvement {numero} lu") + + return MouvementStockResponse(**mouvement) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur lecture mouvement {numero}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture du mouvement: {str(e)}" + ) + + +@app.get( + "/familles/stats/global", + tags=["Familles"], + summary="Statistiques sur les familles", +) +async def statistiques_familles(): + try: + stats = sage_client.get_stats_familles() + + return {"success": True, "data": stats} + + except Exception as e: + logger.error(f"❌ Erreur stats familles: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la récupération des statistiques: {str(e)}", + ) + + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( session: AsyncSession = Depends(get_session), @@ -3937,20 +3742,6 @@ async def lister_utilisateurs_debug( role: Optional[str] = Query(None), verified_only: bool = Query(False), ): - """ - 🔓 **ROUTE DEBUG** - Liste tous les utilisateurs inscrits - - ⚠️ **ATTENTION**: Cette route n'est PAS protégée par authentification. - À utiliser uniquement en développement ou à sécuriser en production. - - Args: - limit: Nombre maximum d'utilisateurs à retourner - role: Filtrer par rôle (user, admin, commercial) - verified_only: Afficher uniquement les utilisateurs vérifiés - - Returns: - Liste des utilisateurs avec leurs informations (mot de passe masqué) - """ from database import User from sqlalchemy import select @@ -4003,11 +3794,6 @@ async def lister_utilisateurs_debug( @app.get("/debug/users/stats", tags=["Debug"]) async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)): - """ - 📊 **ROUTE DEBUG** - Statistiques sur les utilisateurs - - ⚠️ Non protégée - à sécuriser en production - """ from database import User from sqlalchemy import select, func diff --git a/sage_client.py b/sage_client.py index 9c1b2c4..6b9325c 100644 --- a/sage_client.py +++ b/sage_client.py @@ -63,9 +63,6 @@ class SageGatewayClient: raise time.sleep(2**attempt) - # ===================================================== - # CLIENTS - # ===================================================== def lister_clients(self, filtre: str = "") -> List[Dict]: """Liste tous les clients avec filtre optionnel""" return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) @@ -74,9 +71,6 @@ class SageGatewayClient: """Lecture d'un client par code""" return self._post("/sage/clients/get", {"code": code}).get("data") - # ===================================================== - # ARTICLES - # ===================================================== def lister_articles(self, filtre: str = "") -> List[Dict]: """Liste tous les articles avec filtre optionnel""" return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) @@ -85,9 +79,6 @@ class SageGatewayClient: """Lecture d'un article par référence""" return self._post("/sage/articles/get", {"code": ref}).get("data") - # ===================================================== - # DEVIS (US-A1) - # ===================================================== def creer_devis(self, devis_data: Dict) -> Dict: """Création d'un devis""" return self._post("/sage/devis/create", devis_data).get("data", {}) @@ -102,18 +93,12 @@ class SageGatewayClient: statut: Optional[int] = None, inclure_lignes: bool = True, ) -> List[Dict]: - """ - ✅ Liste tous les devis avec filtres - """ payload = {"limit": limit, "inclure_lignes": inclure_lignes} if statut is not None: payload["statut"] = statut return self._post("/sage/devis/list", payload).get("data", []) def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: - """ - ✅ CORRECTION: Utilise query params au lieu du body - """ try: r = requests.post( f"{self.url}/sage/devis/statut", @@ -130,9 +115,6 @@ class SageGatewayClient: logger.error(f"❌ Erreur changement statut: {e}") raise - # ===================================================== - # DOCUMENTS GÉNÉRIQUES - # ===================================================== def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: """Lecture d'un document générique""" return self._post( @@ -142,9 +124,6 @@ class SageGatewayClient: def transformer_document( self, numero_source: str, type_source: int, type_cible: int ) -> Dict: - """ - ✅ CORRECTION: Utilise query params pour la transformation - """ try: r = requests.post( f"{self.url}/sage/documents/transform", @@ -177,15 +156,9 @@ class SageGatewayClient: ) return resp.get("success", False) - # ===================================================== - # COMMANDES (US-A2) - # ===================================================== def lister_commandes( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """ - Utilise l'endpoint /sage/commandes/list qui filtre déjà sur type 10 - """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut @@ -194,10 +167,6 @@ class SageGatewayClient: def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """ - ✅ Liste toutes les factures - Utilise l'endpoint /sage/factures/list qui filtre déjà sur type 60 - """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut @@ -210,24 +179,15 @@ class SageGatewayClient: ) return resp.get("success", False) - # ===================================================== - # CONTACTS (US-A6) - # ===================================================== def lire_contact_client(self, code_client: str) -> Optional[Dict]: """Lecture du contact principal d'un client""" return self._post("/sage/contact/read", {"code": code_client}).get("data") - # ===================================================== - # REMISES (US-A5) - # ===================================================== def lire_remise_max_client(self, code_client: str) -> float: """Récupère la remise max autorisée pour un client""" result = self._post("/sage/client/remise-max", {"code": code_client}) return result.get("data", {}).get("remise_max", 10.0) - # ===================================================== - # GÉNÉRATION PDF (pour email_queue) - # ===================================================== def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: """Génère le PDF d'un document via la gateway Windows""" try: @@ -253,9 +213,6 @@ class SageGatewayClient: logger.error(f"Erreur génération PDF: {e}") raise - # ===================================================== - # PROSPECTS - # ===================================================== def lister_prospects(self, filtre: str = "") -> List[Dict]: """Liste tous les prospects avec filtre optionnel""" return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", []) @@ -264,9 +221,6 @@ class SageGatewayClient: """Lecture d'un prospect par code""" return self._post("/sage/prospects/get", {"code": code}).get("data") - # ===================================================== - # FOURNISSEURS - # ===================================================== def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: """Liste tous les fournisseurs avec filtre optionnel""" return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", []) @@ -276,37 +230,14 @@ class SageGatewayClient: return self._post("/sage/fournisseurs/get", {"code": code}).get("data") def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: - """ - Envoie la requête de création de fournisseur à la gateway Windows. - - Args: - fournisseur_data: Dict contenant intitule, compte_collectif, etc. - - Returns: - Fournisseur créé avec son numéro définitif - """ return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: - """ - ✏️ Modification d'un fournisseur existant - - Args: - code: Code du fournisseur à modifier - fournisseur_data: Dictionnaire contenant les champs à modifier - (seuls les champs présents seront mis à jour) - - Returns: - Fournisseur modifié - """ return self._post( "/sage/fournisseurs/update", {"code": code, "fournisseur_data": fournisseur_data}, ).get("data", {}) - # ===================================================== - # AVOIRS - # ===================================================== def lister_avoirs( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: @@ -320,9 +251,6 @@ class SageGatewayClient: """Lecture d'un avoir avec ses lignes""" return self._post("/sage/avoirs/get", {"code": numero}).get("data") - # ===================================================== - # LIVRAISONS - # ===================================================== def lister_livraisons( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: @@ -359,206 +287,52 @@ class SageGatewayClient: return {"status": "down"} def creer_client(self, client_data: Dict) -> Dict: - """ - Envoie la requête de création de client à la gateway Windows. - :param client_data: Dict contenant intitule, compte_collectif, etc. - """ - # On appelle la route définie dans main.py return self._post("/sage/clients/create", client_data).get("data", {}) def modifier_client(self, code: str, client_data: Dict) -> Dict: - """ - ✏️ Modification d'un client existant - - Args: - code: Code du client à modifier - client_data: Dictionnaire contenant les champs à modifier - (seuls les champs présents seront mis à jour) - - Returns: - Client modifié - """ return self._post( "/sage/clients/update", {"code": code, "client_data": client_data} ).get("data", {}) def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: - """ - ✏️ Modification d'un devis existant - - Args: - numero: Numéro du devis à modifier - devis_data: Dictionnaire contenant les champs à modifier: - - date_devis (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - Returns: - Devis modifié avec totaux recalculés - """ return self._post( "/sage/devis/update", {"numero": numero, "devis_data": devis_data} ).get("data", {}) def creer_commande(self, commande_data: Dict) -> Dict: - """ - ➕ Création d'une nouvelle commande (Bon de commande) - - Args: - commande_data: Dictionnaire contenant: - - client_id (str): Code du client - - date_commande (str, optional): Date au format ISO - - reference (str, optional): Référence externe - - lignes (List[Dict]): Liste des lignes avec: - - article_code (str) - - quantite (float) - - prix_unitaire_ht (float, optional) - - remise_pourcentage (float, optional) - - Returns: - Commande créée avec son numéro et ses totaux - """ return self._post("/sage/commandes/create", commande_data).get("data", {}) def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: - """ - ✏️ Modification d'une commande existante - - Args: - numero: Numéro de la commande à modifier - commande_data: Dictionnaire contenant les champs à modifier: - - date_commande (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - reference (str, optional): Nouvelle référence - - Returns: - Commande modifiée avec totaux recalculés - """ return self._post( "/sage/commandes/update", {"numero": numero, "commande_data": commande_data} ).get("data", {}) def creer_livraison(self, livraison_data: Dict) -> Dict: - """ - ➕ Création d'une nouvelle livraison (Bon de livraison) - """ return self._post("/sage/livraisons/create", livraison_data).get("data", {}) def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: - """ - ✏️ Modification d'une livraison existante - """ return self._post( "/sage/livraisons/update", {"numero": numero, "livraison_data": livraison_data}, ).get("data", {}) def creer_avoir(self, avoir_data: Dict) -> Dict: - """ - ➕ Création d'un avoir (Bon d'avoir) - - Args: - avoir_data: Dictionnaire contenant: - - client_id (str): Code du client - - date_avoir (str, optional): Date au format ISO - - reference (str, optional): Référence externe - - lignes (List[Dict]): Liste des lignes avec: - - article_code (str) - - quantite (float) - - prix_unitaire_ht (float, optional) - - remise_pourcentage (float, optional) - - Returns: - Avoir créé avec son numéro et ses totaux - """ return self._post("/sage/avoirs/create", avoir_data).get("data", {}) def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: - """ - ✏️ Modification d'un avoir existant - - Args: - numero: Numéro de l'avoir à modifier - avoir_data: Dictionnaire contenant les champs à modifier: - - date_avoir (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - reference (str, optional): Nouvelle référence - - Returns: - Avoir modifié avec totaux recalculés - """ return self._post( "/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data} ).get("data", {}) def creer_facture(self, facture_data: Dict) -> Dict: - """ - ➕ Création d'une facture - - Args: - facture_data: Dictionnaire contenant: - - client_id (str): Code du client - - date_facture (str, optional): Date au format ISO - - reference (str, optional): Référence externe - - lignes (List[Dict]): Liste des lignes avec: - - article_code (str) - - quantite (float) - - prix_unitaire_ht (float, optional) - - remise_pourcentage (float, optional) - - Returns: - Facture créée avec son numéro et ses totaux - """ return self._post("/sage/factures/create", facture_data).get("data", {}) def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: - """ - ✏️ Modification d'une facture existante - - Args: - numero: Numéro de la facture à modifier - facture_data: Dictionnaire contenant les champs à modifier: - - date_facture (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - reference (str, optional): Nouvelle référence - - Returns: - Facture modifiée avec totaux recalculés - """ return self._post( "/sage/factures/update", {"numero": numero, "facture_data": facture_data} ).get("data", {}) def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: - """ - 🆕 Génère le PDF d'un document via la gateway Windows (route généralisée) - - **Cette méthode remplace les appels spécifiques par type de document** - - Args: - doc_id: Numéro du document (ex: "DE00001", "FA00001") - type_doc: Type de document Sage: - - 0: Devis - - 10: Bon de commande - - 30: Bon de livraison - - 60: Facture - - 50: Bon d'avoir - - Returns: - bytes: Contenu du PDF (binaire) - - Raises: - ValueError: Si le PDF retourné est vide - RuntimeError: Si erreur de communication avec la gateway - - Example: - >>> pdf_bytes = sage_client.generer_pdf_document("DE00001", 0) - >>> with open("devis.pdf", "wb") as f: - ... f.write(pdf_bytes) - """ try: logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") @@ -610,59 +384,49 @@ class SageGatewayClient: logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) raise - def creer_article(self, article_data: Dict) -> Dict: - """ - ➕ Création d'un article - - Args: - article_data: Dictionnaire contenant: - - reference (str, obligatoire): Référence article - - designation (str, obligatoire): Désignation - - prix_vente (float, optionnel): Prix vente HT - - stock_reel (float, optionnel): Stock initial - - ... (voir ArticleCreateRequest dans main.py) - - Returns: - Article créé - - Example: - >>> article = sage_client.creer_article({ - ... "reference": "ART001", - ... "designation": "Article test", - ... "prix_vente": 10.0, - ... "stock_reel": 100.0 - ... }) - """ return self._post("/sage/articles/create", article_data).get("data", {}) - def modifier_article(self, reference: str, article_data: Dict) -> Dict: - """ - ✏️ Modification d'un article - - **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 - - Args: - reference: Référence de l'article à modifier - article_data: Dictionnaire contenant les champs à modifier: - - stock_reel (float, optionnel): Nouveau stock - - prix_vente (float, optionnel): Nouveau prix - - ... (seuls les champs présents seront mis à jour) - - Returns: - Article modifié - - Example - Résoudre erreur de stock: - >>> # L'erreur 2881 indique un stock insuffisant - >>> sage_client.modifier_article("ART001", { - ... "stock_reel": 100.0 # Augmenter le stock - ... }) - """ return self._post( "/sage/articles/update", - {"reference": reference, "article_data": article_data} + {"reference": reference, "article_data": article_data}, ).get("data", {}) + def lister_familles(self, filtre: str = "") -> List[Dict]: + return self._get("/sage/familles", params={"filtre": filtre}).get("data", []) + + def lire_famille(self, code: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/familles/{code}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture famille {code}: {e}") + return None + + def creer_famille(self, famille_data: Dict) -> Dict: + return self._post("/sage/familles/create", famille_data).get("data", {}) + + def get_stats_familles(self) -> Dict: + return self._get("/sage/familles/stats").get("data", {}) + + + def creer_entree_stock(self, entree_data: Dict) -> Dict: + return self._post("/sage/stock/entree", entree_data).get("data", {}) + + + def creer_sortie_stock(self, sortie_data: Dict) -> Dict: + return self._post("/sage/stock/sortie", sortie_data).get("data", {}) + + + def lire_mouvement_stock(self, numero: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/stock/mouvement/{numero}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture mouvement {numero}: {e}") + return None + + # Instance globale sage_client = SageGatewayClient() From bf4b00ed855b6118d394e26c9c3262a64a53055d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 11:34:45 +0300 Subject: [PATCH 065/199] fix(api): ensure date fields are properly formatted before processing --- api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api.py b/api.py index dd7bd98..8bfd78b 100644 --- a/api.py +++ b/api.py @@ -3622,6 +3622,8 @@ async def creer_entree_stock(entree: EntreeStockRequest): try: # Préparer les données entree_data = entree.dict() + if entree_data.get("date_entree"): + entree_data["date_entree"] = entree_data["date_entree"].isoformat() logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") @@ -3658,6 +3660,8 @@ async def creer_sortie_stock(sortie: SortieStockRequest): try: # Préparer les données sortie_data = sortie.dict() + if sortie_data.get("date_sortie"): + sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") From 737e34067916d69c4e242731f889d9f9e67382a9 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 12:33:05 +0300 Subject: [PATCH 066/199] refactor(api): wrap client and article responses in success object --- api.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 8bfd78b..15df3b4 100644 --- a/api.py +++ b/api.py @@ -1151,7 +1151,9 @@ app.include_router(auth_router) async def rechercher_clients(query: Optional[str] = Query(None)): try: clients = sage_client.lister_clients(filtre=query or "") - return [ClientDetails(**c) for c in clients] + clients_data = [ClientDetails(**c) for c in clients] + return {"success": True, "data": clients_data} + except Exception as e: logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) @@ -1230,7 +1232,8 @@ async def ajouter_client( async def rechercher_articles(query: Optional[str] = Query(None)): try: articles = sage_client.lister_articles(filtre=query or "") - return [ArticleResponse(**a) for a in articles] + articles_data = [ArticleResponse(**a) for a in articles] + return {"success": True, "data": articles_data} except Exception as e: logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) From 421f4d24dc59fb4e6fbdffbf5ea6eb8774e83257 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 12:35:29 +0300 Subject: [PATCH 067/199] refactor(api): simplify client and article search endpoints --- api.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/api.py b/api.py index 15df3b4..8bfd78b 100644 --- a/api.py +++ b/api.py @@ -1151,9 +1151,7 @@ app.include_router(auth_router) async def rechercher_clients(query: Optional[str] = Query(None)): try: clients = sage_client.lister_clients(filtre=query or "") - clients_data = [ClientDetails(**c) for c in clients] - return {"success": True, "data": clients_data} - + return [ClientDetails(**c) for c in clients] except Exception as e: logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) @@ -1232,8 +1230,7 @@ async def ajouter_client( async def rechercher_articles(query: Optional[str] = Query(None)): try: articles = sage_client.lister_articles(filtre=query or "") - articles_data = [ArticleResponse(**a) for a in articles] - return {"success": True, "data": articles_data} + return [ArticleResponse(**a) for a in articles] except Exception as e: logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) From 62e347969c171a58c840dec723cec3cf4ad4cf48 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 14:55:13 +0300 Subject: [PATCH 068/199] feat(stock): enhance stock movement models with lot tracking and min/max stock --- api.py | 154 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 107 insertions(+), 47 deletions(-) diff --git a/api.py b/api.py index 8bfd78b..9cfe688 100644 --- a/api.py +++ b/api.py @@ -914,23 +914,76 @@ class FamilleResponse(BaseModel): } } + class MouvementStockLigneRequest(BaseModel): - """Ligne de mouvement de stock""" article_ref: str = Field(..., description="Référence de l'article") quantite: float = Field(..., gt=0, description="Quantité (>0)") depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')") - prix_unitaire: Optional[float] = Field(None, ge=0, description="Prix unitaire (optionnel)") + prix_unitaire: Optional[float] = Field( + None, ge=0, description="Prix unitaire (optionnel)" + ) commentaire: Optional[str] = Field(None, description="Commentaire ligne") + numero_lot: Optional[str] = Field( + None, description="Numéro de lot (pour FIFO/LIFO)" + ) + stock_mini: Optional[float] = Field( + None, + ge=0, + description="""Stock minimum à définir pour cet article. + Si fourni, met à jour AS_QteMini dans F_ARTSTOCK. + Laisser None pour ne pas modifier.""", + ) + stock_maxi: Optional[float] = Field( + None, + ge=0, + description="""Stock maximum à définir pour cet article. + Doit être > stock_mini si les deux sont fournis.""", + ) + + class Config: + schema_extra = { + "example": { + "article_ref": "ARTS-001", + "quantite": 50.0, + "depot_code": "01", + "prix_unitaire": 100.0, + "commentaire": "Réapprovisionnement", + "numero_lot": "LOT20241217", + "stock_mini": 10.0, + "stock_maxi": 200.0, + } + } + + @validator("stock_maxi") + def validate_stock_maxi(cls, v, values): + """Valide que stock_maxi > stock_mini si les deux sont fournis""" + if ( + v is not None + and "stock_mini" in values + and values["stock_mini"] is not None + ): + if v <= values["stock_mini"]: + raise ValueError( + "stock_maxi doit être strictement supérieur à stock_mini" + ) + return v class EntreeStockRequest(BaseModel): """Création d'un bon d'entrée en stock""" - date_entree: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + + date_entree: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) reference: Optional[str] = Field(None, description="Référence externe") - depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") - lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigneRequest] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) commentaire: Optional[str] = Field(None, description="Commentaire général") - + class Config: json_schema_extra = { "example": { @@ -943,22 +996,29 @@ class EntreeStockRequest(BaseModel): "quantite": 50, "depot_code": "01", "prix_unitaire": 10.50, - "commentaire": "Réception fournisseur" + "commentaire": "Réception fournisseur", } ], - "commentaire": "Réception livraison fournisseur XYZ" + "commentaire": "Réception livraison fournisseur XYZ", } } class SortieStockRequest(BaseModel): """Création d'un bon de sortie de stock""" - date_sortie: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + + date_sortie: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) reference: Optional[str] = Field(None, description="Référence externe") - depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") - lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigneRequest] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) commentaire: Optional[str] = Field(None, description="Commentaire général") - + class Config: json_schema_extra = { "example": { @@ -970,24 +1030,25 @@ class SortieStockRequest(BaseModel): "article_ref": "ART001", "quantite": 10, "depot_code": "01", - "commentaire": "Utilisation interne" + "commentaire": "Utilisation interne", } ], - "commentaire": "Consommation atelier" + "commentaire": "Consommation atelier", } } class MouvementStockResponse(BaseModel): """Réponse pour un mouvement de stock""" + numero: str = Field(..., description="Numéro du mouvement") type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") type_libelle: str = Field(..., description="Libellé du type") date: str = Field(..., description="Date du mouvement") reference: Optional[str] = Field(None, description="Référence externe") nb_lignes: int = Field(..., description="Nombre de lignes") - - + + async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, nom: str ) -> Dict: @@ -1102,6 +1163,7 @@ async def universign_statut(transaction_id: str) -> Dict: logger.error(f"Erreur statut Universign: {e}") return {"statut": "ERREUR", "error": str(e)} + @asynccontextmanager async def lifespan(app: FastAPI): # Init base de données @@ -1805,6 +1867,7 @@ async def changer_statut_devis( logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) + @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): try: @@ -2253,6 +2316,7 @@ async def envoyer_emails_lot( "details": resultats, } + @app.post( "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] ) @@ -2638,6 +2702,7 @@ async def relancer_facture( logger.error(f"Erreur relance facture: {e}") raise HTTPException(500, str(e)) + @app.get("/emails/logs", tags=["Emails"]) async def journal_emails( statut: Optional[StatutEmail] = Query(None), @@ -2733,6 +2798,7 @@ async def exporter_logs_csv( }, ) + class TemplateEmail(BaseModel): id: Optional[str] = None nom: str @@ -3616,7 +3682,7 @@ async def creer_famille(famille: FamilleCreateRequest): response_model=MouvementStockResponse, status_code=status.HTTP_201_CREATED, tags=["Stock"], - summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock" + summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock", ) async def creer_entree_stock(entree: EntreeStockRequest): try: @@ -3624,28 +3690,25 @@ async def creer_entree_stock(entree: EntreeStockRequest): entree_data = entree.dict() if entree_data.get("date_entree"): entree_data["date_entree"] = entree_data["date_entree"].isoformat() - + logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") - + # Appel à la gateway Windows resultat = sage_client.creer_entree_stock(entree_data) - + logger.info(f"✅ Entrée stock créée: {resultat.get('numero')}") - + return MouvementStockResponse(**resultat) - + except ValueError as e: logger.warning(f"⚠️ Erreur métier entrée stock: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la création de l'entrée: {str(e)}" + detail=f"Erreur lors de la création de l'entrée: {str(e)}", ) @@ -3654,7 +3717,7 @@ async def creer_entree_stock(entree: EntreeStockRequest): response_model=MouvementStockResponse, status_code=status.HTTP_201_CREATED, tags=["Stock"], - summary="SORTIE DE STOCK : Retire des articles du stock" + summary="SORTIE DE STOCK : Retire des articles du stock", ) async def creer_sortie_stock(sortie: SortieStockRequest): try: @@ -3662,28 +3725,25 @@ async def creer_sortie_stock(sortie: SortieStockRequest): sortie_data = sortie.dict() if sortie_data.get("date_sortie"): sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() - + logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") - + # Appel à la gateway Windows resultat = sage_client.creer_sortie_stock(sortie_data) - + logger.info(f"✅ Sortie stock créée: {resultat.get('numero')}") - + return MouvementStockResponse(**resultat) - + except ValueError as e: logger.warning(f"⚠️ Erreur métier sortie stock: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: logger.error(f"❌ Erreur technique sortie stock: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la création de la sortie: {str(e)}" + detail=f"Erreur lors de la création de la sortie: {str(e)}", ) @@ -3691,32 +3751,32 @@ async def creer_sortie_stock(sortie: SortieStockRequest): "/stock/mouvement/{numero}", response_model=MouvementStockResponse, tags=["Stock"], - summary="Lecture d'un mouvement de stock" + summary="Lecture d'un mouvement de stock", ) async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)") ): try: mouvement = sage_client.lire_mouvement_stock(numero) - + if not mouvement: logger.warning(f"⚠️ Mouvement {numero} introuvable") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Mouvement de stock {numero} introuvable" + detail=f"Mouvement de stock {numero} introuvable", ) - + logger.info(f"✅ Mouvement {numero} lu") - + return MouvementStockResponse(**mouvement) - + except HTTPException: raise except Exception as e: logger.error(f"❌ Erreur lecture mouvement {numero}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la lecture du mouvement: {str(e)}" + detail=f"Erreur lors de la lecture du mouvement: {str(e)}", ) From 388618603bdd9e0bb5d6066c6aee18626314bfc6 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 16:22:43 +0300 Subject: [PATCH 069/199] feat: add article_ref field to MouvementStockResponse --- api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api.py b/api.py index 9cfe688..7a1086f 100644 --- a/api.py +++ b/api.py @@ -1041,6 +1041,7 @@ class SortieStockRequest(BaseModel): class MouvementStockResponse(BaseModel): """Réponse pour un mouvement de stock""" + article_ref: str = Field(..., description="Numéro d'article") numero: str = Field(..., description="Numéro du mouvement") type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") type_libelle: str = Field(..., description="Libellé du type") From daf96f71ebf394e673a6d87cbb8dbd5cb33c1a01 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 17:25:27 +0300 Subject: [PATCH 070/199] feat: add PDF document generation and model listing functionality --- api.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++ sage_client.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/api.py b/api.py index 7a1086f..166f5af 100644 --- a/api.py +++ b/api.py @@ -3896,7 +3896,60 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) logger.error(f"❌ Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) +@app.get("/modeles", tags=["PDF"]) +async def get_modeles_disponibles(): + """Liste tous les modèles PDF disponibles""" + try: + modeles = sage_client.lister_modeles_disponibles() + return modeles + except Exception as e: + logger.error(f"Erreur listage modèles: {e}") + raise HTTPException(500, str(e)) + +@app.get("/documents/{numero}/pdf", tags=["Documents"]) +async def get_document_pdf( + numero: str, + type_doc: int = Query(..., description="0=devis, 60=facture, etc."), + modele: str = Query(None, description="Nom du modèle (ex: 'Facture client logo.bgc')"), + download: bool = Query(False, description="Télécharger au lieu d'afficher") +): + """ + 📄 Génère et retourne le PDF d'un document + + Exemples: + - GET /documents/DE00001/pdf?type_doc=0 + - GET /documents/FA00123/pdf?type_doc=60&modele=Facture client logo.bgc + - GET /documents/FA00123/pdf?type_doc=60&download=true + """ + try: + # Récupérer le PDF (en bytes) + pdf_bytes = sage_client.generer_pdf_document( + numero=numero, + type_doc=type_doc, + modele=modele, + base64_encode=False # On veut les bytes bruts + ) + + # Retourner le PDF + from fastapi.responses import Response + + disposition = "attachment" if download else "inline" + filename = f"{numero}.pdf" + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'{disposition}; filename="{filename}"' + } + ) + + except Exception as e: + logger.error(f"Erreur génération PDF: {e}") + raise HTTPException(500, str(e)) + + # ===================================================== # LANCEMENT # ===================================================== diff --git a/sage_client.py b/sage_client.py index 6b9325c..7aa034a 100644 --- a/sage_client.py +++ b/sage_client.py @@ -428,5 +428,60 @@ class SageGatewayClient: return None -# Instance globale + def lister_modeles_disponibles(self) -> Dict: + """Liste les modèles Crystal Reports disponibles""" + try: + r = requests.get( + f"{self.url}/sage/modeles/list", + headers=self.headers, + timeout=30 + ) + r.raise_for_status() + return r.json().get("data", {}) + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur listage modèles: {e}") + raise + + + def generer_pdf_document( + self, + numero: str, + type_doc: int, + modele: str = None, + base64_encode: bool = True + ) -> Union[bytes, str, Dict]: + """ + Génère un PDF d'un document Sage + + Returns: + Dict: Avec pdf_base64 si base64_encode=True + bytes: Contenu PDF brut si base64_encode=False + """ + try: + params = { + "type_doc": type_doc, + "base64_encode": base64_encode + } + + if modele: + params["modele"] = modele + + r = requests.get( + f"{self.url}/sage/documents/{numero}/pdf", + params=params, + headers=self.headers, + timeout=60 # PDF peut prendre du temps + ) + r.raise_for_status() + + if base64_encode: + return r.json().get("data", {}) + else: + return r.content + + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur génération PDF: {e}") + raise + + sage_client = SageGatewayClient() From 4cdaea2051b8c3b0489c0e3c6f5dcca9c082036d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 17:26:13 +0300 Subject: [PATCH 071/199] refactor(api): update endpoint tags for better consistency --- api.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/api.py b/api.py index 166f5af..e2e6c03 100644 --- a/api.py +++ b/api.py @@ -3896,7 +3896,7 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) logger.error(f"❌ Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) -@app.get("/modeles", tags=["PDF"]) +@app.get("/modeles", tags=["PDF Sage-Like"]) async def get_modeles_disponibles(): """Liste tous les modèles PDF disponibles""" try: @@ -3907,21 +3907,13 @@ async def get_modeles_disponibles(): raise HTTPException(500, str(e)) -@app.get("/documents/{numero}/pdf", tags=["Documents"]) +@app.get("/documents/{numero}/pdf", tags=["PDF Sage-Like"]) async def get_document_pdf( numero: str, type_doc: int = Query(..., description="0=devis, 60=facture, etc."), modele: str = Query(None, description="Nom du modèle (ex: 'Facture client logo.bgc')"), download: bool = Query(False, description="Télécharger au lieu d'afficher") ): - """ - 📄 Génère et retourne le PDF d'un document - - Exemples: - - GET /documents/DE00001/pdf?type_doc=0 - - GET /documents/FA00123/pdf?type_doc=60&modele=Facture client logo.bgc - - GET /documents/FA00123/pdf?type_doc=60&download=true - """ try: # Récupérer le PDF (en bytes) pdf_bytes = sage_client.generer_pdf_document( From 85da047440ca34a9cd44bd9898c4f9adf32f3375 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 17:28:38 +0300 Subject: [PATCH 072/199] Added missing import --- sage_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sage_client.py b/sage_client.py index 7aa034a..64208ae 100644 --- a/sage_client.py +++ b/sage_client.py @@ -1,5 +1,5 @@ import requests -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from config import settings import logging From de6739e3f5b8d2f1eddbb81708becae5602fe5e4 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 18:21:01 +0300 Subject: [PATCH 073/199] fix: make est_total field optional in FamilleResponse model --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index e2e6c03..c447eb5 100644 --- a/api.py +++ b/api.py @@ -893,7 +893,7 @@ class FamilleResponse(BaseModel): intitule: str = Field(..., description="Intitulé") type: int = Field(..., description="Type (0=Détail, 1=Total)") type_libelle: str = Field(..., description="Libellé du type") - est_total: bool = Field(..., description="True si type Total") + est_total: Optional[bool] = Field(None, description="True si type Total") compte_achat: Optional[str] = Field(None, description="Compte général achat") compte_vente: Optional[str] = Field(None, description="Compte général vente") unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") From 4c53477efedbac537922f3662cc55b5b155d8068 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 18 Dec 2025 04:28:26 +0300 Subject: [PATCH 074/199] refactor(api): replace hardcoded document type with enum value --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index c447eb5..e87f713 100644 --- a/api.py +++ b/api.py @@ -3514,7 +3514,7 @@ async def commande_vers_livraison( try: # Étape 1: Vérifier que la commande existe commande_existante = sage_client.lire_document( - id, settings.SAGE_TYPE_BON_COMMANDE + id, TypeDocumentSQL.BON_COMMANDE ) if not commande_existante: From 282ffe4898879ad3d2a99084387f300c830dc93e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 18 Dec 2025 10:09:48 +0300 Subject: [PATCH 075/199] feat(signature): add email templates and tracking for signature workflow --- api.py | 749 ++++++++++++++++++++++++++++++++++++++++++--- database/models.py | 1 + 2 files changed, 702 insertions(+), 48 deletions(-) diff --git a/api.py b/api.py index e87f713..b4ea354 100644 --- a/api.py +++ b/api.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body +from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, EmailStr, validator, field_validator @@ -1050,34 +1050,389 @@ class MouvementStockResponse(BaseModel): nb_lignes: int = Field(..., description="Nombre de lignes") -async def universign_envoyer( - doc_id: str, pdf_bytes: bytes, email: str, nom: str -) -> Dict: - """Envoi signature via API Universign""" - import requests + +templates_signature_email = { + "demande_signature": { + "id": "demande_signature", + "nom": "Demande de Signature Électronique", + "sujet": "📝 Signature requise - {{TYPE_DOC}} {{NUMERO}}", + "corps_html": """ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ 📝 Signature Électronique Requise +

+
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

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

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

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

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

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

+
+ +

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

+
+

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

+

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

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

+ ✅ Document Signé avec Succès +

+
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

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

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

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

+ + + + + +
+

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

+
+ +

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

+
+

+ Contact : {{CONTACT_EMAIL}} +

+

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

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

+ ⏰ Signature en Attente +

+
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

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

+ + + + + + +
+

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

+

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

+
+ +

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

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

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

+
+

+ Contact : {{CONTACT_EMAIL}} +

+

+ Sage 100c Dataven - Relance automatique +

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

+

📝 Signature Électronique Requise

@@ -1124,7 +1122,7 @@ templates_signature_email = { @@ -1179,10 +1177,9 @@ templates_signature_email = { "DATE", "MONTANT_TTC", "SIGNER_URL", - "CONTACT_EMAIL" - ] + "CONTACT_EMAIL", + ], }, - "signature_confirmee": { "id": "signature_confirmee", "nom": "Confirmation de Signature", @@ -1290,10 +1287,9 @@ templates_signature_email = { "NUMERO", "DATE_SIGNATURE", "TRANSACTION_ID", - "CONTACT_EMAIL" - ] + "CONTACT_EMAIL", + ], }, - "relance_signature": { "id": "relance_signature", "nom": "Relance Signature en Attente", @@ -1393,22 +1389,22 @@ templates_signature_email = { "NB_JOURS", "JOURS_RESTANTS", "SIGNER_URL", - "CONTACT_EMAIL" - ] - } + "CONTACT_EMAIL", + ], + }, } async def universign_envoyer_avec_email( - doc_id: str, - pdf_bytes: bytes, - email: str, + doc_id: str, + pdf_bytes: bytes, + email: str, nom: str, doc_data: Dict, # Données du document (type, montant, date, etc.) - session: AsyncSession + session: AsyncSession, ) -> Dict: import requests - + try: api_key = settings.universign_api_key api_url = settings.universign_api_url @@ -1419,20 +1415,26 @@ async def universign_envoyer_avec_email( auth=auth, json={ "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", - "language": "fr" + "language": "fr", }, timeout=30, ) response.raise_for_status() transaction_id = response.json().get("id") - + logger.info(f"✅ Transaction Universign créée: {transaction_id}") - files = {"file": (f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", pdf_bytes, "application/pdf")} + files = { + "file": ( + f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", + pdf_bytes, + "application/pdf", + ) + } response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=30) response.raise_for_status() file_id = response.json().get("id") - + response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, @@ -1450,7 +1452,7 @@ async def universign_envoyer_avec_email( ) response.raise_for_status() field_id = response.json().get("id") - + response = requests.post( f"{api_url}/transactions/{transaction_id}/signatures", auth=auth, @@ -1460,35 +1462,33 @@ async def universign_envoyer_avec_email( response.raise_for_status() response = requests.post( - f"{api_url}/transactions/{transaction_id}/start", - auth=auth, - timeout=30 + f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 ) response.raise_for_status() - + final_data = response.json() signer_url = ( final_data.get("actions", [{}])[0].get("url", "") if final_data.get("actions") else "" ) - + if not signer_url: raise ValueError("URL de signature non retournée par Universign") - + logger.info(f"✅ Signature Universign démarrée: {transaction_id}") template = templates_signature_email["demande_signature"] - + # Préparer les variables type_labels = { 0: "Devis", - 10: "Commande", + 10: "Commande", 30: "Bon de Livraison", 60: "Facture", - 50: "Avoir" + 50: "Avoir", } - + variables = { "NOM_SIGNATAIRE": nom, "TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"), @@ -1496,17 +1496,17 @@ async def universign_envoyer_avec_email( "DATE": doc_data.get("date", datetime.now().strftime("%d/%m/%Y")), "MONTANT_TTC": f"{doc_data.get('montant_ttc', 0):.2f}", "SIGNER_URL": signer_url, - "CONTACT_EMAIL": settings.smtp_from + "CONTACT_EMAIL": settings.smtp_from, } - + # Remplacer les variables dans le template sujet = template["sujet"] corps = template["corps_html"] - + for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - + # Créer log email email_log = EmailLog( id=str(uuid.uuid4()), @@ -1519,30 +1519,27 @@ async def universign_envoyer_avec_email( date_creation=datetime.now(), nb_tentatives=0, ) - + session.add(email_log) await session.flush() - + # Enqueue l'email email_queue.enqueue(email_log.id) - + logger.info(f"📧 Email de signature envoyé en file: {email}") - + return { "transaction_id": transaction_id, "signer_url": signer_url, "statut": "ENVOYE", "email_log_id": email_log.id, - "email_sent": True + "email_sent": True, } - + except Exception as e: logger.error(f"❌ Erreur Universign+Email: {e}") - return { - "error": str(e), - "statut": "ERREUR", - "email_sent": False - } + return {"error": str(e), "statut": "ERREUR", "email_sent": False} + async def universign_statut(transaction_id: str) -> Dict: """Récupération statut signature""" @@ -2398,6 +2395,7 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) + def normaliser_type_doc(type_doc: int) -> int: TYPES_AUTORISES = {0, 10, 30, 50, 60} @@ -2411,32 +2409,35 @@ def normaliser_type_doc(type_doc: int) -> int: @app.post("/signature/universign/send", tags=["Signatures"]) async def envoyer_signature_optimise( - demande: SignatureRequest, - session: AsyncSession = Depends(get_session) + demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): try: # Récupérer le document depuis Sage - doc = sage_client.lire_document(demande.doc_id, normaliser_type_doc(demande.type_doc)) + doc = sage_client.lire_document( + demande.doc_id, normaliser_type_doc(demande.type_doc) + ) if not doc: raise HTTPException(404, f"Document {demande.doc_id} introuvable") - + # Générer PDF - pdf_bytes = email_queue._generate_pdf(demande.doc_id, normaliser_type_doc(demande.type_doc)) - + pdf_bytes = email_queue._generate_pdf( + demande.doc_id, normaliser_type_doc(demande.type_doc) + ) + # Préparer les données du document pour l'email doc_data = { "type_doc": demande.type_doc, "type_label": { 0: "Devis", 10: "Commande", - 30: "Bon de Livraison", + 30: "Bon de Livraison", 60: "Facture", - 50: "Avoir" + 50: "Avoir", }.get(demande.type_doc, "Document"), "montant_ttc": doc.get("total_ttc", 0), - "date": doc.get("date", datetime.now().strftime("%d/%m/%Y")) + "date": doc.get("date", datetime.now().strftime("%d/%m/%Y")), } - + # Envoi Universign + Email automatique resultat = await universign_envoyer_avec_email( doc_id=demande.doc_id, @@ -2444,12 +2445,12 @@ async def envoyer_signature_optimise( email=demande.email_signataire, nom=demande.nom_signataire, doc_data=doc_data, - session=session + session=session, ) - + if "error" in resultat: raise HTTPException(500, resultat["error"]) - + # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), @@ -2462,97 +2463,98 @@ async def envoyer_signature_optimise( statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), ) - + session.add(signature_log) await session.commit() - + # MAJ champ libre Sage sage_client.mettre_a_jour_champ_libre( - demande.doc_id, - demande.type_doc, - "UniversignID", - resultat["transaction_id"] + demande.doc_id, demande.type_doc, "UniversignID", resultat["transaction_id"] ) - - logger.info(f"✅ Signature envoyée: {demande.doc_id} (Email: {resultat['email_sent']})") - + + logger.info( + f"✅ Signature envoyée: {demande.doc_id} (Email: {resultat['email_sent']})" + ) + return { "success": True, "transaction_id": resultat["transaction_id"], "signer_url": resultat["signer_url"], "email_sent": resultat["email_sent"], "email_log_id": resultat.get("email_log_id"), - "message": f"Demande de signature envoyée à {demande.email_signataire}" + "message": f"Demande de signature envoyée à {demande.email_signataire}", } - + except HTTPException: raise except Exception as e: logger.error(f"❌ Erreur signature: {e}") raise HTTPException(500, str(e)) - - + + @app.post("/webhooks/universign", tags=["Signatures"]) async def webhook_universign( - request: Request, - session: AsyncSession = Depends(get_session) + request: Request, session: AsyncSession = Depends(get_session) ): try: payload = await request.json() - + event_type = payload.get("event") transaction_id = payload.get("transaction_id") - + if not transaction_id: logger.warning("⚠️ Webhook sans transaction_id") return {"status": "ignored"} - + # Chercher la signature dans la DB query = select(SignatureLog).where( SignatureLog.transaction_id == transaction_id ) result = await session.execute(query) signature_log = result.scalar_one_or_none() - + if not signature_log: logger.warning(f"⚠️ Transaction {transaction_id} introuvable en DB") return {"status": "not_found"} - + # ============================================= # TRAITER L'EVENT SELON LE TYPE # ============================================= - + if event_type == "transaction.completed": # ✅ SIGNATURE RÉUSSIE signature_log.statut = StatutSignatureEnum.SIGNE signature_log.date_signature = datetime.now() - + logger.info(f"✅ Signature confirmée: {signature_log.document_id}") - + # ENVOYER EMAIL DE CONFIRMATION template = templates_signature_email["signature_confirmee"] - + type_labels = { - 0: "Devis", 10: "Commande", 30: "Bon de Livraison", - 60: "Facture", 50: "Avoir" + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", } - + variables = { "NOM_SIGNATAIRE": signature_log.nom_signataire, "TYPE_DOC": type_labels.get(signature_log.type_document, "Document"), "NUMERO": signature_log.document_id, "DATE_SIGNATURE": datetime.now().strftime("%d/%m/%Y à %H:%M"), "TRANSACTION_ID": transaction_id, - "CONTACT_EMAIL": settings.smtp_from + "CONTACT_EMAIL": settings.smtp_from, } - + sujet = template["sujet"] corps = template["corps_html"] - + for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - + # Créer email de confirmation email_log = EmailLog( id=str(uuid.uuid4()), @@ -2565,77 +2567,80 @@ async def webhook_universign( date_creation=datetime.now(), nb_tentatives=0, ) - + session.add(email_log) email_queue.enqueue(email_log.id) - - logger.info(f"📧 Email de confirmation envoyé: {signature_log.email_signataire}") - + + logger.info( + f"📧 Email de confirmation envoyé: {signature_log.email_signataire}" + ) + elif event_type == "transaction.refused": # ❌ SIGNATURE REFUSÉE signature_log.statut = StatutSignatureEnum.REFUSE logger.warning(f"❌ Signature refusée: {signature_log.document_id}") - + elif event_type == "transaction.expired": # ⏰ TRANSACTION EXPIRÉE signature_log.statut = StatutSignatureEnum.EXPIRE logger.warning(f"⏰ Transaction expirée: {signature_log.document_id}") - + await session.commit() - + return { "status": "processed", "event": event_type, - "transaction_id": transaction_id + "transaction_id": transaction_id, } - + except Exception as e: logger.error(f"❌ Erreur webhook Universign: {e}") return {"status": "error", "message": str(e)} + @app.get("/admin/signatures/relances-auto", tags=["Admin"]) -async def relancer_signatures_automatique( - session: AsyncSession = Depends(get_session) -): +async def relancer_signatures_automatique(session: AsyncSession = Depends(get_session)): try: from datetime import timedelta - + # Chercher signatures en attente depuis > 7 jours date_limite = datetime.now() - timedelta(days=7) - + query = select(SignatureLog).where( - SignatureLog.statut.in_([ - StatutSignatureEnum.EN_ATTENTE, - StatutSignatureEnum.ENVOYE - ]), + SignatureLog.statut.in_( + [StatutSignatureEnum.EN_ATTENTE, StatutSignatureEnum.ENVOYE] + ), SignatureLog.date_envoi < date_limite, - SignatureLog.nb_relances < 3 # Max 3 relances + SignatureLog.nb_relances < 3, # Max 3 relances ) - + result = await session.execute(query) signatures_a_relancer = result.scalars().all() - + nb_relances = 0 - + for signature in signatures_a_relancer: try: # Calculer jours écoulés nb_jours = (datetime.now() - signature.date_envoi).days jours_restants = 30 - nb_jours # Lien expire après 30 jours - + if jours_restants <= 0: # Transaction expirée signature.statut = StatutSignatureEnum.EXPIRE continue - + # Préparer email de relance template = templates_signature_email["relance_signature"] - + type_labels = { - 0: "Devis", 10: "Commande", 30: "Bon de Livraison", - 60: "Facture", 50: "Avoir" + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", } - + variables = { "NOM_SIGNATAIRE": signature.nom_signataire, "TYPE_DOC": type_labels.get(signature.type_document, "Document"), @@ -2643,16 +2648,16 @@ async def relancer_signatures_automatique( "NB_JOURS": str(nb_jours), "JOURS_RESTANTS": str(jours_restants), "SIGNER_URL": signature.signer_url, - "CONTACT_EMAIL": settings.smtp_from + "CONTACT_EMAIL": settings.smtp_from, } - + sujet = template["sujet"] corps = template["corps_html"] - + for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - + # Créer email de relance email_log = EmailLog( id=str(uuid.uuid4()), @@ -2665,35 +2670,37 @@ async def relancer_signatures_automatique( date_creation=datetime.now(), nb_tentatives=0, ) - + session.add(email_log) email_queue.enqueue(email_log.id) - + # Incrémenter compteur de relances signature.est_relance = True signature.nb_relances = (signature.nb_relances or 0) + 1 - + nb_relances += 1 - - logger.info(f"📧 Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)") - + + logger.info( + f"📧 Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)" + ) + except Exception as e: logger.error(f"❌ Erreur relance signature {signature.id}: {e}") continue - + await session.commit() - + return { "success": True, "signatures_verifiees": len(signatures_a_relancer), "relances_envoyees": nb_relances, - "message": f"{nb_relances} email(s) de relance envoyé(s)" + "message": f"{nb_relances} email(s) de relance envoyé(s)", } - + except Exception as e: logger.error(f"❌ Erreur relances automatiques: {e}") raise HTTPException(500, str(e)) - + @app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): @@ -4166,9 +4173,7 @@ async def commande_vers_livraison( ): try: # Étape 1: Vérifier que la commande existe - commande_existante = sage_client.lire_document( - id, TypeDocumentSQL.BON_COMMANDE - ) + commande_existante = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande_existante: raise HTTPException(404, f"Commande {id} introuvable") @@ -4549,6 +4554,7 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) logger.error(f"❌ Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) + @app.get("/modeles", tags=["PDF Sage-Like"]) async def get_modeles_disponibles(): """Liste tous les modèles PDF disponibles""" @@ -4564,8 +4570,10 @@ async def get_modeles_disponibles(): async def get_document_pdf( numero: str, type_doc: int = Query(..., description="0=devis, 60=facture, etc."), - modele: str = Query(None, description="Nom du modèle (ex: 'Facture client logo.bgc')"), - download: bool = Query(False, description="Télécharger au lieu d'afficher") + modele: str = Query( + None, description="Nom du modèle (ex: 'Facture client logo.bgc')" + ), + download: bool = Query(False, description="Télécharger au lieu d'afficher"), ): try: # Récupérer le PDF (en bytes) @@ -4573,28 +4581,26 @@ async def get_document_pdf( numero=numero, type_doc=type_doc, modele=modele, - base64_encode=False # On veut les bytes bruts + base64_encode=False, # On veut les bytes bruts ) - + # Retourner le PDF from fastapi.responses import Response - + disposition = "attachment" if download else "inline" filename = f"{numero}.pdf" - + return Response( content=pdf_bytes, media_type="application/pdf", - headers={ - "Content-Disposition": f'{disposition}; filename="{filename}"' - } + headers={"Content-Disposition": f'{disposition}; filename="{filename}"'}, ) - + except Exception as e: logger.error(f"Erreur génération PDF: {e}") raise HTTPException(500, str(e)) - - + + # ===================================================== # LANCEMENT # ===================================================== From d8e3fb4b00f24ed06fab1fedb223b8dfb3361d24 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 19 Dec 2025 12:39:35 +0300 Subject: [PATCH 077/199] refactor(api): remove redundant devis status update logic --- api.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/api.py b/api.py index 82e61fe..829bde1 100644 --- a/api.py +++ b/api.py @@ -2315,16 +2315,6 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10 ) - # Étape 2: Mettre à jour le statut du devis à 5 (Transformé) - try: - sage_client.changer_statut_devis(id, nouveau_statut=5) - logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") - except Exception as e: - logger.warning( - f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}" - ) - # On continue même si la MAJ statut échoue - # Étape 3: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), @@ -4121,16 +4111,6 @@ async def devis_vers_facture_direct( type_cible=settings.SAGE_TYPE_FACTURE, # = 60 ) - # Étape 3: Mettre à jour le statut du devis à 5 (Transformé) - try: - sage_client.changer_statut_devis(id, nouveau_statut=5) - logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)") - except Exception as e: - logger.warning( - f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}" - ) - # On continue même si la MAJ statut échoue - # Étape 4: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), From d26a6a0312aacd1bd3a707a80987d39a717a602e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 19 Dec 2025 13:11:26 +0300 Subject: [PATCH 078/199] fix: update devis retrieval to use lire_document method --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 829bde1..6d4f5f9 100644 --- a/api.py +++ b/api.py @@ -2079,7 +2079,7 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): try: - devis = sage_client.lire_devis(id) + devis = sage_client.lire_document(id, 0) if not devis: raise HTTPException(404, f"Devis {id} introuvable") From da4d43dcf7f64ddd5d07e01cdbb6c7088871fe87 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 19 Dec 2025 13:16:56 +0300 Subject: [PATCH 079/199] refactor(api): simplify devis reading by using TypeDocumentSQL enum --- api.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/api.py b/api.py index 6d4f5f9..a3b8523 100644 --- a/api.py +++ b/api.py @@ -2079,19 +2079,11 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): try: - devis = sage_client.lire_document(id, 0) + devis = sage_client.lire_document(id, TypeDocumentSQL.DEVIS) if not devis: raise HTTPException(404, f"Devis {id} introuvable") - # Log informatif - if devis.get("a_deja_ete_transforme"): - docs = devis.get("documents_cibles", []) - logger.info( - f"📊 Devis {id} a été transformé en " - f"{len(docs)} document(s): {[d['numero'] for d in docs]}" - ) - return {"success": True, "data": devis} except HTTPException: From 19ea145bbb0c663d235e5b13637dfc00ea4404f1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 19 Dec 2025 13:23:36 +0300 Subject: [PATCH 080/199] refactor(api): replace hardcoded document types with TypeDocumentSQL enum --- api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api.py b/api.py index a3b8523..14ba956 100644 --- a/api.py +++ b/api.py @@ -1996,7 +1996,7 @@ async def modifier_commande( try: # Vérifier que la commande existe commande_existante = sage_client.lire_document( - id, settings.SAGE_TYPE_BON_COMMANDE + id, TypeDocumentSQL.BON_COMMANDE ) if not commande_existante: @@ -2079,7 +2079,7 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): try: - devis = sage_client.lire_document(id, TypeDocumentSQL.DEVIS) + devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") @@ -3190,7 +3190,7 @@ async def modifier_facture( ): try: # Vérifier que la facture existe - facture_existante = sage_client.lire_document(id, settings.SAGE_TYPE_FACTURE) + facture_existante = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) if not facture_existante: raise HTTPException(404, f"Facture {id} introuvable") @@ -3281,7 +3281,7 @@ async def relancer_facture( ): try: # Lire facture via gateway Windows - facture = sage_client.lire_document(id, TypeDocument.FACTURE) + facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {id} introuvable") From 49341010858d6a813a4b26cc4217ec95ea976f5c Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 09:21:31 +0300 Subject: [PATCH 081/199] feat(api): add optional reference field to DevisRequest --- api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api.py b/api.py index 14ba956..49877b7 100644 --- a/api.py +++ b/api.py @@ -357,6 +357,7 @@ class LigneDevis(BaseModel): class DevisRequest(BaseModel): client_id: str date_devis: Optional[date] = None + reference: Optional[str] = None lignes: List[LigneDevis] From bffca51fcd48be3575d2b86b7b59d69a56e34db3 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 09:46:02 +0300 Subject: [PATCH 082/199] feat(DevisUpdateRequest): add optional reference field to model --- api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api.py b/api.py index 49877b7..248b481 100644 --- a/api.py +++ b/api.py @@ -564,6 +564,7 @@ class DevisUpdateRequest(BaseModel): """Modèle pour modification d'un devis existant""" date_devis: Optional[date] = None + reference: Optional[str] = None lignes: Optional[List[LigneDevis]] = None statut: Optional[int] = Field(None, ge=0, le=6) @@ -571,6 +572,7 @@ class DevisUpdateRequest(BaseModel): json_schema_extra = { "example": { "date_devis": "2024-01-15", + "reference": "DEV-001", "lignes": [ { "article_code": "ART001", From e5fad0ccca8991cfb359c7748822da1a3e0c0f0f Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 09:53:27 +0300 Subject: [PATCH 083/199] feat(devis): add reference field and remove prix_unitaire_ht --- api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api.py b/api.py index 248b481..fb71362 100644 --- a/api.py +++ b/api.py @@ -1850,11 +1850,11 @@ async def creer_devis(devis: DevisRequest): devis_data = { "client_id": devis.client_id, "date_devis": devis.date_devis.isoformat() if devis.date_devis else None, + "reference": devis.reference, "lignes": [ { "article_code": l.article_code, "quantite": l.quantite, - "prix_unitaire_ht": l.prix_unitaire_ht, "remise_pourcentage": l.remise_pourcentage, } for l in devis.lignes @@ -1917,6 +1917,9 @@ async def modifier_devis( if devis_update.statut is not None: update_data["statut"] = devis_update.statut + + if devis_update.reference is not None: + update_data["reference"] = devis_update.reference # Appel à la gateway Windows resultat = sage_client.modifier_devis(id, update_data) From 62c453d7bde8fd0ca994d05a6d30268525e6e76f Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 10:12:58 +0300 Subject: [PATCH 084/199] chore: update gitignore to remove comments and add database file --- .gitignore | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index e1a8191..3a36b60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ -# ================================ -# Python / FastAPI -# ================================ - # Environnements virtuels venv/ .env @@ -35,4 +31,6 @@ htmlcov/ # Docker *~ .build/ -dist/ \ No newline at end of file +dist/ + +data/sage_dataven.db \ No newline at end of file From edfa4a0231adcf2d233584308b15823c10889e7a Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 14:34:16 +0300 Subject: [PATCH 085/199] refactor(api): remove redundant document status validation checks --- api.py | 99 ++-------------------------------------------------------- 1 file changed, 2 insertions(+), 97 deletions(-) diff --git a/api.py b/api.py index fb71362..c95d95e 100644 --- a/api.py +++ b/api.py @@ -636,6 +636,8 @@ class CommandeUpdateRequest(BaseModel): class Config: json_schema_extra = { "example": { + "date_commande": "2024-01-15", + "reference": "CMD-EXT-001", "lignes": [ { "article_code": "ART001", @@ -1887,18 +1889,6 @@ async def modifier_devis( session: AsyncSession = Depends(get_session), ): 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: @@ -2000,29 +1990,6 @@ async def modifier_commande( session: AsyncSession = Depends(get_session), ): try: - # Vérifier que la commande existe - commande_existante = sage_client.lire_document( - id, TypeDocumentSQL.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: @@ -3195,27 +3162,6 @@ async def modifier_facture( session: AsyncSession = Depends(get_session), ): try: - # Vérifier que la facture existe - facture_existante = sage_client.lire_document(id, TypeDocumentSQL.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: @@ -3832,26 +3778,6 @@ async def modifier_avoir( session: AsyncSession = Depends(get_session), ): 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: @@ -3984,27 +3910,6 @@ async def modifier_livraison( session: AsyncSession = Depends(get_session), ): 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: From dbb2a6f16e6cacd0e64607bc27b50de03e92688f Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 16:19:23 +0300 Subject: [PATCH 086/199] feat(api): add date fields to document models --- api.py | 39 +++++++++++++++++++++++++++++++-- sage_client.py | 58 +++++++++++--------------------------------------- 2 files changed, 49 insertions(+), 48 deletions(-) diff --git a/api.py b/api.py index c95d95e..d8a6fb4 100644 --- a/api.py +++ b/api.py @@ -357,6 +357,8 @@ class LigneDevis(BaseModel): class DevisRequest(BaseModel): client_id: str date_devis: Optional[date] = None + date_livraison: Optional[date] = None + date_expedition: Optional[date] = None reference: Optional[str] = None lignes: List[LigneDevis] @@ -564,6 +566,8 @@ class DevisUpdateRequest(BaseModel): """Modèle pour modification d'un devis existant""" date_devis: Optional[date] = None + date_livraison: Optional[date] = None + date_expedition: Optional[date] = None reference: Optional[str] = None lignes: Optional[List[LigneDevis]] = None statut: Optional[int] = Field(None, ge=0, le=6) @@ -572,6 +576,8 @@ class DevisUpdateRequest(BaseModel): json_schema_extra = { "example": { "date_devis": "2024-01-15", + "date_livraison": "2024-01-15", + "date_expedition": "2024-01-15", "reference": "DEV-001", "lignes": [ { @@ -604,8 +610,10 @@ class CommandeCreateRequest(BaseModel): client_id: str date_commande: Optional[date] = None + date_livraison: Optional[date] = None + date_expedition: Optional[date] = None lignes: List[LigneCommande] - reference: Optional[str] = None # Référence externe + reference: Optional[str] = None class Config: json_schema_extra = { @@ -629,6 +637,8 @@ class CommandeUpdateRequest(BaseModel): """Modification d'une commande existante""" date_commande: Optional[date] = None + date_livraison: Optional[date] = None + date_expedition: Optional[date] = None lignes: Optional[List[LigneCommande]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None @@ -637,6 +647,8 @@ class CommandeUpdateRequest(BaseModel): json_schema_extra = { "example": { "date_commande": "2024-01-15", + "date_livraison": "2024-01-15", + "date_expedition": "2024-01-15", "reference": "CMD-EXT-001", "lignes": [ { @@ -668,6 +680,8 @@ class LivraisonCreateRequest(BaseModel): client_id: str date_livraison: Optional[date] = None + date_livraison_prevue: Optional[date] = None + date_expedition: Optional[date] = None lignes: List[LigneLivraison] reference: Optional[str] = None @@ -693,6 +707,8 @@ class LivraisonUpdateRequest(BaseModel): """Modification d'une livraison existante""" date_livraison: Optional[date] = None + date_livraison_prevue: Optional[date] = None + date_expedition: Optional[date] = None lignes: Optional[List[LigneLivraison]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None @@ -700,6 +716,10 @@ class LivraisonUpdateRequest(BaseModel): class Config: json_schema_extra = { "example": { + "date_livraison": "2024-01-15", + "date_livraison_prevue": "2024-01-15", + "date_expedition": "2024-01-15", + "reference": "BL-EXT-001", "lignes": [ { "article_code": "ART001", @@ -730,6 +750,8 @@ class AvoirCreateRequest(BaseModel): client_id: str date_avoir: Optional[date] = None + date_livraison: Optional[date] = None + date_expedition: Optional[date] = None lignes: List[LigneAvoir] reference: Optional[str] = None @@ -755,6 +777,8 @@ class AvoirUpdateRequest(BaseModel): """Modification d'un avoir existant""" date_avoir: Optional[date] = None + date_livraison: Optional[date] = None + date_expedition: Optional[date] = None lignes: Optional[List[LigneAvoir]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None @@ -762,6 +786,10 @@ class AvoirUpdateRequest(BaseModel): class Config: json_schema_extra = { "example": { + "date_avoir": "2024-01-15", + "date_livraison": "2024-01-15", + "date_expedition": "2024-01-15", + "reference": "AV-EXT-001", "lignes": [ { "article_code": "ART001", @@ -792,6 +820,8 @@ class FactureCreateRequest(BaseModel): client_id: str date_facture: Optional[date] = None + date_livraison: Optional[date] = None + date_expedition: Optional[date] = None lignes: List[LigneFacture] reference: Optional[str] = None @@ -817,6 +847,8 @@ class FactureUpdateRequest(BaseModel): """Modification d'une facture existante""" date_facture: Optional[date] = None + date_livraison: Optional[date] = None + date_expedition: Optional[date] = None lignes: Optional[List[LigneFacture]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None @@ -824,6 +856,9 @@ class FactureUpdateRequest(BaseModel): class Config: json_schema_extra = { "example": { + "date_facture": "2024-01-15", + "date_livraison": "2024-01-15", + "date_expedition": "2024-01-15", "lignes": [ { "article_code": "ART001", @@ -1907,7 +1942,7 @@ async def modifier_devis( if devis_update.statut is not None: update_data["statut"] = devis_update.statut - + if devis_update.reference is not None: update_data["reference"] = devis_update.reference diff --git a/sage_client.py b/sage_client.py index 64208ae..631669d 100644 --- a/sage_client.py +++ b/sage_client.py @@ -7,10 +7,6 @@ logger = logging.getLogger(__name__) class SageGatewayClient: - """ - Client HTTP pour communiquer avec la gateway Sage Windows - """ - def __init__(self): self.url = settings.sage_gateway_url.rstrip("/") self.headers = { @@ -264,9 +260,6 @@ class SageGatewayClient: """Lecture d'une livraison avec ses lignes""" return self._post("/sage/livraisons/get", {"code": numero}).get("data") - # ===================================================== - # CACHE (ADMIN) - # ===================================================== def refresh_cache(self) -> Dict: """Force le rafraîchissement du cache Windows""" return self._post("/sage/cache/refresh") @@ -275,9 +268,6 @@ class SageGatewayClient: """Récupère les infos du cache Windows""" return self._get("/sage/cache/info").get("data", {}) - # ===================================================== - # HEALTH - # ===================================================== def health(self) -> dict: """Health check de la gateway Windows""" try: @@ -336,12 +326,11 @@ class SageGatewayClient: try: logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") - # Appel HTTP vers la gateway Windows r = requests.post( f"{self.url}/sage/documents/generate-pdf", json={"doc_id": doc_id, "type_doc": type_doc}, headers=self.headers, - timeout=60, # Timeout élevé pour génération PDF + timeout=60, ) r.raise_for_status() @@ -350,7 +339,6 @@ class SageGatewayClient: response_data = r.json() - # Vérifier que la réponse contient bien le PDF if not response_data.get("success"): error_msg = response_data.get("error", "Erreur inconnue") raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}") @@ -362,7 +350,6 @@ class SageGatewayClient: f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})" ) - # Décoder le base64 pdf_bytes = base64.b64decode(pdf_base64) logger.info(f"✅ PDF décodé: {len(pdf_bytes)} octets") @@ -409,16 +396,13 @@ class SageGatewayClient: def get_stats_familles(self) -> Dict: return self._get("/sage/familles/stats").get("data", {}) - - + def creer_entree_stock(self, entree_data: Dict) -> Dict: return self._post("/sage/stock/entree", entree_data).get("data", {}) - def creer_sortie_stock(self, sortie_data: Dict) -> Dict: return self._post("/sage/stock/sortie", sortie_data).get("data", {}) - def lire_mouvement_stock(self, numero: str) -> Optional[Dict]: try: response = self._get(f"/sage/stock/mouvement/{numero}") @@ -427,14 +411,11 @@ class SageGatewayClient: logger.error(f"Erreur lecture mouvement {numero}: {e}") return None - def lister_modeles_disponibles(self) -> Dict: """Liste les modèles Crystal Reports disponibles""" try: r = requests.get( - f"{self.url}/sage/modeles/list", - headers=self.headers, - timeout=30 + f"{self.url}/sage/modeles/list", headers=self.headers, timeout=30 ) r.raise_for_status() return r.json().get("data", {}) @@ -442,46 +423,31 @@ class SageGatewayClient: logger.error(f"❌ Erreur listage modèles: {e}") raise - def generer_pdf_document( - self, - numero: str, - type_doc: int, - modele: str = None, - base64_encode: bool = True + self, numero: str, type_doc: int, modele: str = None, base64_encode: bool = True ) -> Union[bytes, str, Dict]: - """ - Génère un PDF d'un document Sage - - Returns: - Dict: Avec pdf_base64 si base64_encode=True - bytes: Contenu PDF brut si base64_encode=False - """ try: - params = { - "type_doc": type_doc, - "base64_encode": base64_encode - } - + params = {"type_doc": type_doc, "base64_encode": base64_encode} + if modele: params["modele"] = modele - + r = requests.get( f"{self.url}/sage/documents/{numero}/pdf", params=params, headers=self.headers, - timeout=60 # PDF peut prendre du temps + timeout=60, ) r.raise_for_status() - + if base64_encode: return r.json().get("data", {}) else: return r.content - + except requests.exceptions.RequestException as e: logger.error(f"❌ Erreur génération PDF: {e}") raise - - + + sage_client = SageGatewayClient() From 1240a118e5f34550bb799762c6fe29cdf08a12d1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 16:30:48 +0300 Subject: [PATCH 087/199] Updated pydantic schemas, deleted client's retrieving logics on creating and updating document --- api.py | 692 +++++++++++++++++++++++++++++---------------------------- 1 file changed, 349 insertions(+), 343 deletions(-) diff --git a/api.py b/api.py index d8a6fb4..fc18158 100644 --- a/api.py +++ b/api.py @@ -1096,331 +1096,331 @@ templates_signature_email = { "nom": "Demande de Signature Électronique", "sujet": "📝 Signature requise - {{TYPE_DOC}} {{NUMERO}}", "corps_html": """ - - - - - - - -
- + ✍️ Signer le document
- - + +
- - - - - - - - - -
-

- 📝 Signature Électronique Requise -

-
-

- Bonjour {{NOM_SIGNATAIRE}}, -

+ + + + + + + + + + + + -

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

+ + + + - -
+ -

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

+ + + + - -
+

+ 📝 Signature Électronique Requise +

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

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

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

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

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

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

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

+
+ +

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

+
+

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

+

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

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

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

- - - - - - - - -
+ - -
- - - -
-

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

-
+ +
+

+ ✅ Document Signé avec Succès +

+
-

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

-

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

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

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

+ + + + - -
-

- ✅ Document Signé avec Succès -

-
-

- Bonjour {{NOM_SIGNATAIRE}}, -

+ +
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

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

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

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

+ + + + + +
+

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

+
+ +

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

+
+

+ Contact : {{CONTACT_EMAIL}} +

+

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

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

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

- - - - - - - - -
+ -

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

+ + + + -
+

+ ⏰ Signature en Attente +

+
- - - -
-

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

-
+ +
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

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

+ + + + + + +
+

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

+

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

+
+ +

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

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

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

+
-

- Contact : {{CONTACT_EMAIL}} -

-

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

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

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

- - -
-

- ⏰ Signature en Attente -

-
-

- Bonjour {{NOM_SIGNATAIRE}}, -

+ +
+

+ Contact : {{CONTACT_EMAIL}} +

+

+ Sage 100c Dataven - Relance automatique +

+
- - - -
-

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

-

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

-
- -

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

- - - - - - -
- - ✍️ Signer maintenant - -
- -

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

-
-

- Contact : {{CONTACT_EMAIL}} -

-

- Sage 100c Dataven - Relance automatique -

-
- - - - - + + + + + + """, "variables_disponibles": [ "NOM_SIGNATAIRE", @@ -1887,6 +1887,12 @@ async def creer_devis(devis: DevisRequest): devis_data = { "client_id": devis.client_id, "date_devis": devis.date_devis.isoformat() if devis.date_devis else None, + "date_livraison": ( + devis.date_livraison.isoformat() if devis.date_livraison else None + ), + "date_expedition": ( + devis.date_expedition.isoformat() if devis.date_expedition else None + ), "reference": devis.reference, "lignes": [ { @@ -1969,17 +1975,19 @@ async def creer_commande( commande: CommandeCreateRequest, session: AsyncSession = Depends(get_session) ): 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 ), + "date_livraison": ( + commande.date_livraison.isoformat() if commande.date_livraison else None + ), + "date_expedition": ( + commande.date_expedition.isoformat() + if commande.date_expedition + else None + ), "reference": commande.reference, "lignes": [ { @@ -2184,12 +2192,6 @@ async def envoyer_devis_email( id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) ): 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 = [] @@ -3141,17 +3143,17 @@ async def creer_facture( facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) ): 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 ), + "date_livraison": ( + facture.date_livraison.isoformat() if facture.date_livraison else None + ), + "date_expedition": ( + facture.date_expedition.isoformat() if facture.date_expedition else None + ), "reference": facture.reference, "lignes": [ { @@ -3759,15 +3761,15 @@ async def creer_avoir( avoir: AvoirCreateRequest, session: AsyncSession = Depends(get_session) ): 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), + "date_livraison": ( + facture.date_livraison.isoformat() if facture.date_livraison else None + ), + "date_expedition": ( + facture.date_expedition.isoformat() if facture.date_expedition else None + ), "reference": avoir.reference, "lignes": [ { @@ -3887,12 +3889,6 @@ async def creer_livraison( livraison: LivraisonCreateRequest, session: AsyncSession = Depends(get_session) ): 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": ( @@ -3900,6 +3896,16 @@ async def creer_livraison( if livraison.date_livraison else None ), + "date_livraison_prevue": ( + livraison.date_livraison_prevue.isoformat() + if livraison.date_livraison_prevue + else None + ), + "date_expedition": ( + livraison.date_expedition.isoformat() + if livraison.date_expedition + else None + ), "reference": livraison.reference, "lignes": [ { From 3511b000d53298d3bcfee36661e10597b80ed7cb Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 16:33:26 +0300 Subject: [PATCH 088/199] refactor(models): remove prix_unitaire_ht from line item models --- api.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/api.py b/api.py index fc18158..f219dcf 100644 --- a/api.py +++ b/api.py @@ -346,7 +346,6 @@ class ArticleResponse(BaseModel): 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") @@ -597,7 +596,6 @@ class LigneCommande(BaseModel): article_code: str quantite: float - prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 @field_validator("article_code", mode="before") @@ -667,7 +665,6 @@ class LigneLivraison(BaseModel): article_code: str quantite: float - prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 @field_validator("article_code", mode="before") @@ -737,7 +734,6 @@ class LigneAvoir(BaseModel): article_code: str quantite: float - prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 @field_validator("article_code", mode="before") @@ -807,7 +803,6 @@ class LigneFacture(BaseModel): article_code: str quantite: float - prix_unitaire_ht: Optional[float] = None remise_pourcentage: Optional[float] = 0.0 @field_validator("article_code", mode="before") @@ -1940,7 +1935,6 @@ async def modifier_devis( { "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 @@ -1993,7 +1987,6 @@ async def creer_commande( { "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 @@ -2043,7 +2036,6 @@ async def modifier_commande( { "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 @@ -3159,7 +3151,6 @@ async def creer_facture( { "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 @@ -3209,7 +3200,6 @@ async def modifier_facture( { "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 @@ -3775,7 +3765,6 @@ async def creer_avoir( { "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 @@ -3825,7 +3814,6 @@ async def modifier_avoir( { "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 @@ -3911,7 +3899,6 @@ async def creer_livraison( { "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 @@ -3961,7 +3948,6 @@ async def modifier_livraison( { "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 From ac72d6f896839ee2cff62f813f03a8e255756e0c Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 20 Dec 2025 17:29:04 +0300 Subject: [PATCH 089/199] Removed "date_expedition" handling --- api.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/api.py b/api.py index f219dcf..127ae9a 100644 --- a/api.py +++ b/api.py @@ -357,7 +357,6 @@ class DevisRequest(BaseModel): client_id: str date_devis: Optional[date] = None date_livraison: Optional[date] = None - date_expedition: Optional[date] = None reference: Optional[str] = None lignes: List[LigneDevis] @@ -566,7 +565,6 @@ class DevisUpdateRequest(BaseModel): date_devis: Optional[date] = None date_livraison: Optional[date] = None - date_expedition: Optional[date] = None reference: Optional[str] = None lignes: Optional[List[LigneDevis]] = None statut: Optional[int] = Field(None, ge=0, le=6) @@ -576,7 +574,6 @@ class DevisUpdateRequest(BaseModel): "example": { "date_devis": "2024-01-15", "date_livraison": "2024-01-15", - "date_expedition": "2024-01-15", "reference": "DEV-001", "lignes": [ { @@ -609,7 +606,6 @@ class CommandeCreateRequest(BaseModel): client_id: str date_commande: Optional[date] = None date_livraison: Optional[date] = None - date_expedition: Optional[date] = None lignes: List[LigneCommande] reference: Optional[str] = None @@ -636,7 +632,6 @@ class CommandeUpdateRequest(BaseModel): date_commande: Optional[date] = None date_livraison: Optional[date] = None - date_expedition: Optional[date] = None lignes: Optional[List[LigneCommande]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None @@ -646,7 +641,6 @@ class CommandeUpdateRequest(BaseModel): "example": { "date_commande": "2024-01-15", "date_livraison": "2024-01-15", - "date_expedition": "2024-01-15", "reference": "CMD-EXT-001", "lignes": [ { @@ -678,7 +672,6 @@ class LivraisonCreateRequest(BaseModel): client_id: str date_livraison: Optional[date] = None date_livraison_prevue: Optional[date] = None - date_expedition: Optional[date] = None lignes: List[LigneLivraison] reference: Optional[str] = None @@ -705,7 +698,6 @@ class LivraisonUpdateRequest(BaseModel): date_livraison: Optional[date] = None date_livraison_prevue: Optional[date] = None - date_expedition: Optional[date] = None lignes: Optional[List[LigneLivraison]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None @@ -715,7 +707,6 @@ class LivraisonUpdateRequest(BaseModel): "example": { "date_livraison": "2024-01-15", "date_livraison_prevue": "2024-01-15", - "date_expedition": "2024-01-15", "reference": "BL-EXT-001", "lignes": [ { @@ -747,7 +738,6 @@ class AvoirCreateRequest(BaseModel): client_id: str date_avoir: Optional[date] = None date_livraison: Optional[date] = None - date_expedition: Optional[date] = None lignes: List[LigneAvoir] reference: Optional[str] = None @@ -774,7 +764,6 @@ class AvoirUpdateRequest(BaseModel): date_avoir: Optional[date] = None date_livraison: Optional[date] = None - date_expedition: Optional[date] = None lignes: Optional[List[LigneAvoir]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None @@ -784,7 +773,6 @@ class AvoirUpdateRequest(BaseModel): "example": { "date_avoir": "2024-01-15", "date_livraison": "2024-01-15", - "date_expedition": "2024-01-15", "reference": "AV-EXT-001", "lignes": [ { @@ -816,7 +804,6 @@ class FactureCreateRequest(BaseModel): client_id: str date_facture: Optional[date] = None date_livraison: Optional[date] = None - date_expedition: Optional[date] = None lignes: List[LigneFacture] reference: Optional[str] = None @@ -843,7 +830,6 @@ class FactureUpdateRequest(BaseModel): date_facture: Optional[date] = None date_livraison: Optional[date] = None - date_expedition: Optional[date] = None lignes: Optional[List[LigneFacture]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None @@ -853,7 +839,6 @@ class FactureUpdateRequest(BaseModel): "example": { "date_facture": "2024-01-15", "date_livraison": "2024-01-15", - "date_expedition": "2024-01-15", "lignes": [ { "article_code": "ART001", @@ -1885,9 +1870,6 @@ async def creer_devis(devis: DevisRequest): "date_livraison": ( devis.date_livraison.isoformat() if devis.date_livraison else None ), - "date_expedition": ( - devis.date_expedition.isoformat() if devis.date_expedition else None - ), "reference": devis.reference, "lignes": [ { @@ -1977,11 +1959,6 @@ async def creer_commande( "date_livraison": ( commande.date_livraison.isoformat() if commande.date_livraison else None ), - "date_expedition": ( - commande.date_expedition.isoformat() - if commande.date_expedition - else None - ), "reference": commande.reference, "lignes": [ { @@ -3143,9 +3120,6 @@ async def creer_facture( "date_livraison": ( facture.date_livraison.isoformat() if facture.date_livraison else None ), - "date_expedition": ( - facture.date_expedition.isoformat() if facture.date_expedition else None - ), "reference": facture.reference, "lignes": [ { @@ -3757,9 +3731,6 @@ async def creer_avoir( "date_livraison": ( facture.date_livraison.isoformat() if facture.date_livraison else None ), - "date_expedition": ( - facture.date_expedition.isoformat() if facture.date_expedition else None - ), "reference": avoir.reference, "lignes": [ { @@ -3889,11 +3860,6 @@ async def creer_livraison( if livraison.date_livraison_prevue else None ), - "date_expedition": ( - livraison.date_expedition.isoformat() - if livraison.date_expedition - else None - ), "reference": livraison.reference, "lignes": [ { From 2705de7a071368e49034789a4a5aa57971eb5ce5 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sun, 21 Dec 2025 10:57:26 +0300 Subject: [PATCH 090/199] fix: correct date_livraison reference in creer_avoir function --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 127ae9a..c533c5a 100644 --- a/api.py +++ b/api.py @@ -3729,7 +3729,7 @@ async def creer_avoir( "client_id": avoir.client_id, "date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None), "date_livraison": ( - facture.date_livraison.isoformat() if facture.date_livraison else None + avoir.date_livraison.isoformat() if avoir.date_livraison else None ), "reference": avoir.reference, "lignes": [ From dbdfa1e2dfeee29d908bae71a119b6684c98eaa0 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 22 Dec 2025 09:56:38 +0300 Subject: [PATCH 091/199] Refactored universign message function --- api.py | 142 +++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 123 insertions(+), 19 deletions(-) diff --git a/api.py b/api.py index c533c5a..ab46c3c 100644 --- a/api.py +++ b/api.py @@ -1420,7 +1420,7 @@ async def universign_envoyer_avec_email( pdf_bytes: bytes, email: str, nom: str, - doc_data: Dict, # Données du document (type, montant, date, etc.) + doc_data: Dict, session: AsyncSession, ) -> Dict: import requests @@ -1430,20 +1430,36 @@ async def universign_envoyer_avec_email( api_url = settings.universign_api_url auth = (api_key, "") + # ======================================== + # ÉTAPE 1 : Créer la transaction + # ======================================== + logger.info(f"🔐 Création transaction Universign pour {email}") + response = requests.post( f"{api_url}/transactions", auth=auth, json={ "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", "language": "fr", + "profile": "default", # ✅ Ajout du profil }, timeout=30, ) + + if response.status_code != 200: + logger.error(f"❌ Erreur création transaction: {response.status_code} - {response.text}") + raise Exception(f"Erreur création transaction: {response.status_code}") + response.raise_for_status() transaction_id = response.json().get("id") - logger.info(f"✅ Transaction Universign créée: {transaction_id}") + logger.info(f"✅ Transaction créée: {transaction_id}") + # ======================================== + # ÉTAPE 2 : Upload du fichier PDF + # ======================================== + logger.info(f"📄 Upload PDF ({len(pdf_bytes)} octets)") + files = { "file": ( f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", @@ -1451,56 +1467,138 @@ async def universign_envoyer_avec_email( "application/pdf", ) } - response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=30) + response = requests.post( + f"{api_url}/files", + auth=auth, + files=files, + timeout=30 + ) + + if response.status_code != 200: + logger.error(f"❌ Erreur upload fichier: {response.status_code} - {response.text}") + raise Exception(f"Erreur upload fichier: {response.status_code}") + response.raise_for_status() file_id = response.json().get("id") + logger.info(f"✅ Fichier uploadé: {file_id}") + + # ======================================== + # ÉTAPE 3 : Créer le document dans la transaction + # ======================================== + logger.info(f"📋 Ajout document à la transaction") + response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, - data={"document": file_id}, + json={"file": file_id}, # ✅ Utiliser 'file' au lieu de 'document' timeout=30, ) + + if response.status_code != 200: + logger.error(f"❌ Erreur ajout document: {response.status_code} - {response.text}") + raise Exception(f"Erreur ajout document: {response.status_code}") + response.raise_for_status() document_id = response.json().get("id") + logger.info(f"✅ Document ajouté: {document_id}") + + # ======================================== + # ÉTAPE 4 : Ajouter un champ de signature + # ======================================== + logger.info(f"✍️ Ajout champ signature") + response = requests.post( f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", auth=auth, - data={"type": "signature"}, + json={ + "type": "signature", + "page": 1, # ✅ Préciser la page + # Position optionnelle - Universign peut la placer automatiquement + # "x": 100, + # "y": 600, + }, timeout=30, ) + + if response.status_code != 200: + logger.error(f"❌ Erreur ajout champ: {response.status_code} - {response.text}") + raise Exception(f"Erreur ajout champ signature: {response.status_code}") + response.raise_for_status() field_id = response.json().get("id") + logger.info(f"✅ Champ signature créé: {field_id}") + + # ======================================== + # ÉTAPE 5 : Ajouter le signataire + # ======================================== + logger.info(f"👤 Ajout signataire: {nom} ({email})") + response = requests.post( - f"{api_url}/transactions/{transaction_id}/signatures", + f"{api_url}/transactions/{transaction_id}/signers", auth=auth, - data={"signer": email, "field": field_id}, + json={ + "email": email, + "firstName": nom.split()[0] if ' ' in nom else nom, # ✅ Prénom + "lastName": nom.split()[-1] if ' ' in nom else "", # ✅ Nom + # ✅ Lier le signataire au champ de signature + "fields": [field_id], + }, timeout=30, ) + + if response.status_code != 200: + logger.error(f"❌ Erreur ajout signataire: {response.status_code} - {response.text}") + raise Exception(f"Erreur ajout signataire: {response.status_code}") + response.raise_for_status() + signer_id = response.json().get("id") + logger.info(f"✅ Signataire ajouté: {signer_id}") + + # ======================================== + # ÉTAPE 6 : Démarrer la transaction + # ======================================== + logger.info(f"🚀 Démarrage de la transaction") + response = requests.post( - f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 + f"{api_url}/transactions/{transaction_id}/start", + auth=auth, + json={}, # ✅ Body vide mais présent + timeout=30 ) + + if response.status_code != 200: + logger.error(f"❌ Erreur démarrage transaction: {response.status_code}") + logger.error(f"Réponse complète: {response.text}") + raise Exception(f"Erreur démarrage transaction: {response.status_code} - {response.text}") + response.raise_for_status() - final_data = response.json() - signer_url = ( - final_data.get("actions", [{}])[0].get("url", "") - if final_data.get("actions") - else "" - ) + + # ======================================== + # ÉTAPE 7 : Récupérer l'URL de signature + # ======================================== + signer_url = "" + if final_data.get("signers"): + for signer in final_data["signers"]: + if signer.get("email") == email: + signer_url = signer.get("url", "") + break if not signer_url: + logger.warning("⚠️ URL de signature non trouvée dans la réponse") raise ValueError("URL de signature non retournée par Universign") - logger.info(f"✅ Signature Universign démarrée: {transaction_id}") + logger.info(f"✅ Signature Universign prête: {transaction_id}") + # ======================================== + # ÉTAPE 8 : Créer l'email de notification + # ======================================== template = templates_signature_email["demande_signature"] - # Préparer les variables type_labels = { 0: "Devis", 10: "Commande", @@ -1519,7 +1617,6 @@ async def universign_envoyer_avec_email( "CONTACT_EMAIL": settings.smtp_from, } - # Remplacer les variables dans le template sujet = template["sujet"] corps = template["corps_html"] @@ -1556,11 +1653,18 @@ async def universign_envoyer_avec_email( "email_sent": True, } + except requests.exceptions.HTTPError as e: + logger.error(f"❌ Erreur HTTP Universign: {e}") + logger.error(f"Réponse: {e.response.text if e.response else 'N/A'}") + return { + "error": f"Erreur Universign: {e.response.status_code} - {e.response.text if e.response else str(e)}", + "statut": "ERREUR", + "email_sent": False, + } except Exception as e: - logger.error(f"❌ Erreur Universign+Email: {e}") + logger.error(f"❌ Erreur Universign+Email: {e}", exc_info=True) return {"error": str(e), "statut": "ERREUR", "email_sent": False} - async def universign_statut(transaction_id: str) -> Dict: """Récupération statut signature""" import requests From 0a6dfcdf64c51314353c5d91d7345fd83aa314c4 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 22 Dec 2025 10:11:46 +0300 Subject: [PATCH 092/199] fix(api): correct parameter name in universign request --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index ab46c3c..c00a411 100644 --- a/api.py +++ b/api.py @@ -1491,7 +1491,7 @@ async def universign_envoyer_avec_email( response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, - json={"file": file_id}, # ✅ Utiliser 'file' au lieu de 'document' + json={"document": file_id}, # ✅ Utiliser 'file' au lieu de 'document' timeout=30, ) From f357e9614b26f734dd79be8b0cb00275906281c7 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 22 Dec 2025 10:26:08 +0300 Subject: [PATCH 093/199] refactor(api): improve universign transaction flow with better error handling --- api.py | 287 +++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 178 insertions(+), 109 deletions(-) diff --git a/api.py b/api.py index c00a411..237d719 100644 --- a/api.py +++ b/api.py @@ -1430,182 +1430,262 @@ async def universign_envoyer_avec_email( api_url = settings.universign_api_url auth = (api_key, "") + logger.info(f"🔐 Démarrage processus Universign pour {email}") + logger.info(f"📌 API URL: {api_url}") + logger.info(f"📌 Document: {doc_id} - Type: {doc_data.get('type_label')}") + + # ======================================== + # VÉRIFICATION PRÉLIMINAIRE : PDF valide ? + # ======================================== + if not pdf_bytes or len(pdf_bytes) == 0: + logger.error(f"❌ PDF vide ou None") + raise Exception("Le PDF généré est vide. Vérifiez la génération du PDF.") + + logger.info(f"✅ PDF reçu : {len(pdf_bytes)} octets") + # ======================================== # ÉTAPE 1 : Créer la transaction # ======================================== - logger.info(f"🔐 Création transaction Universign pour {email}") + logger.info(f"📝 ÉTAPE 1/7 : Création transaction") + + transaction_payload = { + "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", + "language": "fr", + } response = requests.post( f"{api_url}/transactions", auth=auth, - json={ - "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", - "language": "fr", - "profile": "default", # ✅ Ajout du profil - }, + json=transaction_payload, timeout=30, ) + logger.info(f"Status création transaction: {response.status_code}") + if response.status_code != 200: - logger.error(f"❌ Erreur création transaction: {response.status_code} - {response.text}") - raise Exception(f"Erreur création transaction: {response.status_code}") + logger.error(f"❌ Réponse: {response.text}") + raise Exception(f"Erreur création transaction: {response.status_code} - {response.text}") - response.raise_for_status() - transaction_id = response.json().get("id") - + transaction_data = response.json() + transaction_id = transaction_data.get("id") + + if not transaction_id: + logger.error(f"❌ Pas de transaction_id dans la réponse: {transaction_data}") + raise Exception("Transaction créée mais ID manquant") + logger.info(f"✅ Transaction créée: {transaction_id}") # ======================================== # ÉTAPE 2 : Upload du fichier PDF # ======================================== - logger.info(f"📄 Upload PDF ({len(pdf_bytes)} octets)") + logger.info(f"📄 ÉTAPE 2/7 : Upload PDF") + # Préparer le fichier multipart + filename = f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf" files = { - "file": ( - f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", - pdf_bytes, - "application/pdf", - ) + "file": (filename, pdf_bytes, "application/pdf") } + + logger.debug(f"Upload fichier: {filename}, taille: {len(pdf_bytes)} octets") + response = requests.post( f"{api_url}/files", auth=auth, files=files, - timeout=30 + timeout=60, # Plus de timeout pour gros fichiers ) - if response.status_code != 200: - logger.error(f"❌ Erreur upload fichier: {response.status_code} - {response.text}") - raise Exception(f"Erreur upload fichier: {response.status_code}") - - response.raise_for_status() - file_id = response.json().get("id") - + logger.info(f"Status upload fichier: {response.status_code}") + + if response.status_code not in [200, 201]: + logger.error(f"❌ Erreur upload: {response.text}") + raise Exception(f"Erreur upload fichier: {response.status_code} - {response.text}") + + # Parser la réponse + try: + file_data = response.json() + logger.debug(f"Réponse upload: {file_data}") + except Exception as e: + logger.error(f"❌ Erreur parsing réponse upload: {e}") + logger.error(f"Réponse brute: {response.text[:500]}") + raise Exception(f"Réponse upload invalide: {e}") + + file_id = file_data.get("id") + + if not file_id: + logger.error(f"❌ Pas de file_id dans la réponse") + logger.error(f"Réponse complète: {file_data}") + raise Exception("Upload réussi mais file_id manquant") + logger.info(f"✅ Fichier uploadé: {file_id}") # ======================================== - # ÉTAPE 3 : Créer le document dans la transaction + # ÉTAPE 3 : Ajouter le document à la transaction # ======================================== - logger.info(f"📋 Ajout document à la transaction") + logger.info(f"📋 ÉTAPE 3/7 : Ajout document à transaction {transaction_id}") + + document_payload = { + "document": file_id # ✅ Utiliser "document" pas "file" + } + + logger.debug(f"Payload document: {document_payload}") + logger.debug(f"URL: {api_url}/transactions/{transaction_id}/documents") response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, - json={"document": file_id}, # ✅ Utiliser 'file' au lieu de 'document' + json=document_payload, timeout=30, ) - if response.status_code != 200: - logger.error(f"❌ Erreur ajout document: {response.status_code} - {response.text}") - raise Exception(f"Erreur ajout document: {response.status_code}") - - response.raise_for_status() - document_id = response.json().get("id") - + logger.info(f"Status ajout document: {response.status_code}") + + if response.status_code not in [200, 201]: + logger.error(f"❌ Erreur ajout document: {response.text}") + logger.error(f"Payload envoyé: {document_payload}") + logger.error(f"file_id utilisé: '{file_id}' (type: {type(file_id)})") + raise Exception(f"Erreur ajout document: {response.status_code} - {response.text}") + + try: + document_data = response.json() + logger.debug(f"Réponse ajout document: {document_data}") + except Exception as e: + logger.error(f"❌ Erreur parsing réponse document: {e}") + raise + + document_id = document_data.get("id") + + if not document_id: + logger.error(f"❌ Pas de document_id dans la réponse") + logger.error(f"Réponse complète: {document_data}") + raise Exception("Document ajouté mais ID manquant") + logger.info(f"✅ Document ajouté: {document_id}") # ======================================== - # ÉTAPE 4 : Ajouter un champ de signature + # ÉTAPE 4 : Créer le signataire # ======================================== - logger.info(f"✍️ Ajout champ signature") + logger.info(f"👤 ÉTAPE 4/7 : Création signataire") - response = requests.post( - f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", - auth=auth, - json={ - "type": "signature", - "page": 1, # ✅ Préciser la page - # Position optionnelle - Universign peut la placer automatiquement - # "x": 100, - # "y": 600, - }, - timeout=30, - ) + nom_parts = nom.split() + first_name = nom_parts[0] if len(nom_parts) > 0 else nom + last_name = " ".join(nom_parts[1:]) if len(nom_parts) > 1 else "" - if response.status_code != 200: - logger.error(f"❌ Erreur ajout champ: {response.status_code} - {response.text}") - raise Exception(f"Erreur ajout champ signature: {response.status_code}") - - response.raise_for_status() - field_id = response.json().get("id") - - logger.info(f"✅ Champ signature créé: {field_id}") - - # ======================================== - # ÉTAPE 5 : Ajouter le signataire - # ======================================== - logger.info(f"👤 Ajout signataire: {nom} ({email})") + signer_payload = { + "email": email, + "firstName": first_name, + "lastName": last_name, + } + + logger.debug(f"Payload signataire: {signer_payload}") response = requests.post( f"{api_url}/transactions/{transaction_id}/signers", auth=auth, - json={ - "email": email, - "firstName": nom.split()[0] if ' ' in nom else nom, # ✅ Prénom - "lastName": nom.split()[-1] if ' ' in nom else "", # ✅ Nom - # ✅ Lier le signataire au champ de signature - "fields": [field_id], - }, + json=signer_payload, timeout=30, ) - if response.status_code != 200: - logger.error(f"❌ Erreur ajout signataire: {response.status_code} - {response.text}") - raise Exception(f"Erreur ajout signataire: {response.status_code}") - - response.raise_for_status() - signer_id = response.json().get("id") + logger.info(f"Status création signataire: {response.status_code}") + + if response.status_code not in [200, 201]: + logger.error(f"❌ Erreur création signataire: {response.text}") + raise Exception(f"Erreur création signataire: {response.status_code} - {response.text}") + + signer_data = response.json() + signer_id = signer_data.get("id") + + if not signer_id: + logger.error(f"❌ Pas de signer_id dans la réponse") + raise Exception("Signataire créé mais ID manquant") + + logger.info(f"✅ Signataire créé: {signer_id}") - logger.info(f"✅ Signataire ajouté: {signer_id}") + # ======================================== + # ÉTAPE 5 : Créer le champ de signature + # ======================================== + logger.info(f"✍️ ÉTAPE 5/7 : Création champ signature") + + field_payload = { + "type": "signature", + "page": 1, + "signer": signer_id, + } + + logger.debug(f"Payload champ: {field_payload}") + + response = requests.post( + f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", + auth=auth, + json=field_payload, + timeout=30, + ) + + logger.info(f"Status création champ: {response.status_code}") + + if response.status_code not in [200, 201]: + logger.error(f"❌ Erreur création champ: {response.text}") + raise Exception(f"Erreur création champ: {response.status_code} - {response.text}") + + field_data = response.json() + field_id = field_data.get("id") + + logger.info(f"✅ Champ créé: {field_id}") # ======================================== # ÉTAPE 6 : Démarrer la transaction # ======================================== - logger.info(f"🚀 Démarrage de la transaction") + logger.info(f"🚀 ÉTAPE 6/7 : Démarrage transaction") response = requests.post( f"{api_url}/transactions/{transaction_id}/start", auth=auth, - json={}, # ✅ Body vide mais présent + json={}, timeout=30 ) - if response.status_code != 200: - logger.error(f"❌ Erreur démarrage transaction: {response.status_code}") - logger.error(f"Réponse complète: {response.text}") - raise Exception(f"Erreur démarrage transaction: {response.status_code} - {response.text}") - - response.raise_for_status() + logger.info(f"Status démarrage: {response.status_code}") + + if response.status_code not in [200, 201]: + logger.error(f"❌ Erreur démarrage: {response.text}") + raise Exception(f"Erreur démarrage: {response.status_code} - {response.text}") + final_data = response.json() # ======================================== # ÉTAPE 7 : Récupérer l'URL de signature # ======================================== + logger.info(f"🔗 ÉTAPE 7/7 : Récupération URL") + signer_url = "" + if final_data.get("signers"): for signer in final_data["signers"]: if signer.get("email") == email: signer_url = signer.get("url", "") break + + if not signer_url: + response = requests.get( + f"{api_url}/transactions/{transaction_id}/signers/{signer_id}", + auth=auth, + timeout=10, + ) + if response.status_code == 200: + signer_url = response.json().get("url", "") if not signer_url: - logger.warning("⚠️ URL de signature non trouvée dans la réponse") - raise ValueError("URL de signature non retournée par Universign") + logger.error(f"❌ URL de signature introuvable") + raise ValueError("URL de signature non retournée") - logger.info(f"✅ Signature Universign prête: {transaction_id}") + logger.info(f"✅ URL récupérée") # ======================================== - # ÉTAPE 8 : Créer l'email de notification + # Créer l'email de notification # ======================================== template = templates_signature_email["demande_signature"] - type_labels = { - 0: "Devis", - 10: "Commande", - 30: "Bon de Livraison", - 60: "Facture", - 50: "Avoir", - } + type_labels = {0: "Devis", 10: "Commande", 30: "Bon de Livraison", 60: "Facture", 50: "Avoir"} variables = { "NOM_SIGNATAIRE": nom, @@ -1624,7 +1704,6 @@ async def universign_envoyer_avec_email( sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - # Créer log email email_log = EmailLog( id=str(uuid.uuid4()), destinataire=email, @@ -1639,11 +1718,9 @@ async def universign_envoyer_avec_email( session.add(email_log) await session.flush() - - # Enqueue l'email email_queue.enqueue(email_log.id) - logger.info(f"📧 Email de signature envoyé en file: {email}") + logger.info(f"✅ Processus terminé avec succès") return { "transaction_id": transaction_id, @@ -1653,18 +1730,10 @@ async def universign_envoyer_avec_email( "email_sent": True, } - except requests.exceptions.HTTPError as e: - logger.error(f"❌ Erreur HTTP Universign: {e}") - logger.error(f"Réponse: {e.response.text if e.response else 'N/A'}") - return { - "error": f"Erreur Universign: {e.response.status_code} - {e.response.text if e.response else str(e)}", - "statut": "ERREUR", - "email_sent": False, - } except Exception as e: - logger.error(f"❌ Erreur Universign+Email: {e}", exc_info=True) + logger.error(f"❌ Erreur Universign: {e}", exc_info=True) return {"error": str(e), "statut": "ERREUR", "email_sent": False} - + async def universign_statut(transaction_id: str) -> Dict: """Récupération statut signature""" import requests From d921c7100a0563c47ab8de8e80e69718fcb0579b Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 22 Dec 2025 11:01:50 +0300 Subject: [PATCH 094/199] refactor(api): simplify Universign transaction flow and error handling --- api.py | 317 +++++++++++++++++++++++++-------------------------------- 1 file changed, 137 insertions(+), 180 deletions(-) diff --git a/api.py b/api.py index 237d719..e8e8f82 100644 --- a/api.py +++ b/api.py @@ -1190,21 +1190,21 @@ templates_signature_email = { """, - "variables_disponibles": [ - "NOM_SIGNATAIRE", - "TYPE_DOC", - "NUMERO", - "DATE", - "MONTANT_TTC", - "SIGNER_URL", - "CONTACT_EMAIL", - ], - }, - "signature_confirmee": { - "id": "signature_confirmee", - "nom": "Confirmation de Signature", - "sujet": "✅ Document signé - {{TYPE_DOC}} {{NUMERO}}", - "corps_html": """ + "variables_disponibles": [ + "NOM_SIGNATAIRE", + "TYPE_DOC", + "NUMERO", + "DATE", + "MONTANT_TTC", + "SIGNER_URL", + "CONTACT_EMAIL", + ], + }, + "signature_confirmee": { + "id": "signature_confirmee", + "nom": "Confirmation de Signature", + "sujet": "✅ Document signé - {{TYPE_DOC}} {{NUMERO}}", + "corps_html": """ @@ -1301,20 +1301,20 @@ templates_signature_email = { """, - "variables_disponibles": [ - "NOM_SIGNATAIRE", - "TYPE_DOC", - "NUMERO", - "DATE_SIGNATURE", - "TRANSACTION_ID", - "CONTACT_EMAIL", - ], - }, - "relance_signature": { - "id": "relance_signature", - "nom": "Relance Signature en Attente", - "sujet": "⏰ Rappel - Signature en attente {{TYPE_DOC}} {{NUMERO}}", - "corps_html": """ + "variables_disponibles": [ + "NOM_SIGNATAIRE", + "TYPE_DOC", + "NUMERO", + "DATE_SIGNATURE", + "TRANSACTION_ID", + "CONTACT_EMAIL", + ], + }, + "relance_signature": { + "id": "relance_signature", + "nom": "Relance Signature en Attente", + "sujet": "⏰ Rappel - Signature en attente {{TYPE_DOC}} {{NUMERO}}", + "corps_html": """ @@ -1431,240 +1431,170 @@ async def universign_envoyer_avec_email( auth = (api_key, "") logger.info(f"🔐 Démarrage processus Universign pour {email}") - logger.info(f"📌 API URL: {api_url}") - logger.info(f"📌 Document: {doc_id} - Type: {doc_data.get('type_label')}") - + logger.info(f"📌 Document: {doc_id} ({doc_data.get('type_label')})") + # ======================================== - # VÉRIFICATION PRÉLIMINAIRE : PDF valide ? + # VÉRIFICATION : PDF valide ? # ======================================== if not pdf_bytes or len(pdf_bytes) == 0: - logger.error(f"❌ PDF vide ou None") - raise Exception("Le PDF généré est vide. Vérifiez la génération du PDF.") - - logger.info(f"✅ PDF reçu : {len(pdf_bytes)} octets") + raise Exception("Le PDF généré est vide") + + logger.info(f"✅ PDF valide : {len(pdf_bytes)} octets") # ======================================== # ÉTAPE 1 : Créer la transaction # ======================================== logger.info(f"📝 ÉTAPE 1/7 : Création transaction") - - transaction_payload = { - "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", - "language": "fr", - } - + response = requests.post( f"{api_url}/transactions", auth=auth, - json=transaction_payload, + json={ + "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", + "language": "fr", + }, timeout=30, ) - - logger.info(f"Status création transaction: {response.status_code}") - + if response.status_code != 200: - logger.error(f"❌ Réponse: {response.text}") - raise Exception(f"Erreur création transaction: {response.status_code} - {response.text}") - - transaction_data = response.json() - transaction_id = transaction_data.get("id") - - if not transaction_id: - logger.error(f"❌ Pas de transaction_id dans la réponse: {transaction_data}") - raise Exception("Transaction créée mais ID manquant") - + logger.error(f"❌ Erreur création transaction: {response.text}") + raise Exception(f"Erreur création transaction: {response.status_code}") + + transaction_id = response.json().get("id") logger.info(f"✅ Transaction créée: {transaction_id}") # ======================================== # ÉTAPE 2 : Upload du fichier PDF # ======================================== logger.info(f"📄 ÉTAPE 2/7 : Upload PDF") - - # Préparer le fichier multipart - filename = f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf" + files = { - "file": (filename, pdf_bytes, "application/pdf") + "file": ( + f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", + pdf_bytes, + "application/pdf", + ) } - - logger.debug(f"Upload fichier: {filename}, taille: {len(pdf_bytes)} octets") - + response = requests.post( - f"{api_url}/files", - auth=auth, - files=files, - timeout=60, # Plus de timeout pour gros fichiers + f"{api_url}/files", + auth=auth, + files=files, + timeout=60, ) - - logger.info(f"Status upload fichier: {response.status_code}") - + if response.status_code not in [200, 201]: logger.error(f"❌ Erreur upload: {response.text}") - raise Exception(f"Erreur upload fichier: {response.status_code} - {response.text}") - - # Parser la réponse - try: - file_data = response.json() - logger.debug(f"Réponse upload: {file_data}") - except Exception as e: - logger.error(f"❌ Erreur parsing réponse upload: {e}") - logger.error(f"Réponse brute: {response.text[:500]}") - raise Exception(f"Réponse upload invalide: {e}") - - file_id = file_data.get("id") - + raise Exception(f"Erreur upload fichier: {response.status_code}") + + file_id = response.json().get("id") + if not file_id: - logger.error(f"❌ Pas de file_id dans la réponse") - logger.error(f"Réponse complète: {file_data}") + logger.error(f"❌ Pas de file_id retourné") raise Exception("Upload réussi mais file_id manquant") - + logger.info(f"✅ Fichier uploadé: {file_id}") # ======================================== - # ÉTAPE 3 : Ajouter le document à la transaction + # ÉTAPE 3 : Ajouter le document (form-data) # ======================================== - logger.info(f"📋 ÉTAPE 3/7 : Ajout document à transaction {transaction_id}") - - document_payload = { - "document": file_id # ✅ Utiliser "document" pas "file" - } - - logger.debug(f"Payload document: {document_payload}") - logger.debug(f"URL: {api_url}/transactions/{transaction_id}/documents") - + logger.info(f"📋 ÉTAPE 3/7 : Ajout document à transaction") + response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, - json=document_payload, + data={"document": file_id}, # ✅ UTILISER data= (form-data) timeout=30, ) - - logger.info(f"Status ajout document: {response.status_code}") - + if response.status_code not in [200, 201]: logger.error(f"❌ Erreur ajout document: {response.text}") - logger.error(f"Payload envoyé: {document_payload}") - logger.error(f"file_id utilisé: '{file_id}' (type: {type(file_id)})") - raise Exception(f"Erreur ajout document: {response.status_code} - {response.text}") - - try: - document_data = response.json() - logger.debug(f"Réponse ajout document: {document_data}") - except Exception as e: - logger.error(f"❌ Erreur parsing réponse document: {e}") - raise - - document_id = document_data.get("id") - - if not document_id: - logger.error(f"❌ Pas de document_id dans la réponse") - logger.error(f"Réponse complète: {document_data}") - raise Exception("Document ajouté mais ID manquant") - + raise Exception(f"Erreur ajout document: {response.status_code}") + + document_id = response.json().get("id") logger.info(f"✅ Document ajouté: {document_id}") # ======================================== # ÉTAPE 4 : Créer le signataire # ======================================== logger.info(f"👤 ÉTAPE 4/7 : Création signataire") - + nom_parts = nom.split() first_name = nom_parts[0] if len(nom_parts) > 0 else nom last_name = " ".join(nom_parts[1:]) if len(nom_parts) > 1 else "" - - signer_payload = { - "email": email, - "firstName": first_name, - "lastName": last_name, - } - - logger.debug(f"Payload signataire: {signer_payload}") - + response = requests.post( f"{api_url}/transactions/{transaction_id}/signers", auth=auth, - json=signer_payload, + data={ # ✅ UTILISER data= (form-data) + "email": email, + "firstName": first_name, + "lastName": last_name, + }, timeout=30, ) - - logger.info(f"Status création signataire: {response.status_code}") - + if response.status_code not in [200, 201]: logger.error(f"❌ Erreur création signataire: {response.text}") - raise Exception(f"Erreur création signataire: {response.status_code} - {response.text}") - - signer_data = response.json() - signer_id = signer_data.get("id") - - if not signer_id: - logger.error(f"❌ Pas de signer_id dans la réponse") - raise Exception("Signataire créé mais ID manquant") - + raise Exception(f"Erreur création signataire: {response.status_code}") + + signer_id = response.json().get("id") logger.info(f"✅ Signataire créé: {signer_id}") # ======================================== # ÉTAPE 5 : Créer le champ de signature # ======================================== logger.info(f"✍️ ÉTAPE 5/7 : Création champ signature") - - field_payload = { - "type": "signature", - "page": 1, - "signer": signer_id, - } - - logger.debug(f"Payload champ: {field_payload}") - + response = requests.post( f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", auth=auth, - json=field_payload, + data={ # ✅ UTILISER data= (form-data) + "type": "signature", + "page": "1", + "signer": signer_id, + }, timeout=30, ) - - logger.info(f"Status création champ: {response.status_code}") - + if response.status_code not in [200, 201]: logger.error(f"❌ Erreur création champ: {response.text}") - raise Exception(f"Erreur création champ: {response.status_code} - {response.text}") - - field_data = response.json() - field_id = field_data.get("id") - + raise Exception(f"Erreur création champ: {response.status_code}") + + field_id = response.json().get("id") logger.info(f"✅ Champ créé: {field_id}") # ======================================== # ÉTAPE 6 : Démarrer la transaction # ======================================== logger.info(f"🚀 ÉTAPE 6/7 : Démarrage transaction") - + response = requests.post( - f"{api_url}/transactions/{transaction_id}/start", - auth=auth, - json={}, - timeout=30 + f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 ) - - logger.info(f"Status démarrage: {response.status_code}") - + if response.status_code not in [200, 201]: logger.error(f"❌ Erreur démarrage: {response.text}") - raise Exception(f"Erreur démarrage: {response.status_code} - {response.text}") - + raise Exception(f"Erreur démarrage: {response.status_code}") + final_data = response.json() - + logger.info(f"✅ Transaction démarrée") + # ======================================== # ÉTAPE 7 : Récupérer l'URL de signature # ======================================== logger.info(f"🔗 ÉTAPE 7/7 : Récupération URL") - + signer_url = "" - + + # Chercher dans la réponse du /start if final_data.get("signers"): for signer in final_data["signers"]: if signer.get("email") == email: signer_url = signer.get("url", "") break - + + # Si pas trouvée, interroger le signataire directement if not signer_url: response = requests.get( f"{api_url}/transactions/{transaction_id}/signers/{signer_id}", @@ -1676,16 +1606,24 @@ async def universign_envoyer_avec_email( if not signer_url: logger.error(f"❌ URL de signature introuvable") - raise ValueError("URL de signature non retournée") + raise ValueError("URL de signature non retournée par Universign") - logger.info(f"✅ URL récupérée") + logger.info(f"✅ URL récupérée avec succès") # ======================================== # Créer l'email de notification # ======================================== + logger.info(f"📧 Préparation email de notification") + template = templates_signature_email["demande_signature"] - type_labels = {0: "Devis", 10: "Commande", 30: "Bon de Livraison", 60: "Facture", 50: "Avoir"} + type_labels = { + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", + } variables = { "NOM_SIGNATAIRE": nom, @@ -1704,6 +1642,7 @@ async def universign_envoyer_avec_email( sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) + # Créer log email email_log = EmailLog( id=str(uuid.uuid4()), destinataire=email, @@ -1718,9 +1657,12 @@ async def universign_envoyer_avec_email( session.add(email_log) await session.flush() + + # Enqueue l'email email_queue.enqueue(email_log.id) - logger.info(f"✅ Processus terminé avec succès") + logger.info(f"✅ Email mis en file pour {email}") + logger.info(f"🎉 Processus Universign terminé avec succès") return { "transaction_id": transaction_id, @@ -1730,10 +1672,25 @@ async def universign_envoyer_avec_email( "email_sent": True, } + except requests.exceptions.HTTPError as e: + logger.error(f"❌ Erreur HTTP Universign: {e}") + if e.response: + logger.error(f"Status: {e.response.status_code}") + logger.error(f"Body: {e.response.text}") + return { + "error": f"Erreur Universign: {e.response.status_code if e.response else 'Unknown'} - {e.response.text if e.response else str(e)}", + "statut": "ERREUR", + "email_sent": False, + } except Exception as e: logger.error(f"❌ Erreur Universign: {e}", exc_info=True) - return {"error": str(e), "statut": "ERREUR", "email_sent": False} - + return { + "error": str(e), + "statut": "ERREUR", + "email_sent": False, + } + + async def universign_statut(transaction_id: str) -> Dict: """Récupération statut signature""" import requests From 215763b679d0565ed1453cc7395794e090de071b Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 22 Dec 2025 11:11:12 +0300 Subject: [PATCH 095/199] Trying to make universign functionnal --- api.py | 175 ++++++++++++++++++++++++--------------------------------- 1 file changed, 74 insertions(+), 101 deletions(-) diff --git a/api.py b/api.py index e8e8f82..a0072de 100644 --- a/api.py +++ b/api.py @@ -1432,20 +1432,18 @@ async def universign_envoyer_avec_email( logger.info(f"🔐 Démarrage processus Universign pour {email}") logger.info(f"📌 Document: {doc_id} ({doc_data.get('type_label')})") - - # ======================================== - # VÉRIFICATION : PDF valide ? - # ======================================== + + # Vérification PDF if not pdf_bytes or len(pdf_bytes) == 0: raise Exception("Le PDF généré est vide") - + logger.info(f"✅ PDF valide : {len(pdf_bytes)} octets") # ======================================== # ÉTAPE 1 : Créer la transaction # ======================================== - logger.info(f"📝 ÉTAPE 1/7 : Création transaction") - + logger.info(f"📝 ÉTAPE 1/6 : Création transaction") + response = requests.post( f"{api_url}/transactions", auth=auth, @@ -1455,19 +1453,19 @@ async def universign_envoyer_avec_email( }, timeout=30, ) - + if response.status_code != 200: logger.error(f"❌ Erreur création transaction: {response.text}") raise Exception(f"Erreur création transaction: {response.status_code}") - + transaction_id = response.json().get("id") logger.info(f"✅ Transaction créée: {transaction_id}") # ======================================== # ÉTAPE 2 : Upload du fichier PDF # ======================================== - logger.info(f"📄 ÉTAPE 2/7 : Upload PDF") - + logger.info(f"📄 ÉTAPE 2/6 : Upload PDF") + files = { "file": ( f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", @@ -1475,146 +1473,133 @@ async def universign_envoyer_avec_email( "application/pdf", ) } - + response = requests.post( - f"{api_url}/files", - auth=auth, - files=files, + f"{api_url}/files", + auth=auth, + files=files, timeout=60, ) - + if response.status_code not in [200, 201]: logger.error(f"❌ Erreur upload: {response.text}") raise Exception(f"Erreur upload fichier: {response.status_code}") - + file_id = response.json().get("id") - - if not file_id: - logger.error(f"❌ Pas de file_id retourné") - raise Exception("Upload réussi mais file_id manquant") - logger.info(f"✅ Fichier uploadé: {file_id}") # ======================================== - # ÉTAPE 3 : Ajouter le document (form-data) + # ÉTAPE 3 : Ajouter le document # ======================================== - logger.info(f"📋 ÉTAPE 3/7 : Ajout document à transaction") - + logger.info(f"📋 ÉTAPE 3/6 : Ajout document à transaction") + response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, - data={"document": file_id}, # ✅ UTILISER data= (form-data) + data={"document": file_id}, timeout=30, ) - + if response.status_code not in [200, 201]: logger.error(f"❌ Erreur ajout document: {response.text}") raise Exception(f"Erreur ajout document: {response.status_code}") - + document_id = response.json().get("id") logger.info(f"✅ Document ajouté: {document_id}") # ======================================== - # ÉTAPE 4 : Créer le signataire + # ÉTAPE 4 : Créer le champ de signature # ======================================== - logger.info(f"👤 ÉTAPE 4/7 : Création signataire") - - nom_parts = nom.split() - first_name = nom_parts[0] if len(nom_parts) > 0 else nom - last_name = " ".join(nom_parts[1:]) if len(nom_parts) > 1 else "" - - response = requests.post( - f"{api_url}/transactions/{transaction_id}/signers", - auth=auth, - data={ # ✅ UTILISER data= (form-data) - "email": email, - "firstName": first_name, - "lastName": last_name, - }, - timeout=30, - ) - - if response.status_code not in [200, 201]: - logger.error(f"❌ Erreur création signataire: {response.text}") - raise Exception(f"Erreur création signataire: {response.status_code}") - - signer_id = response.json().get("id") - logger.info(f"✅ Signataire créé: {signer_id}") - - # ======================================== - # ÉTAPE 5 : Créer le champ de signature - # ======================================== - logger.info(f"✍️ ÉTAPE 5/7 : Création champ signature") - + logger.info(f"✍️ ÉTAPE 4/6 : Création champ signature") + response = requests.post( f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", auth=auth, - data={ # ✅ UTILISER data= (form-data) + data={ "type": "signature", - "page": "1", - "signer": signer_id, + # Laisser Universign positionner automatiquement }, timeout=30, ) - + if response.status_code not in [200, 201]: logger.error(f"❌ Erreur création champ: {response.text}") raise Exception(f"Erreur création champ: {response.status_code}") - + field_id = response.json().get("id") logger.info(f"✅ Champ créé: {field_id}") + # ======================================== + # ÉTAPE 5 : Lier le signataire au champ (ancien endpoint) + # ======================================== + logger.info(f"👤 ÉTAPE 5/6 : Liaison signataire au champ") + + response = requests.post( + f"{api_url}/transactions/{transaction_id}/signatures", # ✅ /signatures pas /signers + auth=auth, + data={ + "signer": email, + "field": field_id, + }, + timeout=30, + ) + + if response.status_code not in [200, 201]: + logger.error(f"❌ Erreur liaison signataire: {response.text}") + raise Exception(f"Erreur liaison signataire: {response.status_code}") + + logger.info(f"✅ Signataire lié: {email}") + # ======================================== # ÉTAPE 6 : Démarrer la transaction # ======================================== - logger.info(f"🚀 ÉTAPE 6/7 : Démarrage transaction") - + logger.info(f"🚀 ÉTAPE 6/6 : Démarrage transaction") + response = requests.post( - f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 + f"{api_url}/transactions/{transaction_id}/start", + auth=auth, + timeout=30 ) - + if response.status_code not in [200, 201]: logger.error(f"❌ Erreur démarrage: {response.text}") raise Exception(f"Erreur démarrage: {response.status_code}") - + final_data = response.json() logger.info(f"✅ Transaction démarrée") # ======================================== - # ÉTAPE 7 : Récupérer l'URL de signature + # Récupérer l'URL de signature # ======================================== - logger.info(f"🔗 ÉTAPE 7/7 : Récupération URL") - + logger.info(f"🔗 Récupération URL de signature") + signer_url = "" - - # Chercher dans la réponse du /start - if final_data.get("signers"): + + # Chercher dans actions + if final_data.get("actions"): + for action in final_data["actions"]: + if action.get("url"): + signer_url = action["url"] + break + + # Sinon chercher dans signers + if not signer_url and final_data.get("signers"): for signer in final_data["signers"]: if signer.get("email") == email: signer_url = signer.get("url", "") break - # Si pas trouvée, interroger le signataire directement if not signer_url: - response = requests.get( - f"{api_url}/transactions/{transaction_id}/signers/{signer_id}", - auth=auth, - timeout=10, - ) - if response.status_code == 200: - signer_url = response.json().get("url", "") - - if not signer_url: - logger.error(f"❌ URL de signature introuvable") + logger.error(f"❌ URL introuvable dans: {final_data}") raise ValueError("URL de signature non retournée par Universign") - logger.info(f"✅ URL récupérée avec succès") + logger.info(f"✅ URL récupérée") # ======================================== # Créer l'email de notification # ======================================== - logger.info(f"📧 Préparation email de notification") - + logger.info(f"📧 Préparation email") + template = templates_signature_email["demande_signature"] type_labels = { @@ -1642,7 +1627,6 @@ async def universign_envoyer_avec_email( sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - # Créer log email email_log = EmailLog( id=str(uuid.uuid4()), destinataire=email, @@ -1658,11 +1642,10 @@ async def universign_envoyer_avec_email( session.add(email_log) await session.flush() - # Enqueue l'email email_queue.enqueue(email_log.id) logger.info(f"✅ Email mis en file pour {email}") - logger.info(f"🎉 Processus Universign terminé avec succès") + logger.info(f"🎉 Processus terminé avec succès") return { "transaction_id": transaction_id, @@ -1672,16 +1655,6 @@ async def universign_envoyer_avec_email( "email_sent": True, } - except requests.exceptions.HTTPError as e: - logger.error(f"❌ Erreur HTTP Universign: {e}") - if e.response: - logger.error(f"Status: {e.response.status_code}") - logger.error(f"Body: {e.response.text}") - return { - "error": f"Erreur Universign: {e.response.status_code if e.response else 'Unknown'} - {e.response.text if e.response else str(e)}", - "statut": "ERREUR", - "email_sent": False, - } except Exception as e: logger.error(f"❌ Erreur Universign: {e}", exc_info=True) return { From f8ea7b48b9b9b81bacf755c6ee33f005072dcbd6 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 24 Dec 2025 11:41:31 +0300 Subject: [PATCH 096/199] Updated client's field --- api.py | 305 ++++++++++++++++++++++++++++++++++++++++++------- email_queue.py | 21 +--- 2 files changed, 268 insertions(+), 58 deletions(-) diff --git a/api.py b/api.py index a0072de..a75ae88 100644 --- a/api.py +++ b/api.py @@ -400,54 +400,275 @@ class BaremeRemiseResponse(BaseModel): 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 ClientCreateRequest(BaseModel): + """Modèle complet pour la création d'un client Sage avec tous les champs disponibles""" + + # ======================================== + # CHAMPS OBLIGATOIRES + # ======================================== + intitule: str = Field(..., max_length=69, description="Nom du client (CT_Intitule)") + + # ======================================== + # IDENTIFICATION & CLASSIFICATION + # ======================================== + num: Optional[str] = Field(None, max_length=17, description="Numéro client (auto si vide)") + compte_collectif: str = Field("411000", max_length=13, description="Compte général (CG_NumPrinc)") + qualite: str = Field("CLI", max_length=3, description="CLI/FOU/SAL/DIV/AUT") + classement: Optional[str] = Field(None, max_length=17, description="Code de classement") + raccourci: Optional[str] = Field(None, max_length=17, description="Code abrégé") + + # ======================================== + # ADRESSE PRINCIPALE + # ======================================== + contact: Optional[str] = Field(None, max_length=69, description="Nom du contact principal") + adresse: Optional[str] = Field(None, max_length=35, description="Adresse ligne 1") + complement: Optional[str] = Field(None, max_length=35, description="Adresse ligne 2") + code_postal: Optional[str] = Field(None, max_length=9, description="Code postal") + ville: Optional[str] = Field(None, max_length=35, description="Ville") + code_region: Optional[str] = Field(None, max_length=25, description="Code région/département") + pays: Optional[str] = Field(None, max_length=35, description="Pays") + + # ======================================== + # CONTACT & COMMUNICATION + # ======================================== + telephone: Optional[str] = Field(None, max_length=21, description="Téléphone principal") + telecopie: Optional[str] = Field(None, max_length=21, description="Fax") + email: Optional[str] = Field(None, max_length=69, description="Email principal") + site: Optional[str] = Field(None, max_length=69, description="Site web") + facebook: Optional[str] = Field(None, max_length=100, description="URL Facebook") + linkedin: Optional[str] = Field(None, max_length=100, description="URL LinkedIn") + + # ======================================== + # IDENTIFIANTS LÉGAUX & FISCAUX + # ======================================== + siret: Optional[str] = Field(None, max_length=14, description="SIRET (14 chiffres)") + tva_intra: Optional[str] = Field(None, max_length=25, description="TVA intracommunautaire (CT_Identifiant)") + ape: Optional[str] = Field(None, max_length=5, description="Code APE/NAF") + type_nif: Optional[int] = Field(None, description="Type de NIF (0-10)") + + # ======================================== + # BANQUE & DEVISE + # ======================================== + banque_num: Optional[str] = Field(None, max_length=13, description="Code banque (BT_Num)") + devise: Optional[int] = Field(0, description="Code devise (N_Devise, 0=EUR)") + + # ======================================== + # CATÉGORIES & CLASSIFICATIONS + # ======================================== + cat_tarif: int = Field(1, ge=1, description="Catégorie tarifaire (N_CatTarif)") + cat_compta: int = Field(1, ge=1, description="Catégorie comptable (N_CatCompta)") + period: int = Field(1, ge=1, description="Période de règlement (N_Period)") + expedition: int = Field(1, ge=1, description="Mode d'expédition (N_Expedition)") + condition: int = Field(1, ge=1, description="Condition de livraison (N_Condition)") + risque: int = Field(1, ge=1, description="Niveau de risque (N_Risque)") + + # ======================================== + # TAUX PERSONNALISÉS + # ======================================== + taux01: Optional[Decimal] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") + taux02: Optional[Decimal] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") + taux03: Optional[Decimal] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") + taux04: Optional[Decimal] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") + + # ======================================== + # GESTION COMMERCIALE + # ======================================== + encours: Optional[Decimal] = Field(None, description="Encours autorisé (CT_Encours)") + assurance: Optional[Decimal] = Field(None, description="Plafond assurance (CT_Assurance)") + num_payeur: Optional[str] = Field(None, max_length=17, description="Numéro client payeur") + langue: Optional[int] = Field(None, description="Code langue (0-25)") + langue_iso2: Optional[str] = Field(None, max_length=2, description="Code ISO langue (FR, EN, etc)") + + # ======================================== + # PARAMÈTRES FACTURATION + # ======================================== + facture: int = Field(1, description="Type facturation (0=aucune, 1=normale, 2=regroupée)") + bl_fact: Optional[int] = Field(None, description="BL en facture (0/1)") + saut: Optional[int] = Field(None, description="Saut de page (0/1)") + lettrage: bool = Field(True, description="Lettrage auto (CT_Lettrage)") + valid_ech: Optional[int] = Field(None, description="Validation échéance (0/1)") + control_enc: Optional[int] = Field(None, description="Contrôle encours (0/1)") + not_rappel: Optional[int] = Field(None, description="Pas de relance (0/1)") + not_penal: Optional[int] = Field(None, description="Pas de pénalités (0/1)") + + # ======================================== + # LIVRAISON & LOGISTIQUE + # ======================================== + priorite_livr: Optional[int] = Field(None, description="Priorité livraison (0-5)") + livr_partielle: Optional[int] = Field(None, description="Livraison partielle autorisée (0/1)") + delai_transport: Optional[int] = Field(None, description="Délai transport (jours)") + delai_appro: Optional[int] = Field(None, description="Délai approvisionnement (jours)") + + # JOURS DE COMMANDE (0=non, 1=oui) + order_day_lundi: Optional[int] = Field(None, ge=0, le=1) + order_day_mardi: Optional[int] = Field(None, ge=0, le=1) + order_day_mercredi: Optional[int] = Field(None, ge=0, le=1) + order_day_jeudi: Optional[int] = Field(None, ge=0, le=1) + order_day_vendredi: Optional[int] = Field(None, ge=0, le=1) + order_day_samedi: Optional[int] = Field(None, ge=0, le=1) + order_day_dimanche: Optional[int] = Field(None, ge=0, le=1) + + # JOURS DE LIVRAISON (0=non, 1=oui) + delivery_day_lundi: Optional[int] = Field(None, ge=0, le=1) + delivery_day_mardi: Optional[int] = Field(None, ge=0, le=1) + delivery_day_mercredi: Optional[int] = Field(None, ge=0, le=1) + delivery_day_jeudi: Optional[int] = Field(None, ge=0, le=1) + delivery_day_vendredi: Optional[int] = Field(None, ge=0, le=1) + delivery_day_samedi: Optional[int] = Field(None, ge=0, le=1) + delivery_day_dimanche: Optional[int] = Field(None, ge=0, le=1) + + # ======================================== + # DATES FERMETURE + # ======================================== + date_ferme_debut: Optional[date] = Field(None, description="Début période fermeture") + date_ferme_fin: Optional[date] = Field(None, description="Fin période fermeture") + + # ======================================== + # STATISTIQUES PERSONNALISÉES (10 champs) + # ======================================== + statistique01: Optional[str] = Field(None, max_length=69) + statistique02: Optional[str] = Field(None, max_length=69) + statistique03: Optional[str] = Field(None, max_length=69) + statistique04: Optional[str] = Field(None, max_length=69) + statistique05: Optional[str] = Field(None, max_length=69) + statistique06: Optional[str] = Field(None, max_length=69) + statistique07: Optional[str] = Field(None, max_length=69) + statistique08: Optional[str] = Field(None, max_length=69) + statistique09: Optional[str] = Field(None, max_length=69) + statistique10: Optional[str] = Field(None, max_length=69) + + # ======================================== + # COMMENTAIRE + # ======================================== + commentaire: Optional[str] = Field(None, description="Commentaire libre (CT_Commentaire, jusqu'à 2Go théorique)") + + # ======================================== + # ÉTAT & STATUT + # ======================================== + sommeil: bool = Field(False, description="Compte en sommeil") + prospect: bool = Field(False, description="Prospect (pas encore client)") + bon_a_payer: Optional[int] = Field(None, description="Bon à payer (0/1)") + + # ======================================== + # ANALYTIQUE + # ======================================== + section_analytique: Optional[str] = Field(None, max_length=13, description="Section analytique (CA_Num)") + section_analytique_ifrs: Optional[str] = Field(None, max_length=13, description="Section IFRS (CA_NumIFRS)") + plan_analytique: Optional[int] = Field(None, description="Plan analytique (N_Analytique)") + plan_analytique_ifrs: Optional[int] = Field(None, description="Plan IFRS (N_AnalytiqueIFRS)") + + # ======================================== + # COLLABORATEUR & DÉPÔT + # ======================================== + collaborateur: Optional[str] = Field(None, max_length=4, description="Code collaborateur (CO_No)") + depot: Optional[str] = Field(None, max_length=13, description="Dépôt par défaut (DE_No)") + etablissement: Optional[str] = Field(None, max_length=13, description="Établissement (EB_No)") + mode_regl: Optional[str] = Field(None, max_length=3, description="Mode règlement (MR_No)") + + # ======================================== + # CENTRALE D'ACHAT + # ======================================== + num_centrale: Optional[str] = Field(None, max_length=17, description="Numéro centrale d'achat") + + # ======================================== + # SURVEILLANCE COFACE + # ======================================== + coface: Optional[str] = Field(None, max_length=25, description="Code Coface") + surveillance: Optional[int] = Field(None, description="Surveillance activée (0/1)") + sv_date_create: Optional[date] = Field(None, description="Date création entreprise") + sv_forme_juri: Optional[str] = Field(None, max_length=50, description="Forme juridique") + sv_effectif: Optional[int] = Field(None, description="Effectif") + sv_ca: Optional[Decimal] = Field(None, description="Chiffre d'affaires") + sv_resultat: Optional[Decimal] = Field(None, description="Résultat") + sv_incident: Optional[int] = Field(None, description="Incidents (0/1)") + sv_date_incid: Optional[date] = Field(None, description="Date dernier incident") + sv_privil: Optional[int] = Field(None, description="Privilèges (0/1)") + sv_regul: Optional[int] = Field(None, description="Régularité (0/1)") + sv_cotation: Optional[str] = Field(None, max_length=25, description="Cotation") + sv_date_maj: Optional[date] = Field(None, description="Date MAJ surveillance") + sv_objet_maj: Optional[str] = Field(None, max_length=35, description="Objet MAJ") + sv_date_bilan: Optional[date] = Field(None, description="Date dernier bilan") + sv_nb_mois_bilan: Optional[int] = Field(None, description="Nb mois bilan") + + # ======================================== + # FACTURATION ÉLECTRONIQUE + # ======================================== + facture_elec: Optional[int] = Field(None, description="Facturation électronique (0/1)") + edi_code_type: Optional[int] = Field(None, description="Type code EDI") + edi_code: Optional[str] = Field(None, max_length=35, description="Code EDI") + edi_code_sage: Optional[str] = Field(None, max_length=35, description="Code EDI Sage") + fe_assujetti: Optional[int] = Field(None, description="Assujetti facture électronique") + fe_autre_identif_type: Optional[str] = Field(None, max_length=10) + fe_autre_identif_val: Optional[str] = Field(None, max_length=50) + fe_entite_type: Optional[int] = Field(None) + fe_emission: Optional[int] = Field(None) + fe_application: Optional[int] = Field(None) + + # ======================================== + # ÉCHANGES & INTÉGRATION + # ======================================== + echange_rappro: Optional[int] = Field(None, description="Échange rapprochement") + echange_cr: Optional[int] = Field(None, description="Échange compte rendu") + annulation_cr: Optional[int] = Field(None, description="Annulation CR") + profil_soc: Optional[int] = Field(None, description="Profil société") + statut_contrat: Optional[int] = Field(None, description="Statut contrat") + + # ======================================== + # RGPD & CONFIDENTIALITÉ + # ======================================== + gdpr: Optional[int] = Field(None, description="Consentement RGPD (0/1)") + exclure_trait: Optional[int] = Field(None, description="Exclure des traitements (0/1)") + + # ======================================== + # REPRÉSENTANT FISCAL + # ======================================== + represent_int: Optional[int] = Field(None, description="Représentant intracommunautaire") + represent_nif: Optional[str] = Field(None, max_length=25, description="NIF représentant") + + # ======================================== + # CHAMPS PERSONNALISÉS (exemples) + # ======================================== + date_creation_societe: Optional[date] = Field(None, description="Date création de la société") + capital_social: Optional[Decimal] = Field(None, description="Capital social") + actionnaire_principal: Optional[str] = Field(None, max_length=100, description="Actionnaire principal") + score_banque_france: Optional[str] = Field(None, max_length=10, description="Score BDF") + + # FIDÉLITÉ + total_points_fidelite: Optional[int] = Field(None, description="Total points fidélité") + points_fidelite_restants: Optional[int] = Field(None, description="Points restants") + fin_validite_carte: Optional[date] = Field(None, description="Fin validité carte") + + # ======================================== + # AUTRES + # ======================================== + calendrier: Optional[str] = Field(None, max_length=13, description="Code calendrier (CAL_No)") + mode_test: Optional[int] = Field(None, description="Mode test (0/1)") + confiance: Optional[int] = Field(None, description="Niveau de confiance") + + @field_validator('siret') + @classmethod + def validate_siret(cls, v): + if v and len(v.replace(' ', '')) != 14: + raise ValueError('Le SIRET doit contenir 14 chiffres') + return v.replace(' ', '') if v else v + + @field_validator('email') + @classmethod + def validate_email(cls, v): + if v and '@' not in v: + raise ValueError('Format email invalide') + return v + 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", + "intitule": "ENTREPRISE EXEMPLE SARL", + "num": "CLI00123", + "compte_collectif": "411000", + "qualite": "CLI" } } - class ClientUpdateRequest(BaseModel): """Modèle pour modification d'un client existant""" diff --git a/email_queue.py b/email_queue.py index 53d65af..05bd286 100644 --- a/email_queue.py +++ b/email_queue.py @@ -1,9 +1,3 @@ -# -*- coding: utf-8 -*- -""" -Queue d'envoi d'emails avec threading et génération PDF -Version VPS Linux - utilise sage_client pour récupérer les données -""" - import threading import queue import time @@ -17,7 +11,11 @@ from email.mime.text import MIMEText from email.mime.application import MIMEApplication from config import settings import logging - +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen import canvas +from reportlab.lib.units import cm +from io import BytesIO + logger = logging.getLogger(__name__) @@ -202,15 +200,6 @@ class EmailQueue: await asyncio.to_thread(self._send_smtp, msg) def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: - """ - Génération PDF via ReportLab + sage_client - - ⚠️ Cette méthode est appelée depuis un thread worker - """ - from reportlab.lib.pagesizes import A4 - from reportlab.pdfgen import canvas - from reportlab.lib.units import cm - from io import BytesIO if not self.sage_client: logger.error("❌ sage_client non configuré") From 5443c5c44a3844351a1f2363570062320a130445 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 24 Dec 2025 11:47:47 +0300 Subject: [PATCH 097/199] Added missing import --- api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api.py b/api.py index a75ae88..24ad684 100644 --- a/api.py +++ b/api.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, EmailStr, validator, field_validator from typing import List, Optional, Dict from datetime import date, datetime from enum import Enum +from decimal import Decimal import uvicorn from contextlib import asynccontextmanager import uuid From a4827c0534967a41ca6a85b863661aa5561b48b4 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 24 Dec 2025 11:49:19 +0300 Subject: [PATCH 098/199] corrected pydantic model's name mismatch --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 24ad684..f9e0e4d 100644 --- a/api.py +++ b/api.py @@ -401,7 +401,7 @@ class BaremeRemiseResponse(BaseModel): message: str -class ClientCreateRequest(BaseModel): +class ClientCreateAPIRequest(BaseModel): """Modèle complet pour la création d'un client Sage avec tous les champs disponibles""" # ======================================== From 3809d3403b223936443d511c1a1ce485412c9a83 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 24 Dec 2025 15:49:37 +0300 Subject: [PATCH 099/199] Refactored Create Client Object --- api.py | 659 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 451 insertions(+), 208 deletions(-) diff --git a/api.py b/api.py index f9e0e4d..7d6783a 100644 --- a/api.py +++ b/api.py @@ -4,7 +4,7 @@ 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 +from enum import Enum, IntEnum from decimal import Decimal import uvicorn from contextlib import asynccontextmanager @@ -401,275 +401,518 @@ class BaremeRemiseResponse(BaseModel): message: str +class TypeTiers(IntEnum): + """CT_Type - Type de tiers""" + CLIENT = 0 + FOURNISSEUR = 1 + SALARIE = 2 + AUTRE = 3 + + class ClientCreateAPIRequest(BaseModel): - """Modèle complet pour la création d'un client Sage avec tous les champs disponibles""" + """ + Modèle complet pour la création d'un client Sage 100c + Noms alignés sur le frontend + mapping vers champs Sage + """ - # ======================================== - # CHAMPS OBLIGATOIRES - # ======================================== + # ══════════════════════════════════════════════════════════════ + # IDENTIFICATION PRINCIPALE + # ══════════════════════════════════════════════════════════════ intitule: str = Field(..., max_length=69, description="Nom du client (CT_Intitule)") + numero: Optional[str] = Field(None, max_length=17, description="Numéro client CT_Num (auto si vide)") + type_tiers: Optional[int] = Field(0, ge=0, le=3, description="CT_Type: 0=Client, 1=Fournisseur, 2=Salarié, 3=Autre") + qualite: str = Field("CLI", max_length=17, description="CT_Qualite: CLI/FOU/SAL/DIV/AUT") + classement: Optional[str] = Field(None, max_length=17, description="CT_Classement") + raccourci: Optional[str] = Field(None, max_length=7, description="CT_Raccourci") - # ======================================== - # IDENTIFICATION & CLASSIFICATION - # ======================================== - num: Optional[str] = Field(None, max_length=17, description="Numéro client (auto si vide)") - compte_collectif: str = Field("411000", max_length=13, description="Compte général (CG_NumPrinc)") - qualite: str = Field("CLI", max_length=3, description="CLI/FOU/SAL/DIV/AUT") - classement: Optional[str] = Field(None, max_length=17, description="Code de classement") - raccourci: Optional[str] = Field(None, max_length=17, description="Code abrégé") + # ══════════════════════════════════════════════════════════════ + # STATUTS & FLAGS + # ══════════════════════════════════════════════════════════════ + est_prospect: bool = Field(False, description="CT_Prospect") + est_actif: bool = Field(True, description="Inverse de CT_Sommeil") + est_en_sommeil: Optional[bool] = Field(None, description="CT_Sommeil (calculé depuis est_actif si None)") - # ======================================== + # ══════════════════════════════════════════════════════════════ + # INFORMATIONS ENTREPRISE / PERSONNE + # ══════════════════════════════════════════════════════════════ + est_entreprise: Optional[bool] = Field(None, description="Flag interne - pas de champ Sage direct") + est_particulier: Optional[bool] = Field(None, description="Flag interne - pas de champ Sage direct") + forme_juridique: Optional[str] = Field(None, max_length=33, description="CT_SvFormeJuri") + civilite: Optional[str] = Field(None, max_length=17, description="Stocké dans CT_Qualite ou champ libre") + nom: Optional[str] = Field(None, max_length=35, description="Pour particuliers - partie de CT_Intitule") + prenom: Optional[str] = Field(None, max_length=35, description="Pour particuliers - partie de CT_Intitule") + nom_complet: Optional[str] = Field(None, max_length=69, description="Calculé ou CT_Intitule") + + # ══════════════════════════════════════════════════════════════ # ADRESSE PRINCIPALE - # ======================================== - contact: Optional[str] = Field(None, max_length=69, description="Nom du contact principal") - adresse: Optional[str] = Field(None, max_length=35, description="Adresse ligne 1") - complement: Optional[str] = Field(None, max_length=35, description="Adresse ligne 2") - code_postal: Optional[str] = Field(None, max_length=9, description="Code postal") - ville: Optional[str] = Field(None, max_length=35, description="Ville") - code_region: Optional[str] = Field(None, max_length=25, description="Code région/département") - pays: Optional[str] = Field(None, max_length=35, description="Pays") + # ══════════════════════════════════════════════════════════════ + contact: Optional[str] = Field(None, max_length=35, description="CT_Contact") + adresse: Optional[str] = Field(None, max_length=35, description="CT_Adresse") + complement: Optional[str] = Field(None, max_length=35, description="CT_Complement") + code_postal: Optional[str] = Field(None, max_length=9, description="CT_CodePostal") + ville: Optional[str] = Field(None, max_length=35, description="CT_Ville") + region: Optional[str] = Field(None, max_length=25, description="CT_CodeRegion") + pays: Optional[str] = Field(None, max_length=35, description="CT_Pays") - # ======================================== + # ══════════════════════════════════════════════════════════════ # CONTACT & COMMUNICATION - # ======================================== - telephone: Optional[str] = Field(None, max_length=21, description="Téléphone principal") - telecopie: Optional[str] = Field(None, max_length=21, description="Fax") - email: Optional[str] = Field(None, max_length=69, description="Email principal") - site: Optional[str] = Field(None, max_length=69, description="Site web") - facebook: Optional[str] = Field(None, max_length=100, description="URL Facebook") - linkedin: Optional[str] = Field(None, max_length=100, description="URL LinkedIn") + # ══════════════════════════════════════════════════════════════ + telephone: Optional[str] = Field(None, max_length=21, description="CT_Telephone") + portable: Optional[str] = Field(None, max_length=21, description="Stocké dans statistiques ou contact") + telecopie: Optional[str] = Field(None, max_length=21, description="CT_Telecopie") + email: Optional[str] = Field(None, max_length=69, description="CT_EMail") + site_web: Optional[str] = Field(None, max_length=69, description="CT_Site") + facebook: Optional[str] = Field(None, max_length=35, description="CT_Facebook") + linkedin: Optional[str] = Field(None, max_length=35, description="CT_LinkedIn") - # ======================================== + # ══════════════════════════════════════════════════════════════ # IDENTIFIANTS LÉGAUX & FISCAUX - # ======================================== - siret: Optional[str] = Field(None, max_length=14, description="SIRET (14 chiffres)") - tva_intra: Optional[str] = Field(None, max_length=25, description="TVA intracommunautaire (CT_Identifiant)") - ape: Optional[str] = Field(None, max_length=5, description="Code APE/NAF") - type_nif: Optional[int] = Field(None, description="Type de NIF (0-10)") + # ══════════════════════════════════════════════════════════════ + siret: Optional[str] = Field(None, max_length=15, description="CT_Siret (14-15 chars)") + siren: Optional[str] = Field(None, max_length=9, description="Extrait du SIRET") + tva_intra: Optional[str] = Field(None, max_length=25, description="CT_Identifiant") + code_naf: Optional[str] = Field(None, max_length=7, description="CT_Ape") + type_nif: Optional[int] = Field(None, ge=0, le=10, description="CT_TypeNIF") - # ======================================== + # ══════════════════════════════════════════════════════════════ # BANQUE & DEVISE - # ======================================== - banque_num: Optional[str] = Field(None, max_length=13, description="Code banque (BT_Num)") - devise: Optional[int] = Field(0, description="Code devise (N_Devise, 0=EUR)") + # ══════════════════════════════════════════════════════════════ + banque_num: Optional[int] = Field(None, description="BT_Num (smallint)") + devise: Optional[int] = Field(0, description="N_Devise (0=EUR)") - # ======================================== - # CATÉGORIES & CLASSIFICATIONS - # ======================================== - cat_tarif: int = Field(1, ge=1, description="Catégorie tarifaire (N_CatTarif)") - cat_compta: int = Field(1, ge=1, description="Catégorie comptable (N_CatCompta)") - period: int = Field(1, ge=1, description="Période de règlement (N_Period)") - expedition: int = Field(1, ge=1, description="Mode d'expédition (N_Expedition)") - condition: int = Field(1, ge=1, description="Condition de livraison (N_Condition)") - risque: int = Field(1, ge=1, description="Niveau de risque (N_Risque)") + # ══════════════════════════════════════════════════════════════ + # CATÉGORIES & CLASSIFICATIONS COMMERCIALES + # ══════════════════════════════════════════════════════════════ + categorie_tarifaire: Optional[int] = Field(1, ge=0, description="N_CatTarif") + categorie_comptable: Optional[int] = Field(1, ge=0, description="N_CatCompta") + periode_reglement: Optional[int] = Field(1, ge=0, description="N_Period") + mode_expedition: Optional[int] = Field(1, ge=0, description="N_Expedition") + condition_livraison: Optional[int] = Field(1, ge=0, description="N_Condition") + niveau_risque: Optional[int] = Field(1, ge=0, description="N_Risque") + secteur: Optional[str] = Field(None, max_length=21, description="CT_Statistique01 ou champ libre") - # ======================================== + # ══════════════════════════════════════════════════════════════ # TAUX PERSONNALISÉS - # ======================================== - taux01: Optional[Decimal] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") - taux02: Optional[Decimal] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") - taux03: Optional[Decimal] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") - taux04: Optional[Decimal] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") + # ══════════════════════════════════════════════════════════════ + taux01: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux01") + taux02: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux02") + taux03: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux03") + taux04: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux04") - # ======================================== + # ══════════════════════════════════════════════════════════════ # GESTION COMMERCIALE - # ======================================== - encours: Optional[Decimal] = Field(None, description="Encours autorisé (CT_Encours)") - assurance: Optional[Decimal] = Field(None, description="Plafond assurance (CT_Assurance)") - num_payeur: Optional[str] = Field(None, max_length=17, description="Numéro client payeur") - langue: Optional[int] = Field(None, description="Code langue (0-25)") - langue_iso2: Optional[str] = Field(None, max_length=2, description="Code ISO langue (FR, EN, etc)") + # ══════════════════════════════════════════════════════════════ + encours_autorise: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Encours") + assurance_credit: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Assurance") + num_payeur: Optional[str] = Field(None, max_length=17, description="CT_NumPayeur") + langue: Optional[int] = Field(None, ge=0, description="CT_Langue") + langue_iso2: Optional[str] = Field(None, max_length=3, description="CT_LangueISO2") + commercial_code: Optional[int] = Field(None, description="CO_No (int)") + commercial_nom: Optional[str] = Field(None, description="Résolu depuis CO_No - non stocké") + effectif: Optional[str] = Field(None, max_length=11, description="CT_SvEffectif") + ca_annuel: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvCA") - # ======================================== + # ══════════════════════════════════════════════════════════════ + # COMPTABILITÉ + # ══════════════════════════════════════════════════════════════ + compte_general: Optional[str] = Field("411000", max_length=13, description="CG_NumPrinc") + + # ══════════════════════════════════════════════════════════════ # PARAMÈTRES FACTURATION - # ======================================== - facture: int = Field(1, description="Type facturation (0=aucune, 1=normale, 2=regroupée)") - bl_fact: Optional[int] = Field(None, description="BL en facture (0/1)") - saut: Optional[int] = Field(None, description="Saut de page (0/1)") - lettrage: bool = Field(True, description="Lettrage auto (CT_Lettrage)") - valid_ech: Optional[int] = Field(None, description="Validation échéance (0/1)") - control_enc: Optional[int] = Field(None, description="Contrôle encours (0/1)") - not_rappel: Optional[int] = Field(None, description="Pas de relance (0/1)") - not_penal: Optional[int] = Field(None, description="Pas de pénalités (0/1)") + # ══════════════════════════════════════════════════════════════ + type_facture: Optional[int] = Field(1, ge=0, le=2, description="CT_Facture: 0=aucune, 1=normale, 2=regroupée") + bl_en_facture: Optional[int] = Field(None, ge=0, le=1, description="CT_BLFact") + saut_page: Optional[int] = Field(None, ge=0, le=1, description="CT_Saut") + lettrage_auto: Optional[bool] = Field(True, description="CT_Lettrage") + validation_echeance: Optional[int] = Field(None, ge=0, le=1, description="CT_ValidEch") + controle_encours: Optional[int] = Field(None, ge=0, le=1, description="CT_ControlEnc") + exclure_relance: Optional[int] = Field(None, ge=0, le=1, description="CT_NotRappel") + exclure_penalites: Optional[int] = Field(None, ge=0, le=1, description="CT_NotPenal") + bon_a_payer: Optional[int] = Field(None, ge=0, le=1, description="CT_BonAPayer") - # ======================================== + # ══════════════════════════════════════════════════════════════ # LIVRAISON & LOGISTIQUE - # ======================================== - priorite_livr: Optional[int] = Field(None, description="Priorité livraison (0-5)") - livr_partielle: Optional[int] = Field(None, description="Livraison partielle autorisée (0/1)") - delai_transport: Optional[int] = Field(None, description="Délai transport (jours)") - delai_appro: Optional[int] = Field(None, description="Délai approvisionnement (jours)") + # ══════════════════════════════════════════════════════════════ + priorite_livraison: Optional[int] = Field(None, ge=0, le=5, description="CT_PrioriteLivr") + livraison_partielle: Optional[int] = Field(None, ge=0, le=1, description="CT_LivrPartielle") + delai_transport: Optional[int] = Field(None, ge=0, description="CT_DelaiTransport (jours)") + delai_appro: Optional[int] = Field(None, ge=0, description="CT_DelaiAppro (jours)") - # JOURS DE COMMANDE (0=non, 1=oui) - order_day_lundi: Optional[int] = Field(None, ge=0, le=1) - order_day_mardi: Optional[int] = Field(None, ge=0, le=1) - order_day_mercredi: Optional[int] = Field(None, ge=0, le=1) - order_day_jeudi: Optional[int] = Field(None, ge=0, le=1) - order_day_vendredi: Optional[int] = Field(None, ge=0, le=1) - order_day_samedi: Optional[int] = Field(None, ge=0, le=1) - order_day_dimanche: Optional[int] = Field(None, ge=0, le=1) + # JOURS DE COMMANDE (0=non, 1=oui) - CT_OrderDay01-07 + jours_commande: Optional[dict] = Field( + None, + description="Dict avec clés: lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche" + ) - # JOURS DE LIVRAISON (0=non, 1=oui) - delivery_day_lundi: Optional[int] = Field(None, ge=0, le=1) - delivery_day_mardi: Optional[int] = Field(None, ge=0, le=1) - delivery_day_mercredi: Optional[int] = Field(None, ge=0, le=1) - delivery_day_jeudi: Optional[int] = Field(None, ge=0, le=1) - delivery_day_vendredi: Optional[int] = Field(None, ge=0, le=1) - delivery_day_samedi: Optional[int] = Field(None, ge=0, le=1) - delivery_day_dimanche: Optional[int] = Field(None, ge=0, le=1) + # JOURS DE LIVRAISON (0=non, 1=oui) - CT_DeliveryDay01-07 + jours_livraison: Optional[dict] = Field( + None, + description="Dict avec clés: lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche" + ) - # ======================================== # DATES FERMETURE - # ======================================== - date_ferme_debut: Optional[date] = Field(None, description="Début période fermeture") - date_ferme_fin: Optional[date] = Field(None, description="Fin période fermeture") + date_fermeture_debut: Optional[date] = Field(None, description="CT_DateFermeDebut") + date_fermeture_fin: Optional[date] = Field(None, description="CT_DateFermeFin") - # ======================================== - # STATISTIQUES PERSONNALISÉES (10 champs) - # ======================================== - statistique01: Optional[str] = Field(None, max_length=69) - statistique02: Optional[str] = Field(None, max_length=69) - statistique03: Optional[str] = Field(None, max_length=69) - statistique04: Optional[str] = Field(None, max_length=69) - statistique05: Optional[str] = Field(None, max_length=69) - statistique06: Optional[str] = Field(None, max_length=69) - statistique07: Optional[str] = Field(None, max_length=69) - statistique08: Optional[str] = Field(None, max_length=69) - statistique09: Optional[str] = Field(None, max_length=69) - statistique10: Optional[str] = Field(None, max_length=69) + # ══════════════════════════════════════════════════════════════ + # STATISTIQUES PERSONNALISÉES (10 champs de 21 chars max) + # ══════════════════════════════════════════════════════════════ + statistique01: Optional[str] = Field(None, max_length=21, description="CT_Statistique01") + statistique02: Optional[str] = Field(None, max_length=21, description="CT_Statistique02") + statistique03: Optional[str] = Field(None, max_length=21, description="CT_Statistique03") + statistique04: Optional[str] = Field(None, max_length=21, description="CT_Statistique04") + statistique05: Optional[str] = Field(None, max_length=21, description="CT_Statistique05") + statistique06: Optional[str] = Field(None, max_length=21, description="CT_Statistique06") + statistique07: Optional[str] = Field(None, max_length=21, description="CT_Statistique07") + statistique08: Optional[str] = Field(None, max_length=21, description="CT_Statistique08") + statistique09: Optional[str] = Field(None, max_length=21, description="CT_Statistique09") + statistique10: Optional[str] = Field(None, max_length=21, description="CT_Statistique10") - # ======================================== + # ══════════════════════════════════════════════════════════════ # COMMENTAIRE - # ======================================== - commentaire: Optional[str] = Field(None, description="Commentaire libre (CT_Commentaire, jusqu'à 2Go théorique)") + # ══════════════════════════════════════════════════════════════ + commentaire: Optional[str] = Field(None, max_length=35, description="CT_Commentaire") - # ======================================== - # ÉTAT & STATUT - # ======================================== - sommeil: bool = Field(False, description="Compte en sommeil") - prospect: bool = Field(False, description="Prospect (pas encore client)") - bon_a_payer: Optional[int] = Field(None, description="Bon à payer (0/1)") - - # ======================================== + # ══════════════════════════════════════════════════════════════ # ANALYTIQUE - # ======================================== - section_analytique: Optional[str] = Field(None, max_length=13, description="Section analytique (CA_Num)") - section_analytique_ifrs: Optional[str] = Field(None, max_length=13, description="Section IFRS (CA_NumIFRS)") - plan_analytique: Optional[int] = Field(None, description="Plan analytique (N_Analytique)") - plan_analytique_ifrs: Optional[int] = Field(None, description="Plan IFRS (N_AnalytiqueIFRS)") + # ══════════════════════════════════════════════════════════════ + section_analytique: Optional[str] = Field(None, max_length=13, description="CA_Num") + section_analytique_ifrs: Optional[str] = Field(None, max_length=13, description="CA_NumIFRS") + plan_analytique: Optional[int] = Field(None, ge=0, description="N_Analytique") + plan_analytique_ifrs: Optional[int] = Field(None, ge=0, description="N_AnalytiqueIFRS") - # ======================================== - # COLLABORATEUR & DÉPÔT - # ======================================== - collaborateur: Optional[str] = Field(None, max_length=4, description="Code collaborateur (CO_No)") - depot: Optional[str] = Field(None, max_length=13, description="Dépôt par défaut (DE_No)") - etablissement: Optional[str] = Field(None, max_length=13, description="Établissement (EB_No)") - mode_regl: Optional[str] = Field(None, max_length=3, description="Mode règlement (MR_No)") + # ══════════════════════════════════════════════════════════════ + # ORGANISATION + # ══════════════════════════════════════════════════════════════ + depot_code: Optional[int] = Field(None, description="DE_No (int)") + etablissement_code: Optional[int] = Field(None, description="EB_No (int)") + mode_reglement_code: Optional[int] = Field(None, description="MR_No (int)") + calendrier_code: Optional[int] = Field(None, description="CAL_No (int)") + num_centrale: Optional[str] = Field(None, max_length=17, description="CT_NumCentrale") - # ======================================== - # CENTRALE D'ACHAT - # ======================================== - num_centrale: Optional[str] = Field(None, max_length=17, description="Numéro centrale d'achat") - - # ======================================== + # ══════════════════════════════════════════════════════════════ # SURVEILLANCE COFACE - # ======================================== - coface: Optional[str] = Field(None, max_length=25, description="Code Coface") - surveillance: Optional[int] = Field(None, description="Surveillance activée (0/1)") - sv_date_create: Optional[date] = Field(None, description="Date création entreprise") - sv_forme_juri: Optional[str] = Field(None, max_length=50, description="Forme juridique") - sv_effectif: Optional[int] = Field(None, description="Effectif") - sv_ca: Optional[Decimal] = Field(None, description="Chiffre d'affaires") - sv_resultat: Optional[Decimal] = Field(None, description="Résultat") - sv_incident: Optional[int] = Field(None, description="Incidents (0/1)") - sv_date_incid: Optional[date] = Field(None, description="Date dernier incident") - sv_privil: Optional[int] = Field(None, description="Privilèges (0/1)") - sv_regul: Optional[int] = Field(None, description="Régularité (0/1)") - sv_cotation: Optional[str] = Field(None, max_length=25, description="Cotation") - sv_date_maj: Optional[date] = Field(None, description="Date MAJ surveillance") - sv_objet_maj: Optional[str] = Field(None, max_length=35, description="Objet MAJ") - sv_date_bilan: Optional[date] = Field(None, description="Date dernier bilan") - sv_nb_mois_bilan: Optional[int] = Field(None, description="Nb mois bilan") + # ══════════════════════════════════════════════════════════════ + coface: Optional[str] = Field(None, max_length=25, description="CT_Coface") + surveillance_active: Optional[int] = Field(None, ge=0, le=1, description="CT_Surveillance") + sv_date_creation: Optional[datetime] = Field(None, description="CT_SvDateCreate") + sv_chiffre_affaires: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvCA") + sv_resultat: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvResultat") + sv_incident: Optional[int] = Field(None, ge=0, le=1, description="CT_SvIncident") + sv_date_incident: Optional[datetime] = Field(None, description="CT_SvDateIncid") + sv_privilege: Optional[int] = Field(None, ge=0, le=1, description="CT_SvPrivil") + sv_regularite: Optional[str] = Field(None, max_length=3, description="CT_SvRegul") + sv_cotation: Optional[str] = Field(None, max_length=5, description="CT_SvCotation") + sv_date_maj: Optional[datetime] = Field(None, description="CT_SvDateMaj") + sv_objet_maj: Optional[str] = Field(None, max_length=61, description="CT_SvObjetMaj") + sv_date_bilan: Optional[datetime] = Field(None, description="CT_SvDateBilan") + sv_nb_mois_bilan: Optional[int] = Field(None, ge=0, description="CT_SvNbMoisBilan") - # ======================================== + # ══════════════════════════════════════════════════════════════ # FACTURATION ÉLECTRONIQUE - # ======================================== - facture_elec: Optional[int] = Field(None, description="Facturation électronique (0/1)") - edi_code_type: Optional[int] = Field(None, description="Type code EDI") - edi_code: Optional[str] = Field(None, max_length=35, description="Code EDI") - edi_code_sage: Optional[str] = Field(None, max_length=35, description="Code EDI Sage") - fe_assujetti: Optional[int] = Field(None, description="Assujetti facture électronique") - fe_autre_identif_type: Optional[str] = Field(None, max_length=10) - fe_autre_identif_val: Optional[str] = Field(None, max_length=50) - fe_entite_type: Optional[int] = Field(None) - fe_emission: Optional[int] = Field(None) - fe_application: Optional[int] = Field(None) + # ══════════════════════════════════════════════════════════════ + facture_electronique: Optional[int] = Field(None, ge=0, le=1, description="CT_FactureElec") + edi_code_type: Optional[int] = Field(None, description="CT_EdiCodeType") + edi_code: Optional[str] = Field(None, max_length=23, description="CT_EdiCode") + edi_code_sage: Optional[str] = Field(None, max_length=9, description="CT_EdiCodeSage") + fe_assujetti: Optional[int] = Field(None, description="CT_FEAssujetti") + fe_autre_identif_type: Optional[int] = Field(None, description="CT_FEAutreIdentifType") + fe_autre_identif_val: Optional[str] = Field(None, max_length=81, description="CT_FEAutreIdentifVal") + fe_entite_type: Optional[int] = Field(None, description="CT_FEEntiteType") + fe_emission: Optional[int] = Field(None, description="CT_FEEmission") + fe_application: Optional[int] = Field(None, description="CT_FEApplication") + fe_date_synchro: Optional[datetime] = Field(None, description="CT_FEDateSynchro") - # ======================================== + # ══════════════════════════════════════════════════════════════ # ÉCHANGES & INTÉGRATION - # ======================================== - echange_rappro: Optional[int] = Field(None, description="Échange rapprochement") - echange_cr: Optional[int] = Field(None, description="Échange compte rendu") - annulation_cr: Optional[int] = Field(None, description="Annulation CR") - profil_soc: Optional[int] = Field(None, description="Profil société") - statut_contrat: Optional[int] = Field(None, description="Statut contrat") + # ══════════════════════════════════════════════════════════════ + echange_rappro: Optional[int] = Field(None, description="CT_EchangeRappro") + echange_cr: Optional[int] = Field(None, description="CT_EchangeCR") + pi_no_echange: Optional[int] = Field(None, description="PI_NoEchange") + annulation_cr: Optional[int] = Field(None, description="CT_AnnulationCR") + profil_societe: Optional[int] = Field(None, description="CT_ProfilSoc") + statut_contrat: Optional[int] = Field(None, description="CT_StatutContrat") - # ======================================== + # ══════════════════════════════════════════════════════════════ # RGPD & CONFIDENTIALITÉ - # ======================================== - gdpr: Optional[int] = Field(None, description="Consentement RGPD (0/1)") - exclure_trait: Optional[int] = Field(None, description="Exclure des traitements (0/1)") + # ══════════════════════════════════════════════════════════════ + rgpd_consentement: Optional[int] = Field(None, ge=0, le=1, description="CT_GDPR") + exclure_traitement: Optional[int] = Field(None, ge=0, le=1, description="CT_ExclureTrait") - # ======================================== + # ══════════════════════════════════════════════════════════════ # REPRÉSENTANT FISCAL - # ======================================== - represent_int: Optional[int] = Field(None, description="Représentant intracommunautaire") - represent_nif: Optional[str] = Field(None, max_length=25, description="NIF représentant") + # ══════════════════════════════════════════════════════════════ + representant_intl: Optional[str] = Field(None, max_length=35, description="CT_RepresentInt") + representant_nif: Optional[str] = Field(None, max_length=25, description="CT_RepresentNIF") - # ======================================== - # CHAMPS PERSONNALISÉS (exemples) - # ======================================== - date_creation_societe: Optional[date] = Field(None, description="Date création de la société") - capital_social: Optional[Decimal] = Field(None, description="Capital social") - actionnaire_principal: Optional[str] = Field(None, max_length=100, description="Actionnaire principal") - score_banque_france: Optional[str] = Field(None, max_length=10, description="Score BDF") + # ══════════════════════════════════════════════════════════════ + # CHAMPS PERSONNALISÉS (Info Libres Sage) + # ══════════════════════════════════════════════════════════════ + date_creation_societe: Optional[datetime] = Field(None, description="Date création société (Info Libre)") + capital_social: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="Capital social") + actionnaire_principal: Optional[str] = Field(None, max_length=69, description="Actionnaire Pal") + score_banque_france: Optional[str] = Field(None, max_length=14, description="Score Banque de France") # FIDÉLITÉ - total_points_fidelite: Optional[int] = Field(None, description="Total points fidélité") - points_fidelite_restants: Optional[int] = Field(None, description="Points restants") - fin_validite_carte: Optional[date] = Field(None, description="Fin validité carte") + total_points_fidelite: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6) + points_fidelite_restants: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6) + date_fin_carte_fidelite: Optional[datetime] = Field(None, description="Fin validité carte fidélité") + date_negociation_reglement: Optional[datetime] = Field(None, description="Date négociation règlement") - # ======================================== + # ══════════════════════════════════════════════════════════════ # AUTRES - # ======================================== - calendrier: Optional[str] = Field(None, max_length=13, description="Code calendrier (CAL_No)") - mode_test: Optional[int] = Field(None, description="Mode test (0/1)") - confiance: Optional[int] = Field(None, description="Niveau de confiance") + # ══════════════════════════════════════════════════════════════ + mode_test: Optional[int] = Field(None, ge=0, le=1, description="CT_ModeTest") + confiance: Optional[int] = Field(None, description="CT_Confiance") + dn_id: Optional[str] = Field(None, max_length=37, description="DN_Id") + + # ══════════════════════════════════════════════════════════════ + # MÉTADONNÉES (en lecture seule généralement) + # ══════════════════════════════════════════════════════════════ + date_creation: Optional[datetime] = Field(None, description="cbCreation - géré par Sage") + date_modification: Optional[datetime] = Field(None, description="cbModification - géré par Sage") + date_maj: Optional[datetime] = Field(None, description="CT_DateMAJ") + + # ══════════════════════════════════════════════════════════════ + # VALIDATORS + # ══════════════════════════════════════════════════════════════ @field_validator('siret') @classmethod def validate_siret(cls, v): - if v and len(v.replace(' ', '')) != 14: - raise ValueError('Le SIRET doit contenir 14 chiffres') - return v.replace(' ', '') if v else v + if v and v.lower() not in ('none', ''): + cleaned = v.replace(' ', '').replace('-', '') + if len(cleaned) not in (14, 15): + raise ValueError('Le SIRET doit contenir 14 ou 15 caractères') + return cleaned + return None + + @field_validator('siren') + @classmethod + def validate_siren(cls, v): + if v and v.lower() not in ('none', ''): + cleaned = v.replace(' ', '') + if len(cleaned) != 9: + raise ValueError('Le SIREN doit contenir 9 caractères') + return cleaned + return None @field_validator('email') @classmethod def validate_email(cls, v): - if v and '@' not in v: - raise ValueError('Format email invalide') + if v and v.lower() not in ('none', ''): + if '@' not in v: + raise ValueError('Format email invalide') + return v.strip() + return None + + @field_validator('adresse', 'code_postal', 'ville', 'pays', 'telephone', 'tva_intra', mode='before') + @classmethod + def clean_none_strings(cls, v): + """Convertit les chaînes 'None' en None""" + if isinstance(v, str) and v.lower() in ('none', 'null', ''): + return None return v + @field_validator('est_en_sommeil', mode='before') + @classmethod + def compute_sommeil(cls, v, info): + """Calcule est_en_sommeil depuis est_actif si non fourni""" + if v is None and 'est_actif' in info.data: + return not info.data.get('est_actif', True) + return v + + def to_sage_dict(self) -> dict: + """Convertit le modèle en dictionnaire compatible avec la méthode creer_client""" + return { + # Identification + "intitule": self.intitule, + "num": self.numero, + "type_tiers": self.type_tiers, + "qualite": self.qualite, + "classement": self.classement, + "raccourci": self.raccourci, + + # Statuts + "prospect": self.est_prospect, + "sommeil": not self.est_actif if self.est_en_sommeil is None else self.est_en_sommeil, + + # Adresse + "contact": self.contact, + "adresse": self.adresse, + "complement": self.complement, + "code_postal": self.code_postal, + "ville": self.ville, + "code_region": self.region, + "pays": self.pays, + + # Communication + "telephone": self.telephone, + "telecopie": self.telecopie, + "email": self.email, + "site": self.site_web, + "facebook": self.facebook, + "linkedin": self.linkedin, + + # Identifiants légaux + "siret": self.siret, + "tva_intra": self.tva_intra, + "ape": self.code_naf, + "type_nif": self.type_nif, + + # Banque & devise + "banque_num": self.banque_num, + "devise": self.devise, + + # Catégories + "cat_tarif": self.categorie_tarifaire or 1, + "cat_compta": self.categorie_comptable or 1, + "period": self.periode_reglement or 1, + "expedition": self.mode_expedition or 1, + "condition": self.condition_livraison or 1, + "risque": self.niveau_risque or 1, + + # Taux + "taux01": self.taux01, + "taux02": self.taux02, + "taux03": self.taux03, + "taux04": self.taux04, + + # Gestion commerciale + "encours": self.encours_autorise, + "assurance": self.assurance_credit, + "num_payeur": self.num_payeur, + "langue": self.langue, + "langue_iso2": self.langue_iso2, + "compte_collectif": self.compte_general or "411000", + "collaborateur": self.commercial_code, + + # Facturation + "facture": self.type_facture, + "bl_fact": self.bl_en_facture, + "saut": self.saut_page, + "lettrage": self.lettrage_auto, + "valid_ech": self.validation_echeance, + "control_enc": self.controle_encours, + "not_rappel": self.exclure_relance, + "not_penal": self.exclure_penalites, + "bon_a_payer": self.bon_a_payer, + + # Livraison + "priorite_livr": self.priorite_livraison, + "livr_partielle": self.livraison_partielle, + "delai_transport": self.delai_transport, + "delai_appro": self.delai_appro, + "date_ferme_debut": self.date_fermeture_debut, + "date_ferme_fin": self.date_fermeture_fin, + + # Jours commande/livraison + **(self._expand_jours("order_day", self.jours_commande) if self.jours_commande else {}), + **(self._expand_jours("delivery_day", self.jours_livraison) if self.jours_livraison else {}), + + # Statistiques + "statistique01": self.statistique01 or self.secteur, + "statistique02": self.statistique02, + "statistique03": self.statistique03, + "statistique04": self.statistique04, + "statistique05": self.statistique05, + "statistique06": self.statistique06, + "statistique07": self.statistique07, + "statistique08": self.statistique08, + "statistique09": self.statistique09, + "statistique10": self.statistique10, + + # Commentaire + "commentaire": self.commentaire, + + # Analytique + "section_analytique": self.section_analytique, + "section_analytique_ifrs": self.section_analytique_ifrs, + "plan_analytique": self.plan_analytique, + "plan_analytique_ifrs": self.plan_analytique_ifrs, + + # Organisation + "depot": self.depot_code, + "etablissement": self.etablissement_code, + "mode_regl": self.mode_reglement_code, + "calendrier": self.calendrier_code, + "num_centrale": self.num_centrale, + + # Surveillance + "coface": self.coface, + "surveillance": self.surveillance_active, + "sv_forme_juri": self.forme_juridique, + "sv_effectif": self.effectif, + "sv_ca": self.sv_chiffre_affaires or self.ca_annuel, + "sv_resultat": self.sv_resultat, + "sv_incident": self.sv_incident, + "sv_date_incid": self.sv_date_incident, + "sv_privil": self.sv_privilege, + "sv_regul": self.sv_regularite, + "sv_cotation": self.sv_cotation, + "sv_date_create": self.sv_date_creation, + "sv_date_maj": self.sv_date_maj, + "sv_objet_maj": self.sv_objet_maj, + "sv_date_bilan": self.sv_date_bilan, + "sv_nb_mois_bilan": self.sv_nb_mois_bilan, + + # Facturation électronique + "facture_elec": self.facture_electronique, + "edi_code_type": self.edi_code_type, + "edi_code": self.edi_code, + "edi_code_sage": self.edi_code_sage, + "fe_assujetti": self.fe_assujetti, + "fe_autre_identif_type": self.fe_autre_identif_type, + "fe_autre_identif_val": self.fe_autre_identif_val, + "fe_entite_type": self.fe_entite_type, + "fe_emission": self.fe_emission, + "fe_application": self.fe_application, + + # Échanges + "echange_rappro": self.echange_rappro, + "echange_cr": self.echange_cr, + "annulation_cr": self.annulation_cr, + "profil_soc": self.profil_societe, + "statut_contrat": self.statut_contrat, + + # RGPD + "gdpr": self.rgpd_consentement, + "exclure_trait": self.exclure_traitement, + + # Représentant + "represent_int": self.representant_intl, + "represent_nif": self.representant_nif, + + # Autres + "mode_test": self.mode_test, + "confiance": self.confiance, + } + + def _expand_jours(self, prefix: str, jours: dict) -> dict: + """Expand les jours en champs individuels""" + mapping = { + "lundi": f"{prefix}_lundi", + "mardi": f"{prefix}_mardi", + "mercredi": f"{prefix}_mercredi", + "jeudi": f"{prefix}_jeudi", + "vendredi": f"{prefix}_vendredi", + "samedi": f"{prefix}_samedi", + "dimanche": f"{prefix}_dimanche", + } + return {v: jours.get(k) for k, v in mapping.items() if jours.get(k) is not None} + class Config: json_schema_extra = { "example": { "intitule": "ENTREPRISE EXEMPLE SARL", - "num": "CLI00123", - "compte_collectif": "411000", - "qualite": "CLI" + "numero": "CLI00123", + "compte_general": "411000", + "qualite": "CLI", + "est_prospect": False, + "est_actif": True } } - + class ClientUpdateRequest(BaseModel): """Modèle pour modification d'un client existant""" From c47c2c43fb0ad3810418ffd4ab544e389a61e0bc Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 24 Dec 2025 15:54:43 +0300 Subject: [PATCH 100/199] Corrected error "Object of type Decimal is not JSON serializable" --- api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 7d6783a..7604f58 100644 --- a/api.py +++ b/api.py @@ -2273,11 +2273,11 @@ async def ajouter_client( logger.info(f"✅ Client créé via API: {nouveau_client.get('numero')}") - return { + return jsonable_encoder({ "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}") From 07ec8af191e950138448e2f71a70bf3e0d7e3bfb Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 24 Dec 2025 16:01:41 +0300 Subject: [PATCH 101/199] style: remove emoji icons from log messages and comments --- api.py | 222 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 111 insertions(+), 111 deletions(-) diff --git a/api.py b/api.py index 7604f58..a439a8c 100644 --- a/api.py +++ b/api.py @@ -267,7 +267,7 @@ class ArticleResponse(BaseModel): """ Modèle de réponse pour un article Sage - ✅ ENRICHI avec tous les champs disponibles + ENRICHI avec tous les champs disponibles """ # === IDENTIFICATION === @@ -1339,7 +1339,7 @@ class ArticleUpdateRequest(BaseModel): prix_vente: Optional[float] = Field(None, ge=0) prix_achat: Optional[float] = Field(None, ge=0) stock_reel: Optional[float] = Field( - None, ge=0, description="⚠️ Critique pour erreur 2881" + None, ge=0, description="Critique pour erreur 2881" ) stock_mini: Optional[float] = Field(None, ge=0) code_ean: Optional[str] = Field(None, max_length=13) @@ -1539,7 +1539,7 @@ templates_signature_email = { "demande_signature": { "id": "demande_signature", "nom": "Demande de Signature Électronique", - "sujet": "📝 Signature requise - {{TYPE_DOC}} {{NUMERO}}", + "sujet": "Signature requise - {{TYPE_DOC}} {{NUMERO}}", "corps_html": """ @@ -1557,7 +1557,7 @@ templates_signature_email = {

- 📝 Signature Électronique Requise + Signature Électronique Requise

@@ -1668,7 +1668,7 @@ templates_signature_email = { "signature_confirmee": { "id": "signature_confirmee", "nom": "Confirmation de Signature", - "sujet": "✅ Document signé - {{TYPE_DOC}} {{NUMERO}}", + "sujet": "Document signé - {{TYPE_DOC}} {{NUMERO}}", "corps_html": """ @@ -1686,7 +1686,7 @@ templates_signature_email = {

- ✅ Document Signé avec Succès + Document Signé avec Succès

@@ -1902,12 +1902,12 @@ async def universign_envoyer_avec_email( if not pdf_bytes or len(pdf_bytes) == 0: raise Exception("Le PDF généré est vide") - logger.info(f"✅ PDF valide : {len(pdf_bytes)} octets") + logger.info(f"PDF valide : {len(pdf_bytes)} octets") # ======================================== # ÉTAPE 1 : Créer la transaction # ======================================== - logger.info(f"📝 ÉTAPE 1/6 : Création transaction") + logger.info(f"ÉTAPE 1/6 : Création transaction") response = requests.post( f"{api_url}/transactions", @@ -1920,16 +1920,16 @@ async def universign_envoyer_avec_email( ) if response.status_code != 200: - logger.error(f"❌ Erreur création transaction: {response.text}") + logger.error(f"Erreur création transaction: {response.text}") raise Exception(f"Erreur création transaction: {response.status_code}") transaction_id = response.json().get("id") - logger.info(f"✅ Transaction créée: {transaction_id}") + logger.info(f"Transaction créée: {transaction_id}") # ======================================== # ÉTAPE 2 : Upload du fichier PDF # ======================================== - logger.info(f"📄 ÉTAPE 2/6 : Upload PDF") + logger.info(f"ÉTAPE 2/6 : Upload PDF") files = { "file": ( @@ -1947,11 +1947,11 @@ async def universign_envoyer_avec_email( ) if response.status_code not in [200, 201]: - logger.error(f"❌ Erreur upload: {response.text}") + logger.error(f"Erreur upload: {response.text}") raise Exception(f"Erreur upload fichier: {response.status_code}") file_id = response.json().get("id") - logger.info(f"✅ Fichier uploadé: {file_id}") + logger.info(f"Fichier uploadé: {file_id}") # ======================================== # ÉTAPE 3 : Ajouter le document @@ -1966,11 +1966,11 @@ async def universign_envoyer_avec_email( ) if response.status_code not in [200, 201]: - logger.error(f"❌ Erreur ajout document: {response.text}") + logger.error(f"Erreur ajout document: {response.text}") raise Exception(f"Erreur ajout document: {response.status_code}") document_id = response.json().get("id") - logger.info(f"✅ Document ajouté: {document_id}") + logger.info(f"Document ajouté: {document_id}") # ======================================== # ÉTAPE 4 : Créer le champ de signature @@ -1988,11 +1988,11 @@ async def universign_envoyer_avec_email( ) if response.status_code not in [200, 201]: - logger.error(f"❌ Erreur création champ: {response.text}") + logger.error(f"Erreur création champ: {response.text}") raise Exception(f"Erreur création champ: {response.status_code}") field_id = response.json().get("id") - logger.info(f"✅ Champ créé: {field_id}") + logger.info(f"Champ créé: {field_id}") # ======================================== # ÉTAPE 5 : Lier le signataire au champ (ancien endpoint) @@ -2000,7 +2000,7 @@ async def universign_envoyer_avec_email( logger.info(f"👤 ÉTAPE 5/6 : Liaison signataire au champ") response = requests.post( - f"{api_url}/transactions/{transaction_id}/signatures", # ✅ /signatures pas /signers + f"{api_url}/transactions/{transaction_id}/signatures", # /signatures pas /signers auth=auth, data={ "signer": email, @@ -2010,10 +2010,10 @@ async def universign_envoyer_avec_email( ) if response.status_code not in [200, 201]: - logger.error(f"❌ Erreur liaison signataire: {response.text}") + logger.error(f"Erreur liaison signataire: {response.text}") raise Exception(f"Erreur liaison signataire: {response.status_code}") - logger.info(f"✅ Signataire lié: {email}") + logger.info(f"Signataire lié: {email}") # ======================================== # ÉTAPE 6 : Démarrer la transaction @@ -2027,11 +2027,11 @@ async def universign_envoyer_avec_email( ) if response.status_code not in [200, 201]: - logger.error(f"❌ Erreur démarrage: {response.text}") + logger.error(f"Erreur démarrage: {response.text}") raise Exception(f"Erreur démarrage: {response.status_code}") final_data = response.json() - logger.info(f"✅ Transaction démarrée") + logger.info(f"Transaction démarrée") # ======================================== # Récupérer l'URL de signature @@ -2055,10 +2055,10 @@ async def universign_envoyer_avec_email( break if not signer_url: - logger.error(f"❌ URL introuvable dans: {final_data}") + logger.error(f"URL introuvable dans: {final_data}") raise ValueError("URL de signature non retournée par Universign") - logger.info(f"✅ URL récupérée") + logger.info(f"URL récupérée") # ======================================== # Créer l'email de notification @@ -2109,7 +2109,7 @@ async def universign_envoyer_avec_email( email_queue.enqueue(email_log.id) - logger.info(f"✅ Email mis en file pour {email}") + logger.info(f"Email mis en file pour {email}") logger.info(f"🎉 Processus terminé avec succès") return { @@ -2121,7 +2121,7 @@ async def universign_envoyer_avec_email( } except Exception as e: - logger.error(f"❌ Erreur Universign: {e}", exc_info=True) + logger.error(f"Erreur Universign: {e}", exc_info=True) return { "error": str(e), "statut": "ERREUR", @@ -2166,16 +2166,16 @@ async def universign_statut(transaction_id: str) -> Dict: async def lifespan(app: FastAPI): # Init base de données await init_db() - logger.info("✅ Base de données initialisée") + logger.info("Base de données initialisée") email_queue.session_factory = async_session_factory email_queue.sage_client = sage_client - logger.info("✅ sage_client injecté dans email_queue") + 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") + logger.info(f"Email queue démarrée") yield @@ -2246,7 +2246,7 @@ async def modifier_client( code, client_update.dict(exclude_none=True) ) - logger.info(f"✅ Client {code} modifié avec succès") + logger.info(f"Client {code} modifié avec succès") return { "success": True, @@ -2269,9 +2269,9 @@ async def ajouter_client( client: ClientCreateAPIRequest, session: AsyncSession = Depends(get_session) ): try: - nouveau_client = sage_client.creer_client(client.dict()) - - logger.info(f"✅ Client créé via API: {nouveau_client.get('numero')}") + nouveau_client = sage_client.creer_client(client.model_dump(mode='json')) + + logger.info(f"Client créé via API: {nouveau_client.get('numero')}") return jsonable_encoder({ "success": True, @@ -2313,20 +2313,20 @@ async def creer_article(article: ArticleCreateRequest): article_data = article.dict(exclude_unset=True) - logger.info(f"📝 Création article: {article.reference} - {article.designation}") + logger.info(f"Création article: {article.reference} - {article.designation}") # Appel à la gateway Windows resultat = sage_client.creer_article(article_data) logger.info( - f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" + f"Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" ) return ArticleResponse(**resultat) except ValueError as e: # Erreur métier (ex: article existe déjà) - logger.warning(f"⚠️ Erreur métier création article: {e}") + logger.warning(f"Erreur métier création article: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except HTTPException: @@ -2334,7 +2334,7 @@ async def creer_article(article: ArticleCreateRequest): except Exception as e: # Erreur technique Sage - logger.error(f"❌ Erreur technique création article: {e}", exc_info=True) + logger.error(f"Erreur technique création article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la création de l'article: {str(e)}", @@ -2355,7 +2355,7 @@ async def modifier_article( detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour.", ) - logger.info(f"📝 Modification article {reference}: {list(article_data.keys())}") + logger.info(f"Modification article {reference}: {list(article_data.keys())}") # Appel à la gateway Windows resultat = sage_client.modifier_article(reference, article_data) @@ -2363,17 +2363,17 @@ async def modifier_article( # Log spécial pour modification de stock (important pour erreur 2881) if "stock_reel" in article_data: logger.info( - f"📦 Stock {reference} modifié: {article_data['stock_reel']} " + f"Stock {reference} modifié: {article_data['stock_reel']} " f"(peut résoudre erreur 2881)" ) - logger.info(f"✅ Article {reference} modifié ({len(article_data)} champs)") + logger.info(f"Article {reference} modifié ({len(article_data)} champs)") return ArticleResponse(**resultat) except ValueError as e: # Erreur métier (ex: article introuvable) - logger.warning(f"⚠️ Erreur métier modification article: {e}") + logger.warning(f"Erreur métier modification article: {e}") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except HTTPException: @@ -2381,7 +2381,7 @@ async def modifier_article( except Exception as e: # Erreur technique Sage - logger.error(f"❌ Erreur technique modification article: {e}", exc_info=True) + logger.error(f"Erreur technique modification article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la modification de l'article: {str(e)}", @@ -2396,20 +2396,20 @@ async def lire_article( article = sage_client.lire_article(reference) if not article: - logger.warning(f"⚠️ Article {reference} introuvable") + logger.warning(f"Article {reference} introuvable") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Article {reference} introuvable", ) - logger.info(f"✅ Article {reference} lu: {article.get('designation', '')}") + logger.info(f"Article {reference} lu: {article.get('designation', '')}") return ArticleResponse(**article) except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur lecture article {reference}: {e}", exc_info=True) + logger.error(f"Erreur lecture article {reference}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la lecture de l'article: {str(e)}", @@ -2452,7 +2452,7 @@ async def creer_devis(devis: DevisRequest): # Appel HTTP vers Windows resultat = sage_client.creer_devis(devis_data) - logger.info(f"✅ Devis créé: {resultat.get('numero_devis')}") + logger.info(f"Devis créé: {resultat.get('numero_devis')}") return DevisResponse( id=resultat["numero_devis"], @@ -2499,7 +2499,7 @@ async def modifier_devis( # Appel à la gateway Windows resultat = sage_client.modifier_devis(id, update_data) - logger.info(f"✅ Devis {id} modifié avec succès") + logger.info(f"Devis {id} modifié avec succès") return { "success": True, @@ -2541,7 +2541,7 @@ async def creer_commande( # Appel à la gateway Windows resultat = sage_client.creer_commande(commande_data) - logger.info(f"✅ Commande créée: {resultat.get('numero_commande')}") + logger.info(f"Commande créée: {resultat.get('numero_commande')}") return { "success": True, @@ -2595,7 +2595,7 @@ async def modifier_commande( # Appel à la gateway Windows resultat = sage_client.modifier_commande(id, update_data) - logger.info(f"✅ Commande {id} modifiée avec succès") + logger.info(f"Commande {id} modifiée avec succès") return { "success": True, @@ -2693,7 +2693,7 @@ async def telecharger_document_pdf( label = types_labels[type_doc] - logger.info(f"📄 Génération PDF: {label} {numero} (type={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) @@ -2701,7 +2701,7 @@ async def telecharger_document_pdf( 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") + logger.info(f"PDF généré: {len(pdf_bytes)} octets") # Nom de fichier formaté filename = f"{label}_{numero}.pdf" @@ -2719,7 +2719,7 @@ async def telecharger_document_pdf( raise except Exception as e: logger.error( - f"❌ Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True + 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)}") @@ -2754,7 +2754,7 @@ async def envoyer_devis_email( await session.commit() logger.info( - f"✅ Email devis {id} mis en file: {len(tous_destinataires)} destinataire(s)" + f"Email devis {id} mis en file: {len(tous_destinataires)} destinataire(s)" ) return { @@ -2800,7 +2800,7 @@ async def changer_statut_devis( resultat = sage_client.changer_statut_devis(id, nouveau_statut) - logger.info(f"✅ Statut devis {id} changé: {statut_actuel} → {nouveau_statut}") + logger.info(f"Statut devis {id} changé: {statut_actuel} → {nouveau_statut}") return { "success": True, @@ -2870,7 +2870,7 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi await session.commit() logger.info( - f"✅ Transformation: Devis {id} → Commande {resultat['document_cible']}" + f"Transformation: Devis {id} → Commande {resultat['document_cible']}" ) return { @@ -2910,7 +2910,7 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses await session.commit() logger.info( - f"✅ Transformation: Commande {id} → Facture {resultat['document_cible']}" + f"Transformation: Commande {id} → Facture {resultat['document_cible']}" ) return { @@ -3002,7 +3002,7 @@ async def envoyer_signature_optimise( ) logger.info( - f"✅ Signature envoyée: {demande.doc_id} (Email: {resultat['email_sent']})" + f"Signature envoyée: {demande.doc_id} (Email: {resultat['email_sent']})" ) return { @@ -3017,7 +3017,7 @@ async def envoyer_signature_optimise( except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur signature: {e}") + logger.error(f"Erreur signature: {e}") raise HTTPException(500, str(e)) @@ -3032,7 +3032,7 @@ async def webhook_universign( transaction_id = payload.get("transaction_id") if not transaction_id: - logger.warning("⚠️ Webhook sans transaction_id") + logger.warning("Webhook sans transaction_id") return {"status": "ignored"} # Chercher la signature dans la DB @@ -3043,7 +3043,7 @@ async def webhook_universign( signature_log = result.scalar_one_or_none() if not signature_log: - logger.warning(f"⚠️ Transaction {transaction_id} introuvable en DB") + logger.warning(f"Transaction {transaction_id} introuvable en DB") return {"status": "not_found"} # ============================================= @@ -3051,11 +3051,11 @@ async def webhook_universign( # ============================================= if event_type == "transaction.completed": - # ✅ SIGNATURE RÉUSSIE + # SIGNATURE RÉUSSIE signature_log.statut = StatutSignatureEnum.SIGNE signature_log.date_signature = datetime.now() - logger.info(f"✅ Signature confirmée: {signature_log.document_id}") + logger.info(f"Signature confirmée: {signature_log.document_id}") # ENVOYER EMAIL DE CONFIRMATION template = templates_signature_email["signature_confirmee"] @@ -3105,9 +3105,9 @@ async def webhook_universign( ) elif event_type == "transaction.refused": - # ❌ SIGNATURE REFUSÉE + # SIGNATURE REFUSÉE signature_log.statut = StatutSignatureEnum.REFUSE - logger.warning(f"❌ Signature refusée: {signature_log.document_id}") + logger.warning(f"Signature refusée: {signature_log.document_id}") elif event_type == "transaction.expired": # ⏰ TRANSACTION EXPIRÉE @@ -3123,7 +3123,7 @@ async def webhook_universign( } except Exception as e: - logger.error(f"❌ Erreur webhook Universign: {e}") + logger.error(f"Erreur webhook Universign: {e}") return {"status": "error", "message": str(e)} @@ -3214,7 +3214,7 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se ) except Exception as e: - logger.error(f"❌ Erreur relance signature {signature.id}: {e}") + logger.error(f"Erreur relance signature {signature.id}: {e}") continue await session.commit() @@ -3227,7 +3227,7 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se } except Exception as e: - logger.error(f"❌ Erreur relances automatiques: {e}") + logger.error(f"Erreur relances automatiques: {e}") raise HTTPException(500, str(e)) @@ -3496,7 +3496,7 @@ async def envoyer_emails_lot( 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" + f"{len(batch.destinataires)} emails mis en file avec {nb_documents} docs" ) return { @@ -3520,12 +3520,12 @@ async def valider_remise( autorisee = remise_pourcentage <= remise_max if not autorisee: - message = f"⚠️ Remise trop élevée (max autorisé: {remise_max}%)" + 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" + message = "Remise autorisée" return BaremeRemiseResponse( client_id=client_id, @@ -3702,7 +3702,7 @@ async def creer_facture( # Appel à la gateway Windows resultat = sage_client.creer_facture(facture_data) - logger.info(f"✅ Facture créée: {resultat.get('numero_facture')}") + logger.info(f"Facture créée: {resultat.get('numero_facture')}") return { "success": True, @@ -3756,7 +3756,7 @@ async def modifier_facture( # Appel à la gateway Windows resultat = sage_client.modifier_facture(id, update_data) - logger.info(f"✅ Facture {id} modifiée avec succès") + logger.info(f"Facture {id} modifiée avec succès") return { "success": True, @@ -3852,7 +3852,7 @@ async def relancer_facture( await session.commit() - logger.info(f"✅ Relance facture: {id} → {contact['email']}") + logger.info(f"Relance facture: {id} → {contact['email']}") return { "success": True, @@ -4175,15 +4175,15 @@ async def rechercher_fournisseurs(query: Optional[str] = Query(None)): try: fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") - logger.info(f"✅ {len(fournisseurs)} fournisseurs") + logger.info(f"{len(fournisseurs)} fournisseurs") if len(fournisseurs) == 0: - logger.warning("⚠️ Aucun fournisseur retourné - vérifier la gateway Windows") + logger.warning("Aucun fournisseur retourné - vérifier la gateway Windows") return fournisseurs except Exception as e: - logger.error(f"❌ Erreur recherche fournisseurs: {e}") + logger.error(f"Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) @@ -4196,7 +4196,7 @@ async def ajouter_fournisseur( # 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')}") + logger.info(f"Fournisseur créé via API: {nouveau_fournisseur.get('numero')}") return { "success": True, @@ -4206,12 +4206,12 @@ async def ajouter_fournisseur( except ValueError as e: # Erreur métier (doublon, validation) - logger.warning(f"⚠️ Erreur métier création fournisseur: {e}") + 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}") + logger.error(f"Erreur technique création fournisseur: {e}") raise HTTPException(500, str(e)) @@ -4227,7 +4227,7 @@ async def modifier_fournisseur( code, fournisseur_update.dict(exclude_none=True) ) - logger.info(f"✅ Fournisseur {code} modifié avec succès") + logger.info(f"Fournisseur {code} modifié avec succès") return { "success": True, @@ -4313,7 +4313,7 @@ async def creer_avoir( # Appel à la gateway Windows resultat = sage_client.creer_avoir(avoir_data) - logger.info(f"✅ Avoir créé: {resultat.get('numero_avoir')}") + logger.info(f"Avoir créé: {resultat.get('numero_avoir')}") return { "success": True, @@ -4367,7 +4367,7 @@ async def modifier_avoir( # Appel à la gateway Windows resultat = sage_client.modifier_avoir(id, update_data) - logger.info(f"✅ Avoir {id} modifié avec succès") + logger.info(f"Avoir {id} modifié avec succès") return { "success": True, @@ -4442,7 +4442,7 @@ async def creer_livraison( # Appel à la gateway Windows resultat = sage_client.creer_livraison(livraison_data) - logger.info(f"✅ Livraison créée: {resultat.get('numero_livraison')}") + logger.info(f"Livraison créée: {resultat.get('numero_livraison')}") return { "success": True, @@ -4496,7 +4496,7 @@ async def modifier_livraison( # Appel à la gateway Windows resultat = sage_client.modifier_livraison(id, update_data) - logger.info(f"✅ Livraison {id} modifiée avec succès") + logger.info(f"Livraison {id} modifiée avec succès") return { "success": True, @@ -4535,7 +4535,7 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se await session.commit() logger.info( - f"✅ Transformation: Livraison {id} → Facture {resultat['document_cible']}" + f"Transformation: Livraison {id} → Facture {resultat['document_cible']}" ) return { @@ -4591,7 +4591,7 @@ async def devis_vers_facture_direct( await session.commit() logger.info( - f"✅ Transformation DIRECTE: Devis {id} → Facture {resultat['document_cible']}" + f"Transformation DIRECTE: Devis {id} → Facture {resultat['document_cible']}" ) return { @@ -4607,7 +4607,7 @@ async def devis_vers_facture_direct( except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur transformation devis→facture: {e}", exc_info=True) + logger.error(f"Erreur transformation devis→facture: {e}", exc_info=True) raise HTTPException(500, str(e)) @@ -4659,7 +4659,7 @@ async def commande_vers_livraison( await session.commit() logger.info( - f"✅ Transformation: Commande {id} → Livraison {resultat['document_cible']}" + f"Transformation: Commande {id} → Livraison {resultat['document_cible']}" ) return { @@ -4675,7 +4675,7 @@ async def commande_vers_livraison( except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur transformation commande→livraison: {e}", exc_info=True) + logger.error(f"Erreur transformation commande→livraison: {e}", exc_info=True) raise HTTPException(500, str(e)) @@ -4691,12 +4691,12 @@ async def lister_familles( try: familles = sage_client.lister_familles(filtre or "") - logger.info(f"✅ {len(familles)} famille(s) retournée(s)") + logger.info(f"{len(familles)} famille(s) retournée(s)") return [FamilleResponse(**f) for f in familles] except Exception as e: - logger.error(f"❌ Erreur liste familles: {e}", exc_info=True) + logger.error(f"Erreur liste familles: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la récupération des familles: {str(e)}", @@ -4716,20 +4716,20 @@ async def lire_famille( famille = sage_client.lire_famille(code) if not famille: - logger.warning(f"⚠️ Famille {code} introuvable") + logger.warning(f"Famille {code} introuvable") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Famille {code} introuvable", ) - logger.info(f"✅ Famille {code} lue: {famille.get('intitule', '')}") + logger.info(f"Famille {code} lue: {famille.get('intitule', '')}") return FamilleResponse(**famille) except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur lecture famille {code}: {e}", exc_info=True) + logger.error(f"Erreur lecture famille {code}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la lecture de la famille: {str(e)}", @@ -4754,18 +4754,18 @@ async def creer_famille(famille: FamilleCreateRequest): famille_data = famille.dict() - logger.info(f"📦 Création famille: {famille.code} - {famille.intitule}") + logger.info(f"Création famille: {famille.code} - {famille.intitule}") # Appel à la gateway Windows resultat = sage_client.creer_famille(famille_data) - logger.info(f"✅ Famille créée: {resultat.get('code')}") + logger.info(f"Famille créée: {resultat.get('code')}") return FamilleResponse(**resultat) except ValueError as e: # Erreur métier (ex: famille existe déjà) - logger.warning(f"⚠️ Erreur métier création famille: {e}") + logger.warning(f"Erreur métier création famille: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except HTTPException: @@ -4773,7 +4773,7 @@ async def creer_famille(famille: FamilleCreateRequest): except Exception as e: # Erreur technique Sage - logger.error(f"❌ Erreur technique création famille: {e}", exc_info=True) + logger.error(f"Erreur technique création famille: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la création de la famille: {str(e)}", @@ -4794,21 +4794,21 @@ async def creer_entree_stock(entree: EntreeStockRequest): if entree_data.get("date_entree"): entree_data["date_entree"] = entree_data["date_entree"].isoformat() - logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") + logger.info(f"Création entrée stock: {len(entree.lignes)} ligne(s)") # Appel à la gateway Windows resultat = sage_client.creer_entree_stock(entree_data) - logger.info(f"✅ Entrée stock créée: {resultat.get('numero')}") + logger.info(f"Entrée stock créée: {resultat.get('numero')}") return MouvementStockResponse(**resultat) except ValueError as e: - logger.warning(f"⚠️ Erreur métier entrée stock: {e}") + logger.warning(f"Erreur métier entrée stock: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: - logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True) + logger.error(f"Erreur technique entrée stock: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la création de l'entrée: {str(e)}", @@ -4834,16 +4834,16 @@ async def creer_sortie_stock(sortie: SortieStockRequest): # Appel à la gateway Windows resultat = sage_client.creer_sortie_stock(sortie_data) - logger.info(f"✅ Sortie stock créée: {resultat.get('numero')}") + logger.info(f"Sortie stock créée: {resultat.get('numero')}") return MouvementStockResponse(**resultat) except ValueError as e: - logger.warning(f"⚠️ Erreur métier sortie stock: {e}") + logger.warning(f"Erreur métier sortie stock: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: - logger.error(f"❌ Erreur technique sortie stock: {e}", exc_info=True) + logger.error(f"Erreur technique sortie stock: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la création de la sortie: {str(e)}", @@ -4863,20 +4863,20 @@ async def lire_mouvement_stock( mouvement = sage_client.lire_mouvement_stock(numero) if not mouvement: - logger.warning(f"⚠️ Mouvement {numero} introuvable") + logger.warning(f"Mouvement {numero} introuvable") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Mouvement de stock {numero} introuvable", ) - logger.info(f"✅ Mouvement {numero} lu") + logger.info(f"Mouvement {numero} lu") return MouvementStockResponse(**mouvement) except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur lecture mouvement {numero}: {e}", exc_info=True) + logger.error(f"Erreur lecture mouvement {numero}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la lecture du mouvement: {str(e)}", @@ -4895,7 +4895,7 @@ async def statistiques_familles(): return {"success": True, "data": stats} except Exception as e: - logger.error(f"❌ Erreur stats familles: {e}", exc_info=True) + logger.error(f"Erreur stats familles: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Erreur lors de la récupération des statistiques: {str(e)}", @@ -4955,7 +4955,7 @@ async def lister_utilisateurs_debug( return users_response except Exception as e: - logger.error(f"❌ Erreur liste utilisateurs: {e}") + logger.error(f"Erreur liste utilisateurs: {e}") raise HTTPException(500, str(e)) @@ -4995,7 +4995,7 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) } except Exception as e: - logger.error(f"❌ Erreur stats utilisateurs: {e}") + logger.error(f"Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) From 2e267d6faff4f7d65dd81439c16246a6c1f6bbd8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 24 Dec 2025 22:56:58 +0300 Subject: [PATCH 102/199] Added missing import --- api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api.py b/api.py index a439a8c..0aeb0da 100644 --- a/api.py +++ b/api.py @@ -1,6 +1,7 @@ from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse +from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field, EmailStr, validator, field_validator from typing import List, Optional, Dict from datetime import date, datetime From 7e7c274724474fe782fa4591538c443c09fad93a Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 12:09:48 +0300 Subject: [PATCH 103/199] feat: Updated pydantic schema for create and update client function --- .gitignore | 4 +- api.py | 1307 +++++++++++++++++++++++++--------------------------- 2 files changed, 628 insertions(+), 683 deletions(-) diff --git a/.gitignore b/.gitignore index 3a36b60..b88f070 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ htmlcov/ .build/ dist/ -data/sage_dataven.db \ No newline at end of file +data/sage_dataven.db + +cleaner.py \ No newline at end of file diff --git a/api.py b/api.py index 0aeb0da..a3b9ce9 100644 --- a/api.py +++ b/api.py @@ -6,7 +6,6 @@ from pydantic import BaseModel, Field, EmailStr, validator, field_validator from typing import List, Optional, Dict from datetime import date, datetime from enum import Enum, IntEnum -from decimal import Decimal import uvicorn from contextlib import asynccontextmanager import uuid @@ -17,18 +16,6 @@ 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, @@ -43,6 +30,13 @@ from database import ( from email_queue import email_queue from sage_client import sage_client +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__) + TAGS_METADATA = [ { @@ -79,9 +73,7 @@ TAGS_METADATA = [ ] -# ===================================================== -# ENUMS -# ===================================================== + class TypeDocument(int, Enum): DEVIS = settings.SAGE_TYPE_DEVIS BON_COMMANDE = settings.SAGE_TYPE_BON_COMMANDE @@ -119,9 +111,7 @@ class StatutEmail(str, Enum): BOUNCE = "BOUNCE" -# ===================================================== -# MODÈLES PYDANTIC -# ===================================================== + class ClientResponse(BaseModel): """Modèle de réponse client simplifié (pour listes)""" @@ -137,13 +127,11 @@ class ClientResponse(BaseModel): class ClientDetails(BaseModel): """Modèle de réponse client complet (pour GET /clients/{code})""" - # === IDENTIFICATION === numero: Optional[str] = Field(None, description="Code client (CT_Num)") intitule: Optional[str] = Field( None, description="Raison sociale ou Nom complet (CT_Intitule)" ) - # === TYPE DE TIERS === type_tiers: Optional[str] = Field( None, description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'", @@ -159,7 +147,6 @@ class ClientDetails(BaseModel): 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", @@ -171,13 +158,11 @@ class ClientDetails(BaseModel): 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)") @@ -185,12 +170,10 @@ class ClientDetails(BaseModel): 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") @@ -198,20 +181,17 @@ class ClientDetails(BaseModel): 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") @@ -220,7 +200,6 @@ class ClientDetails(BaseModel): ) commercial_nom: Optional[str] = Field(None, description="Nom du commercial") - # === CATÉGORIES === categorie_tarifaire: Optional[int] = Field( None, description="Catégorie tarifaire (N_CatTarif)" ) @@ -228,7 +207,6 @@ class ClientDetails(BaseModel): None, description="Catégorie comptable (N_CatCompta)" ) - # === INFORMATIONS FINANCIÈRES === encours_autorise: Optional[float] = Field( None, description="Encours maximum autorisé" ) @@ -237,7 +215,6 @@ class ClientDetails(BaseModel): ) 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" @@ -271,23 +248,19 @@ class ArticleResponse(BaseModel): ENRICHI avec tous les champs disponibles """ - # === IDENTIFICATION === reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") designation_complementaire: Optional[str] = Field( None, description="Désignation complémentaire" ) - # === CODE EAN / CODE-BARRES === code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") code_barre: Optional[str] = Field(None, description="Code-barres (alias)") - # === PRIX === prix_vente: float = Field(..., description="Prix de vente HT") prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") prix_revient: Optional[float] = Field(None, description="Prix de revient") - # === STOCK === stock_reel: float = Field(..., description="Stock réel") stock_mini: Optional[float] = Field(None, description="Stock minimum") stock_maxi: Optional[float] = Field(None, description="Stock maximum") @@ -301,12 +274,10 @@ class ArticleResponse(BaseModel): None, description="Stock disponible (réel - réservé)" ) - # === DESCRIPTIONS === description: Optional[str] = Field( None, description="Description détaillée / Commentaire" ) - # === CLASSIFICATION === type_article: Optional[int] = Field( None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)" ) @@ -314,7 +285,6 @@ class ArticleResponse(BaseModel): famille_code: Optional[str] = Field(None, description="Code famille") famille_libelle: Optional[str] = Field(None, description="Libellé famille") - # === FOURNISSEUR PRINCIPAL === fournisseur_principal: Optional[str] = Field( None, description="Code fournisseur principal" ) @@ -322,23 +292,18 @@ class ArticleResponse(BaseModel): None, description="Nom fournisseur principal" ) - # === UNITÉS === unite_vente: Optional[str] = Field(None, description="Unité de vente") unite_achat: Optional[str] = Field(None, description="Unité d'achat") - # === CARACTÉRISTIQUES PHYSIQUES === poids: Optional[float] = Field(None, description="Poids (kg)") volume: Optional[float] = Field(None, description="Volume (m³)") - # === STATUT === est_actif: bool = Field(True, description="Article actif") en_sommeil: bool = Field(False, description="Article en sommeil") - # === TVA === tva_code: Optional[str] = Field(None, description="Code TVA") tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)") - # === DATES === date_creation: Optional[str] = Field(None, description="Date de création") date_modification: Optional[str] = Field( None, description="Date de dernière modification" @@ -410,154 +375,174 @@ class TypeTiers(IntEnum): AUTRE = 3 -class ClientCreateAPIRequest(BaseModel): - """ - Modèle complet pour la création d'un client Sage 100c - Noms alignés sur le frontend + mapping vers champs Sage - """ - - # ══════════════════════════════════════════════════════════════ - # IDENTIFICATION PRINCIPALE - # ══════════════════════════════════════════════════════════════ - intitule: str = Field(..., max_length=69, description="Nom du client (CT_Intitule)") - numero: Optional[str] = Field(None, max_length=17, description="Numéro client CT_Num (auto si vide)") - type_tiers: Optional[int] = Field(0, ge=0, le=3, description="CT_Type: 0=Client, 1=Fournisseur, 2=Salarié, 3=Autre") - qualite: str = Field("CLI", max_length=17, description="CT_Qualite: CLI/FOU/SAL/DIV/AUT") - classement: Optional[str] = Field(None, max_length=17, description="CT_Classement") - raccourci: Optional[str] = Field(None, max_length=7, description="CT_Raccourci") - - # ══════════════════════════════════════════════════════════════ - # STATUTS & FLAGS - # ══════════════════════════════════════════════════════════════ - est_prospect: bool = Field(False, description="CT_Prospect") - est_actif: bool = Field(True, description="Inverse de CT_Sommeil") - est_en_sommeil: Optional[bool] = Field(None, description="CT_Sommeil (calculé depuis est_actif si None)") - - # ══════════════════════════════════════════════════════════════ - # INFORMATIONS ENTREPRISE / PERSONNE - # ══════════════════════════════════════════════════════════════ - est_entreprise: Optional[bool] = Field(None, description="Flag interne - pas de champ Sage direct") - est_particulier: Optional[bool] = Field(None, description="Flag interne - pas de champ Sage direct") - forme_juridique: Optional[str] = Field(None, max_length=33, description="CT_SvFormeJuri") - civilite: Optional[str] = Field(None, max_length=17, description="Stocké dans CT_Qualite ou champ libre") - nom: Optional[str] = Field(None, max_length=35, description="Pour particuliers - partie de CT_Intitule") - prenom: Optional[str] = Field(None, max_length=35, description="Pour particuliers - partie de CT_Intitule") - nom_complet: Optional[str] = Field(None, max_length=69, description="Calculé ou CT_Intitule") - - # ══════════════════════════════════════════════════════════════ - # ADRESSE PRINCIPALE - # ══════════════════════════════════════════════════════════════ - contact: Optional[str] = Field(None, max_length=35, description="CT_Contact") - adresse: Optional[str] = Field(None, max_length=35, description="CT_Adresse") - complement: Optional[str] = Field(None, max_length=35, description="CT_Complement") - code_postal: Optional[str] = Field(None, max_length=9, description="CT_CodePostal") - ville: Optional[str] = Field(None, max_length=35, description="CT_Ville") - region: Optional[str] = Field(None, max_length=25, description="CT_CodeRegion") - pays: Optional[str] = Field(None, max_length=35, description="CT_Pays") - - # ══════════════════════════════════════════════════════════════ - # CONTACT & COMMUNICATION - # ══════════════════════════════════════════════════════════════ - telephone: Optional[str] = Field(None, max_length=21, description="CT_Telephone") - portable: Optional[str] = Field(None, max_length=21, description="Stocké dans statistiques ou contact") - telecopie: Optional[str] = Field(None, max_length=21, description="CT_Telecopie") - email: Optional[str] = Field(None, max_length=69, description="CT_EMail") - site_web: Optional[str] = Field(None, max_length=69, description="CT_Site") - facebook: Optional[str] = Field(None, max_length=35, description="CT_Facebook") - linkedin: Optional[str] = Field(None, max_length=35, description="CT_LinkedIn") - - # ══════════════════════════════════════════════════════════════ - # IDENTIFIANTS LÉGAUX & FISCAUX - # ══════════════════════════════════════════════════════════════ - siret: Optional[str] = Field(None, max_length=15, description="CT_Siret (14-15 chars)") - siren: Optional[str] = Field(None, max_length=9, description="Extrait du SIRET") - tva_intra: Optional[str] = Field(None, max_length=25, description="CT_Identifiant") - code_naf: Optional[str] = Field(None, max_length=7, description="CT_Ape") - type_nif: Optional[int] = Field(None, ge=0, le=10, description="CT_TypeNIF") - - # ══════════════════════════════════════════════════════════════ - # BANQUE & DEVISE - # ══════════════════════════════════════════════════════════════ - banque_num: Optional[int] = Field(None, description="BT_Num (smallint)") - devise: Optional[int] = Field(0, description="N_Devise (0=EUR)") - - # ══════════════════════════════════════════════════════════════ - # CATÉGORIES & CLASSIFICATIONS COMMERCIALES - # ══════════════════════════════════════════════════════════════ - categorie_tarifaire: Optional[int] = Field(1, ge=0, description="N_CatTarif") - categorie_comptable: Optional[int] = Field(1, ge=0, description="N_CatCompta") - periode_reglement: Optional[int] = Field(1, ge=0, description="N_Period") - mode_expedition: Optional[int] = Field(1, ge=0, description="N_Expedition") - condition_livraison: Optional[int] = Field(1, ge=0, description="N_Condition") - niveau_risque: Optional[int] = Field(1, ge=0, description="N_Risque") - secteur: Optional[str] = Field(None, max_length=21, description="CT_Statistique01 ou champ libre") - - # ══════════════════════════════════════════════════════════════ - # TAUX PERSONNALISÉS - # ══════════════════════════════════════════════════════════════ - taux01: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux01") - taux02: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux02") - taux03: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux03") - taux04: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux04") - - # ══════════════════════════════════════════════════════════════ - # GESTION COMMERCIALE - # ══════════════════════════════════════════════════════════════ - encours_autorise: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Encours") - assurance_credit: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Assurance") - num_payeur: Optional[str] = Field(None, max_length=17, description="CT_NumPayeur") - langue: Optional[int] = Field(None, ge=0, description="CT_Langue") - langue_iso2: Optional[str] = Field(None, max_length=3, description="CT_LangueISO2") - commercial_code: Optional[int] = Field(None, description="CO_No (int)") - commercial_nom: Optional[str] = Field(None, description="Résolu depuis CO_No - non stocké") - effectif: Optional[str] = Field(None, max_length=11, description="CT_SvEffectif") - ca_annuel: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvCA") - - # ══════════════════════════════════════════════════════════════ - # COMPTABILITÉ - # ══════════════════════════════════════════════════════════════ - compte_general: Optional[str] = Field("411000", max_length=13, description="CG_NumPrinc") - - # ══════════════════════════════════════════════════════════════ - # PARAMÈTRES FACTURATION - # ══════════════════════════════════════════════════════════════ - type_facture: Optional[int] = Field(1, ge=0, le=2, description="CT_Facture: 0=aucune, 1=normale, 2=regroupée") - bl_en_facture: Optional[int] = Field(None, ge=0, le=1, description="CT_BLFact") - saut_page: Optional[int] = Field(None, ge=0, le=1, description="CT_Saut") - lettrage_auto: Optional[bool] = Field(True, description="CT_Lettrage") - validation_echeance: Optional[int] = Field(None, ge=0, le=1, description="CT_ValidEch") - controle_encours: Optional[int] = Field(None, ge=0, le=1, description="CT_ControlEnc") - exclure_relance: Optional[int] = Field(None, ge=0, le=1, description="CT_NotRappel") - exclure_penalites: Optional[int] = Field(None, ge=0, le=1, description="CT_NotPenal") - bon_a_payer: Optional[int] = Field(None, ge=0, le=1, description="CT_BonAPayer") - - # ══════════════════════════════════════════════════════════════ - # LIVRAISON & LOGISTIQUE - # ══════════════════════════════════════════════════════════════ - priorite_livraison: Optional[int] = Field(None, ge=0, le=5, description="CT_PrioriteLivr") - livraison_partielle: Optional[int] = Field(None, ge=0, le=1, description="CT_LivrPartielle") - delai_transport: Optional[int] = Field(None, ge=0, description="CT_DelaiTransport (jours)") - delai_appro: Optional[int] = Field(None, ge=0, description="CT_DelaiAppro (jours)") - - # JOURS DE COMMANDE (0=non, 1=oui) - CT_OrderDay01-07 - jours_commande: Optional[dict] = Field( - None, - description="Dict avec clés: lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche" +class ClientCreateRequest(BaseModel): + + intitule: str = Field( + ..., + max_length=69, + description="Nom du client (CT_Intitule) - OBLIGATOIRE" ) - # JOURS DE LIVRAISON (0=non, 1=oui) - CT_DeliveryDay01-07 - jours_livraison: Optional[dict] = Field( - None, - description="Dict avec clés: lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche" + numero: Optional[str] = Field( + None, + max_length=17, + description="Numéro client CT_Num (auto si None)" ) - # DATES FERMETURE - date_fermeture_debut: Optional[date] = Field(None, description="CT_DateFermeDebut") - date_fermeture_fin: Optional[date] = Field(None, description="CT_DateFermeFin") + type_tiers: int = Field( + 0, + ge=0, + le=3, + description="CT_Type: 0=Client, 1=Fournisseur, 2=Salarié, 3=Autre" + ) + + qualite: Optional[str] = Field( + "CLI", + max_length=17, + description="CT_Qualite: CLI/FOU/SAL/DIV/AUT" + ) + + classement: Optional[str] = Field( + None, + max_length=17, + description="CT_Classement" + ) + + raccourci: Optional[str] = Field( + None, + max_length=7, + description="CT_Raccourci (7 chars max, unique)" + ) + + siret: Optional[str] = Field( + None, + max_length=15, + description="CT_Siret (14-15 chars)" + ) + + tva_intra: Optional[str] = Field( + None, + max_length=25, + description="CT_Identifiant (TVA intracommunautaire)" + ) + + code_naf: Optional[str] = Field( + None, + max_length=7, + description="CT_Ape (Code NAF/APE)" + ) + + contact: Optional[str] = Field( + None, + max_length=35, + description="CT_Contact (double affectation: client + adresse)" + ) + + adresse: Optional[str] = Field( + None, + max_length=35, + description="Adresse.Adresse" + ) + + complement: Optional[str] = Field( + None, + max_length=35, + description="Adresse.Complement" + ) + + code_postal: Optional[str] = Field( + None, + max_length=9, + description="Adresse.CodePostal" + ) + + ville: Optional[str] = Field( + None, + max_length=35, + description="Adresse.Ville" + ) + + region: Optional[str] = Field( + None, + max_length=25, + description="Adresse.CodeRegion" + ) + + pays: Optional[str] = Field( + None, + max_length=35, + description="Adresse.Pays" + ) + + telephone: Optional[str] = Field( + None, + max_length=21, + description="Telecom.Telephone" + ) + + telecopie: Optional[str] = Field( + None, + max_length=21, + description="Telecom.Telecopie (fax)" + ) + + email: Optional[str] = Field( + None, + max_length=69, + description="Telecom.EMail" + ) + + site_web: Optional[str] = Field( + None, + max_length=69, + description="Telecom.Site" + ) + + portable: Optional[str] = Field( + None, + max_length=21, + description="Telecom.Portable" + ) + + facebook: Optional[str] = Field( + None, + max_length=69, + description="Telecom.Facebook ou CT_Facebook" + ) + + linkedin: Optional[str] = Field( + None, + max_length=69, + description="Telecom.LinkedIn ou CT_LinkedIn" + ) + + compte_general: Optional[str] = Field( + None, + max_length=13, + description="CompteGPrinc (défaut selon type_tiers: 4110000, 4010000, 421, 471)" + ) + + categorie_tarifaire: Optional[str] = Field( + None, + description="N_CatTarif (ID catégorie tarifaire, défaut '0' ou '1')" + ) + + categorie_comptable: Optional[str] = Field( + None, + description="N_CatCompta (ID catégorie comptable, défaut '0' ou '1')" + ) + + taux01: Optional[float] = Field(None, description="CT_Taux01") + taux02: Optional[float] = Field(None, description="CT_Taux02") + taux03: Optional[float] = Field(None, description="CT_Taux03") + taux04: Optional[float] = Field(None, description="CT_Taux04") + + secteur: Optional[str] = Field( + None, + max_length=21, + description="Alias de statistique01 (CT_Statistique01)" + ) - # ══════════════════════════════════════════════════════════════ - # STATISTIQUES PERSONNALISÉES (10 champs de 21 chars max) - # ══════════════════════════════════════════════════════════════ statistique01: Optional[str] = Field(None, max_length=21, description="CT_Statistique01") statistique02: Optional[str] = Field(None, max_length=21, description="CT_Statistique02") statistique03: Optional[str] = Field(None, max_length=21, description="CT_Statistique03") @@ -569,250 +554,289 @@ class ClientCreateAPIRequest(BaseModel): statistique09: Optional[str] = Field(None, max_length=21, description="CT_Statistique09") statistique10: Optional[str] = Field(None, max_length=21, description="CT_Statistique10") - # ══════════════════════════════════════════════════════════════ - # COMMENTAIRE - # ══════════════════════════════════════════════════════════════ - commentaire: Optional[str] = Field(None, max_length=35, description="CT_Commentaire") + encours_autorise: Optional[float] = Field( + None, + description="CT_Encours (montant max autorisé)" + ) - # ══════════════════════════════════════════════════════════════ - # ANALYTIQUE - # ══════════════════════════════════════════════════════════════ - section_analytique: Optional[str] = Field(None, max_length=13, description="CA_Num") - section_analytique_ifrs: Optional[str] = Field(None, max_length=13, description="CA_NumIFRS") - plan_analytique: Optional[int] = Field(None, ge=0, description="N_Analytique") - plan_analytique_ifrs: Optional[int] = Field(None, ge=0, description="N_AnalytiqueIFRS") + assurance_credit: Optional[float] = Field( + None, + description="CT_Assurance (montant assurance crédit)" + ) - # ══════════════════════════════════════════════════════════════ - # ORGANISATION - # ══════════════════════════════════════════════════════════════ - depot_code: Optional[int] = Field(None, description="DE_No (int)") - etablissement_code: Optional[int] = Field(None, description="EB_No (int)") - mode_reglement_code: Optional[int] = Field(None, description="MR_No (int)") - calendrier_code: Optional[int] = Field(None, description="CAL_No (int)") - num_centrale: Optional[str] = Field(None, max_length=17, description="CT_NumCentrale") + langue: Optional[int] = Field( + None, + ge=0, + description="CT_Langue (0=Français, 1=Anglais, etc.)" + ) - # ══════════════════════════════════════════════════════════════ - # SURVEILLANCE COFACE - # ══════════════════════════════════════════════════════════════ - coface: Optional[str] = Field(None, max_length=25, description="CT_Coface") - surveillance_active: Optional[int] = Field(None, ge=0, le=1, description="CT_Surveillance") - sv_date_creation: Optional[datetime] = Field(None, description="CT_SvDateCreate") - sv_chiffre_affaires: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvCA") - sv_resultat: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvResultat") - sv_incident: Optional[int] = Field(None, ge=0, le=1, description="CT_SvIncident") - sv_date_incident: Optional[datetime] = Field(None, description="CT_SvDateIncid") - sv_privilege: Optional[int] = Field(None, ge=0, le=1, description="CT_SvPrivil") - sv_regularite: Optional[str] = Field(None, max_length=3, description="CT_SvRegul") - sv_cotation: Optional[str] = Field(None, max_length=5, description="CT_SvCotation") - sv_date_maj: Optional[datetime] = Field(None, description="CT_SvDateMaj") - sv_objet_maj: Optional[str] = Field(None, max_length=61, description="CT_SvObjetMaj") - sv_date_bilan: Optional[datetime] = Field(None, description="CT_SvDateBilan") - sv_nb_mois_bilan: Optional[int] = Field(None, ge=0, description="CT_SvNbMoisBilan") + commercial_code: Optional[int] = Field( + None, + description="CO_No (ID du collaborateur commercial)" + ) - # ══════════════════════════════════════════════════════════════ - # FACTURATION ÉLECTRONIQUE - # ══════════════════════════════════════════════════════════════ - facture_electronique: Optional[int] = Field(None, ge=0, le=1, description="CT_FactureElec") - edi_code_type: Optional[int] = Field(None, description="CT_EdiCodeType") - edi_code: Optional[str] = Field(None, max_length=23, description="CT_EdiCode") - edi_code_sage: Optional[str] = Field(None, max_length=9, description="CT_EdiCodeSage") - fe_assujetti: Optional[int] = Field(None, description="CT_FEAssujetti") - fe_autre_identif_type: Optional[int] = Field(None, description="CT_FEAutreIdentifType") - fe_autre_identif_val: Optional[str] = Field(None, max_length=81, description="CT_FEAutreIdentifVal") - fe_entite_type: Optional[int] = Field(None, description="CT_FEEntiteType") - fe_emission: Optional[int] = Field(None, description="CT_FEEmission") - fe_application: Optional[int] = Field(None, description="CT_FEApplication") - fe_date_synchro: Optional[datetime] = Field(None, description="CT_FEDateSynchro") + lettrage_auto: Optional[bool] = Field( + True, + description="CT_Lettrage (1=oui, 0=non)" + ) - # ══════════════════════════════════════════════════════════════ - # ÉCHANGES & INTÉGRATION - # ══════════════════════════════════════════════════════════════ - echange_rappro: Optional[int] = Field(None, description="CT_EchangeRappro") - echange_cr: Optional[int] = Field(None, description="CT_EchangeCR") - pi_no_echange: Optional[int] = Field(None, description="PI_NoEchange") - annulation_cr: Optional[int] = Field(None, description="CT_AnnulationCR") - profil_societe: Optional[int] = Field(None, description="CT_ProfilSoc") - statut_contrat: Optional[int] = Field(None, description="CT_StatutContrat") + est_actif: Optional[bool] = Field( + True, + description="Inverse de CT_Sommeil (True=actif, False=en sommeil)" + ) - # ══════════════════════════════════════════════════════════════ - # RGPD & CONFIDENTIALITÉ - # ══════════════════════════════════════════════════════════════ - rgpd_consentement: Optional[int] = Field(None, ge=0, le=1, description="CT_GDPR") - exclure_traitement: Optional[int] = Field(None, ge=0, le=1, description="CT_ExclureTrait") + type_facture: Optional[int] = Field( + 1, + ge=0, + le=2, + description="CT_Facture: 0=aucune, 1=normale, 2=regroupée" + ) - # ══════════════════════════════════════════════════════════════ - # REPRÉSENTANT FISCAL - # ══════════════════════════════════════════════════════════════ - representant_intl: Optional[str] = Field(None, max_length=35, description="CT_RepresentInt") - representant_nif: Optional[str] = Field(None, max_length=25, description="CT_RepresentNIF") + est_prospect: Optional[bool] = Field( + False, + description="CT_Prospect (1=oui, 0=non)" + ) - # ══════════════════════════════════════════════════════════════ - # CHAMPS PERSONNALISÉS (Info Libres Sage) - # ══════════════════════════════════════════════════════════════ - date_creation_societe: Optional[datetime] = Field(None, description="Date création société (Info Libre)") - capital_social: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="Capital social") - actionnaire_principal: Optional[str] = Field(None, max_length=69, description="Actionnaire Pal") - score_banque_france: Optional[str] = Field(None, max_length=14, description="Score Banque de France") + bl_en_facture: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_BLFact (impression BL sur facture)" + ) - # FIDÉLITÉ - total_points_fidelite: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6) - points_fidelite_restants: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6) - date_fin_carte_fidelite: Optional[datetime] = Field(None, description="Fin validité carte fidélité") - date_negociation_reglement: Optional[datetime] = Field(None, description="Date négociation règlement") + saut_page: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_Saut (saut de page après impression)" + ) - # ══════════════════════════════════════════════════════════════ - # AUTRES - # ══════════════════════════════════════════════════════════════ - mode_test: Optional[int] = Field(None, ge=0, le=1, description="CT_ModeTest") - confiance: Optional[int] = Field(None, description="CT_Confiance") - dn_id: Optional[str] = Field(None, max_length=37, description="DN_Id") + validation_echeance: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_ValidEch" + ) - # ══════════════════════════════════════════════════════════════ - # MÉTADONNÉES (en lecture seule généralement) - # ══════════════════════════════════════════════════════════════ - date_creation: Optional[datetime] = Field(None, description="cbCreation - géré par Sage") - date_modification: Optional[datetime] = Field(None, description="cbModification - géré par Sage") - date_maj: Optional[datetime] = Field(None, description="CT_DateMAJ") + controle_encours: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_ControlEnc" + ) + + exclure_relance: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_NotRappel" + ) + + exclure_penalites: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_NotPenal" + ) + + bon_a_payer: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_BonAPayer" + ) + + priorite_livraison: Optional[int] = Field( + None, + ge=0, + le=5, + description="CT_PrioriteLivr" + ) + + livraison_partielle: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_LivrPartielle" + ) + + delai_transport: Optional[int] = Field( + None, + ge=0, + description="CT_DelaiTransport (jours)" + ) + + delai_appro: Optional[int] = Field( + None, + ge=0, + description="CT_DelaiAppro (jours)" + ) + + commentaire: Optional[str] = Field( + None, + max_length=35, + description="CT_Commentaire" + ) + + section_analytique: Optional[str] = Field( + None, + max_length=13, + description="CA_Num" + ) + + mode_reglement_code: Optional[int] = Field( + None, + description="MR_No (ID du mode de règlement)" + ) + + surveillance_active: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_Surveillance (DOIT être défini AVANT coface)" + ) + + coface: Optional[str] = Field( + None, + max_length=25, + description="CT_Coface (code Coface)" + ) + + forme_juridique: Optional[str] = Field( + None, + max_length=33, + description="CT_SvFormeJuri (SARL, SA, etc.)" + ) + + effectif: Optional[str] = Field( + None, + max_length=11, + description="CT_SvEffectif" + ) + + sv_regularite: Optional[str] = Field( + None, + max_length=3, + description="CT_SvRegul" + ) + + sv_cotation: Optional[str] = Field( + None, + max_length=5, + description="CT_SvCotation" + ) + + sv_objet_maj: Optional[str] = Field( + None, + max_length=61, + description="CT_SvObjetMaj" + ) + + ca_annuel: Optional[float] = Field( + None, + description="CT_SvCA (Chiffre d'affaires annuel) - alias: sv_chiffre_affaires" + ) + + sv_chiffre_affaires: Optional[float] = Field( + None, + description="CT_SvCA (alias de ca_annuel)" + ) + + sv_resultat: Optional[float] = Field( + None, + description="CT_SvResultat" + ) - # ══════════════════════════════════════════════════════════════ - # VALIDATORS - # ══════════════════════════════════════════════════════════════ @field_validator('siret') @classmethod def validate_siret(cls, v): - if v and v.lower() not in ('none', ''): + """Valide et nettoie le SIRET""" + if v and v.lower() not in ('none', 'null', ''): cleaned = v.replace(' ', '').replace('-', '') if len(cleaned) not in (14, 15): raise ValueError('Le SIRET doit contenir 14 ou 15 caractères') return cleaned return None - @field_validator('siren') - @classmethod - def validate_siren(cls, v): - if v and v.lower() not in ('none', ''): - cleaned = v.replace(' ', '') - if len(cleaned) != 9: - raise ValueError('Le SIREN doit contenir 9 caractères') - return cleaned - return None - @field_validator('email') @classmethod def validate_email(cls, v): - if v and v.lower() not in ('none', ''): + """Valide le format email""" + if v and v.lower() not in ('none', 'null', ''): + v = v.strip() if '@' not in v: raise ValueError('Format email invalide') - return v.strip() + return v return None - @field_validator('adresse', 'code_postal', 'ville', 'pays', 'telephone', 'tva_intra', mode='before') + @field_validator('raccourci') + @classmethod + def validate_raccourci(cls, v): + """Force le raccourci en majuscules""" + if v and v.lower() not in ('none', 'null', ''): + return v.upper().strip()[:7] + return None + + @field_validator( + 'adresse', 'code_postal', 'ville', 'pays', 'telephone', + 'tva_intra', 'contact', 'complement', mode='before' + ) @classmethod def clean_none_strings(cls, v): - """Convertit les chaînes 'None' en None""" + """Convertit les chaînes 'None'/'null'/'' en None""" if isinstance(v, str) and v.lower() in ('none', 'null', ''): return None return v - @field_validator('est_en_sommeil', mode='before') - @classmethod - def compute_sommeil(cls, v, info): - """Calcule est_en_sommeil depuis est_actif si non fourni""" - if v is None and 'est_actif' in info.data: - return not info.data.get('est_actif', True) - return v - def to_sage_dict(self) -> dict: - """Convertit le modèle en dictionnaire compatible avec la méthode creer_client""" + """ + Convertit le modèle en dictionnaire compatible avec creer_client() + ✅ Mapping 1:1 avec les paramètres réels de la fonction + """ + stat01 = self.statistique01 or self.secteur + + ca = self.ca_annuel or self.sv_chiffre_affaires + return { - # Identification "intitule": self.intitule, - "num": self.numero, + "numero": self.numero, "type_tiers": self.type_tiers, "qualite": self.qualite, "classement": self.classement, "raccourci": self.raccourci, - # Statuts - "prospect": self.est_prospect, - "sommeil": not self.est_actif if self.est_en_sommeil is None else self.est_en_sommeil, + "siret": self.siret, + "tva_intra": self.tva_intra, + "code_naf": self.code_naf, - # Adresse "contact": self.contact, "adresse": self.adresse, "complement": self.complement, "code_postal": self.code_postal, "ville": self.ville, - "code_region": self.region, + "region": self.region, "pays": self.pays, - # Communication "telephone": self.telephone, "telecopie": self.telecopie, "email": self.email, - "site": self.site_web, + "site_web": self.site_web, + "portable": self.portable, "facebook": self.facebook, "linkedin": self.linkedin, - # Identifiants légaux - "siret": self.siret, - "tva_intra": self.tva_intra, - "ape": self.code_naf, - "type_nif": self.type_nif, + "compte_general": self.compte_general, - # Banque & devise - "banque_num": self.banque_num, - "devise": self.devise, + "categorie_tarifaire": self.categorie_tarifaire, + "categorie_comptable": self.categorie_comptable, - # Catégories - "cat_tarif": self.categorie_tarifaire or 1, - "cat_compta": self.categorie_comptable or 1, - "period": self.periode_reglement or 1, - "expedition": self.mode_expedition or 1, - "condition": self.condition_livraison or 1, - "risque": self.niveau_risque or 1, - - # Taux "taux01": self.taux01, "taux02": self.taux02, "taux03": self.taux03, "taux04": self.taux04, - # Gestion commerciale - "encours": self.encours_autorise, - "assurance": self.assurance_credit, - "num_payeur": self.num_payeur, - "langue": self.langue, - "langue_iso2": self.langue_iso2, - "compte_collectif": self.compte_general or "411000", - "collaborateur": self.commercial_code, - - # Facturation - "facture": self.type_facture, - "bl_fact": self.bl_en_facture, - "saut": self.saut_page, - "lettrage": self.lettrage_auto, - "valid_ech": self.validation_echeance, - "control_enc": self.controle_encours, - "not_rappel": self.exclure_relance, - "not_penal": self.exclure_penalites, - "bon_a_payer": self.bon_a_payer, - - # Livraison - "priorite_livr": self.priorite_livraison, - "livr_partielle": self.livraison_partielle, - "delai_transport": self.delai_transport, - "delai_appro": self.delai_appro, - "date_ferme_debut": self.date_fermeture_debut, - "date_ferme_fin": self.date_fermeture_fin, - - # Jours commande/livraison - **(self._expand_jours("order_day", self.jours_commande) if self.jours_commande else {}), - **(self._expand_jours("delivery_day", self.jours_livraison) if self.jours_livraison else {}), - - # Statistiques - "statistique01": self.statistique01 or self.secteur, + "statistique01": stat01, "statistique02": self.statistique02, "statistique03": self.statistique03, "statistique04": self.statistique04, @@ -822,130 +846,210 @@ class ClientCreateAPIRequest(BaseModel): "statistique08": self.statistique08, "statistique09": self.statistique09, "statistique10": self.statistique10, + "secteur": self.secteur, # Gardé pour compatibilité + + "encours_autorise": self.encours_autorise, + "assurance_credit": self.assurance_credit, + "langue": self.langue, + "commercial_code": self.commercial_code, + + "lettrage_auto": self.lettrage_auto, + "est_actif": self.est_actif, + "type_facture": self.type_facture, + "est_prospect": self.est_prospect, + "bl_en_facture": self.bl_en_facture, + "saut_page": self.saut_page, + "validation_echeance": self.validation_echeance, + "controle_encours": self.controle_encours, + "exclure_relance": self.exclure_relance, + "exclure_penalites": self.exclure_penalites, + "bon_a_payer": self.bon_a_payer, + + "priorite_livraison": self.priorite_livraison, + "livraison_partielle": self.livraison_partielle, + "delai_transport": self.delai_transport, + "delai_appro": self.delai_appro, - # Commentaire "commentaire": self.commentaire, - # Analytique "section_analytique": self.section_analytique, - "section_analytique_ifrs": self.section_analytique_ifrs, - "plan_analytique": self.plan_analytique, - "plan_analytique_ifrs": self.plan_analytique_ifrs, - # Organisation - "depot": self.depot_code, - "etablissement": self.etablissement_code, - "mode_regl": self.mode_reglement_code, - "calendrier": self.calendrier_code, - "num_centrale": self.num_centrale, + "mode_reglement_code": self.mode_reglement_code, - # Surveillance + "surveillance_active": self.surveillance_active, "coface": self.coface, - "surveillance": self.surveillance_active, - "sv_forme_juri": self.forme_juridique, - "sv_effectif": self.effectif, - "sv_ca": self.sv_chiffre_affaires or self.ca_annuel, - "sv_resultat": self.sv_resultat, - "sv_incident": self.sv_incident, - "sv_date_incid": self.sv_date_incident, - "sv_privil": self.sv_privilege, - "sv_regul": self.sv_regularite, + "forme_juridique": self.forme_juridique, + "effectif": self.effectif, + "sv_regularite": self.sv_regularite, "sv_cotation": self.sv_cotation, - "sv_date_create": self.sv_date_creation, - "sv_date_maj": self.sv_date_maj, "sv_objet_maj": self.sv_objet_maj, - "sv_date_bilan": self.sv_date_bilan, - "sv_nb_mois_bilan": self.sv_nb_mois_bilan, - - # Facturation électronique - "facture_elec": self.facture_electronique, - "edi_code_type": self.edi_code_type, - "edi_code": self.edi_code, - "edi_code_sage": self.edi_code_sage, - "fe_assujetti": self.fe_assujetti, - "fe_autre_identif_type": self.fe_autre_identif_type, - "fe_autre_identif_val": self.fe_autre_identif_val, - "fe_entite_type": self.fe_entite_type, - "fe_emission": self.fe_emission, - "fe_application": self.fe_application, - - # Échanges - "echange_rappro": self.echange_rappro, - "echange_cr": self.echange_cr, - "annulation_cr": self.annulation_cr, - "profil_soc": self.profil_societe, - "statut_contrat": self.statut_contrat, - - # RGPD - "gdpr": self.rgpd_consentement, - "exclure_trait": self.exclure_traitement, - - # Représentant - "represent_int": self.representant_intl, - "represent_nif": self.representant_nif, - - # Autres - "mode_test": self.mode_test, - "confiance": self.confiance, + "ca_annuel": ca, + "sv_chiffre_affaires": self.sv_chiffre_affaires, + "sv_resultat": self.sv_resultat, } - def _expand_jours(self, prefix: str, jours: dict) -> dict: - """Expand les jours en champs individuels""" - mapping = { - "lundi": f"{prefix}_lundi", - "mardi": f"{prefix}_mardi", - "mercredi": f"{prefix}_mercredi", - "jeudi": f"{prefix}_jeudi", - "vendredi": f"{prefix}_vendredi", - "samedi": f"{prefix}_samedi", - "dimanche": f"{prefix}_dimanche", - } - return {v: jours.get(k) for k, v in mapping.items() if jours.get(k) is not None} - class Config: json_schema_extra = { "example": { "intitule": "ENTREPRISE EXEMPLE SARL", "numero": "CLI00123", - "compte_general": "411000", + "type_tiers": 0, "qualite": "CLI", + "compte_general": "411000", "est_prospect": False, - "est_actif": True + "est_actif": True, + "email": "contact@exemple.fr", + "telephone": "0123456789", + "adresse": "123 Rue de la Paix", + "code_postal": "75001", + "ville": "Paris", + "pays": "France" } } - -class ClientUpdateRequest(BaseModel): - """Modèle pour modification d'un client existant""" - intitule: Optional[str] = Field(None, min_length=1, max_length=69) + +class ClientUpdateRequest(BaseModel): + """ + Modèle pour modification d'un client existant + ✅ TOUS les champs de ClientCreateRequest sont modifiables + ✅ TOUS optionnels (seuls les champs fournis sont modifiés) + """ + + intitule: Optional[str] = Field(None, max_length=69) + qualite: Optional[str] = Field(None, max_length=17) + classement: Optional[str] = Field(None, max_length=17) + raccourci: Optional[str] = Field(None, max_length=7) + + siret: Optional[str] = Field(None, max_length=15) + tva_intra: Optional[str] = Field(None, max_length=25) + code_naf: Optional[str] = Field(None, max_length=7) + + contact: Optional[str] = Field(None, max_length=35) adresse: Optional[str] = Field(None, max_length=35) + complement: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) ville: Optional[str] = Field(None, max_length=35) + region: Optional[str] = Field(None, max_length=25) pays: Optional[str] = Field(None, max_length=35) - email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + telecopie: Optional[str] = Field(None, max_length=21) + email: Optional[str] = Field(None, max_length=69) + site_web: Optional[str] = Field(None, max_length=69) 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) - + facebook: Optional[str] = Field(None, max_length=69) + linkedin: Optional[str] = Field(None, max_length=69) + + compte_general: Optional[str] = Field(None, max_length=13) + + categorie_tarifaire: Optional[str] = None + categorie_comptable: Optional[str] = None + + taux01: Optional[float] = None + taux02: Optional[float] = None + taux03: Optional[float] = None + taux04: Optional[float] = None + + secteur: Optional[str] = Field(None, max_length=21) + statistique01: Optional[str] = Field(None, max_length=21) + statistique02: Optional[str] = Field(None, max_length=21) + statistique03: Optional[str] = Field(None, max_length=21) + statistique04: Optional[str] = Field(None, max_length=21) + statistique05: Optional[str] = Field(None, max_length=21) + statistique06: Optional[str] = Field(None, max_length=21) + statistique07: Optional[str] = Field(None, max_length=21) + statistique08: Optional[str] = Field(None, max_length=21) + statistique09: Optional[str] = Field(None, max_length=21) + statistique10: Optional[str] = Field(None, max_length=21) + + encours_autorise: Optional[float] = None + assurance_credit: Optional[float] = None + langue: Optional[int] = Field(None, ge=0) + commercial_code: Optional[int] = None + + lettrage_auto: Optional[bool] = None + est_actif: Optional[bool] = None + type_facture: Optional[int] = Field(None, ge=0, le=2) + est_prospect: Optional[bool] = None + bl_en_facture: Optional[int] = Field(None, ge=0, le=1) + saut_page: Optional[int] = Field(None, ge=0, le=1) + validation_echeance: Optional[int] = Field(None, ge=0, le=1) + controle_encours: Optional[int] = Field(None, ge=0, le=1) + exclure_relance: Optional[int] = Field(None, ge=0, le=1) + exclure_penalites: Optional[int] = Field(None, ge=0, le=1) + bon_a_payer: Optional[int] = Field(None, ge=0, le=1) + + priorite_livraison: Optional[int] = Field(None, ge=0, le=5) + livraison_partielle: Optional[int] = Field(None, ge=0, le=1) + delai_transport: Optional[int] = Field(None, ge=0) + delai_appro: Optional[int] = Field(None, ge=0) + + commentaire: Optional[str] = Field(None, max_length=35) + + section_analytique: Optional[str] = Field(None, max_length=13) + + mode_reglement_code: Optional[int] = None + + surveillance_active: Optional[int] = Field(None, ge=0, le=1) + coface: Optional[str] = Field(None, max_length=25) + forme_juridique: Optional[str] = Field(None, max_length=33) + effectif: Optional[str] = Field(None, max_length=11) + sv_regularite: Optional[str] = Field(None, max_length=3) + sv_cotation: Optional[str] = Field(None, max_length=5) + sv_objet_maj: Optional[str] = Field(None, max_length=61) + ca_annuel: Optional[float] = None + sv_chiffre_affaires: Optional[float] = None + sv_resultat: Optional[float] = None + + @field_validator('siret') + @classmethod + def validate_siret(cls, v): + if v and v.lower() not in ('none', 'null', ''): + cleaned = v.replace(' ', '').replace('-', '') + if len(cleaned) not in (14, 15): + raise ValueError('Le SIRET doit contenir 14 ou 15 caractères') + return cleaned + return None + + @field_validator('email') + @classmethod + def validate_email(cls, v): + if v and v.lower() not in ('none', 'null', ''): + v = v.strip() + if '@' not in v: + raise ValueError('Format email invalide') + return v + return None + + @field_validator('raccourci') + @classmethod + def validate_raccourci(cls, v): + if v and v.lower() not in ('none', 'null', ''): + return v.upper().strip()[:7] + return None + + @field_validator( + 'adresse', 'code_postal', 'ville', 'pays', 'telephone', + 'tva_intra', 'contact', 'complement', mode='before' + ) + @classmethod + def clean_none_strings(cls, v): + if isinstance(v, str) and v.lower() in ('none', 'null', ''): + return None + return v + class Config: json_schema_extra = { "example": { "email": "nouveau@email.fr", "telephone": "0198765432", "portable": "0687654321", + "adresse": "456 Avenue Nouvelle", + "ville": "Lyon" } } -from pydantic import BaseModel -from typing import List, Optional -from datetime import datetime - -# ===================================================== -# MODÈLES PYDANTIC POUR USERS -# ===================================================== class UserResponse(BaseModel): @@ -1881,7 +1985,7 @@ templates_signature_email = { } -async def universign_envoyer_avec_email( +async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, @@ -1899,16 +2003,12 @@ async def universign_envoyer_avec_email( logger.info(f"🔐 Démarrage processus Universign pour {email}") logger.info(f"📌 Document: {doc_id} ({doc_data.get('type_label')})") - # Vérification PDF if not pdf_bytes or len(pdf_bytes) == 0: raise Exception("Le PDF généré est vide") logger.info(f"PDF valide : {len(pdf_bytes)} octets") - # ======================================== - # ÉTAPE 1 : Créer la transaction - # ======================================== - logger.info(f"ÉTAPE 1/6 : Création transaction") + logger.info("ÉTAPE 1/6 : Création transaction") response = requests.post( f"{api_url}/transactions", @@ -1927,10 +2027,7 @@ async def universign_envoyer_avec_email( transaction_id = response.json().get("id") logger.info(f"Transaction créée: {transaction_id}") - # ======================================== - # ÉTAPE 2 : Upload du fichier PDF - # ======================================== - logger.info(f"ÉTAPE 2/6 : Upload PDF") + logger.info("ÉTAPE 2/6 : Upload PDF") files = { "file": ( @@ -1954,10 +2051,7 @@ async def universign_envoyer_avec_email( file_id = response.json().get("id") logger.info(f"Fichier uploadé: {file_id}") - # ======================================== - # ÉTAPE 3 : Ajouter le document - # ======================================== - logger.info(f"📋 ÉTAPE 3/6 : Ajout document à transaction") + logger.info("📋 ÉTAPE 3/6 : Ajout document à transaction") response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", @@ -1973,17 +2067,13 @@ async def universign_envoyer_avec_email( document_id = response.json().get("id") logger.info(f"Document ajouté: {document_id}") - # ======================================== - # ÉTAPE 4 : Créer le champ de signature - # ======================================== - logger.info(f"✍️ ÉTAPE 4/6 : Création champ signature") + logger.info("✍️ ÉTAPE 4/6 : Création champ signature") response = requests.post( f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", auth=auth, data={ "type": "signature", - # Laisser Universign positionner automatiquement }, timeout=30, ) @@ -1995,10 +2085,7 @@ async def universign_envoyer_avec_email( field_id = response.json().get("id") logger.info(f"Champ créé: {field_id}") - # ======================================== - # ÉTAPE 5 : Lier le signataire au champ (ancien endpoint) - # ======================================== - logger.info(f"👤 ÉTAPE 5/6 : Liaison signataire au champ") + logger.info("👤 ÉTAPE 5/6 : Liaison signataire au champ") response = requests.post( f"{api_url}/transactions/{transaction_id}/signatures", # /signatures pas /signers @@ -2016,10 +2103,7 @@ async def universign_envoyer_avec_email( logger.info(f"Signataire lié: {email}") - # ======================================== - # ÉTAPE 6 : Démarrer la transaction - # ======================================== - logger.info(f"🚀 ÉTAPE 6/6 : Démarrage transaction") + logger.info("🚀 ÉTAPE 6/6 : Démarrage transaction") response = requests.post( f"{api_url}/transactions/{transaction_id}/start", @@ -2032,23 +2116,18 @@ async def universign_envoyer_avec_email( raise Exception(f"Erreur démarrage: {response.status_code}") final_data = response.json() - logger.info(f"Transaction démarrée") + logger.info("Transaction démarrée") - # ======================================== - # Récupérer l'URL de signature - # ======================================== - logger.info(f"🔗 Récupération URL de signature") + logger.info("🔗 Récupération URL de signature") signer_url = "" - # Chercher dans actions if final_data.get("actions"): for action in final_data["actions"]: if action.get("url"): signer_url = action["url"] break - # Sinon chercher dans signers if not signer_url and final_data.get("signers"): for signer in final_data["signers"]: if signer.get("email") == email: @@ -2059,12 +2138,9 @@ async def universign_envoyer_avec_email( logger.error(f"URL introuvable dans: {final_data}") raise ValueError("URL de signature non retournée par Universign") - logger.info(f"URL récupérée") + logger.info("URL récupérée") - # ======================================== - # Créer l'email de notification - # ======================================== - logger.info(f"📧 Préparation email") + logger.info("📧 Préparation email") template = templates_signature_email["demande_signature"] @@ -2111,7 +2187,7 @@ async def universign_envoyer_avec_email( email_queue.enqueue(email_log.id) logger.info(f"Email mis en file pour {email}") - logger.info(f"🎉 Processus terminé avec succès") + logger.info("🎉 Processus terminé avec succès") return { "transaction_id": transaction_id, @@ -2165,7 +2241,6 @@ async def universign_statut(transaction_id: str) -> Dict: @asynccontextmanager async def lifespan(app: FastAPI): - # Init base de données await init_db() logger.info("Base de données initialisée") @@ -2174,20 +2249,16 @@ async def lifespan(app: FastAPI): 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") + logger.info("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", @@ -2242,7 +2313,6 @@ async def modifier_client( session: AsyncSession = Depends(get_session), ): try: - # Appel à la gateway Windows resultat = sage_client.modifier_client( code, client_update.dict(exclude_none=True) ) @@ -2256,18 +2326,16 @@ async def modifier_client( } 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) + client: ClientCreateRequest, session: AsyncSession = Depends(get_session) ): try: nouveau_client = sage_client.creer_client(client.model_dump(mode='json')) @@ -2282,7 +2350,6 @@ async def ajouter_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)) @@ -2305,7 +2372,6 @@ async def rechercher_articles(query: Optional[str] = Query(None)): ) async def creer_article(article: ArticleCreateRequest): try: - # Validation des données if not article.reference or not article.designation: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -2316,7 +2382,6 @@ async def creer_article(article: ArticleCreateRequest): logger.info(f"Création article: {article.reference} - {article.designation}") - # Appel à la gateway Windows resultat = sage_client.creer_article(article_data) logger.info( @@ -2326,7 +2391,6 @@ async def creer_article(article: ArticleCreateRequest): return ArticleResponse(**resultat) except ValueError as e: - # Erreur métier (ex: article existe déjà) logger.warning(f"Erreur métier création article: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @@ -2334,7 +2398,6 @@ async def creer_article(article: ArticleCreateRequest): raise except Exception as e: - # Erreur technique Sage logger.error(f"Erreur technique création article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -2358,10 +2421,8 @@ async def modifier_article( logger.info(f"Modification article {reference}: {list(article_data.keys())}") - # Appel à la gateway Windows resultat = sage_client.modifier_article(reference, article_data) - # Log spécial pour modification de stock (important pour erreur 2881) if "stock_reel" in article_data: logger.info( f"Stock {reference} modifié: {article_data['stock_reel']} " @@ -2373,7 +2434,6 @@ async def modifier_article( return ArticleResponse(**resultat) except ValueError as e: - # Erreur métier (ex: article introuvable) logger.warning(f"Erreur métier modification article: {e}") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @@ -2381,7 +2441,6 @@ async def modifier_article( raise except Exception as e: - # Erreur technique Sage logger.error(f"Erreur technique modification article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -2432,7 +2491,6 @@ def lister_articles(filtre: str = ""): @app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"]) async def creer_devis(devis: DevisRequest): 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, @@ -2442,15 +2500,14 @@ async def creer_devis(devis: DevisRequest): "reference": devis.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in devis.lignes + for ligne in devis.lignes ], } - # Appel HTTP vers Windows resultat = sage_client.creer_devis(devis_data) logger.info(f"Devis créé: {resultat.get('numero_devis')}") @@ -2484,11 +2541,11 @@ async def modifier_devis( if devis_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in devis_update.lignes + for ligne in devis_update.lignes ] if devis_update.statut is not None: @@ -2497,7 +2554,6 @@ async def modifier_devis( if devis_update.reference is not None: update_data["reference"] = devis_update.reference - # Appel à la gateway Windows resultat = sage_client.modifier_devis(id, update_data) logger.info(f"Devis {id} modifié avec succès") @@ -2531,15 +2587,14 @@ async def creer_commande( "reference": commande.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in commande.lignes + for ligne in commande.lignes ], } - # Appel à la gateway Windows resultat = sage_client.creer_commande(commande_data) logger.info(f"Commande créée: {resultat.get('numero_commande')}") @@ -2580,11 +2635,11 @@ async def modifier_commande( if commande_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in commande_update.lignes + for ligne in commande_update.lignes ] if commande_update.statut is not None: @@ -2593,7 +2648,6 @@ async def modifier_commande( 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") @@ -2650,8 +2704,6 @@ async def lire_devis(id: str): @app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): 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( @@ -2673,7 +2725,6 @@ async def telecharger_document_pdf( numero: str = Path(..., description="Numéro du document"), ): try: - # Mapping des types vers les libellés types_labels = { 0: "Devis", 10: "Commande", @@ -2684,7 +2735,6 @@ async def telecharger_document_pdf( 60: "Facture", } - # Vérifier que le type est valide if type_doc not in types_labels: raise HTTPException( 400, @@ -2696,7 +2746,6 @@ async def telecharger_document_pdf( 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: @@ -2704,7 +2753,6 @@ async def telecharger_document_pdf( logger.info(f"PDF généré: {len(pdf_bytes)} octets") - # Nom de fichier formaté filename = f"{label}_{numero}.pdf" return StreamingResponse( @@ -2780,14 +2828,12 @@ async def changer_statut_devis( ), ): 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, @@ -2848,14 +2894,12 @@ async def lister_commandes( @app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): 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 3: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, @@ -2942,19 +2986,16 @@ async def envoyer_signature_optimise( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): try: - # Récupérer le document depuis Sage doc = sage_client.lire_document( demande.doc_id, normaliser_type_doc(demande.type_doc) ) if not doc: raise HTTPException(404, f"Document {demande.doc_id} introuvable") - # Générer PDF pdf_bytes = email_queue._generate_pdf( demande.doc_id, normaliser_type_doc(demande.type_doc) ) - # Préparer les données du document pour l'email doc_data = { "type_doc": demande.type_doc, "type_label": { @@ -2968,8 +3009,7 @@ async def envoyer_signature_optimise( "date": doc.get("date", datetime.now().strftime("%d/%m/%Y")), } - # Envoi Universign + Email automatique - resultat = await universign_envoyer_avec_email( + resultat = await universign_envoyer( doc_id=demande.doc_id, pdf_bytes=pdf_bytes, email=demande.email_signataire, @@ -2981,7 +3021,6 @@ async def envoyer_signature_optimise( if "error" in resultat: raise HTTPException(500, resultat["error"]) - # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=demande.doc_id, @@ -2997,7 +3036,6 @@ async def envoyer_signature_optimise( session.add(signature_log) await session.commit() - # MAJ champ libre Sage sage_client.mettre_a_jour_champ_libre( demande.doc_id, demande.type_doc, "UniversignID", resultat["transaction_id"] ) @@ -3036,7 +3074,6 @@ async def webhook_universign( logger.warning("Webhook sans transaction_id") return {"status": "ignored"} - # Chercher la signature dans la DB query = select(SignatureLog).where( SignatureLog.transaction_id == transaction_id ) @@ -3047,18 +3084,13 @@ async def webhook_universign( logger.warning(f"Transaction {transaction_id} introuvable en DB") return {"status": "not_found"} - # ============================================= - # TRAITER L'EVENT SELON LE TYPE - # ============================================= if event_type == "transaction.completed": - # SIGNATURE RÉUSSIE signature_log.statut = StatutSignatureEnum.SIGNE signature_log.date_signature = datetime.now() logger.info(f"Signature confirmée: {signature_log.document_id}") - # ENVOYER EMAIL DE CONFIRMATION template = templates_signature_email["signature_confirmee"] type_labels = { @@ -3085,7 +3117,6 @@ async def webhook_universign( sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - # Créer email de confirmation email_log = EmailLog( id=str(uuid.uuid4()), destinataire=signature_log.email_signataire, @@ -3106,12 +3137,10 @@ async def webhook_universign( ) elif event_type == "transaction.refused": - # SIGNATURE REFUSÉE signature_log.statut = StatutSignatureEnum.REFUSE logger.warning(f"Signature refusée: {signature_log.document_id}") elif event_type == "transaction.expired": - # ⏰ TRANSACTION EXPIRÉE signature_log.statut = StatutSignatureEnum.EXPIRE logger.warning(f"⏰ Transaction expirée: {signature_log.document_id}") @@ -3133,7 +3162,6 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se try: from datetime import timedelta - # Chercher signatures en attente depuis > 7 jours date_limite = datetime.now() - timedelta(days=7) query = select(SignatureLog).where( @@ -3151,16 +3179,13 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se for signature in signatures_a_relancer: try: - # Calculer jours écoulés nb_jours = (datetime.now() - signature.date_envoi).days jours_restants = 30 - nb_jours # Lien expire après 30 jours if jours_restants <= 0: - # Transaction expirée signature.statut = StatutSignatureEnum.EXPIRE continue - # Préparer email de relance template = templates_signature_email["relance_signature"] type_labels = { @@ -3188,7 +3213,6 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - # Créer email de relance email_log = EmailLog( id=str(uuid.uuid4()), destinataire=signature.email_signataire, @@ -3204,7 +3228,6 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se session.add(email_log) email_queue.enqueue(email_log.id) - # Incrémenter compteur de relances signature.est_relance = True signature.nb_relances = (signature.nb_relances or 0) + 1 @@ -3234,7 +3257,6 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se @app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): - # Chercher dans la DB locale try: async with async_session_factory() as session: query = select(SignatureLog).where(SignatureLog.document_id == docId) @@ -3244,7 +3266,6 @@ async def statut_signature(docId: str = Query(...)): if not signature_log: raise HTTPException(404, "Signature introuvable") - # Interroger Universign statut = await universign_statut(signature_log.transaction_id) return { @@ -3307,7 +3328,6 @@ async def statut_signature_detail( 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": @@ -3402,15 +3422,12 @@ async def envoyer_devis_signature( id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session) ): 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 ) @@ -3418,7 +3435,6 @@ async def envoyer_devis_signature( 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, @@ -3434,7 +3450,6 @@ async def envoyer_devis_signature( 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"] ) @@ -3546,20 +3561,16 @@ async def relancer_devis_signature( id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): 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, @@ -3570,7 +3581,6 @@ async def relancer_devis_signature( if "error" in resultat: raise HTTPException(500, resultat["error"]) - # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=id, @@ -3614,12 +3624,10 @@ class ContactClientResponse(BaseModel): @app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis(id: str): 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( @@ -3636,9 +3644,7 @@ async def recuperer_contact_devis(id: str): raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A7 -# ===================================================== + @app.get("/factures", tags=["Factures"]) @@ -3692,15 +3698,14 @@ async def creer_facture( "reference": facture.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in facture.lignes + for ligne in facture.lignes ], } - # Appel à la gateway Windows resultat = sage_client.creer_facture(facture_data) logger.info(f"Facture créée: {resultat.get('numero_facture')}") @@ -3741,11 +3746,11 @@ async def modifier_facture( if facture_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in facture_update.lignes + for ligne in facture_update.lignes ] if facture_update.statut is not None: @@ -3754,7 +3759,6 @@ async def modifier_facture( 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") @@ -3802,17 +3806,14 @@ async def relancer_facture( session: AsyncSession = Depends(get_session), ): try: - # Lire facture via gateway Windows facture = sage_client.lire_document(id, TypeDocumentSQL.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 = { @@ -3830,7 +3831,6 @@ async def relancer_facture( 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"], @@ -3846,7 +3846,6 @@ async def relancer_facture( session.add(email_log) await session.flush() - # Enqueue email_queue.enqueue(email_log.id) sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) @@ -3918,11 +3917,9 @@ async def exporter_logs_csv( 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", @@ -3937,7 +3934,6 @@ async def exporter_logs_csv( ] ) - # Données for log in logs: writer.writerow( [ @@ -4017,7 +4013,6 @@ async def modifier_template(template_id: str, template: TemplateEmail): 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") @@ -4056,12 +4051,10 @@ async def previsualiser_email(preview: TemplatePreviewRequest): 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", "")), @@ -4070,7 +4063,6 @@ async def previsualiser_email(preview: TemplatePreviewRequest): "DO_TotalTTC": f"{doc.get('total_ttc', 0):.2f}", } - # Fusion sujet_preview = template["sujet"] corps_preview = template["corps_html"] @@ -4087,9 +4079,7 @@ async def previsualiser_email(preview: TemplatePreviewRequest): } -# ===================================================== -# ENDPOINTS - HEALTH -# ===================================================== + @app.get("/health", tags=["System"]) async def health_check(): gateway_health = sage_client.health() @@ -4116,9 +4106,7 @@ async def root(): } -# ===================================================== -# ENDPOINTS - ADMIN -# ===================================================== + @app.get("/admin/cache/info", tags=["Admin"]) @@ -4141,9 +4129,7 @@ async def statut_queue(): } -# ===================================================== -# ENDPOINTS - PROSPECTS -# ===================================================== + @app.get("/prospects", tags=["Prospects"]) async def rechercher_prospects(query: Optional[str] = Query(None)): try: @@ -4168,9 +4154,7 @@ async def lire_prospect(code: str): raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - FOURNISSEURS -# ===================================================== + @app.get("/fournisseurs", tags=["Fournisseurs"]) async def rechercher_fournisseurs(query: Optional[str] = Query(None)): try: @@ -4194,7 +4178,6 @@ async def ajouter_fournisseur( session: AsyncSession = Depends(get_session), ): 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')}") @@ -4206,12 +4189,10 @@ async def ajouter_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)) @@ -4223,7 +4204,6 @@ async def modifier_fournisseur( session: AsyncSession = Depends(get_session), ): try: - # Appel à la gateway Windows resultat = sage_client.modifier_fournisseur( code, fournisseur_update.dict(exclude_none=True) ) @@ -4237,11 +4217,9 @@ async def modifier_fournisseur( } 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)) @@ -4260,9 +4238,7 @@ async def lire_fournisseur(code: str): 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) @@ -4303,15 +4279,14 @@ async def creer_avoir( "reference": avoir.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in avoir.lignes + for ligne in avoir.lignes ], } - # Appel à la gateway Windows resultat = sage_client.creer_avoir(avoir_data) logger.info(f"Avoir créé: {resultat.get('numero_avoir')}") @@ -4352,11 +4327,11 @@ async def modifier_avoir( if avoir_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in avoir_update.lignes + for ligne in avoir_update.lignes ] if avoir_update.statut is not None: @@ -4365,7 +4340,6 @@ async def modifier_avoir( 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") @@ -4383,9 +4357,7 @@ async def modifier_avoir( 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) @@ -4432,15 +4404,14 @@ async def creer_livraison( "reference": livraison.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in livraison.lignes + for ligne in livraison.lignes ], } - # Appel à la gateway Windows resultat = sage_client.creer_livraison(livraison_data) logger.info(f"Livraison créée: {resultat.get('numero_livraison')}") @@ -4481,11 +4452,11 @@ async def modifier_livraison( if livraison_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in livraison_update.lignes + for ligne in livraison_update.lignes ] if livraison_update.statut is not None: @@ -4494,7 +4465,6 @@ async def modifier_livraison( 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") @@ -4556,7 +4526,6 @@ async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session) ): 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") @@ -4569,14 +4538,12 @@ async def devis_vers_facture_direct( 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 4: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, @@ -4617,7 +4584,6 @@ async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session) ): try: - # Étape 1: Vérifier que la commande existe commande_existante = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande_existante: @@ -4637,14 +4603,12 @@ async def commande_vers_livraison( 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, @@ -4746,7 +4710,6 @@ async def lire_famille( ) async def creer_famille(famille: FamilleCreateRequest): try: - # Validation des données if not famille.code or not famille.intitule: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -4757,7 +4720,6 @@ async def creer_famille(famille: FamilleCreateRequest): logger.info(f"Création famille: {famille.code} - {famille.intitule}") - # Appel à la gateway Windows resultat = sage_client.creer_famille(famille_data) logger.info(f"Famille créée: {resultat.get('code')}") @@ -4765,7 +4727,6 @@ async def creer_famille(famille: FamilleCreateRequest): return FamilleResponse(**resultat) except ValueError as e: - # Erreur métier (ex: famille existe déjà) logger.warning(f"Erreur métier création famille: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @@ -4773,7 +4734,6 @@ async def creer_famille(famille: FamilleCreateRequest): raise except Exception as e: - # Erreur technique Sage logger.error(f"Erreur technique création famille: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -4790,14 +4750,12 @@ async def creer_famille(famille: FamilleCreateRequest): ) async def creer_entree_stock(entree: EntreeStockRequest): try: - # Préparer les données entree_data = entree.dict() if entree_data.get("date_entree"): entree_data["date_entree"] = entree_data["date_entree"].isoformat() logger.info(f"Création entrée stock: {len(entree.lignes)} ligne(s)") - # Appel à la gateway Windows resultat = sage_client.creer_entree_stock(entree_data) logger.info(f"Entrée stock créée: {resultat.get('numero')}") @@ -4825,14 +4783,12 @@ async def creer_entree_stock(entree: EntreeStockRequest): ) async def creer_sortie_stock(sortie: SortieStockRequest): try: - # Préparer les données sortie_data = sortie.dict() if sortie_data.get("date_sortie"): sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") - # Appel à la gateway Windows resultat = sage_client.creer_sortie_stock(sortie_data) logger.info(f"Sortie stock créée: {resultat.get('numero')}") @@ -4914,24 +4870,19 @@ async def lister_utilisateurs_debug( 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) + query = query.where(User.is_verified) - # 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( @@ -4966,22 +4917,18 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) 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_query = select(func.count(User.id)).where(User.is_verified) 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_query = select(func.count(User.id)).where(User.is_active) 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()} @@ -5021,7 +4968,6 @@ async def get_document_pdf( download: bool = Query(False, description="Télécharger au lieu d'afficher"), ): try: - # Récupérer le PDF (en bytes) pdf_bytes = sage_client.generer_pdf_document( numero=numero, type_doc=type_doc, @@ -5029,7 +4975,6 @@ async def get_document_pdf( base64_encode=False, # On veut les bytes bruts ) - # Retourner le PDF from fastapi.responses import Response disposition = "attachment" if download else "inline" @@ -5046,9 +4991,7 @@ async def get_document_pdf( raise HTTPException(500, str(e)) -# ===================================================== -# LANCEMENT -# ===================================================== + if __name__ == "__main__": uvicorn.run( "api:app", From 5a23f37e64fd796138c7a25c0553684519491bf9 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 13:04:30 +0300 Subject: [PATCH 104/199] fix: make client number field required in ClientCreateRequest --- api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index a3b9ce9..a036707 100644 --- a/api.py +++ b/api.py @@ -383,8 +383,8 @@ class ClientCreateRequest(BaseModel): description="Nom du client (CT_Intitule) - OBLIGATOIRE" ) - numero: Optional[str] = Field( - None, + numero: str = Field( + ..., max_length=17, description="Numéro client CT_Num (auto si None)" ) From 82c43627d961fbcdba4396eb58a66ebc6963a83b Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 16:57:30 +0300 Subject: [PATCH 105/199] enrich client's details --- api.py | 246 +++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 206 insertions(+), 40 deletions(-) diff --git a/api.py b/api.py index a036707..7c726cb 100644 --- a/api.py +++ b/api.py @@ -125,13 +125,15 @@ class ClientResponse(BaseModel): class ClientDetails(BaseModel): - """Modèle de réponse client complet (pour GET /clients/{code})""" + """ + Modèle de réponse client complet (GET /clients/{code}) + Aligné avec tous les champs gérés par creer_client et lister_tous_clients + """ numero: Optional[str] = Field(None, description="Code client (CT_Num)") intitule: Optional[str] = Field( None, description="Raison sociale ou Nom complet (CT_Intitule)" ) - type_tiers: Optional[str] = Field( None, description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'", @@ -140,24 +142,19 @@ class ClientDetails(BaseModel): None, description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)", ) + classement: Optional[str] = Field( + None, description="Code de classement personnalisé (CT_Classement)" + ) + raccourci: Optional[str] = Field( + None, description="Code raccourci (7 car. max) (CT_Raccourci)" + ) + 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)" + None, description="True si fournisseur (CT_Type=1 ou CT_Qualite contient FOU)" ) - - 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)" - ) - 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)" @@ -169,36 +166,99 @@ class ClientDetails(BaseModel): nom_complet: Optional[str] = Field( None, description="Nom complet formaté : 'Civilité Prénom Nom'" ) - contact: Optional[str] = Field( None, description="Nom du contact principal (CT_Contact)" ) - 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") + adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)") + complement: Optional[str] = Field( + None, description="Complément d'adresse (CT_Complement)" + ) + code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)") + ville: Optional[str] = Field(None, description="Ville (CT_Ville)") + region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") + pays: Optional[str] = Field(None, description="Pays (CT_Pays)") - telephone: Optional[str] = Field(None, description="Téléphone fixe") + telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") 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") + telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") + email: Optional[str] = Field(None, description="Email principal (CT_EMail)") + site_web: Optional[str] = Field(None, description="Site web (CT_Site)") + facebook: Optional[str] = Field( + None, description="Profil Facebook (CT_Facebook)" + ) + linkedin: Optional[str] = Field( + None, description="Profil LinkedIn (CT_LinkedIn)" + ) - 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") + forme_juridique: Optional[str] = Field( + None, + description="Forme juridique (SA, SARL, SAS, EI, etc.) (CT_SvFormeJuri)", + ) + 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)" + ) + siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)") + siren: Optional[str] = Field( + None, description="N° SIREN 9 chiffres (extrait du SIRET)" + ) + tva_intra: Optional[str] = Field( + None, description="N° TVA intracommunautaire (CT_Identifiant)" + ) + code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") - 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é" + taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") + taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") + taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") + taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") + + secteur: Optional[str] = Field( + None, description="Secteur d'activité (CT_Statistique01)" + ) + statistique02: Optional[str] = Field( + None, description="Statistique 2 (CT_Statistique02)" + ) + statistique03: Optional[str] = Field( + None, description="Statistique 3 (CT_Statistique03)" + ) + statistique04: Optional[str] = Field( + None, description="Statistique 4 (CT_Statistique04)" + ) + statistique05: Optional[str] = Field( + None, description="Statistique 5 (CT_Statistique05)" + ) + statistique06: Optional[str] = Field( + None, description="Statistique 6 (CT_Statistique06)" + ) + statistique07: Optional[str] = Field( + None, description="Statistique 7 (CT_Statistique07)" + ) + statistique08: Optional[str] = Field( + None, description="Statistique 8 (CT_Statistique08)" + ) + statistique09: Optional[str] = Field( + None, description="Statistique 9 (CT_Statistique09)" + ) + statistique10: Optional[str] = Field( + None, description="Statistique 10 (CT_Statistique10)" + ) + + effectif: Optional[str] = Field( + None, description="Nombre d'employés (CT_SvEffectif)" + ) + ca_annuel: Optional[float] = Field( + None, description="Chiffre d'affaires annuel (CT_SvCA)" + ) + commercial_code: Optional[int] = Field( + None, description="Code du commercial rattaché (CO_No)" ) commercial_nom: Optional[str] = Field(None, description="Nom du commercial") + langue: Optional[int] = Field( + None, description="Code langue (0=Français, 1=Anglais...) (CT_Langue)" + ) categorie_tarifaire: Optional[int] = Field( None, description="Catégorie tarifaire (N_CatTarif)" @@ -206,18 +266,90 @@ class ClientDetails(BaseModel): categorie_comptable: Optional[int] = Field( None, description="Catégorie comptable (N_CatCompta)" ) + compte_general: Optional[str] = Field( + None, description="Compte général principal (CG_NumPrinc)" + ) + lettrage_auto: Optional[bool] = Field( + None, description="Lettrage automatique (CT_Lettrage)" + ) + type_facture: Optional[int] = Field( + None, description="Type de facture (0=Facture, 1=BL facturant) (CT_Facture)" + ) + bl_en_facture: Optional[int] = Field( + None, description="Imprimer BL en facture (CT_BLFact)" + ) + saut_page: Optional[int] = Field( + None, description="Saut de page sur documents (CT_Saut)" + ) + validation_echeance: Optional[int] = Field( + None, description="Valider les échéances (CT_ValidEch)" + ) + controle_encours: Optional[int] = Field( + None, description="Contrôler l'encours (CT_ControlEnc)" + ) + exclure_relance: Optional[bool] = Field( + None, description="Exclure des relances (CT_NotRappel)" + ) + exclure_penalites: Optional[bool] = Field( + None, description="Exclure des pénalités (CT_NotPenal)" + ) + bon_a_payer: Optional[int] = Field( + None, description="Bon à payer obligatoire (CT_BonAPayer)" + ) encours_autorise: Optional[float] = Field( - None, description="Encours maximum autorisé" + None, description="Encours maximum autorisé (CT_Encours)" ) assurance_credit: Optional[float] = Field( - None, description="Montant assurance crédit" + None, description="Montant assurance crédit (CT_Assurance)" + ) + + priorite_livraison: Optional[int] = Field( + None, description="Priorité de livraison (CT_PrioriteLivr)" + ) + livraison_partielle: Optional[int] = Field( + None, description="Autoriser livraison partielle (CT_LivrPartielle)" + ) + delai_transport: Optional[int] = Field( + None, description="Délai de transport (jours) (CT_DelaiTransport)" + ) + delai_appro: Optional[int] = Field( + None, description="Délai d'approvisionnement (jours) (CT_DelaiAppro)" + ) + + commentaire: Optional[str] = Field( + None, description="Commentaire libre (CT_Commentaire)" + ) + + section_analytique: Optional[str] = Field( + None, description="Section analytique (CA_Num)" + ) + + mode_reglement_code: Optional[int] = Field( + None, description="Code mode de règlement (MR_No)" + ) + surveillance_active: Optional[bool] = Field( + None, description="Surveillance financière active (CT_Surveillance)" + ) + coface: Optional[str] = Field( + None, description="Code Coface (25 car.) (CT_Coface)" + ) + sv_regularite: Optional[str] = Field( + None, description="Régularité paiements (CT_SvRegul)" + ) + sv_cotation: Optional[str] = Field( + None, description="Cotation crédit (CT_SvCotation)" + ) + sv_objet_maj: Optional[str] = Field( + None, description="Objet dernière MAJ surveillance (CT_SvObjetMaj)" + ) + sv_resultat: Optional[float] = Field( + None, description="Résultat financier (CT_SvResultat)" ) - compte_general: Optional[str] = Field(None, description="Compte général principal") date_creation: Optional[str] = Field(None, description="Date de création") date_modification: Optional[str] = Field( - None, description="Date de dernière modification" + None, description="Date de dernière modification (CT_DateMAJ)" ) class Config: @@ -227,20 +359,54 @@ class ClientDetails(BaseModel): "intitule": "SARL EXEMPLE", "type_tiers": "client", "qualite": "CLI", + "classement": "A", + "raccourci": "EXEMPL", "est_entreprise": True, + "est_actif": True, "forme_juridique": "SARL", + "contact": "Jean Dupont", "adresse": "123 Rue de la Paix", + "complement": "Bâtiment B", "code_postal": "75001", "ville": "Paris", + "region": "Île-de-France", + "pays": "France", "telephone": "0123456789", "portable": "0612345678", "email": "contact@exemple.fr", + "site_web": "https://www.exemple.fr", + "facebook": "https://facebook.com/exemple", + "linkedin": "https://linkedin.com/company/exemple", "siret": "12345678901234", + "siren": "123456789", "tva_intra": "FR12345678901", + "code_naf": "6201Z", + "secteur": "Informatique", + "effectif": "50-99", + "ca_annuel": 2500000.0, + "commercial_code": 1, + "langue": 0, + "categorie_tarifaire": 0, + "categorie_comptable": 0, + "compte_general": "4110000", + "lettrage_auto": True, + "type_facture": 1, + "controle_encours": 1, + "exclure_relance": False, + "encours_autorise": 50000.0, + "assurance_credit": 40000.0, + "priorite_livraison": 1, + "livraison_partielle": 1, + "delai_transport": 2, + "commentaire": "Client important", + "mode_reglement_code": 1, + "surveillance_active": True, + "coface": "COF12345", + "date_creation": "2024-01-15T10:30:00", + "date_modification": "2024-12-20T14:22:00", } } - class ArticleResponse(BaseModel): """ Modèle de réponse pour un article Sage From ca532fc8903575980849a7f6a03233f8d8a56703 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 17:32:24 +0300 Subject: [PATCH 106/199] feat(ClientDetails): add validator for type_tiers field --- api.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/api.py b/api.py index 7c726cb..542a8f6 100644 --- a/api.py +++ b/api.py @@ -351,6 +351,20 @@ class ClientDetails(BaseModel): date_modification: Optional[str] = Field( None, description="Date de dernière modification (CT_DateMAJ)" ) + + @field_validator("type_tiers", mode="before") + @classmethod + def convertir_type_tiers(cls, v): + if v is None: + return None + if isinstance(v, int): + mapping = { + 0: "client", + 1: "fournisseur", + 2: "prospect", + } + return mapping.get(v) + return v class Config: json_schema_extra = { From 61869f329376f7093a5e7f0e3c98bc2147674a6c Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 17:49:14 +0300 Subject: [PATCH 107/199] refactor(api): simplify ClientDetails model by removing unused fields and reorganizing structure --- api.py | 350 +++++++++++++++++++-------------------------------------- 1 file changed, 115 insertions(+), 235 deletions(-) diff --git a/api.py b/api.py index 542a8f6..04f7e9a 100644 --- a/api.py +++ b/api.py @@ -127,257 +127,115 @@ class ClientResponse(BaseModel): class ClientDetails(BaseModel): """ Modèle de réponse client complet (GET /clients/{code}) - Aligné avec tous les champs gérés par creer_client et lister_tous_clients + Strictement aligné avec les champs retournés par lister_tous_clients """ + # IDENTIFICATION (9 champs) numero: Optional[str] = Field(None, description="Code client (CT_Num)") - intitule: Optional[str] = Field( - None, description="Raison sociale ou Nom complet (CT_Intitule)" - ) - 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)", - ) - classement: Optional[str] = Field( - None, description="Code de classement personnalisé (CT_Classement)" - ) - raccourci: Optional[str] = Field( - None, description="Code raccourci (7 car. max) (CT_Raccourci)" - ) - - est_prospect: Optional[bool] = Field( - None, description="True si prospect (CT_Prospect=1)" - ) - est_fournisseur: Optional[bool] = Field( - None, description="True si fournisseur (CT_Type=1 ou CT_Qualite contient FOU)" - ) - 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)" - ) - - 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: Optional[str] = Field( - None, description="Nom du contact principal (CT_Contact)" - ) + intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") + type_tiers: Optional[int] = Field(None, description="Type : 0=Client, 1=Fournisseur (CT_Type)") + qualite: Optional[str] = Field(None, description="Qualité Sage : CLI, FOU, PRO (CT_Qualite)") + classement: Optional[str] = Field(None, description="Code de classement (CT_Classement)") + raccourci: Optional[str] = Field(None, description="Code raccourci 7 car. (CT_Raccourci)") + siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)") + tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire (CT_Identifiant)") + code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") + # ADRESSE (7 champs) + contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)") - complement: Optional[str] = Field( - None, description="Complément d'adresse (CT_Complement)" - ) + complement: Optional[str] = Field(None, description="Complément d'adresse (CT_Complement)") code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)") ville: Optional[str] = Field(None, description="Ville (CT_Ville)") region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") pays: Optional[str] = Field(None, description="Pays (CT_Pays)") + # TELECOM (6 champs - pas de portable dans le SQL) telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") - portable: Optional[str] = Field(None, description="Téléphone mobile") telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") email: Optional[str] = Field(None, description="Email principal (CT_EMail)") site_web: Optional[str] = Field(None, description="Site web (CT_Site)") - facebook: Optional[str] = Field( - None, description="Profil Facebook (CT_Facebook)" - ) - linkedin: Optional[str] = Field( - None, description="Profil LinkedIn (CT_LinkedIn)" - ) - - forme_juridique: Optional[str] = Field( - None, - description="Forme juridique (SA, SARL, SAS, EI, etc.) (CT_SvFormeJuri)", - ) - 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)" - ) - siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)") - siren: Optional[str] = Field( - None, description="N° SIREN 9 chiffres (extrait du SIRET)" - ) - tva_intra: Optional[str] = Field( - None, description="N° TVA intracommunautaire (CT_Identifiant)" - ) - code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") + facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") + linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") + # TAUX (4 champs) taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") - secteur: Optional[str] = Field( - None, description="Secteur d'activité (CT_Statistique01)" - ) - statistique02: Optional[str] = Field( - None, description="Statistique 2 (CT_Statistique02)" - ) - statistique03: Optional[str] = Field( - None, description="Statistique 3 (CT_Statistique03)" - ) - statistique04: Optional[str] = Field( - None, description="Statistique 4 (CT_Statistique04)" - ) - statistique05: Optional[str] = Field( - None, description="Statistique 5 (CT_Statistique05)" - ) - statistique06: Optional[str] = Field( - None, description="Statistique 6 (CT_Statistique06)" - ) - statistique07: Optional[str] = Field( - None, description="Statistique 7 (CT_Statistique07)" - ) - statistique08: Optional[str] = Field( - None, description="Statistique 8 (CT_Statistique08)" - ) - statistique09: Optional[str] = Field( - None, description="Statistique 9 (CT_Statistique09)" - ) - statistique10: Optional[str] = Field( - None, description="Statistique 10 (CT_Statistique10)" - ) + # STATISTIQUES (10 champs - utiliser les noms exacts du SQL) + statistique01: Optional[str] = Field(None, description="Statistique 1 (CT_Statistique01)") + statistique02: Optional[str] = Field(None, description="Statistique 2 (CT_Statistique02)") + statistique03: Optional[str] = Field(None, description="Statistique 3 (CT_Statistique03)") + statistique04: Optional[str] = Field(None, description="Statistique 4 (CT_Statistique04)") + statistique05: Optional[str] = Field(None, description="Statistique 5 (CT_Statistique05)") + statistique06: Optional[str] = Field(None, description="Statistique 6 (CT_Statistique06)") + statistique07: Optional[str] = Field(None, description="Statistique 7 (CT_Statistique07)") + statistique08: Optional[str] = Field(None, description="Statistique 8 (CT_Statistique08)") + statistique09: Optional[str] = Field(None, description="Statistique 9 (CT_Statistique09)") + statistique10: Optional[str] = Field(None, description="Statistique 10 (CT_Statistique10)") - effectif: Optional[str] = Field( - None, description="Nombre d'employés (CT_SvEffectif)" - ) - ca_annuel: Optional[float] = Field( - None, description="Chiffre d'affaires annuel (CT_SvCA)" - ) - commercial_code: Optional[int] = Field( - None, description="Code du commercial rattaché (CO_No)" - ) - commercial_nom: Optional[str] = Field(None, description="Nom du commercial") - langue: Optional[int] = Field( - None, description="Code langue (0=Français, 1=Anglais...) (CT_Langue)" - ) + # COMMERCIAL (4 champs) + encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé (CT_Encours)") + assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit (CT_Assurance)") + langue: Optional[int] = Field(None, description="Code langue 0=FR, 1=EN (CT_Langue)") + commercial_code: Optional[int] = Field(None, description="Code du commercial (CO_No)") - categorie_tarifaire: Optional[int] = Field( - None, description="Catégorie tarifaire (N_CatTarif)" - ) - categorie_comptable: Optional[int] = Field( - None, description="Catégorie comptable (N_CatCompta)" - ) - compte_general: Optional[str] = Field( - None, description="Compte général principal (CG_NumPrinc)" - ) + # FACTURATION (11 champs) + lettrage_auto: Optional[bool] = Field(None, description="Lettrage automatique (CT_Lettrage)") + est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") + type_facture: Optional[int] = Field(None, description="Type facture 0=Facture, 1=BL (CT_Facture)") + est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") + bl_en_facture: Optional[int] = Field(None, description="Imprimer BL en facture (CT_BLFact)") + saut_page: Optional[int] = Field(None, description="Saut de page sur documents (CT_Saut)") + validation_echeance: Optional[int] = Field(None, description="Valider les échéances (CT_ValidEch)") + controle_encours: Optional[int] = Field(None, description="Contrôler l'encours (CT_ControlEnc)") + exclure_relance: Optional[bool] = Field(None, description="Exclure des relances (CT_NotRappel)") + exclure_penalites: Optional[bool] = Field(None, description="Exclure des pénalités (CT_NotPenal)") + bon_a_payer: Optional[int] = Field(None, description="Bon à payer obligatoire (CT_BonAPayer)") - lettrage_auto: Optional[bool] = Field( - None, description="Lettrage automatique (CT_Lettrage)" - ) - type_facture: Optional[int] = Field( - None, description="Type de facture (0=Facture, 1=BL facturant) (CT_Facture)" - ) - bl_en_facture: Optional[int] = Field( - None, description="Imprimer BL en facture (CT_BLFact)" - ) - saut_page: Optional[int] = Field( - None, description="Saut de page sur documents (CT_Saut)" - ) - validation_echeance: Optional[int] = Field( - None, description="Valider les échéances (CT_ValidEch)" - ) - controle_encours: Optional[int] = Field( - None, description="Contrôler l'encours (CT_ControlEnc)" - ) - exclure_relance: Optional[bool] = Field( - None, description="Exclure des relances (CT_NotRappel)" - ) - exclure_penalites: Optional[bool] = Field( - None, description="Exclure des pénalités (CT_NotPenal)" - ) - bon_a_payer: Optional[int] = Field( - None, description="Bon à payer obligatoire (CT_BonAPayer)" - ) - encours_autorise: Optional[float] = Field( - None, description="Encours maximum autorisé (CT_Encours)" - ) - assurance_credit: Optional[float] = Field( - None, description="Montant assurance crédit (CT_Assurance)" - ) + # LOGISTIQUE (4 champs) + priorite_livraison: Optional[int] = Field(None, description="Priorité livraison (CT_PrioriteLivr)") + livraison_partielle: Optional[int] = Field(None, description="Livraison partielle (CT_LivrPartielle)") + delai_transport: Optional[int] = Field(None, description="Délai transport jours (CT_DelaiTransport)") + delai_appro: Optional[int] = Field(None, description="Délai appro jours (CT_DelaiAppro)") - priorite_livraison: Optional[int] = Field( - None, description="Priorité de livraison (CT_PrioriteLivr)" - ) - livraison_partielle: Optional[int] = Field( - None, description="Autoriser livraison partielle (CT_LivrPartielle)" - ) - delai_transport: Optional[int] = Field( - None, description="Délai de transport (jours) (CT_DelaiTransport)" - ) - delai_appro: Optional[int] = Field( - None, description="Délai d'approvisionnement (jours) (CT_DelaiAppro)" - ) + # COMMENTAIRE (1 champ) + commentaire: Optional[str] = Field(None, description="Commentaire libre (CT_Commentaire)") - commentaire: Optional[str] = Field( - None, description="Commentaire libre (CT_Commentaire)" - ) + # ANALYTIQUE (1 champ) + section_analytique: Optional[str] = Field(None, description="Section analytique (CA_Num)") - section_analytique: Optional[str] = Field( - None, description="Section analytique (CA_Num)" - ) + # ORGANISATION / SURVEILLANCE (10 champs) + mode_reglement_code: Optional[int] = Field(None, description="Code mode règlement (MR_No)") + surveillance_active: Optional[bool] = Field(None, description="Surveillance financière (CT_Surveillance)") + coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)") + forme_juridique: Optional[str] = Field(None, description="Forme juridique SA, SARL (CT_SvFormeJuri)") + effectif: Optional[str] = Field(None, description="Nombre d'employés (CT_SvEffectif)") + sv_regularite: Optional[str] = Field(None, description="Régularité paiements (CT_SvRegul)") + sv_cotation: Optional[str] = Field(None, description="Cotation crédit (CT_SvCotation)") + sv_objet_maj: Optional[str] = Field(None, description="Objet dernière MAJ (CT_SvObjetMaj)") + sv_chiffre_affaires: Optional[float] = Field(None, description="Chiffre d'affaires (CT_SvCA)") + sv_resultat: Optional[float] = Field(None, description="Résultat financier (CT_SvResultat)") - mode_reglement_code: Optional[int] = Field( - None, description="Code mode de règlement (MR_No)" - ) - surveillance_active: Optional[bool] = Field( - None, description="Surveillance financière active (CT_Surveillance)" - ) - coface: Optional[str] = Field( - None, description="Code Coface (25 car.) (CT_Coface)" - ) - sv_regularite: Optional[str] = Field( - None, description="Régularité paiements (CT_SvRegul)" - ) - sv_cotation: Optional[str] = Field( - None, description="Cotation crédit (CT_SvCotation)" - ) - sv_objet_maj: Optional[str] = Field( - None, description="Objet dernière MAJ surveillance (CT_SvObjetMaj)" - ) - sv_resultat: Optional[float] = Field( - None, description="Résultat financier (CT_SvResultat)" - ) - - date_creation: Optional[str] = Field(None, description="Date de création") - date_modification: Optional[str] = Field( - None, description="Date de dernière modification (CT_DateMAJ)" - ) - - @field_validator("type_tiers", mode="before") - @classmethod - def convertir_type_tiers(cls, v): - if v is None: - return None - if isinstance(v, int): - mapping = { - 0: "client", - 1: "fournisseur", - 2: "prospect", - } - return mapping.get(v) - return v + # COMPTE GENERAL ET CATEGORIES (3 champs) + compte_general: Optional[str] = Field(None, description="Compte général principal (CG_NumPrinc)") + categorie_tarif: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") + categorie_compta: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") class Config: json_schema_extra = { "example": { "numero": "CLI000001", "intitule": "SARL EXEMPLE", - "type_tiers": "client", + "type_tiers": 0, "qualite": "CLI", "classement": "A", "raccourci": "EXEMPL", - "est_entreprise": True, - "est_actif": True, - "forme_juridique": "SARL", + "siret": "12345678901234", + "tva_intra": "FR12345678901", + "code_naf": "6201Z", "contact": "Jean Dupont", "adresse": "123 Rue de la Paix", "complement": "Bâtiment B", @@ -386,41 +244,63 @@ class ClientDetails(BaseModel): "region": "Île-de-France", "pays": "France", "telephone": "0123456789", - "portable": "0612345678", + "telecopie": "0123456788", "email": "contact@exemple.fr", "site_web": "https://www.exemple.fr", "facebook": "https://facebook.com/exemple", "linkedin": "https://linkedin.com/company/exemple", - "siret": "12345678901234", - "siren": "123456789", - "tva_intra": "FR12345678901", - "code_naf": "6201Z", - "secteur": "Informatique", - "effectif": "50-99", - "ca_annuel": 2500000.0, - "commercial_code": 1, - "langue": 0, - "categorie_tarifaire": 0, - "categorie_comptable": 0, - "compte_general": "4110000", - "lettrage_auto": True, - "type_facture": 1, - "controle_encours": 1, - "exclure_relance": False, + "taux01": 0.0, + "taux02": 0.0, + "taux03": 0.0, + "taux04": 0.0, + "statistique01": "Informatique", + "statistique02": "", + "statistique03": "", + "statistique04": "", + "statistique05": "", + "statistique06": "", + "statistique07": "", + "statistique08": "", + "statistique09": "", + "statistique10": "", "encours_autorise": 50000.0, "assurance_credit": 40000.0, + "langue": 0, + "commercial_code": 1, + "lettrage_auto": True, + "est_actif": True, + "type_facture": 1, + "est_prospect": False, + "bl_en_facture": 0, + "saut_page": 0, + "validation_echeance": 0, + "controle_encours": 1, + "exclure_relance": False, + "exclure_penalites": False, + "bon_a_payer": 0, "priorite_livraison": 1, "livraison_partielle": 1, "delai_transport": 2, + "delai_appro": 0, "commentaire": "Client important", + "section_analytique": "", "mode_reglement_code": 1, "surveillance_active": True, "coface": "COF12345", - "date_creation": "2024-01-15T10:30:00", - "date_modification": "2024-12-20T14:22:00", + "forme_juridique": "SARL", + "effectif": "50-99", + "sv_regularite": "", + "sv_cotation": "", + "sv_objet_maj": "", + "sv_chiffre_affaires": 2500000.0, + "sv_resultat": 150000.0, + "compte_general": "4110000", + "categorie_tarif": 0, + "categorie_compta": 0, } } - + + class ArticleResponse(BaseModel): """ Modèle de réponse pour un article Sage From f414a2889e13637cb79d5ff2559046d135e31098 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 18:51:36 +0300 Subject: [PATCH 108/199] feat(client): add Contact model and contacts field to ClientDetails --- api.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/api.py b/api.py index 04f7e9a..182eac1 100644 --- a/api.py +++ b/api.py @@ -124,13 +124,60 @@ class ClientResponse(BaseModel): telephone: Optional[str] = None # Téléphone principal (fixe ou mobile) +class Contact(BaseModel): + """ + Contact associé à un tiers (client/fournisseur) + Tous les champs de F_CONTACTT + """ + + ct_num: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)") + ct_no: Optional[int] = Field(None, description="Numéro unique du contact (CT_No)") + n_contact: Optional[int] = Field(None, description="Numéro de référence contact (N_Contact)") + + civilite: Optional[str] = Field(None, description="Civilité : 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)") + fonction: Optional[str] = Field(None, description="Fonction/Titre (CT_Fonction)") + + service_code: Optional[int] = Field(None, description="Code du service (N_Service)") + + telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") + portable: Optional[str] = Field(None, description="Téléphone mobile (CT_TelPortable)") + telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") + email: Optional[str] = Field(None, description="Adresse email (CT_EMail)") + + facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") + linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") + skype: Optional[str] = Field(None, description="Identifiant Skype (CT_Skype)") + + class Config: + json_schema_extra = { + "example": { + "ct_num": "CLI000001", + "ct_no": 1, + "n_contact": 1, + "civilite": "M.", + "nom": "Dupont", + "prenom": "Jean", + "fonction": "Directeur Commercial", + "service_code": 1, + "telephone": "0123456789", + "portable": "0612345678", + "telecopie": "0123456788", + "email": "j.dupont@exemple.fr", + "facebook": "https://facebook.com/jean.dupont", + "linkedin": "https://linkedin.com/in/jeandupont", + "skype": "jean.dupont.pro" + } + } + + class ClientDetails(BaseModel): """ Modèle de réponse client complet (GET /clients/{code}) Strictement aligné avec les champs retournés par lister_tous_clients """ - # IDENTIFICATION (9 champs) numero: Optional[str] = Field(None, description="Code client (CT_Num)") intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") type_tiers: Optional[int] = Field(None, description="Type : 0=Client, 1=Fournisseur (CT_Type)") @@ -141,7 +188,6 @@ class ClientDetails(BaseModel): tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire (CT_Identifiant)") code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") - # ADRESSE (7 champs) contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)") complement: Optional[str] = Field(None, description="Complément d'adresse (CT_Complement)") @@ -150,7 +196,6 @@ class ClientDetails(BaseModel): region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") pays: Optional[str] = Field(None, description="Pays (CT_Pays)") - # TELECOM (6 champs - pas de portable dans le SQL) telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") email: Optional[str] = Field(None, description="Email principal (CT_EMail)") @@ -158,13 +203,11 @@ class ClientDetails(BaseModel): facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") - # TAUX (4 champs) taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") - # STATISTIQUES (10 champs - utiliser les noms exacts du SQL) statistique01: Optional[str] = Field(None, description="Statistique 1 (CT_Statistique01)") statistique02: Optional[str] = Field(None, description="Statistique 2 (CT_Statistique02)") statistique03: Optional[str] = Field(None, description="Statistique 3 (CT_Statistique03)") @@ -176,13 +219,11 @@ class ClientDetails(BaseModel): statistique09: Optional[str] = Field(None, description="Statistique 9 (CT_Statistique09)") statistique10: Optional[str] = Field(None, description="Statistique 10 (CT_Statistique10)") - # COMMERCIAL (4 champs) encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé (CT_Encours)") assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit (CT_Assurance)") langue: Optional[int] = Field(None, description="Code langue 0=FR, 1=EN (CT_Langue)") commercial_code: Optional[int] = Field(None, description="Code du commercial (CO_No)") - # FACTURATION (11 champs) lettrage_auto: Optional[bool] = Field(None, description="Lettrage automatique (CT_Lettrage)") est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") type_facture: Optional[int] = Field(None, description="Type facture 0=Facture, 1=BL (CT_Facture)") @@ -195,19 +236,15 @@ class ClientDetails(BaseModel): exclure_penalites: Optional[bool] = Field(None, description="Exclure des pénalités (CT_NotPenal)") bon_a_payer: Optional[int] = Field(None, description="Bon à payer obligatoire (CT_BonAPayer)") - # LOGISTIQUE (4 champs) priorite_livraison: Optional[int] = Field(None, description="Priorité livraison (CT_PrioriteLivr)") livraison_partielle: Optional[int] = Field(None, description="Livraison partielle (CT_LivrPartielle)") delai_transport: Optional[int] = Field(None, description="Délai transport jours (CT_DelaiTransport)") delai_appro: Optional[int] = Field(None, description="Délai appro jours (CT_DelaiAppro)") - # COMMENTAIRE (1 champ) commentaire: Optional[str] = Field(None, description="Commentaire libre (CT_Commentaire)") - # ANALYTIQUE (1 champ) section_analytique: Optional[str] = Field(None, description="Section analytique (CA_Num)") - # ORGANISATION / SURVEILLANCE (10 champs) mode_reglement_code: Optional[int] = Field(None, description="Code mode règlement (MR_No)") surveillance_active: Optional[bool] = Field(None, description="Surveillance financière (CT_Surveillance)") coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)") @@ -219,10 +256,14 @@ class ClientDetails(BaseModel): sv_chiffre_affaires: Optional[float] = Field(None, description="Chiffre d'affaires (CT_SvCA)") sv_resultat: Optional[float] = Field(None, description="Résultat financier (CT_SvResultat)") - # COMPTE GENERAL ET CATEGORIES (3 champs) compte_general: Optional[str] = Field(None, description="Compte général principal (CG_NumPrinc)") categorie_tarif: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") categorie_compta: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") + + contacts: Optional[List[Contact]] = Field( + default_factory=list, + description="Liste des contacts du client" + ) class Config: json_schema_extra = { From c101e45afd337f38074312449909aecb748de628 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 19:03:34 +0300 Subject: [PATCH 109/199] feat(Contact): add civilite mapping and validator --- api.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/api.py b/api.py index 182eac1..22a2136 100644 --- a/api.py +++ b/api.py @@ -150,6 +150,25 @@ class Contact(BaseModel): linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") skype: Optional[str] = Field(None, description="Identifiant Skype (CT_Skype)") + _civilite_map = { + 0: "M.", + 1: "Mme", + 2: "Mlle", + 3: "Société", + } + + @validator("civilite", pre=True, always=True) + def convert_civilite(cls, v): + """ + Si la civilité est fournie sous forme de code numérique, + on la transforme en chaîne de caractères. + """ + if v is None: + return v + if isinstance(v, int): + return cls._civilite_map.get(v, str(v)) # retourne le code en string si non mappé + return v + class Config: json_schema_extra = { "example": { From db3776c000a1db5ecb91e72a6b48040958513676 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 19:18:24 +0300 Subject: [PATCH 110/199] refactor(Contact): rename _civilite_map to civilite_map for better accessibility --- api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 22a2136..bfb30df 100644 --- a/api.py +++ b/api.py @@ -150,7 +150,7 @@ class Contact(BaseModel): linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") skype: Optional[str] = Field(None, description="Identifiant Skype (CT_Skype)") - _civilite_map = { + civilite_map = { 0: "M.", 1: "Mme", 2: "Mlle", @@ -166,7 +166,7 @@ class Contact(BaseModel): if v is None: return v if isinstance(v, int): - return cls._civilite_map.get(v, str(v)) # retourne le code en string si non mappé + return cls.civilite_map.get(v, str(v)) # retourne le code en string si non mappé return v class Config: From 8b42db686ce0d4c2cd05f6a3aae65ef0be7dc429 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 19:27:15 +0300 Subject: [PATCH 111/199] refactor(Contact): simplify civilite_map and validator comments --- api.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/api.py b/api.py index bfb30df..9f7ec45 100644 --- a/api.py +++ b/api.py @@ -150,7 +150,7 @@ class Contact(BaseModel): linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") skype: Optional[str] = Field(None, description="Identifiant Skype (CT_Skype)") - civilite_map = { + civilite_map: ClassVar[dict] = { 0: "M.", 1: "Mme", 2: "Mlle", @@ -159,14 +159,10 @@ class Contact(BaseModel): @validator("civilite", pre=True, always=True) def convert_civilite(cls, v): - """ - Si la civilité est fournie sous forme de code numérique, - on la transforme en chaîne de caractères. - """ if v is None: return v if isinstance(v, int): - return cls.civilite_map.get(v, str(v)) # retourne le code en string si non mappé + return cls.civilite_map.get(v, str(v)) return v class Config: From 3546c581651631be59edd931adec690447b6f46d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 19:31:53 +0300 Subject: [PATCH 112/199] Added missing import --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 9f7ec45..2dab2a2 100644 --- a/api.py +++ b/api.py @@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field, EmailStr, validator, field_validator -from typing import List, Optional, Dict +from typing import List, Optional, Dict, ClassVar from datetime import date, datetime from enum import Enum, IntEnum import uvicorn From 0f060757799bcb1bcd3772b1b56b81e8b26d3b8d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 19:54:00 +0300 Subject: [PATCH 113/199] feat(fournisseurs): add FournisseurDetails model and update endpoints --- api.py | 186 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 176 insertions(+), 10 deletions(-) diff --git a/api.py b/api.py index 2dab2a2..c4ea553 100644 --- a/api.py +++ b/api.py @@ -356,7 +356,177 @@ class ClientDetails(BaseModel): } } + +class FournisseurDetails(BaseModel): + """ + Modèle de réponse fournisseur complet (GET /fournisseurs/{code}) + Strictement aligné avec ClientDetails + """ + + numero: Optional[str] = Field(None, description="Code fournisseur (CT_Num)") + intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") + type_tiers: Optional[int] = Field(None, description="Type : 0=Client, 1=Fournisseur (CT_Type)") + qualite: Optional[str] = Field(None, description="Qualité Sage : CLI, FOU, PRO (CT_Qualite)") + classement: Optional[str] = Field(None, description="Code de classement (CT_Classement)") + raccourci: Optional[str] = Field(None, description="Code raccourci 7 car. (CT_Raccourci)") + siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)") + tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire (CT_Identifiant)") + code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") + + contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") + adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)") + complement: Optional[str] = Field(None, description="Complément d'adresse (CT_Complement)") + code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)") + ville: Optional[str] = Field(None, description="Ville (CT_Ville)") + region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") + pays: Optional[str] = Field(None, description="Pays (CT_Pays)") + + telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") + telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") + email: Optional[str] = Field(None, description="Email principal (CT_EMail)") + site_web: Optional[str] = Field(None, description="Site web (CT_Site)") + facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") + linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") + + taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") + taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") + taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") + taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") + + statistique01: Optional[str] = Field(None, description="Statistique 1 (CT_Statistique01)") + statistique02: Optional[str] = Field(None, description="Statistique 2 (CT_Statistique02)") + statistique03: Optional[str] = Field(None, description="Statistique 3 (CT_Statistique03)") + statistique04: Optional[str] = Field(None, description="Statistique 4 (CT_Statistique04)") + statistique05: Optional[str] = Field(None, description="Statistique 5 (CT_Statistique05)") + statistique06: Optional[str] = Field(None, description="Statistique 6 (CT_Statistique06)") + statistique07: Optional[str] = Field(None, description="Statistique 7 (CT_Statistique07)") + statistique08: Optional[str] = Field(None, description="Statistique 8 (CT_Statistique08)") + statistique09: Optional[str] = Field(None, description="Statistique 9 (CT_Statistique09)") + statistique10: Optional[str] = Field(None, description="Statistique 10 (CT_Statistique10)") + + encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé (CT_Encours)") + assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit (CT_Assurance)") + langue: Optional[int] = Field(None, description="Code langue 0=FR, 1=EN (CT_Langue)") + commercial_code: Optional[int] = Field(None, description="Code du commercial (CO_No)") + + lettrage_auto: Optional[bool] = Field(None, description="Lettrage automatique (CT_Lettrage)") + est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") + type_facture: Optional[int] = Field(None, description="Type facture 0=Facture, 1=BL (CT_Facture)") + est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") + bl_en_facture: Optional[int] = Field(None, description="Imprimer BL en facture (CT_BLFact)") + saut_page: Optional[int] = Field(None, description="Saut de page sur documents (CT_Saut)") + validation_echeance: Optional[int] = Field(None, description="Valider les échéances (CT_ValidEch)") + controle_encours: Optional[int] = Field(None, description="Contrôler l'encours (CT_ControlEnc)") + exclure_relance: Optional[bool] = Field(None, description="Exclure des relances (CT_NotRappel)") + exclure_penalites: Optional[bool] = Field(None, description="Exclure des pénalités (CT_NotPenal)") + bon_a_payer: Optional[int] = Field(None, description="Bon à payer obligatoire (CT_BonAPayer)") + + priorite_livraison: Optional[int] = Field(None, description="Priorité livraison (CT_PrioriteLivr)") + livraison_partielle: Optional[int] = Field(None, description="Livraison partielle (CT_LivrPartielle)") + delai_transport: Optional[int] = Field(None, description="Délai transport jours (CT_DelaiTransport)") + delai_appro: Optional[int] = Field(None, description="Délai appro jours (CT_DelaiAppro)") + + commentaire: Optional[str] = Field(None, description="Commentaire libre (CT_Commentaire)") + + section_analytique: Optional[str] = Field(None, description="Section analytique (CA_Num)") + + mode_reglement_code: Optional[int] = Field(None, description="Code mode règlement (MR_No)") + surveillance_active: Optional[bool] = Field(None, description="Surveillance financière (CT_Surveillance)") + coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)") + forme_juridique: Optional[str] = Field(None, description="Forme juridique SA, SARL (CT_SvFormeJuri)") + effectif: Optional[str] = Field(None, description="Nombre d'employés (CT_SvEffectif)") + sv_regularite: Optional[str] = Field(None, description="Régularité paiements (CT_SvRegul)") + sv_cotation: Optional[str] = Field(None, description="Cotation crédit (CT_SvCotation)") + sv_objet_maj: Optional[str] = Field(None, description="Objet dernière MAJ (CT_SvObjetMaj)") + sv_chiffre_affaires: Optional[float] = Field(None, description="Chiffre d'affaires (CT_SvCA)") + sv_resultat: Optional[float] = Field(None, description="Résultat financier (CT_SvResultat)") + + compte_general: Optional[str] = Field(None, description="Compte général principal (CG_NumPrinc)") + categorie_tarif: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") + categorie_compta: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") + + contacts: Optional[List[Contact]] = Field( + default_factory=list, + description="Liste des contacts du fournisseur" + ) + + class Config: + json_schema_extra = { + "example": { + "numero": "FOU000001", + "intitule": "SARL FOURNISSEUR EXEMPLE", + "type_tiers": 1, + "qualite": "FOU", + "classement": "A", + "raccourci": "EXEMPL", + "siret": "12345678901234", + "tva_intra": "FR12345678901", + "code_naf": "6201Z", + "contact": "Jean Dupont", + "adresse": "123 Rue de la Paix", + "complement": "Bâtiment B", + "code_postal": "75001", + "ville": "Paris", + "region": "Île-de-France", + "pays": "France", + "telephone": "0123456789", + "telecopie": "0123456788", + "email": "contact@exemple.fr", + "site_web": "https://www.exemple.fr", + "facebook": "https://facebook.com/exemple", + "linkedin": "https://linkedin.com/company/exemple", + "taux01": 0.0, + "taux02": 0.0, + "taux03": 0.0, + "taux04": 0.0, + "statistique01": "Informatique", + "statistique02": "", + "statistique03": "", + "statistique04": "", + "statistique05": "", + "statistique06": "", + "statistique07": "", + "statistique08": "", + "statistique09": "", + "statistique10": "", + "encours_autorise": 50000.0, + "assurance_credit": 40000.0, + "langue": 0, + "commercial_code": 1, + "lettrage_auto": True, + "est_actif": True, + "type_facture": 1, + "est_prospect": False, + "bl_en_facture": 0, + "saut_page": 0, + "validation_echeance": 0, + "controle_encours": 1, + "exclure_relance": False, + "exclure_penalites": False, + "bon_a_payer": 0, + "priorite_livraison": 1, + "livraison_partielle": 1, + "delai_transport": 2, + "delai_appro": 0, + "commentaire": "Client important", + "section_analytique": "", + "mode_reglement_code": 1, + "surveillance_active": True, + "coface": "COF12345", + "forme_juridique": "SARL", + "effectif": "50-99", + "sv_regularite": "", + "sv_cotation": "", + "sv_objet_maj": "", + "sv_chiffre_affaires": 2500000.0, + "sv_resultat": 150000.0, + "compte_general": "4110000", + "categorie_tarif": 0, + "categorie_compta": 0, + } + } + class ArticleResponse(BaseModel): """ Modèle de réponse pour un article Sage @@ -2405,7 +2575,7 @@ async def rechercher_clients(query: Optional[str] = Query(None)): raise HTTPException(500, str(e)) -@app.get("/clients/{code}", tags=["Clients"]) +@app.get("/clients/{code}", response_model=ClientDetails , tags=["Clients"]) async def lire_client_detail(code: str): try: client = sage_client.lire_client(code) @@ -2413,7 +2583,7 @@ async def lire_client_detail(code: str): if not client: raise HTTPException(404, f"Client {code} introuvable") - return {"success": True, "data": client} + return ClientDetails(**client) except HTTPException: raise @@ -4271,7 +4441,7 @@ async def lire_prospect(code: str): -@app.get("/fournisseurs", tags=["Fournisseurs"]) +@app.get("/fournisseurs", response_model=List[FournisseurDetails], tags=["Fournisseurs"]) async def rechercher_fournisseurs(query: Optional[str] = Query(None)): try: fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") @@ -4281,7 +4451,7 @@ async def rechercher_fournisseurs(query: Optional[str] = Query(None)): if len(fournisseurs) == 0: logger.warning("Aucun fournisseur retourné - vérifier la gateway Windows") - return fournisseurs + return [FournisseurDetails(**f) for f in fournisseurs] except Exception as e: logger.error(f"Erreur recherche fournisseurs: {e}") @@ -4313,7 +4483,7 @@ async def ajouter_fournisseur( raise HTTPException(500, str(e)) -@app.put("/fournisseurs/{code}", tags=["Fournisseurs"]) +@app.put("/fournisseurs/{code}", response_model=FournisseurDetails, tags=["Fournisseurs"]) async def modifier_fournisseur( code: str, fournisseur_update: FournisseurUpdateRequest, @@ -4326,11 +4496,7 @@ async def modifier_fournisseur( logger.info(f"Fournisseur {code} modifié avec succès") - return { - "success": True, - "message": f"Fournisseur {code} modifié avec succès", - "fournisseur": resultat, - } + return FournisseurDetails(**resultat) except ValueError as e: logger.warning(f"Erreur métier modification fournisseur {code}: {e}") From 459ce26766d4a985983c2a3772c98fb13fed5539 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 20:33:53 +0300 Subject: [PATCH 114/199] feat(api): enhance FamilleResponse model with additional fields --- api.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 6 deletions(-) diff --git a/api.py b/api.py index c4ea553..e6d28be 100644 --- a/api.py +++ b/api.py @@ -1763,17 +1763,81 @@ class FamilleCreateRequest(BaseModel): class FamilleResponse(BaseModel): - """Modèle de réponse pour une famille d'articles""" + """Modèle de réponse pour une famille d'articles - aligné sur lister_toutes_familles""" + # === Identification === code: str = Field(..., description="Code famille") intitule: str = Field(..., description="Intitulé") type: int = Field(..., description="Type (0=Détail, 1=Total)") type_libelle: str = Field(..., description="Libellé du type") - est_total: Optional[bool] = Field(None, description="True si type Total") + est_total: bool = Field(..., description="True si type Total") + est_detail: Optional[bool] = Field(None, description="True si type Détail") + + # === Vente et unités === + unite_vente: Optional[str] = Field(None, description="Unité de vente") + unite_poids: Optional[str] = Field(None, description="Unité de poids") + coef: Optional[float] = Field(None, description="Coefficient") + + # === Stock et logistique === + suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé") + garantie: Optional[int] = Field(None, description="Durée de garantie (mois)") + delai: Optional[int] = Field(None, description="Délai de livraison (jours)") + nb_colis: Optional[int] = Field(None, description="Nombre de colis") + + # === Comptabilité === compte_achat: Optional[str] = Field(None, description="Compte général achat") compte_vente: Optional[str] = Field(None, description="Compte général vente") - unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") - coef: Optional[float] = Field(None, description="Coefficient") + code_fiscal: Optional[str] = Field(None, description="Code fiscal") + escompte: Optional[bool] = Field(None, description="Escompte autorisé") + + # === Organisation === + est_centrale: Optional[bool] = Field(None, description="Famille centrale") + nature: Optional[int] = Field(None, description="Nature de la famille") + pays: Optional[str] = Field(None, description="Pays d'origine") + + # === Classifications === + categorie_1: Optional[int] = Field(None, description="Catégorie 1") + categorie_2: Optional[int] = Field(None, description="Catégorie 2") + categorie_3: Optional[int] = Field(None, description="Catégorie 3") + categorie_4: Optional[int] = Field(None, description="Catégorie 4") + + # === Statistiques === + stat_01: Optional[str] = Field(None, description="Statistique 1") + stat_02: Optional[str] = Field(None, description="Statistique 2") + stat_03: Optional[str] = Field(None, description="Statistique 3") + stat_04: Optional[str] = Field(None, description="Statistique 4") + stat_05: Optional[str] = Field(None, description="Statistique 5") + hors_statistique: Optional[bool] = Field(None, description="Exclue des statistiques") + est_statistique: Optional[bool] = Field(None, description="Incluse dans les statistiques (legacy)") + + # === Paramètres commerciaux === + vente_debit: Optional[bool] = Field(None, description="Vente au débit") + non_imprimable: Optional[bool] = Field(None, description="Non imprimable") + contremarque: Optional[bool] = Field(None, description="Contremarque") + fact_poids: Optional[bool] = Field(None, description="Facturation au poids") + fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire") + publie: Optional[bool] = Field(None, description="Publié") + + # === Références === + racine_reference: Optional[str] = Field(None, description="Racine référence article") + racine_code_barre: Optional[str] = Field(None, description="Racine code-barres") + raccourci: Optional[str] = Field(None, description="Raccourci clavier") + + # === Gestion === + sous_traitance: Optional[bool] = Field(None, description="Sous-traitance") + fictif: Optional[bool] = Field(None, description="Famille fictive") + criticite: Optional[int] = Field(None, description="Niveau de criticité") + + # === Métadonnées (spécifiques à lire_famille) === + avertissement: Optional[str] = Field(None, description="Avertissement si famille Total") + index_com: Optional[int] = Field(None, description="Index COM (lire_famille uniquement)") + date_creation: Optional[str] = Field(None, description="Date de création") + date_modification: Optional[str] = Field(None, description="Date de modification") + nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille") + + # === Champs bruts SQL (optionnels) === + # Permet de conserver tous les champs SQL si besoin + champs_sql: Optional[Dict[str, Any]] = Field(None, description="Champs SQL bruts (si demandés)") class Config: json_schema_extra = { @@ -1783,14 +1847,57 @@ class FamilleResponse(BaseModel): "type": 0, "type_libelle": "Détail", "est_total": False, + "est_detail": True, + "unite_vente": "U", + "unite_poids": "KG", + "coef": 2.0, + "suivi_stock": True, + "garantie": 12, + "delai": 7, + "nb_colis": 1, "compte_achat": "607000", "compte_vente": "707000", - "unite_vente": "U", - "coef": 2.0, + "code_fiscal": "TVA20", + "escompte": True, + "est_centrale": False, + "nature": 0, + "pays": "FR", + "categorie_1": 1, + "categorie_2": 0, + "categorie_3": 0, + "categorie_4": 0, + "nb_articles": 45 } } +class FamilleListResponse(BaseModel): + """Modèle de réponse pour une liste de familles""" + + familles: list[FamilleResponse] = Field(..., description="Liste des familles") + total: int = Field(..., description="Nombre total de familles") + filtre_applique: Optional[str] = Field(None, description="Filtre appliqué") + inclut_totaux: bool = Field(..., description="Inclut les familles de type Total") + + class Config: + json_schema_extra = { + "example": { + "familles": [ + { + "code": "ZDIVERS", + "intitule": "Frais et accessoires", + "type": 0, + "type_libelle": "Détail", + "est_total": False, + "nb_articles": 45 + } + ], + "total": 1, + "filtre_applique": "frais", + "inclut_totaux": False + } + } + class MouvementStockLigneRequest(BaseModel): article_ref: str = Field(..., description="Référence de l'article") quantite: float = Field(..., gt=0, description="Quantité (>0)") From e55ff7562485d57b5384cb5fb5557a81170b906d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 20:37:39 +0300 Subject: [PATCH 115/199] Added missing import --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index e6d28be..98cb6d7 100644 --- a/api.py +++ b/api.py @@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field, EmailStr, validator, field_validator -from typing import List, Optional, Dict, ClassVar +from typing import List, Optional, Dict, ClassVar, Any from datetime import date, datetime from enum import Enum, IntEnum import uvicorn From be7a8badddd7553c301071a38f4134c54f8f3982 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 27 Dec 2025 05:29:18 +0300 Subject: [PATCH 116/199] Enriched article response --- .gitignore | 2 +- api.py | 319 ++++++++++++++++++++++++++++++++++--------- database/__init__.py | 5 - database/models.py | 42 ------ email_queue.py | 30 ---- routes/auth.py | 40 ------ security/auth.py | 5 - 7 files changed, 255 insertions(+), 188 deletions(-) diff --git a/.gitignore b/.gitignore index b88f070..fe762fa 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,4 @@ dist/ data/sage_dataven.db -cleaner.py \ No newline at end of file +tools/ \ No newline at end of file diff --git a/api.py b/api.py index 98cb6d7..6995bb5 100644 --- a/api.py +++ b/api.py @@ -528,73 +528,276 @@ class FournisseurDetails(BaseModel): class ArticleResponse(BaseModel): - """ - Modèle de réponse pour un article Sage - - ENRICHI avec tous les champs disponibles - """ - + """Modèle de réponse pour un article avec toutes ses informations""" + reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") designation_complementaire: Optional[str] = Field( - None, description="Désignation complémentaire" + None, description="Désignation complémentaire (AR_Design2)" + ) + description: Optional[str] = Field( + None, description="Description détaillée / Commentaire (AR_Commentaire, AR_Info)" + ) + + code_ean: Optional[str] = Field( + None, description="Code EAN / Code-barres principal (AR_CodeBarre)" + ) + code_barre: Optional[str] = Field( + None, description="Code-barres (alias de code_ean)" + ) + raccourci: Optional[str] = Field( + None, description="Code raccourci (AR_Raccourci)" + ) + racine: Optional[str] = Field( + None, description="Racine de référence (AR_Racine)" + ) + + prix_vente: float = Field( + ..., ge=0, description="Prix de vente HT unitaire (AR_PrixVen)" + ) + prix_achat: Optional[float] = Field( + None, ge=0, description="Prix d'achat HT (AR_PrixAch)" + ) + prix_revient: Optional[float] = Field( + None, ge=0, description="Prix de revient (AR_PrixRev)" + ) + prix_ttc: Optional[float] = Field( + None, ge=0, description="Prix de vente TTC (AR_PrixTTC)" + ) + coef: Optional[float] = Field( + None, ge=0, description="Coefficient multiplicateur (AR_Coef)" + ) + + stock_reel: float = Field( + default=0.0, description="Stock réel total (F_ARTSTOCK.AS_QteSto)" + ) + stock_mini: Optional[float] = Field( + None, ge=0, description="Stock minimum (F_ARTSTOCK.AS_QteMini)" + ) + stock_maxi: Optional[float] = Field( + None, ge=0, description="Stock maximum (F_ARTSTOCK.AS_QteMaxi)" ) - - code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") - code_barre: Optional[str] = Field(None, description="Code-barres (alias)") - - prix_vente: float = Field(..., description="Prix de vente HT") - prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") - prix_revient: Optional[float] = Field(None, description="Prix de revient") - - stock_reel: float = Field(..., description="Stock réel") - stock_mini: Optional[float] = Field(None, description="Stock minimum") - stock_maxi: Optional[float] = Field(None, description="Stock maximum") stock_reserve: Optional[float] = Field( - None, description="Stock réservé (en commande)" + None, ge=0, description="Stock réservé / en commande client (F_ARTSTOCK.AS_QteRes)" ) stock_commande: Optional[float] = Field( - None, description="Stock en commande fournisseur" + None, ge=0, description="Stock en commande fournisseur (F_ARTSTOCK.AS_QteCom)" ) stock_disponible: Optional[float] = Field( - None, description="Stock disponible (réel - réservé)" + None, description="Stock disponible = réel - réservé" ) - - description: Optional[str] = Field( - None, description="Description détaillée / Commentaire" + suivi_stock: Optional[bool] = Field( + None, description="Suivi de stock activé (AR_SuiviStock)" ) - + + unite_vente: Optional[str] = Field( + None, max_length=10, description="Unité de vente (AR_UniteVen)" + ) + unite_achat: Optional[str] = Field( + None, max_length=10, description="Unité d'achat/stock (AR_Unite)" + ) + unite_poids: Optional[str] = Field( + None, max_length=10, description="Unité de poids (AR_UnitePoids)" + ) + + poids: Optional[float] = Field( + None, ge=0, description="Poids net unitaire (AR_Poids, AR_PoidsNet)" + ) + poids_brut: Optional[float] = Field( + None, ge=0, description="Poids brut unitaire (AR_PoidsBrut)" + ) + volume: Optional[float] = Field( + None, ge=0, description="Volume unitaire en m³ (AR_Volume)" + ) + type_article: Optional[int] = Field( - None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)" + None, + ge=0, + le=3, + description="Type d'article : 0=Article, 1=Prestation, 2=Divers, 3=Nomenclature (AR_Type)" ) - type_article_libelle: Optional[str] = Field(None, description="Libellé du type") - famille_code: Optional[str] = Field(None, description="Code famille") - famille_libelle: Optional[str] = Field(None, description="Libellé famille") - - fournisseur_principal: Optional[str] = Field( - None, description="Code fournisseur principal" + type_article_libelle: Optional[str] = Field( + None, description="Libellé du type d'article" + ) + + famille_code: Optional[str] = Field( + None, max_length=20, description="Code famille (FA_CodeFamille)" + ) + famille_libelle: Optional[str] = Field( + None, description="Libellé de la famille (F_FAMILLE.FA_Intitule)" + ) + famille_type: Optional[int] = Field( + None, description="Type de famille : 0=Détail, 1=Total (F_FAMILLE.FA_Type)" + ) + famille_unite_vente: Optional[str] = Field( + None, description="Unité de vente héritée de la famille (F_FAMILLE.FA_UniteVen)" + ) + famille_coef: Optional[float] = Field( + None, description="Coefficient hérité de la famille (F_FAMILLE.FA_Coef)" + ) + famille_suivi_stock: Optional[bool] = Field( + None, description="Suivi stock hérité de la famille (F_FAMILLE.FA_SuiviStock)" + ) + famille_compte_vente: Optional[str] = Field( + None, description="Compte comptable de vente de la famille (F_FAMILLE.CG_NumVte)" + ) + famille_compte_achat: Optional[str] = Field( + None, description="Compte comptable d'achat de la famille (F_FAMILLE.CG_NumAch)" + ) + + nature: Optional[int] = Field( + None, description="Nature de l'article (AR_Nature)" + ) + garantie: Optional[int] = Field( + None, ge=0, description="Durée de garantie en mois (AR_Garantie)" + ) + + fournisseur_principal: Optional[int] = Field( + None, description="N° compte du fournisseur principal (CO_No)" ) fournisseur_nom: Optional[str] = Field( - None, description="Nom fournisseur principal" + None, description="Nom du fournisseur principal (F_COMPTET.CT_Intitule)" + ) + conditionnement: Optional[str] = Field( + None, description="Conditionnement d'achat (AR_Condition)" + ) + nb_colis: Optional[int] = Field( + None, ge=0, description="Nombre de colis par unité (AR_NbColis)" + ) + article_substitut: Optional[str] = Field( + None, description="Référence article de substitution (AR_Substitut)" + ) + + est_actif: bool = Field( + default=True, description="Article actif (AR_Sommeil = 0)" + ) + en_sommeil: bool = Field( + default=False, description="Article en sommeil (AR_Sommeil = 1)" + ) + soumis_escompte: Optional[bool] = Field( + None, description="Soumis à escompte (AR_Escompte)" + ) + publie: Optional[bool] = Field( + None, description="Publié sur web/catalogue (AR_Publie)" + ) + hors_statistique: Optional[bool] = Field( + None, description="Exclus des statistiques (AR_HorsStat)" + ) + vente_debit: Optional[bool] = Field( + None, description="Vente au débit (AR_VteDebit)" + ) + non_imprimable: Optional[bool] = Field( + None, description="Non imprimable sur documents (AR_NotImp)" + ) + fictif: Optional[bool] = Field( + None, description="Article fictif (AR_Fictif)" + ) + sous_traitance: Optional[bool] = Field( + None, description="Article en sous-traitance (AR_SousTraitance)" + ) + criticite: Optional[int] = Field( + None, ge=0, description="Niveau de criticité (AR_Criticite)" + ) + + code_fiscal: Optional[str] = Field( + None, max_length=10, description="Code fiscal/TVA (AR_CodeFiscal)" + ) + tva_code: Optional[str] = Field( + None, description="Code TVA (F_TAXE.TA_Code)" + ) + tva_taux: Optional[float] = Field( + None, ge=0, le=100, description="Taux de TVA en % (F_TAXE.TA_Taux)" + ) + + stat_01: Optional[str] = Field(None, description="Statistique 1 (AR_Stat01)") + stat_02: Optional[str] = Field(None, description="Statistique 2 (AR_Stat02)") + stat_03: Optional[str] = Field(None, description="Statistique 3 (AR_Stat03)") + stat_04: Optional[str] = Field(None, description="Statistique 4 (AR_Stat04)") + stat_05: Optional[str] = Field(None, description="Statistique 5 (AR_Stat05)") + + categorie_1: Optional[int] = Field( + None, description="Catégorie comptable 1 (CL_No1)" + ) + categorie_2: Optional[int] = Field( + None, description="Catégorie comptable 2 (CL_No2)" + ) + categorie_3: Optional[int] = Field( + None, description="Catégorie comptable 3 (CL_No3)" + ) + categorie_4: Optional[int] = Field( + None, description="Catégorie comptable 4 (CL_No4)" + ) + + date_creation: Optional[str] = Field( + None, description="Date de création (AR_DateCre)" ) - - unite_vente: Optional[str] = Field(None, description="Unité de vente") - unite_achat: Optional[str] = Field(None, description="Unité d'achat") - - poids: Optional[float] = Field(None, description="Poids (kg)") - volume: Optional[float] = Field(None, description="Volume (m³)") - - est_actif: bool = Field(True, description="Article actif") - en_sommeil: bool = Field(False, description="Article en sommeil") - - tva_code: Optional[str] = Field(None, description="Code TVA") - tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)") - - date_creation: Optional[str] = Field(None, description="Date de création") date_modification: Optional[str] = Field( - None, description="Date de dernière modification" + None, description="Date de dernière modification (AR_DateModif)" ) - + date_sommeil: Optional[str] = Field( + None, description="Date de mise en sommeil (AR_DateSommeil)" + ) + + class Config: + json_schema_extra = { + "example": { + "reference": "ART-001", + "designation": "Ordinateur portable 15 pouces", + "designation_complementaire": "Intel i7, 16GB RAM, 512GB SSD", + "code_ean": "3760123456789", + "prix_vente": 899.00, + "prix_achat": 650.00, + "prix_revient": 675.50, + "stock_reel": 25.0, + "stock_mini": 5.0, + "stock_maxi": 50.0, + "stock_disponible": 22.0, + "unite_vente": "PCE", + "poids": 2.1, + "type_article": 0, + "type_article_libelle": "Article", + "famille_code": "INFO", + "famille_libelle": "Informatique", + "fournisseur_principal": 101, + "fournisseur_nom": "TechSupply SAS", + "est_actif": True, + "en_sommeil": False, + "tva_code": "T20", + "tva_taux": 20.0, + "date_creation": "2023-01-15", + "date_modification": "2024-11-20" + } + } + + +class ArticleListResponse(BaseModel): + """Réponse pour une liste d'articles""" + + total: int = Field(..., description="Nombre total d'articles") + articles: list[ArticleResponse] = Field(..., description="Liste des articles") + filtre_applique: Optional[str] = Field( + None, description="Filtre de recherche appliqué" + ) + avec_stock: bool = Field( + True, description="Indique si les stocks ont été chargés" + ) + avec_famille: bool = Field( + True, description="Indique si les familles ont été enrichies" + ) + + class Config: + json_schema_extra = { + "example": { + "total": 1250, + "filtre_applique": "ordinateur", + "avec_stock": True, + "avec_famille": True, + "articles": [ + # Exemple d'article (voir ArticleResponse) + ] + } + } + class LigneDevis(BaseModel): article_code: str @@ -1765,7 +1968,6 @@ class FamilleCreateRequest(BaseModel): class FamilleResponse(BaseModel): """Modèle de réponse pour une famille d'articles - aligné sur lister_toutes_familles""" - # === Identification === code: str = Field(..., description="Code famille") intitule: str = Field(..., description="Intitulé") type: int = Field(..., description="Type (0=Détail, 1=Total)") @@ -1773,35 +1975,29 @@ class FamilleResponse(BaseModel): est_total: bool = Field(..., description="True si type Total") est_detail: Optional[bool] = Field(None, description="True si type Détail") - # === Vente et unités === unite_vente: Optional[str] = Field(None, description="Unité de vente") unite_poids: Optional[str] = Field(None, description="Unité de poids") coef: Optional[float] = Field(None, description="Coefficient") - # === Stock et logistique === suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé") garantie: Optional[int] = Field(None, description="Durée de garantie (mois)") delai: Optional[int] = Field(None, description="Délai de livraison (jours)") nb_colis: Optional[int] = Field(None, description="Nombre de colis") - # === Comptabilité === compte_achat: Optional[str] = Field(None, description="Compte général achat") compte_vente: Optional[str] = Field(None, description="Compte général vente") code_fiscal: Optional[str] = Field(None, description="Code fiscal") escompte: Optional[bool] = Field(None, description="Escompte autorisé") - # === Organisation === est_centrale: Optional[bool] = Field(None, description="Famille centrale") nature: Optional[int] = Field(None, description="Nature de la famille") pays: Optional[str] = Field(None, description="Pays d'origine") - # === Classifications === categorie_1: Optional[int] = Field(None, description="Catégorie 1") categorie_2: Optional[int] = Field(None, description="Catégorie 2") categorie_3: Optional[int] = Field(None, description="Catégorie 3") categorie_4: Optional[int] = Field(None, description="Catégorie 4") - # === Statistiques === stat_01: Optional[str] = Field(None, description="Statistique 1") stat_02: Optional[str] = Field(None, description="Statistique 2") stat_03: Optional[str] = Field(None, description="Statistique 3") @@ -1810,7 +2006,6 @@ class FamilleResponse(BaseModel): hors_statistique: Optional[bool] = Field(None, description="Exclue des statistiques") est_statistique: Optional[bool] = Field(None, description="Incluse dans les statistiques (legacy)") - # === Paramètres commerciaux === vente_debit: Optional[bool] = Field(None, description="Vente au débit") non_imprimable: Optional[bool] = Field(None, description="Non imprimable") contremarque: Optional[bool] = Field(None, description="Contremarque") @@ -1818,26 +2013,20 @@ class FamilleResponse(BaseModel): fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire") publie: Optional[bool] = Field(None, description="Publié") - # === Références === racine_reference: Optional[str] = Field(None, description="Racine référence article") racine_code_barre: Optional[str] = Field(None, description="Racine code-barres") raccourci: Optional[str] = Field(None, description="Raccourci clavier") - # === Gestion === sous_traitance: Optional[bool] = Field(None, description="Sous-traitance") fictif: Optional[bool] = Field(None, description="Famille fictive") criticite: Optional[int] = Field(None, description="Niveau de criticité") - # === Métadonnées (spécifiques à lire_famille) === avertissement: Optional[str] = Field(None, description="Avertissement si famille Total") index_com: Optional[int] = Field(None, description="Index COM (lire_famille uniquement)") date_creation: Optional[str] = Field(None, description="Date de création") date_modification: Optional[str] = Field(None, description="Date de modification") nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille") - - # === Champs bruts SQL (optionnels) === - # Permet de conserver tous les champs SQL si besoin - champs_sql: Optional[Dict[str, Any]] = Field(None, description="Champs SQL bruts (si demandés)") + class Config: json_schema_extra = { @@ -5387,4 +5576,4 @@ if __name__ == "__main__": host=settings.api_host, port=settings.api_port, reload=settings.api_reload, - ) + ) \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py index 579c644..829aa19 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -15,30 +15,25 @@ from database.models import ( AuditLog, StatutEmail, StatutSignature, - # Nouveaux modèles auth User, RefreshToken, LoginAttempt, ) __all__ = [ - # Config "engine", "async_session_factory", "init_db", "get_session", "close_db", - # Models existants "Base", "EmailLog", "SignatureLog", "WorkflowLog", "CacheMetadata", "AuditLog", - # Enums "StatutEmail", "StatutSignature", - # Modèles auth "User", "RefreshToken", "LoginAttempt", diff --git a/database/models.py b/database/models.py index da8c7b2..77c1b30 100644 --- a/database/models.py +++ b/database/models.py @@ -14,9 +14,6 @@ import enum Base = declarative_base() -# ============================================================================ -# Enums -# ============================================================================ class StatutEmail(str, enum.Enum): @@ -40,9 +37,6 @@ class StatutSignature(str, enum.Enum): EXPIRE = "EXPIRE" -# ============================================================================ -# Tables -# ============================================================================ class EmailLog(Base): @@ -53,36 +47,28 @@ class EmailLog(Base): __tablename__ = "email_logs" - # Identifiant id = Column(String(36), primary_key=True) - # Destinataires destinataire = Column(String(255), nullable=False, index=True) cc = Column(Text, nullable=True) # JSON stringifié cci = Column(Text, nullable=True) # JSON stringifié - # Contenu sujet = Column(String(500), nullable=False) corps_html = Column(Text, nullable=False) - # Documents attachés document_ids = Column(Text, nullable=True) # Séparés par virgules type_document = Column(Integer, nullable=True) - # Statut statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) - # Tracking temporel date_creation = Column(DateTime, default=datetime.now, nullable=False) date_envoi = Column(DateTime, nullable=True) date_ouverture = Column(DateTime, nullable=True) - # Retry automatique nb_tentatives = Column(Integer, default=0) derniere_erreur = Column(Text, nullable=True) prochain_retry = Column(DateTime, nullable=True) - # Métadonnées ip_envoi = Column(String(45), nullable=True) user_agent = Column(String(500), nullable=True) @@ -98,22 +84,17 @@ class SignatureLog(Base): __tablename__ = "signature_logs" - # Identifiant id = Column(String(36), primary_key=True) - # Document Sage associé document_id = Column(String(100), nullable=False, index=True) type_document = Column(Integer, nullable=False) - # Universign transaction_id = Column(String(100), unique=True, index=True, nullable=True) signer_url = Column(String(500), nullable=True) - # Signataire email_signataire = Column(String(255), nullable=False, index=True) nom_signataire = Column(String(255), nullable=False) - # Statut statut = Column( SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True ) @@ -121,12 +102,10 @@ class SignatureLog(Base): date_signature = Column(DateTime, nullable=True) date_refus = Column(DateTime, nullable=True) - # Relances est_relance = Column(Boolean, default=False) nb_relances = Column(Integer, default=0) derniere_relance = Column(DateTime, nullable=True) - # Métadonnées raison_refus = Column(Text, nullable=True) ip_signature = Column(String(45), nullable=True) @@ -142,26 +121,21 @@ class WorkflowLog(Base): __tablename__ = "workflow_logs" - # Identifiant id = Column(String(36), primary_key=True) - # Documents document_source = Column(String(100), nullable=False, index=True) type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc. document_cible = Column(String(100), nullable=False, index=True) type_cible = Column(Integer, nullable=False) - # Métadonnées de transformation nb_lignes = Column(Integer, nullable=True) montant_ht = Column(Float, nullable=True) montant_ttc = Column(Float, nullable=True) - # Tracking date_transformation = Column(DateTime, default=datetime.now, nullable=False) utilisateur = Column(String(100), nullable=True) - # Résultat succes = Column(Boolean, default=True) erreur = Column(Text, nullable=True) duree_ms = Column(Integer, nullable=True) # Durée en millisecondes @@ -180,17 +154,14 @@ class CacheMetadata(Base): id = Column(Integer, primary_key=True, autoincrement=True) - # Type de cache cache_type = Column( String(50), unique=True, nullable=False ) # 'clients' ou 'articles' - # Statistiques last_refresh = Column(DateTime, default=datetime.now) item_count = Column(Integer, default=0) refresh_duration_ms = Column(Float, nullable=True) - # Santé last_error = Column(Text, nullable=True) error_count = Column(Integer, default=0) @@ -208,30 +179,25 @@ class AuditLog(Base): id = Column(Integer, primary_key=True, autoincrement=True) - # Action action = Column( String(100), nullable=False, index=True ) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. ressource_type = Column(String(50), nullable=True) # 'devis', 'facture', etc. ressource_id = Column(String(100), nullable=True, index=True) - # Utilisateur (si authentification ajoutée plus tard) utilisateur = Column(String(100), nullable=True) ip_address = Column(String(45), nullable=True) - # Résultat succes = Column(Boolean, default=True) details = Column(Text, nullable=True) # JSON stringifié erreur = Column(Text, nullable=True) - # Timestamp date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) def __repr__(self): return f"" -# Ajouter ces modèles à la fin de database/models.py class User(Base): @@ -245,26 +211,21 @@ class User(Base): email = Column(String(255), unique=True, nullable=False, index=True) hashed_password = Column(String(255), nullable=False) - # Profil nom = Column(String(100), nullable=False) prenom = Column(String(100), nullable=False) role = Column(String(50), default="user") # user, admin, commercial - # Validation email is_verified = Column(Boolean, default=False) verification_token = Column(String(255), nullable=True, unique=True, index=True) verification_token_expires = Column(DateTime, nullable=True) - # Sécurité is_active = Column(Boolean, default=True) failed_login_attempts = Column(Integer, default=0) locked_until = Column(DateTime, nullable=True) - # Mot de passe oublié reset_token = Column(String(255), nullable=True, unique=True, index=True) reset_token_expires = Column(DateTime, nullable=True) - # Timestamps created_at = Column(DateTime, default=datetime.now, nullable=False) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) last_login = Column(DateTime, nullable=True) @@ -284,15 +245,12 @@ class RefreshToken(Base): user_id = Column(String(36), nullable=False, index=True) token_hash = Column(String(255), nullable=False, unique=True, index=True) - # Métadonnées device_info = Column(String(500), nullable=True) ip_address = Column(String(45), nullable=True) - # Expiration expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False) - # Révocation is_revoked = Column(Boolean, default=False) revoked_at = Column(DateTime, nullable=True) diff --git a/email_queue.py b/email_queue.py index 05bd286..344f986 100644 --- a/email_queue.py +++ b/email_queue.py @@ -52,7 +52,6 @@ class EmailQueue: logger.info("🛑 Arrêt de la queue email...") self.running = False - # Attendre que la queue soit vide (max 30s) try: self.queue.join() logger.info("✅ Queue email arrêtée proprement") @@ -66,20 +65,16 @@ class EmailQueue: def _worker(self): """Worker qui traite les emails dans un thread""" - # Créer une event loop pour ce thread loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: while self.running: try: - # Récupérer un email de la queue (timeout 1s) email_log_id = self.queue.get(timeout=1) - # Traiter l'email loop.run_until_complete(self._process_email(email_log_id)) - # Marquer comme traité self.queue.task_done() except queue.Empty: @@ -103,7 +98,6 @@ class EmailQueue: return async with self.session_factory() as session: - # Charger l'email log result = await session.execute( select(EmailLog).where(EmailLog.id == email_log_id) ) @@ -113,34 +107,28 @@ class EmailQueue: logger.error(f"❌ Email log {email_log_id} introuvable") return - # Marquer comme en cours email_log.statut = StatutEmail.EN_COURS email_log.nb_tentatives += 1 await session.commit() try: - # Envoi avec retry automatique await self._send_with_retry(email_log) - # Succès email_log.statut = StatutEmail.ENVOYE email_log.date_envoi = datetime.now() email_log.derniere_erreur = None logger.info(f"✅ Email envoyé: {email_log.destinataire}") except Exception as e: - # Échec email_log.statut = StatutEmail.ERREUR email_log.derniere_erreur = str(e)[:1000] # Limiter la taille - # Programmer un retry si < max attempts if email_log.nb_tentatives < settings.max_retry_attempts: delay = settings.retry_delay_seconds * ( 2 ** (email_log.nb_tentatives - 1) ) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) - # Programmer le retry timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer.daemon = True timer.start() @@ -158,16 +146,13 @@ class EmailQueue: ) async def _send_with_retry(self, email_log): """Envoi SMTP avec retry Tenacity + génération PDF""" - # Préparer le message msg = MIMEMultipart() msg["From"] = settings.smtp_from msg["To"] = email_log.destinataire msg["Subject"] = email_log.sujet - # Corps HTML msg.attach(MIMEText(email_log.corps_html, "html")) - # 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs if email_log.document_ids: document_ids = email_log.document_ids.split(",") type_doc = email_log.type_document @@ -178,13 +163,11 @@ class EmailQueue: continue try: - # Générer PDF (appel bloquant dans thread séparé) pdf_bytes = await asyncio.to_thread( self._generate_pdf, doc_id, type_doc ) if pdf_bytes: - # Attacher PDF part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf") part["Content-Disposition"] = ( f'attachment; filename="{doc_id}.pdf"' @@ -194,9 +177,7 @@ class EmailQueue: except Exception as e: logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") - # Continuer avec les autres PDFs - # Envoi SMTP (bloquant mais dans thread séparé) await asyncio.to_thread(self._send_smtp, msg) def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: @@ -205,7 +186,6 @@ class EmailQueue: logger.error("❌ sage_client non configuré") raise Exception("sage_client non disponible") - # 📡 Récupérer document depuis gateway Windows via HTTP try: doc = self.sage_client.lire_document(doc_id, type_doc) except Exception as e: @@ -215,16 +195,13 @@ class EmailQueue: if not doc: raise Exception(f"Document {doc_id} introuvable") - # 📄 Créer PDF avec ReportLab buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 - # === EN-TÊTE === pdf.setFont("Helvetica-Bold", 20) pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}") - # Type de document type_labels = { 0: "DEVIS", 1: "BON DE LIVRAISON", @@ -238,7 +215,6 @@ class EmailQueue: pdf.setFont("Helvetica", 12) pdf.drawString(2 * cm, height - 4 * cm, f"Type: {type_label}") - # === INFORMATIONS CLIENT === y = height - 5 * cm pdf.setFont("Helvetica-Bold", 14) pdf.drawString(2 * cm, y, "CLIENT") @@ -251,7 +227,6 @@ class EmailQueue: y -= 0.6 * cm pdf.drawString(2 * cm, y, f"Date: {doc.get('date', '')}") - # === LIGNES === y -= 1.5 * cm pdf.setFont("Helvetica-Bold", 14) pdf.drawString(2 * cm, y, "ARTICLES") @@ -270,7 +245,6 @@ class EmailQueue: pdf.setFont("Helvetica", 9) for ligne in doc.get("lignes", []): - # Nouvelle page si nécessaire if y < 3 * cm: pdf.showPage() y = height - 3 * cm @@ -283,7 +257,6 @@ class EmailQueue: pdf.drawString(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}€") y -= 0.6 * cm - # === TOTAUX === y -= 1 * cm pdf.line(12 * cm, y, width - 2 * cm, y) @@ -302,14 +275,12 @@ class EmailQueue: pdf.drawString(12 * cm, y, "Total TTC:") pdf.drawString(15 * cm, y, f"{doc.get('total_ttc', 0):.2f}€") - # === PIED DE PAGE === pdf.setFont("Helvetica", 8) pdf.drawString( 2 * cm, 2 * cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}" ) pdf.drawString(2 * cm, 1.5 * cm, "Sage 100c - API Dataven") - # Finaliser pdf.save() buffer.seek(0) @@ -336,5 +307,4 @@ class EmailQueue: raise Exception(f"Erreur envoi: {str(e)}") -# Instance globale email_queue = EmailQueue() diff --git a/routes/auth.py b/routes/auth.py index 3d682e0..54406c1 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -27,7 +27,6 @@ import logging logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["Authentication"]) -# === MODÈLES PYDANTIC === class RegisterRequest(BaseModel): @@ -70,7 +69,6 @@ class ResendVerificationRequest(BaseModel): email: EmailStr -# === UTILITAIRES === async def log_login_attempt( @@ -103,7 +101,6 @@ async def check_rate_limit( Returns: (is_allowed, error_message) """ - # Vérifier les tentatives échouées des 15 dernières minutes time_window = datetime.now() - timedelta(minutes=15) result = await session.execute( @@ -121,7 +118,6 @@ async def check_rate_limit( return True, "" -# === ENDPOINTS === @router.post("/register", status_code=status.HTTP_201_CREATED) @@ -137,7 +133,6 @@ async def register( - Crée le compte (non vérifié) - Envoie email de vérification """ - # Vérifier si l'email existe déjà result = await session.execute(select(User).where(User.email == data.email)) existing_user = result.scalar_one_or_none() @@ -146,15 +141,12 @@ async def register( status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé" ) - # Valider le mot de passe is_valid, error_msg = validate_password_strength(data.password) if not is_valid: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) - # Générer token de vérification verification_token = generate_verification_token() - # Créer l'utilisateur new_user = User( id=str(uuid.uuid4()), email=data.email.lower(), @@ -170,7 +162,6 @@ async def register( session.add(new_user) await session.commit() - # Envoyer email de vérification base_url = str(request.base_url).rstrip("/") email_sent = AuthEmailService.send_verification_email( data.email, verification_token, base_url @@ -204,7 +195,6 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi "message": "Token de vérification invalide ou déjà utilisé.", } - # Vérifier l'expiration if user.verification_token_expires < datetime.now(): return { "success": False, @@ -212,7 +202,6 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi "expired": True, } - # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None @@ -246,14 +235,12 @@ async def verify_email_post( detail="Token de vérification invalide", ) - # Vérifier l'expiration if user.verification_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Token expiré. Demandez un nouvel email de vérification.", ) - # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None @@ -280,7 +267,6 @@ async def resend_verification( user = result.scalar_one_or_none() if not user: - # Ne pas révéler si l'utilisateur existe return { "success": True, "message": "Si cet email existe, un nouveau lien de vérification a été envoyé.", @@ -291,13 +277,11 @@ async def resend_verification( status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié" ) - # Générer nouveau token verification_token = generate_verification_token() user.verification_token = verification_token user.verification_token_expires = datetime.now() + timedelta(hours=24) await session.commit() - # Envoyer email base_url = str(request.base_url).rstrip("/") AuthEmailService.send_verification_email(user.email, verification_token, base_url) @@ -316,18 +300,15 @@ async def login( ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") - # Rate limiting is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) if not is_allowed: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg ) - # Charger l'utilisateur result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - # Vérifications if not user or not verify_password(data.password, user.hashed_password): await log_login_attempt( session, @@ -338,11 +319,9 @@ async def login( "Identifiants incorrects", ) - # Incrémenter compteur échecs if user: user.failed_login_attempts += 1 - # Verrouiller après 5 échecs if user.failed_login_attempts >= 5: user.locked_until = datetime.now() + timedelta(minutes=15) await session.commit() @@ -358,7 +337,6 @@ async def login( detail="Email ou mot de passe incorrect", ) - # Vérifier statut compte if not user.is_active: await log_login_attempt( session, data.email.lower(), ip, user_agent, False, "Compte désactivé" @@ -376,7 +354,6 @@ async def login( detail="Email non vérifié. Consultez votre boîte de réception.", ) - # Vérifier verrouillage if user.locked_until and user.locked_until > datetime.now(): await log_login_attempt( session, data.email.lower(), ip, user_agent, False, "Compte verrouillé" @@ -386,20 +363,16 @@ async def login( detail="Compte temporairement verrouillé", ) - # ✅ CONNEXION RÉUSSIE - # Réinitialiser compteur échecs user.failed_login_attempts = 0 user.locked_until = None user.last_login = datetime.now() - # Créer tokens access_token = create_access_token( {"sub": user.id, "email": user.email, "role": user.role} ) refresh_token_jwt = create_refresh_token(user.id) - # Stocker refresh token en DB (hashé) refresh_token_record = RefreshToken( id=str(uuid.uuid4()), user_id=user.id, @@ -413,7 +386,6 @@ async def login( session.add(refresh_token_record) await session.commit() - # Logger succès await log_login_attempt(session, data.email.lower(), ip, user_agent, True) logger.info(f"✅ Connexion réussie: {user.email}") @@ -432,7 +404,6 @@ async def refresh_access_token( """ 🔄 Renouvellement du access_token via refresh_token """ - # Décoder le refresh token payload = decode_token(data.refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException( @@ -442,7 +413,6 @@ async def refresh_access_token( user_id = payload.get("sub") token_hash = hash_token(data.refresh_token) - # Vérifier en DB result = await session.execute( select(RefreshToken).where( RefreshToken.user_id == user_id, @@ -458,13 +428,11 @@ async def refresh_access_token( detail="Refresh token révoqué ou introuvable", ) - # Vérifier expiration if token_record.expires_at < datetime.now(): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré" ) - # Charger utilisateur result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() @@ -474,7 +442,6 @@ async def refresh_access_token( detail="Utilisateur introuvable ou désactivé", ) - # Générer nouveau access token new_access_token = create_access_token( {"sub": user.id, "email": user.email, "role": user.role} ) @@ -500,20 +467,17 @@ async def forgot_password( result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - # Ne pas révéler si l'utilisateur existe if not user: return { "success": True, "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } - # Générer token de reset reset_token = generate_reset_token() user.reset_token = reset_token user.reset_token_expires = datetime.now() + timedelta(hours=1) await session.commit() - # Envoyer email frontend_url = ( settings.frontend_url if hasattr(settings, "frontend_url") @@ -545,19 +509,16 @@ async def reset_password( detail="Token de réinitialisation invalide", ) - # Vérifier expiration if user.reset_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Token expiré. Demandez un nouveau lien de réinitialisation.", ) - # Valider nouveau mot de passe is_valid, error_msg = validate_password_strength(data.new_password) if not is_valid: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) - # Mettre à jour user.hashed_password = hash_password(data.new_password) user.reset_token = None user.reset_token_expires = None @@ -565,7 +526,6 @@ async def reset_password( user.locked_until = None await session.commit() - # Envoyer notification AuthEmailService.send_password_changed_notification(user.email) logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") diff --git a/security/auth.py b/security/auth.py index 7fc182c..05b8d8a 100644 --- a/security/auth.py +++ b/security/auth.py @@ -5,7 +5,6 @@ import jwt import secrets import hashlib -# Configuration SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" # À remplacer par settings.jwt_secret ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 @@ -14,7 +13,6 @@ REFRESH_TOKEN_EXPIRE_DAYS = 7 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -# === Hachage de mots de passe === def hash_password(password: str) -> str: """Hash un mot de passe avec bcrypt""" return pwd_context.hash(password) @@ -25,7 +23,6 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) -# === Génération de tokens aléatoires === def generate_verification_token() -> str: """Génère un token de vérification email sécurisé""" return secrets.token_urlsafe(32) @@ -41,7 +38,6 @@ def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() -# === JWT Access Token === def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: """ Crée un JWT access token @@ -100,7 +96,6 @@ def decode_token(token: str) -> Optional[Dict]: return None -# === Validation mot de passe === def validate_password_strength(password: str) -> tuple[bool, str]: """ Valide la robustesse d'un mot de passe From a4f5274663ace070c527483e05e07651224bee2d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 27 Dec 2025 06:12:10 +0300 Subject: [PATCH 117/199] refactor(api): align ArticleResponse model with Sage database structure --- api.py | 292 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 230 insertions(+), 62 deletions(-) diff --git a/api.py b/api.py index 6995bb5..602361c 100644 --- a/api.py +++ b/api.py @@ -528,16 +528,9 @@ class FournisseurDetails(BaseModel): class ArticleResponse(BaseModel): - """Modèle de réponse pour un article avec toutes ses informations""" - + reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") - designation_complementaire: Optional[str] = Field( - None, description="Désignation complémentaire (AR_Design2)" - ) - description: Optional[str] = Field( - None, description="Description détaillée / Commentaire (AR_Commentaire, AR_Info)" - ) code_ean: Optional[str] = Field( None, description="Code EAN / Code-barres principal (AR_CodeBarre)" @@ -545,12 +538,12 @@ class ArticleResponse(BaseModel): code_barre: Optional[str] = Field( None, description="Code-barres (alias de code_ean)" ) + edi_code: Optional[str] = Field( + None, description="Code EDI (AR_EdiCode)" + ) raccourci: Optional[str] = Field( None, description="Code raccourci (AR_Raccourci)" ) - racine: Optional[str] = Field( - None, description="Racine de référence (AR_Racine)" - ) prix_vente: float = Field( ..., ge=0, description="Prix de vente HT unitaire (AR_PrixVen)" @@ -558,15 +551,29 @@ class ArticleResponse(BaseModel): prix_achat: Optional[float] = Field( None, ge=0, description="Prix d'achat HT (AR_PrixAch)" ) - prix_revient: Optional[float] = Field( - None, ge=0, description="Prix de revient (AR_PrixRev)" - ) - prix_ttc: Optional[float] = Field( - None, ge=0, description="Prix de vente TTC (AR_PrixTTC)" - ) coef: Optional[float] = Field( None, ge=0, description="Coefficient multiplicateur (AR_Coef)" ) + prix_net: Optional[float] = Field( + None, ge=0, description="Prix unitaire net (AR_PUNet)" + ) + + prix_achat_nouveau: Optional[float] = Field( + None, ge=0, description="Nouveau prix d'achat à venir (AR_PrixAchNouv)" + ) + coef_nouveau: Optional[float] = Field( + None, ge=0, description="Nouveau coefficient à venir (AR_CoefNouv)" + ) + prix_vente_nouveau: Optional[float] = Field( + None, ge=0, description="Nouveau prix de vente à venir (AR_PrixVenNouv)" + ) + date_application_prix: Optional[str] = Field( + None, description="Date d'application des nouveaux prix (AR_DateApplication)" + ) + + cout_standard: Optional[float] = Field( + None, ge=0, description="Coût standard (AR_CoutStd)" + ) stock_reel: float = Field( default=0.0, description="Stock réel total (F_ARTSTOCK.AS_QteSto)" @@ -589,32 +596,42 @@ class ArticleResponse(BaseModel): suivi_stock: Optional[bool] = Field( None, description="Suivi de stock activé (AR_SuiviStock)" ) + nomenclature: Optional[bool] = Field( + None, description="Article avec nomenclature (AR_Nomencl)" + ) + qte_composant: Optional[float] = Field( + None, description="Quantité de composant (AR_QteComp)" + ) + qte_operatoire: Optional[float] = Field( + None, description="Quantité opératoire (AR_QteOperatoire)" + ) unite_vente: Optional[str] = Field( None, max_length=10, description="Unité de vente (AR_UniteVen)" ) - unite_achat: Optional[str] = Field( - None, max_length=10, description="Unité d'achat/stock (AR_Unite)" - ) unite_poids: Optional[str] = Field( None, max_length=10, description="Unité de poids (AR_UnitePoids)" ) - poids: Optional[float] = Field( - None, ge=0, description="Poids net unitaire (AR_Poids, AR_PoidsNet)" + poids_net: Optional[float] = Field( + None, ge=0, description="Poids net unitaire en kg (AR_PoidsNet)" ) poids_brut: Optional[float] = Field( - None, ge=0, description="Poids brut unitaire (AR_PoidsBrut)" + None, ge=0, description="Poids brut unitaire en kg (AR_PoidsBrut)" ) - volume: Optional[float] = Field( - None, ge=0, description="Volume unitaire en m³ (AR_Volume)" + + gamme_1: Optional[str] = Field( + None, description="Énumération gamme 1 (AR_Gamme1)" + ) + gamme_2: Optional[str] = Field( + None, description="Énumération gamme 2 (AR_Gamme2)" ) type_article: Optional[int] = Field( None, ge=0, le=3, - description="Type d'article : 0=Article, 1=Prestation, 2=Divers, 3=Nomenclature (AR_Type)" + description="Type : 0=Article, 1=Prestation, 2=Divers, 3=Nomenclature (AR_Type)" ) type_article_libelle: Optional[str] = Field( None, description="Libellé du type d'article" @@ -630,19 +647,43 @@ class ArticleResponse(BaseModel): None, description="Type de famille : 0=Détail, 1=Total (F_FAMILLE.FA_Type)" ) famille_unite_vente: Optional[str] = Field( - None, description="Unité de vente héritée de la famille (F_FAMILLE.FA_UniteVen)" + None, description="Unité de vente de la famille (F_FAMILLE.FA_UniteVen)" ) famille_coef: Optional[float] = Field( - None, description="Coefficient hérité de la famille (F_FAMILLE.FA_Coef)" + None, description="Coefficient de la famille (F_FAMILLE.FA_Coef)" ) famille_suivi_stock: Optional[bool] = Field( - None, description="Suivi stock hérité de la famille (F_FAMILLE.FA_SuiviStock)" + None, description="Suivi stock de la famille (F_FAMILLE.FA_SuiviStock)" ) - famille_compte_vente: Optional[str] = Field( - None, description="Compte comptable de vente de la famille (F_FAMILLE.CG_NumVte)" + famille_garantie: Optional[int] = Field( + None, description="Garantie de la famille (F_FAMILLE.FA_Garantie)" ) - famille_compte_achat: Optional[str] = Field( - None, description="Compte comptable d'achat de la famille (F_FAMILLE.CG_NumAch)" + famille_unite_poids: Optional[str] = Field( + None, description="Unité de poids de la famille (F_FAMILLE.FA_UnitePoids)" + ) + famille_delai: Optional[int] = Field( + None, description="Délai de la famille (F_FAMILLE.FA_Delai)" + ) + famille_nb_colis: Optional[int] = Field( + None, description="Nombre de colis de la famille (F_FAMILLE.FA_NbColis)" + ) + famille_code_fiscal: Optional[str] = Field( + None, description="Code fiscal de la famille (F_FAMILLE.FA_CodeFiscal)" + ) + famille_escompte: Optional[bool] = Field( + None, description="Escompte de la famille (F_FAMILLE.FA_Escompte)" + ) + famille_centrale: Optional[bool] = Field( + None, description="Famille centrale (F_FAMILLE.FA_Central)" + ) + famille_nature: Optional[int] = Field( + None, description="Nature de la famille (F_FAMILLE.FA_Nature)" + ) + famille_hors_stat: Optional[bool] = Field( + None, description="Hors statistique famille (F_FAMILLE.FA_HorsStat)" + ) + famille_pays: Optional[str] = Field( + None, description="Pays de la famille (F_FAMILLE.FA_Pays)" ) nature: Optional[int] = Field( @@ -651,9 +692,15 @@ class ArticleResponse(BaseModel): garantie: Optional[int] = Field( None, ge=0, description="Durée de garantie en mois (AR_Garantie)" ) + code_fiscal: Optional[str] = Field( + None, max_length=10, description="Code fiscal/TVA (AR_CodeFiscal)" + ) + pays: Optional[str] = Field( + None, description="Pays d'origine (AR_Pays)" + ) fournisseur_principal: Optional[int] = Field( - None, description="N° compte du fournisseur principal (CO_No)" + None, description="N° compte du fournisseur principal (CO_No → F_COMPTET.CT_Num)" ) fournisseur_nom: Optional[str] = Field( None, description="Nom du fournisseur principal (F_COMPTET.CT_Intitule)" @@ -664,8 +711,8 @@ class ArticleResponse(BaseModel): nb_colis: Optional[int] = Field( None, ge=0, description="Nombre de colis par unité (AR_NbColis)" ) - article_substitut: Optional[str] = Field( - None, description="Référence article de substitution (AR_Substitut)" + prevision: Optional[bool] = Field( + None, description="Gestion en prévision (AR_Prevision)" ) est_actif: bool = Field( @@ -674,9 +721,16 @@ class ArticleResponse(BaseModel): en_sommeil: bool = Field( default=False, description="Article en sommeil (AR_Sommeil = 1)" ) + article_substitut: Optional[str] = Field( + None, description="Référence article de substitution (AR_Substitut)" + ) soumis_escompte: Optional[bool] = Field( None, description="Soumis à escompte (AR_Escompte)" ) + delai: Optional[int] = Field( + None, description="Délai de livraison en jours (AR_Delai)" + ) + publie: Optional[bool] = Field( None, description="Publié sur web/catalogue (AR_Publie)" ) @@ -689,6 +743,21 @@ class ArticleResponse(BaseModel): non_imprimable: Optional[bool] = Field( None, description="Non imprimable sur documents (AR_NotImp)" ) + transfere: Optional[bool] = Field( + None, description="Article transféré (AR_Transfere)" + ) + contremarque: Optional[bool] = Field( + None, description="Article en contremarque (AR_Contremarque)" + ) + fact_poids: Optional[bool] = Field( + None, description="Facturation au poids (AR_FactPoids)" + ) + fact_forfait: Optional[bool] = Field( + None, description="Facturation au forfait (AR_FactForfait)" + ) + saisie_variable: Optional[bool] = Field( + None, description="Saisie variable (AR_SaisieVar)" + ) fictif: Optional[bool] = Field( None, description="Article fictif (AR_Fictif)" ) @@ -699,9 +768,45 @@ class ArticleResponse(BaseModel): None, ge=0, description="Niveau de criticité (AR_Criticite)" ) - code_fiscal: Optional[str] = Field( - None, max_length=10, description="Code fiscal/TVA (AR_CodeFiscal)" + reprise_code_defaut: Optional[str] = Field( + None, description="Code reprise par défaut (RP_CodeDefaut)" ) + delai_fabrication: Optional[int] = Field( + None, description="Délai de fabrication (AR_DelaiFabrication)" + ) + delai_peremption: Optional[int] = Field( + None, description="Délai de péremption (AR_DelaiPeremption)" + ) + delai_securite: Optional[int] = Field( + None, description="Délai de sécurité (AR_DelaiSecurite)" + ) + type_lancement: Optional[int] = Field( + None, description="Type de lancement production (AR_TypeLancement)" + ) + cycle: Optional[int] = Field( + None, description="Cycle de production (AR_Cycle)" + ) + + photo: Optional[str] = Field( + None, description="Chemin/nom du fichier photo (AR_Photo)" + ) + langue_1: Optional[str] = Field( + None, description="Texte en langue 1 (AR_Langue1)" + ) + langue_2: Optional[str] = Field( + None, description="Texte en langue 2 (AR_Langue2)" + ) + + frais_01_denomination: Optional[str] = Field( + None, description="Dénomination frais 1 (AR_Frais01FR_Denomination)" + ) + frais_02_denomination: Optional[str] = Field( + None, description="Dénomination frais 2 (AR_Frais02FR_Denomination)" + ) + frais_03_denomination: Optional[str] = Field( + None, description="Dénomination frais 3 (AR_Frais03FR_Denomination)" + ) + tva_code: Optional[str] = Field( None, description="Code TVA (F_TAXE.TA_Code)" ) @@ -728,47 +833,111 @@ class ArticleResponse(BaseModel): None, description="Catégorie comptable 4 (CL_No4)" ) - date_creation: Optional[str] = Field( - None, description="Date de création (AR_DateCre)" - ) date_modification: Optional[str] = Field( None, description="Date de dernière modification (AR_DateModif)" ) - date_sommeil: Optional[str] = Field( - None, description="Date de mise en sommeil (AR_DateSommeil)" + + marque_commerciale: Optional[str] = Field( + None, description="Marque commerciale (champ personnalisé)" + ) + objectif_qtes_vendues: Optional[str] = Field( + None, description="Objectif / Quantités vendues (champ personnalisé)" + ) + pourcentage_or: Optional[str] = Field( + None, description="Pourcentage teneur en or (champ personnalisé)" + ) + premiere_commercialisation: Optional[str] = Field( + None, description="Date de 1ère commercialisation (champ personnalisé)" + ) + interdire_commande: Optional[bool] = Field( + None, description="Interdire la commande (champ personnalisé)" + ) + exclure: Optional[bool] = Field( + None, description="Exclure de certains traitements (champ personnalisé)" ) class Config: json_schema_extra = { "example": { - "reference": "ART-001", - "designation": "Ordinateur portable 15 pouces", - "designation_complementaire": "Intel i7, 16GB RAM, 512GB SSD", + "reference": "BAGUE-001", + "designation": "Bague Or 18K Diamant", "code_ean": "3760123456789", - "prix_vente": 899.00, - "prix_achat": 650.00, - "prix_revient": 675.50, - "stock_reel": 25.0, - "stock_mini": 5.0, - "stock_maxi": 50.0, - "stock_disponible": 22.0, + "edi_code": "EAN13", + "raccourci": "BAG001", + "prix_vente": 1299.00, + "prix_achat": 850.00, + "coef": 1.53, + "prix_net": 1199.00, + "prix_achat_nouveau": 875.00, + "date_application_prix": "2025-01-01", + "cout_standard": 860.00, + "stock_reel": 15.0, + "stock_mini": 3.0, + "stock_maxi": 30.0, + "stock_reserve": 2.0, + "stock_commande": 10.0, + "stock_disponible": 13.0, + "suivi_stock": True, + "nomenclature": False, "unite_vente": "PCE", - "poids": 2.1, + "unite_poids": "GR", + "poids_net": 3.5, + "poids_brut": 3.8, + "gamme_1": "Or", + "gamme_2": "18K", "type_article": 0, "type_article_libelle": "Article", - "famille_code": "INFO", - "famille_libelle": "Informatique", - "fournisseur_principal": 101, - "fournisseur_nom": "TechSupply SAS", + "famille_code": "BAGUE", + "famille_libelle": "Bagues", + "famille_type": 0, + "famille_unite_vente": "PCE", + "famille_coef": 1.5, + "famille_suivi_stock": True, + "famille_garantie": 24, + "nature": 1, + "garantie": 24, + "code_fiscal": "TVA20", + "pays": "FR", + "fournisseur_principal": 150, + "fournisseur_nom": "Bijoux & Co SAS", + "conditionnement": "Écrin", + "nb_colis": 1, + "prevision": False, "est_actif": True, "en_sommeil": False, + "soumis_escompte": True, + "delai": 7, + "publie": True, + "hors_statistique": False, + "vente_debit": False, + "non_imprimable": False, + "transfere": False, + "contremarque": False, + "fact_poids": False, + "fact_forfait": False, + "fictif": False, + "sous_traitance": False, + "criticite": 2, + "delai_fabrication": 5, + "photo": "bague001.jpg", + "langue_1": "Gold Ring with Diamond", + "langue_2": "Anillo de Oro con Diamante", "tva_code": "T20", "tva_taux": 20.0, - "date_creation": "2023-01-15", - "date_modification": "2024-11-20" + "stat_01": "Luxe", + "stat_02": "Féminin", + "categorie_1": 1, + "categorie_2": 5, + "date_modification": "2024-12-15", + "marque_commerciale": "Elite Collection", + "objectif_qtes_vendues": "50", + "pourcentage_or": "75.0", + "premiere_commercialisation": "2024-01-01", + "interdire_commande": False, + "exclure": False } } - + class ArticleListResponse(BaseModel): """Réponse pour une liste d'articles""" @@ -793,7 +962,6 @@ class ArticleListResponse(BaseModel): "avec_stock": True, "avec_famille": True, "articles": [ - # Exemple d'article (voir ArticleResponse) ] } } From 7f51992dda554c7f328d48cb35aa22bd5a7ea694 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 27 Dec 2025 06:20:24 +0300 Subject: [PATCH 118/199] refactor(api): remove ge constraints from numeric fields in ArticleResponse --- api.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/api.py b/api.py index 602361c..e670b2d 100644 --- a/api.py +++ b/api.py @@ -527,8 +527,9 @@ class FournisseurDetails(BaseModel): } -class ArticleResponse(BaseModel): +class ArticleResponse(BaseModel): + reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") @@ -546,49 +547,49 @@ class ArticleResponse(BaseModel): ) prix_vente: float = Field( - ..., ge=0, description="Prix de vente HT unitaire (AR_PrixVen)" + ..., description="Prix de vente HT unitaire (AR_PrixVen)" ) prix_achat: Optional[float] = Field( - None, ge=0, description="Prix d'achat HT (AR_PrixAch)" + None, description="Prix d'achat HT (AR_PrixAch)" ) coef: Optional[float] = Field( - None, ge=0, description="Coefficient multiplicateur (AR_Coef)" + None, description="Coefficient multiplicateur (AR_Coef)" ) prix_net: Optional[float] = Field( - None, ge=0, description="Prix unitaire net (AR_PUNet)" + None, description="Prix unitaire net (AR_PUNet)" ) prix_achat_nouveau: Optional[float] = Field( - None, ge=0, description="Nouveau prix d'achat à venir (AR_PrixAchNouv)" + None, description="Nouveau prix d'achat à venir (AR_PrixAchNouv)" ) coef_nouveau: Optional[float] = Field( - None, ge=0, description="Nouveau coefficient à venir (AR_CoefNouv)" + None, description="Nouveau coefficient à venir (AR_CoefNouv)" ) prix_vente_nouveau: Optional[float] = Field( - None, ge=0, description="Nouveau prix de vente à venir (AR_PrixVenNouv)" + None, description="Nouveau prix de vente à venir (AR_PrixVenNouv)" ) date_application_prix: Optional[str] = Field( None, description="Date d'application des nouveaux prix (AR_DateApplication)" ) cout_standard: Optional[float] = Field( - None, ge=0, description="Coût standard (AR_CoutStd)" + None, description="Coût standard (AR_CoutStd)" ) stock_reel: float = Field( default=0.0, description="Stock réel total (F_ARTSTOCK.AS_QteSto)" ) stock_mini: Optional[float] = Field( - None, ge=0, description="Stock minimum (F_ARTSTOCK.AS_QteMini)" + None, description="Stock minimum (F_ARTSTOCK.AS_QteMini)" ) stock_maxi: Optional[float] = Field( - None, ge=0, description="Stock maximum (F_ARTSTOCK.AS_QteMaxi)" + None, description="Stock maximum (F_ARTSTOCK.AS_QteMaxi)" ) stock_reserve: Optional[float] = Field( - None, ge=0, description="Stock réservé / en commande client (F_ARTSTOCK.AS_QteRes)" + None, description="Stock réservé / en commande client (F_ARTSTOCK.AS_QteRes)" ) stock_commande: Optional[float] = Field( - None, ge=0, description="Stock en commande fournisseur (F_ARTSTOCK.AS_QteCom)" + None, description="Stock en commande fournisseur (F_ARTSTOCK.AS_QteCom)" ) stock_disponible: Optional[float] = Field( None, description="Stock disponible = réel - réservé" @@ -614,10 +615,10 @@ class ArticleResponse(BaseModel): ) poids_net: Optional[float] = Field( - None, ge=0, description="Poids net unitaire en kg (AR_PoidsNet)" + None, description="Poids net unitaire en kg (AR_PoidsNet)" ) poids_brut: Optional[float] = Field( - None, ge=0, description="Poids brut unitaire en kg (AR_PoidsBrut)" + None, description="Poids brut unitaire en kg (AR_PoidsBrut)" ) gamme_1: Optional[str] = Field( @@ -690,7 +691,7 @@ class ArticleResponse(BaseModel): None, description="Nature de l'article (AR_Nature)" ) garantie: Optional[int] = Field( - None, ge=0, description="Durée de garantie en mois (AR_Garantie)" + None, description="Durée de garantie en mois (AR_Garantie)" ) code_fiscal: Optional[str] = Field( None, max_length=10, description="Code fiscal/TVA (AR_CodeFiscal)" @@ -709,7 +710,7 @@ class ArticleResponse(BaseModel): None, description="Conditionnement d'achat (AR_Condition)" ) nb_colis: Optional[int] = Field( - None, ge=0, description="Nombre de colis par unité (AR_NbColis)" + None, description="Nombre de colis par unité (AR_NbColis)" ) prevision: Optional[bool] = Field( None, description="Gestion en prévision (AR_Prevision)" @@ -765,7 +766,7 @@ class ArticleResponse(BaseModel): None, description="Article en sous-traitance (AR_SousTraitance)" ) criticite: Optional[int] = Field( - None, ge=0, description="Niveau de criticité (AR_Criticite)" + None, description="Niveau de criticité (AR_Criticite)" ) reprise_code_defaut: Optional[str] = Field( @@ -811,7 +812,7 @@ class ArticleResponse(BaseModel): None, description="Code TVA (F_TAXE.TA_Code)" ) tva_taux: Optional[float] = Field( - None, ge=0, le=100, description="Taux de TVA en % (F_TAXE.TA_Taux)" + None, description="Taux de TVA en % (F_TAXE.TA_Taux)" ) stat_01: Optional[str] = Field(None, description="Statistique 1 (AR_Stat01)") @@ -937,7 +938,7 @@ class ArticleResponse(BaseModel): "exclure": False } } - + class ArticleListResponse(BaseModel): """Réponse pour une liste d'articles""" From ce84c66ee6fe7fe4fa3653be1b8dbee795c598a8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 27 Dec 2025 07:08:01 +0300 Subject: [PATCH 119/199] refactor(api): enhance FamilleResponse model with detailed fields and sections --- api.py | 196 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 136 insertions(+), 60 deletions(-) diff --git a/api.py b/api.py index e670b2d..c4d0cb4 100644 --- a/api.py +++ b/api.py @@ -2134,88 +2134,130 @@ class FamilleCreateRequest(BaseModel): } -class FamilleResponse(BaseModel): - """Modèle de réponse pour une famille d'articles - aligné sur lister_toutes_familles""" +class FamilleResponse(BaseModel): + """Modèle complet d'une famille avec données comptables et fournisseur""" + + # ========== IDENTIFICATION ========== code: str = Field(..., description="Code famille") intitule: str = Field(..., description="Intitulé") type: int = Field(..., description="Type (0=Détail, 1=Total)") type_libelle: str = Field(..., description="Libellé du type") est_total: bool = Field(..., description="True si type Total") - est_detail: Optional[bool] = Field(None, description="True si type Détail") + est_detail: bool = Field(..., description="True si type Détail") - unite_vente: Optional[str] = Field(None, description="Unité de vente") + # ========== UNITÉS ET COEFFICIENTS ========== + unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") unite_poids: Optional[str] = Field(None, description="Unité de poids") - coef: Optional[float] = Field(None, description="Coefficient") + coef: Optional[float] = Field(None, description="Coefficient multiplicateur") + # ========== GESTION STOCK ========== suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé") garantie: Optional[int] = Field(None, description="Durée de garantie (mois)") delai: Optional[int] = Field(None, description="Délai de livraison (jours)") nb_colis: Optional[int] = Field(None, description="Nombre de colis") - compte_achat: Optional[str] = Field(None, description="Compte général achat") - compte_vente: Optional[str] = Field(None, description="Compte général vente") - code_fiscal: Optional[str] = Field(None, description="Code fiscal") + # ========== FISCAL ET COMPTABLE ========== + code_fiscal: Optional[str] = Field(None, description="Code fiscal par défaut") escompte: Optional[bool] = Field(None, description="Escompte autorisé") - est_centrale: Optional[bool] = Field(None, description="Famille centrale") + # ========== CARACTÉRISTIQUES ========== + est_centrale: Optional[bool] = Field(None, description="Famille d'achat centralisé") nature: Optional[int] = Field(None, description="Nature de la famille") pays: Optional[str] = Field(None, description="Pays d'origine") - categorie_1: Optional[int] = Field(None, description="Catégorie 1") - categorie_2: Optional[int] = Field(None, description="Catégorie 2") - categorie_3: Optional[int] = Field(None, description="Catégorie 3") - categorie_4: Optional[int] = Field(None, description="Catégorie 4") + # ========== CATÉGORIES COMPTABLES ========== + categorie_1: Optional[int] = Field(None, description="Catégorie comptable 1 (CL_No1)") + categorie_2: Optional[int] = Field(None, description="Catégorie comptable 2 (CL_No2)") + categorie_3: Optional[int] = Field(None, description="Catégorie comptable 3 (CL_No3)") + categorie_4: Optional[int] = Field(None, description="Catégorie comptable 4 (CL_No4)") - stat_01: Optional[str] = Field(None, description="Statistique 1") - stat_02: Optional[str] = Field(None, description="Statistique 2") - stat_03: Optional[str] = Field(None, description="Statistique 3") - stat_04: Optional[str] = Field(None, description="Statistique 4") - stat_05: Optional[str] = Field(None, description="Statistique 5") + # ========== STATISTIQUES ========== + stat_01: Optional[str] = Field(None, description="Statistique libre 1") + stat_02: Optional[str] = Field(None, description="Statistique libre 2") + stat_03: Optional[str] = Field(None, description="Statistique libre 3") + stat_04: Optional[str] = Field(None, description="Statistique libre 4") + stat_05: Optional[str] = Field(None, description="Statistique libre 5") hors_statistique: Optional[bool] = Field(None, description="Exclue des statistiques") - est_statistique: Optional[bool] = Field(None, description="Incluse dans les statistiques (legacy)") + # ========== OPTIONS DIVERSES ========== vente_debit: Optional[bool] = Field(None, description="Vente au débit") - non_imprimable: Optional[bool] = Field(None, description="Non imprimable") - contremarque: Optional[bool] = Field(None, description="Contremarque") + non_imprimable: Optional[bool] = Field(None, description="Non imprimable sur documents") + contremarque: Optional[bool] = Field(None, description="Article en contremarque") fact_poids: Optional[bool] = Field(None, description="Facturation au poids") fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire") - publie: Optional[bool] = Field(None, description="Publié") + publie: Optional[bool] = Field(None, description="Publié (e-commerce)") - racine_reference: Optional[str] = Field(None, description="Racine référence article") - racine_code_barre: Optional[str] = Field(None, description="Racine code-barres") + # ========== RÉFÉRENCES ET RACCOURCIS ========== + racine_reference: Optional[str] = Field(None, description="Racine pour génération auto de références") + racine_code_barre: Optional[str] = Field(None, description="Racine pour génération auto de codes-barres") raccourci: Optional[str] = Field(None, description="Raccourci clavier") - sous_traitance: Optional[bool] = Field(None, description="Sous-traitance") - fictif: Optional[bool] = Field(None, description="Famille fictive") - criticite: Optional[int] = Field(None, description="Niveau de criticité") + # ========== GESTION AVANCÉE ========== + sous_traitance: Optional[bool] = Field(None, description="Famille en sous-traitance") + fictif: Optional[bool] = Field(None, description="Famille fictive (nomenclature)") + criticite: Optional[int] = Field(None, description="Niveau de criticité (0-5)") - avertissement: Optional[str] = Field(None, description="Avertissement si famille Total") - index_com: Optional[int] = Field(None, description="Index COM (lire_famille uniquement)") - date_creation: Optional[str] = Field(None, description="Date de création") - date_modification: Optional[str] = Field(None, description="Date de modification") + # ========== COMPTABILITÉ VENTE (F_FAMCOMPTA Type=0) ========== + compte_vente: Optional[str] = Field(None, description="Compte général de vente") + compte_auxiliaire_vente: Optional[str] = Field(None, description="Compte auxiliaire de vente") + tva_vente_1: Optional[str] = Field(None, description="Code TVA vente principal") + tva_vente_2: Optional[str] = Field(None, description="Code TVA vente secondaire") + tva_vente_3: Optional[str] = Field(None, description="Code TVA vente tertiaire") + type_facture_vente: Optional[int] = Field(None, description="Type de facture vente") + + # ========== COMPTABILITÉ ACHAT (F_FAMCOMPTA Type=1) ========== + compte_achat: Optional[str] = Field(None, description="Compte général d'achat") + compte_auxiliaire_achat: Optional[str] = Field(None, description="Compte auxiliaire d'achat") + tva_achat_1: Optional[str] = Field(None, description="Code TVA achat principal") + tva_achat_2: Optional[str] = Field(None, description="Code TVA achat secondaire") + tva_achat_3: Optional[str] = Field(None, description="Code TVA achat tertiaire") + type_facture_achat: Optional[int] = Field(None, description="Type de facture achat") + + # ========== COMPTABILITÉ STOCK (F_FAMCOMPTA Type=2) ========== + compte_stock: Optional[str] = Field(None, description="Compte de stock") + compte_auxiliaire_stock: Optional[str] = Field(None, description="Compte auxiliaire de stock") + + # ========== FOURNISSEUR PRINCIPAL (F_FAMFOURNISS) ========== + fournisseur_principal: Optional[str] = Field(None, description="N° compte fournisseur principal") + fournisseur_unite: Optional[str] = Field(None, description="Unité d'achat fournisseur") + fournisseur_conversion: Optional[float] = Field(None, description="Coefficient de conversion") + fournisseur_delai_appro: Optional[int] = Field(None, description="Délai d'approvisionnement (jours)") + fournisseur_garantie: Optional[int] = Field(None, description="Garantie fournisseur (mois)") + fournisseur_colisage: Optional[int] = Field(None, description="Colisage fournisseur") + fournisseur_qte_mini: Optional[float] = Field(None, description="Quantité minimum de commande") + fournisseur_qte_mont: Optional[float] = Field(None, description="Quantité montant") + fournisseur_devise: Optional[int] = Field(None, description="Devise fournisseur (0=Euro)") + fournisseur_remise: Optional[float] = Field(None, description="Remise fournisseur (%)") + fournisseur_type_remise: Optional[int] = Field(None, description="Type de remise (0=%, 1=Montant)") + + # ========== INFORMATIONS COMPLÉMENTAIRES ========== nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille") - + + # ========== CHAMPS LEGACY (rétrocompatibilité) ========== + FA_CodeFamille: Optional[str] = Field(None, description="[Legacy] Code famille") + FA_Intitule: Optional[str] = Field(None, description="[Legacy] Intitulé") + FA_Type: Optional[int] = Field(None, description="[Legacy] Type") + CG_NumVte: Optional[str] = Field(None, description="[Legacy] Compte vente") + CG_NumAch: Optional[str] = Field(None, description="[Legacy] Compte achat") class Config: json_schema_extra = { "example": { - "code": "ZDIVERS", - "intitule": "Frais et accessoires", + "code": "ELECT", + "intitule": "Électronique et Informatique", "type": 0, "type_libelle": "Détail", "est_total": False, "est_detail": True, "unite_vente": "U", "unite_poids": "KG", - "coef": 2.0, + "coef": 2.5, "suivi_stock": True, - "garantie": 12, - "delai": 7, + "garantie": 24, + "delai": 5, "nb_colis": 1, - "compte_achat": "607000", - "compte_vente": "707000", - "code_fiscal": "TVA20", + "code_fiscal": "C19", "escompte": True, "est_centrale": False, "nature": 0, @@ -2224,37 +2266,71 @@ class FamilleResponse(BaseModel): "categorie_2": 0, "categorie_3": 0, "categorie_4": 0, - "nb_articles": 45 + "stat_01": "HIGH_TECH", + "stat_02": "", + "stat_03": "", + "stat_04": "", + "stat_05": "", + "hors_statistique": False, + "vente_debit": False, + "non_imprimable": False, + "contremarque": False, + "fact_poids": False, + "fact_forfait": False, + "publie": True, + "racine_reference": "ELEC", + "racine_code_barre": "339", + "raccourci": "F5", + "sous_traitance": False, + "fictif": False, + "criticite": 2, + "compte_vente": "707100", + "compte_auxiliaire_vente": "", + "tva_vente_1": "C19", + "tva_vente_2": "", + "tva_vente_3": "", + "type_facture_vente": 0, + "compte_achat": "607100", + "compte_auxiliaire_achat": "", + "tva_achat_1": "C19", + "tva_achat_2": "", + "tva_achat_3": "", + "type_facture_achat": 0, + "compte_stock": "350000", + "compte_auxiliaire_stock": "", + "fournisseur_principal": "FTECH001", + "fournisseur_unite": "U", + "fournisseur_conversion": 1.0, + "fournisseur_delai_appro": 7, + "fournisseur_garantie": 12, + "fournisseur_colisage": 10, + "fournisseur_qte_mini": 5.0, + "fournisseur_qte_mont": 100.0, + "fournisseur_devise": 0, + "fournisseur_remise": 5.0, + "fournisseur_type_remise": 0, + "nb_articles": 156 } } class FamilleListResponse(BaseModel): - """Modèle de réponse pour une liste de familles""" - - familles: list[FamilleResponse] = Field(..., description="Liste des familles") - total: int = Field(..., description="Nombre total de familles") - filtre_applique: Optional[str] = Field(None, description="Filtre appliqué") - inclut_totaux: bool = Field(..., description="Inclut les familles de type Total") + """Réponse pour la liste des familles""" + familles: list[FamilleResponse] + total: int + filtre: Optional[str] = None + inclure_totaux: bool = True class Config: json_schema_extra = { "example": { - "familles": [ - { - "code": "ZDIVERS", - "intitule": "Frais et accessoires", - "type": 0, - "type_libelle": "Détail", - "est_total": False, - "nb_articles": 45 - } - ], - "total": 1, - "filtre_applique": "frais", - "inclut_totaux": False + "familles": [], + "total": 42, + "filtre": "ELECT", + "inclure_totaux": False } } + class MouvementStockLigneRequest(BaseModel): article_ref: str = Field(..., description="Référence de l'article") From e9e4aff0dbc024796ad1ba652f2c00d65244eed0 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sat, 27 Dec 2025 18:08:03 +0300 Subject: [PATCH 120/199] Enriched article response --- api.py | 889 ++++++++++++++++++++++++++++++++------------------------- 1 file changed, 500 insertions(+), 389 deletions(-) diff --git a/api.py b/api.py index c4d0cb4..764cc97 100644 --- a/api.py +++ b/api.py @@ -528,292 +528,494 @@ class FournisseurDetails(BaseModel): + +class EmplacementStockModel(BaseModel): + """Détail du stock dans un emplacement spécifique""" + + depot: str = Field(..., description="Numéro du dépôt (DE_No)") + emplacement: str = Field(..., description="Code emplacement (DP_No)") + + qte_stockee: float = Field(0.0, description="Quantité stockée (AE_QteSto)") + qte_preparee: float = Field(0.0, description="Quantité préparée (AE_QtePrepa)") + qte_a_controler: float = Field(0.0, description="Quantité à contrôler (AE_QteAControler)") + + date_creation: Optional[datetime] = Field(None, description="Date création") + date_modification: Optional[datetime] = Field(None, description="Date modification") + + depot_num: Optional[str] = Field(None, description="Numéro dépôt") + depot_nom: Optional[str] = Field(None, description="Nom du dépôt (DE_Intitule)") + depot_code: Optional[str] = Field(None, description="Code dépôt (DE_Code)") + depot_adresse: Optional[str] = Field(None, description="Adresse (DE_Adresse)") + depot_complement: Optional[str] = Field(None, description="Complément adresse") + depot_code_postal: Optional[str] = Field(None, description="Code postal") + depot_ville: Optional[str] = Field(None, description="Ville") + depot_contact: Optional[str] = Field(None, description="Contact") + depot_est_principal: Optional[bool] = Field(None, description="Dépôt principal (DE_Principal)") + depot_categorie_compta: Optional[int] = Field(None, description="Catégorie comptable") + depot_region: Optional[str] = Field(None, description="Région") + depot_pays: Optional[str] = Field(None, description="Pays") + depot_email: Optional[str] = Field(None, description="Email") + depot_telephone: Optional[str] = Field(None, description="Téléphone") + depot_fax: Optional[str] = Field(None, description="Fax") + depot_emplacement_defaut: Optional[str] = Field(None, description="Emplacement par défaut") + depot_exclu: Optional[bool] = Field(None, description="Dépôt exclu") + + emplacement_code: Optional[str] = Field(None, description="Code emplacement (DP_Code)") + emplacement_libelle: Optional[str] = Field(None, description="Libellé emplacement (DP_Intitule)") + emplacement_zone: Optional[str] = Field(None, description="Zone (DP_Zone)") + emplacement_type: Optional[int] = Field(None, description="Type emplacement (DP_Type)") + + class Config: + json_schema_extra = { + "example": { + "depot": "01", + "emplacement": "A1-01", + "qte_stockee": 100.0, + "qte_preparee": 5.0, + "depot_nom": "Dépôt principal", + "depot_ville": "Paris", + "emplacement_libelle": "Allée A, Niveau 1, Case 01", + "emplacement_zone": "Zone A" + } + } + + +class GammeArticleModel(BaseModel): + """Gamme d'un article (taille, couleur, etc.)""" + + numero_gamme: int = Field(..., description="Numéro de gamme (AG_No)") + enumere: str = Field(..., description="Code énuméré (EG_Enumere)") + type_gamme: int = Field(0, description="Type de gamme (AG_Type)") + + date_creation: Optional[datetime] = Field(None, description="Date création") + date_modification: Optional[datetime] = Field(None, description="Date modification") + + ligne: Optional[int] = Field(None, description="Ligne énuméré (EG_Ligne)") + borne_sup: Optional[float] = Field(None, description="Borne supérieure (EG_BorneSup)") + gamme_nom: Optional[str] = Field(None, description="Nom de la gamme (P_GAMME.G_Intitule)") + + class Config: + json_schema_extra = { + "example": { + "numero_gamme": 1, + "enumere": "001", + "type_gamme": 0, + "ligne": 1, + "gamme_nom": "Taille" + } + } + + +class TarifClientModel(BaseModel): + """Tarif spécifique pour un client ou catégorie tarifaire""" + + categorie: int = Field(..., description="Catégorie tarifaire (AC_Categorie)") + client_num: Optional[str] = Field(None, description="Numéro client (CT_Num)") + + prix_vente: float = Field(0.0, description="Prix de vente HT (AC_PrixVen)") + coefficient: float = Field(0.0, description="Coefficient (AC_Coef)") + prix_ttc: float = Field(0.0, description="Prix TTC (AC_PrixTTC)") + arrondi: float = Field(0.0, description="Arrondi (AC_Arrondi)") + qte_montant: float = Field(0.0, description="Quantité montant (AC_QteMont)") + + enumere_gamme: int = Field(0, description="Énuméré gamme (EG_Champ)") + prix_devise: float = Field(0.0, description="Prix en devise (AC_PrixDev)") + devise: int = Field(0, description="Code devise (AC_Devise)") + + remise: float = Field(0.0, description="Remise (AC_Remise)") + mode_calcul: int = Field(0, description="Mode de calcul (AC_Calcul)") + type_remise: int = Field(0, description="Type de remise (AC_TypeRem)") + ref_client: Optional[str] = Field(None, description="Référence client (AC_RefClient)") + + coef_nouveau: float = Field(0.0, description="Nouveau coefficient (AC_CoefNouv)") + prix_vente_nouveau: float = Field(0.0, description="Nouveau prix vente (AC_PrixVenNouv)") + prix_devise_nouveau: float = Field(0.0, description="Nouveau prix devise (AC_PrixDevNouv)") + remise_nouvelle: float = Field(0.0, description="Nouvelle remise (AC_RemiseNouv)") + date_application: Optional[datetime] = Field(None, description="Date application (AC_DateApplication)") + + date_creation: Optional[datetime] = Field(None, description="Date création") + date_modification: Optional[datetime] = Field(None, description="Date modification") + + class Config: + json_schema_extra = { + "example": { + "categorie": 1, + "client_num": "CLI001", + "prix_vente": 110.00, + "coefficient": 1.294, + "remise": 12.0 + } + } + + +class ComposantModel(BaseModel): + """Composant/Opération de nomenclature""" + + operation: str = Field(..., description="Code opération (AT_Operation)") + code_ressource: Optional[str] = Field(None, description="Code ressource (RP_Code)") + + temps: float = Field(0.0, description="Temps nécessaire (AT_Temps)") + type: int = Field(0, description="Type composant (AT_Type)") + description: Optional[str] = Field(None, description="Description (AT_Description)") + ordre: int = Field(0, description="Ordre d'exécution (AT_Ordre)") + + gamme_1_comp: int = Field(0, description="Gamme 1 composant (AG_No1Comp)") + gamme_2_comp: int = Field(0, description="Gamme 2 composant (AG_No2Comp)") + + type_ressource: int = Field(0, description="Type ressource (AT_TypeRessource)") + chevauche: int = Field(0, description="Chevauchement (AT_Chevauche)") + demarre: int = Field(0, description="Démarrage (AT_Demarre)") + operation_chevauche: Optional[str] = Field(None, description="Opération chevauchée (AT_OperationChevauche)") + valeur_chevauche: float = Field(0.0, description="Valeur chevauchement (AT_ValeurChevauche)") + type_chevauche: int = Field(0, description="Type chevauchement (AT_TypeChevauche)") + + date_creation: Optional[datetime] = Field(None, description="Date création") + date_modification: Optional[datetime] = Field(None, description="Date modification") + + class Config: + json_schema_extra = { + "example": { + "operation": "OP010", + "code_ressource": "RES01", + "temps": 15.5, + "description": "Montage pièce A", + "ordre": 10 + } + } + + +class ComptaArticleModel(BaseModel): + """Comptabilité spécifique d'un article""" + + champ: int = Field(..., description="Champ (ACP_Champ)") + compte_general: Optional[str] = Field(None, description="Compte général (ACP_ComptaCPT_CompteG)") + compte_auxiliaire: Optional[str] = Field(None, description="Compte auxiliaire (ACP_ComptaCPT_CompteA)") + + taxe_1: Optional[str] = Field(None, description="Taxe 1 (ACP_ComptaCPT_Taxe1)") + taxe_2: Optional[str] = Field(None, description="Taxe 2 (ACP_ComptaCPT_Taxe2)") + taxe_3: Optional[str] = Field(None, description="Taxe 3 (ACP_ComptaCPT_Taxe3)") + + taxe_date_1: Optional[datetime] = Field(None, description="Date taxe 1") + taxe_date_2: Optional[datetime] = Field(None, description="Date taxe 2") + taxe_date_3: Optional[datetime] = Field(None, description="Date taxe 3") + + taxe_anc_1: Optional[str] = Field(None, description="Ancienne taxe 1") + taxe_anc_2: Optional[str] = Field(None, description="Ancienne taxe 2") + taxe_anc_3: Optional[str] = Field(None, description="Ancienne taxe 3") + + type_facture: int = Field(0, description="Type de facture (ACP_TypeFacture)") + + date_creation: Optional[datetime] = Field(None, description="Date création") + date_modification: Optional[datetime] = Field(None, description="Date modification") + + class Config: + json_schema_extra = { + "example": { + "champ": 1, + "compte_general": "707100", + "taxe_1": "TVA20", + "type_facture": 0 + } + } + + +class FournisseurArticleModel(BaseModel): + """Fournisseur d'un article""" + + fournisseur_num: str = Field(..., description="Numéro fournisseur (CT_Num)") + ref_fournisseur: Optional[str] = Field(None, description="Référence fournisseur (AF_RefFourniss)") + + prix_achat: float = Field(0.0, description="Prix d'achat (AF_PrixAch)") + unite: Optional[str] = Field(None, description="Unité (AF_Unite)") + conversion: float = Field(0.0, description="Conversion (AF_Conversion)") + + delai_appro: int = Field(0, description="Délai approvisionnement (AF_DelaiAppro)") + garantie: int = Field(0, description="Garantie (AF_Garantie)") + colisage: int = Field(0, description="Colisage (AF_Colisage)") + qte_mini: float = Field(0.0, description="Quantité minimum (AF_QteMini)") + qte_montant: float = Field(0.0, description="Quantité montant (AF_QteMont)") + + enumere_gamme: int = Field(0, description="Énuméré gamme (EG_Champ)") + est_principal: bool = Field(False, description="Fournisseur principal (AF_Principal)") + + prix_devise: float = Field(0.0, description="Prix devise (AF_PrixDev)") + devise: int = Field(0, description="Code devise (AF_Devise)") + remise: float = Field(0.0, description="Remise (AF_Remise)") + conversion_devise: float = Field(0.0, description="Conversion devise (AF_ConvDiv)") + type_remise: int = Field(0, description="Type remise (AF_TypeRem)") + + code_barre_fournisseur: Optional[str] = Field(None, description="Code-barres fournisseur (AF_CodeBarre)") + + prix_achat_nouveau: float = Field(0.0, description="Nouveau prix achat (AF_PrixAchNouv)") + prix_devise_nouveau: float = Field(0.0, description="Nouveau prix devise (AF_PrixDevNouv)") + remise_nouvelle: float = Field(0.0, description="Nouvelle remise (AF_RemiseNouv)") + date_application: Optional[datetime] = Field(None, description="Date application (AF_DateApplication)") + + date_creation: Optional[datetime] = Field(None, description="Date création") + date_modification: Optional[datetime] = Field(None, description="Date modification") + + class Config: + json_schema_extra = { + "example": { + "fournisseur_num": "F001", + "ref_fournisseur": "REF-FOURN-001", + "prix_achat": 85.00, + "delai_appro": 15, + "est_principal": True + } + } + + +class ReferenceEnumereeModel(BaseModel): + """Référence énumérée (article avec gammes)""" + + gamme_1: int = Field(0, description="Gamme 1 (AG_No1)") + gamme_2: int = Field(0, description="Gamme 2 (AG_No2)") + reference_enumeree: str = Field(..., description="Référence énumérée (AE_Ref)") + + prix_achat: float = Field(0.0, description="Prix achat (AE_PrixAch)") + code_barre: Optional[str] = Field(None, description="Code-barres (AE_CodeBarre)") + prix_achat_nouveau: float = Field(0.0, description="Nouveau prix achat (AE_PrixAchNouv)") + edi_code: Optional[str] = Field(None, description="Code EDI (AE_EdiCode)") + en_sommeil: bool = Field(False, description="En sommeil (AE_Sommeil)") + + date_creation: Optional[datetime] = Field(None, description="Date création") + date_modification: Optional[datetime] = Field(None, description="Date modification") + + class Config: + json_schema_extra = { + "example": { + "gamme_1": 1, + "gamme_2": 3, + "reference_enumeree": "ART001-T1-C3", + "prix_achat": 85.00 + } + } + + +class MediaArticleModel(BaseModel): + """Média attaché à un article (photo, document, etc.)""" + + commentaire: Optional[str] = Field(None, description="Commentaire (ME_Commentaire)") + fichier: Optional[str] = Field(None, description="Nom fichier (ME_Fichier)") + type_mime: Optional[str] = Field(None, description="Type MIME (ME_TypeMIME)") + origine: int = Field(0, description="Origine (ME_Origine)") + ged_id: Optional[str] = Field(None, description="ID GED (ME_GedId)") + + date_creation: Optional[datetime] = Field(None, description="Date création") + date_modification: Optional[datetime] = Field(None, description="Date modification") + + class Config: + json_schema_extra = { + "example": { + "commentaire": "Photo produit principale", + "fichier": "ART001_photo1.jpg", + "type_mime": "image/jpeg" + } + } + + +class PrixGammeModel(BaseModel): + """Prix spécifique par combinaison de gammes""" + + gamme_1: int = Field(0, description="Gamme 1 (AG_No1)") + gamme_2: int = Field(0, description="Gamme 2 (AG_No2)") + prix_net: float = Field(0.0, description="Prix net (AR_PUNet)") + cout_standard: float = Field(0.0, description="Coût standard (AR_CoutStd)") + + date_creation: Optional[datetime] = Field(None, description="Date création") + date_modification: Optional[datetime] = Field(None, description="Date modification") + + class Config: + json_schema_extra = { + "example": { + "gamme_1": 1, + "gamme_2": 3, + "prix_net": 125.50, + "cout_standard": 82.30 + } + } + + + class ArticleResponse(BaseModel): + """Article complet avec tous les enrichissements disponibles""" reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") - code_ean: Optional[str] = Field( - None, description="Code EAN / Code-barres principal (AR_CodeBarre)" + code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres principal (AR_CodeBarre)") + code_barre: Optional[str] = Field(None, description="Code-barres (alias de code_ean)") + edi_code: Optional[str] = Field(None, description="Code EDI (AR_EdiCode)") + raccourci: Optional[str] = Field(None, description="Code raccourci (AR_Raccourci)") + + prix_vente: float = Field(..., description="Prix de vente HT unitaire (AR_PrixVen)") + prix_achat: Optional[float] = Field(None, description="Prix d'achat HT (AR_PrixAch)") + coef: Optional[float] = Field(None, description="Coefficient multiplicateur (AR_Coef)") + prix_net: Optional[float] = Field(None, description="Prix unitaire net (AR_PUNet)") + + prix_achat_nouveau: Optional[float] = Field(None, description="Nouveau prix d'achat à venir (AR_PrixAchNouv)") + coef_nouveau: Optional[float] = Field(None, description="Nouveau coefficient à venir (AR_CoefNouv)") + prix_vente_nouveau: Optional[float] = Field(None, description="Nouveau prix de vente à venir (AR_PrixVenNouv)") + date_application_prix: Optional[str] = Field(None, description="Date d'application des nouveaux prix (AR_DateApplication)") + + cout_standard: Optional[float] = Field(None, description="Coût standard (AR_CoutStd)") + + stock_reel: float = Field(default=0.0, description="Stock réel total (F_ARTSTOCK.AS_QteSto)") + stock_mini: Optional[float] = Field(None, description="Stock minimum (F_ARTSTOCK.AS_QteMini)") + stock_maxi: Optional[float] = Field(None, description="Stock maximum (F_ARTSTOCK.AS_QteMaxi)") + stock_reserve: Optional[float] = Field(None, description="Stock réservé / en commande client (F_ARTSTOCK.AS_QteRes)") + stock_commande: Optional[float] = Field(None, description="Stock en commande fournisseur (F_ARTSTOCK.AS_QteCom)") + stock_disponible: Optional[float] = Field(None, description="Stock disponible = réel - réservé") + + emplacements: List[EmplacementStockModel] = Field( + default_factory=list, + description="Détail du stock par emplacement (F_ARTSTOCKEMPL + F_DEPOT + F_DEPOTEMPL)" ) - code_barre: Optional[str] = Field( - None, description="Code-barres (alias de code_ean)" + nb_emplacements: int = Field(0, description="Nombre d'emplacements") + + suivi_stock: Optional[bool] = Field(None, description="Suivi de stock activé (AR_SuiviStock)") + nomenclature: Optional[bool] = Field(None, description="Article avec nomenclature (AR_Nomencl)") + qte_composant: Optional[float] = Field(None, description="Quantité de composant (AR_QteComp)") + qte_operatoire: Optional[float] = Field(None, description="Quantité opératoire (AR_QteOperatoire)") + + unite_vente: Optional[str] = Field(None, max_length=10, description="Unité de vente (AR_UniteVen)") + unite_poids: Optional[str] = Field(None, max_length=10, description="Unité de poids (AR_UnitePoids)") + poids_net: Optional[float] = Field(None, description="Poids net unitaire en kg (AR_PoidsNet)") + poids_brut: Optional[float] = Field(None, description="Poids brut unitaire en kg (AR_PoidsBrut)") + + gamme_1: Optional[str] = Field(None, description="Énumération gamme 1 (AR_Gamme1)") + gamme_2: Optional[str] = Field(None, description="Énumération gamme 2 (AR_Gamme2)") + + gammes: List[GammeArticleModel] = Field( + default_factory=list, + description="Détail des gammes (F_ARTGAMME + F_ENUMGAMME + P_GAMME)" ) - edi_code: Optional[str] = Field( - None, description="Code EDI (AR_EdiCode)" + nb_gammes: int = Field(0, description="Nombre de gammes") + + tarifs_clients: List[TarifClientModel] = Field( + default_factory=list, + description="Tarifs spécifiques par client/catégorie (F_ARTCLIENT)" ) - raccourci: Optional[str] = Field( - None, description="Code raccourci (AR_Raccourci)" + nb_tarifs_clients: int = Field(0, description="Nombre de tarifs clients") + + composants: List[ComposantModel] = Field( + default_factory=list, + description="Composants/Opérations de production (F_ARTCOMPO)" + ) + nb_composants: int = Field(0, description="Nombre de composants") + + compta_vente: List[ComptaArticleModel] = Field( + default_factory=list, + description="Comptabilité vente (F_ARTCOMPTA type 0)" + ) + compta_achat: List[ComptaArticleModel] = Field( + default_factory=list, + description="Comptabilité achat (F_ARTCOMPTA type 1)" + ) + compta_stock: List[ComptaArticleModel] = Field( + default_factory=list, + description="Comptabilité stock (F_ARTCOMPTA type 2)" ) - prix_vente: float = Field( - ..., description="Prix de vente HT unitaire (AR_PrixVen)" - ) - prix_achat: Optional[float] = Field( - None, description="Prix d'achat HT (AR_PrixAch)" - ) - coef: Optional[float] = Field( - None, description="Coefficient multiplicateur (AR_Coef)" - ) - prix_net: Optional[float] = Field( - None, description="Prix unitaire net (AR_PUNet)" + fournisseurs: List[FournisseurArticleModel] = Field( + default_factory=list, + description="Tous les fournisseurs de l'article (F_ARTFOURNISS)" ) + nb_fournisseurs: int = Field(0, description="Nombre de fournisseurs") - prix_achat_nouveau: Optional[float] = Field( - None, description="Nouveau prix d'achat à venir (AR_PrixAchNouv)" - ) - coef_nouveau: Optional[float] = Field( - None, description="Nouveau coefficient à venir (AR_CoefNouv)" - ) - prix_vente_nouveau: Optional[float] = Field( - None, description="Nouveau prix de vente à venir (AR_PrixVenNouv)" - ) - date_application_prix: Optional[str] = Field( - None, description="Date d'application des nouveaux prix (AR_DateApplication)" + refs_enumerees: List[ReferenceEnumereeModel] = Field( + default_factory=list, + description="Références énumérées (F_ARTENUMREF)" ) + nb_refs_enumerees: int = Field(0, description="Nombre de références énumérées") - cout_standard: Optional[float] = Field( - None, description="Coût standard (AR_CoutStd)" + medias: List[MediaArticleModel] = Field( + default_factory=list, + description="Médias attachés (F_ARTICLEMEDIA)" ) + nb_medias: int = Field(0, description="Nombre de médias") - stock_reel: float = Field( - default=0.0, description="Stock réel total (F_ARTSTOCK.AS_QteSto)" - ) - stock_mini: Optional[float] = Field( - None, description="Stock minimum (F_ARTSTOCK.AS_QteMini)" - ) - stock_maxi: Optional[float] = Field( - None, description="Stock maximum (F_ARTSTOCK.AS_QteMaxi)" - ) - stock_reserve: Optional[float] = Field( - None, description="Stock réservé / en commande client (F_ARTSTOCK.AS_QteRes)" - ) - stock_commande: Optional[float] = Field( - None, description="Stock en commande fournisseur (F_ARTSTOCK.AS_QteCom)" - ) - stock_disponible: Optional[float] = Field( - None, description="Stock disponible = réel - réservé" - ) - suivi_stock: Optional[bool] = Field( - None, description="Suivi de stock activé (AR_SuiviStock)" - ) - nomenclature: Optional[bool] = Field( - None, description="Article avec nomenclature (AR_Nomencl)" - ) - qte_composant: Optional[float] = Field( - None, description="Quantité de composant (AR_QteComp)" - ) - qte_operatoire: Optional[float] = Field( - None, description="Quantité opératoire (AR_QteOperatoire)" - ) - - unite_vente: Optional[str] = Field( - None, max_length=10, description="Unité de vente (AR_UniteVen)" - ) - unite_poids: Optional[str] = Field( - None, max_length=10, description="Unité de poids (AR_UnitePoids)" - ) - - poids_net: Optional[float] = Field( - None, description="Poids net unitaire en kg (AR_PoidsNet)" - ) - poids_brut: Optional[float] = Field( - None, description="Poids brut unitaire en kg (AR_PoidsBrut)" - ) - - gamme_1: Optional[str] = Field( - None, description="Énumération gamme 1 (AR_Gamme1)" - ) - gamme_2: Optional[str] = Field( - None, description="Énumération gamme 2 (AR_Gamme2)" + prix_gammes: List[PrixGammeModel] = Field( + default_factory=list, + description="Prix par combinaison de gammes (F_ARTPRIX)" ) + nb_prix_gammes: int = Field(0, description="Nombre de prix par gammes") type_article: Optional[int] = Field( - None, - ge=0, - le=3, + None, ge=0, le=3, description="Type : 0=Article, 1=Prestation, 2=Divers, 3=Nomenclature (AR_Type)" ) - type_article_libelle: Optional[str] = Field( - None, description="Libellé du type d'article" - ) + type_article_libelle: Optional[str] = Field(None, description="Libellé du type d'article") - famille_code: Optional[str] = Field( - None, max_length=20, description="Code famille (FA_CodeFamille)" - ) - famille_libelle: Optional[str] = Field( - None, description="Libellé de la famille (F_FAMILLE.FA_Intitule)" - ) - famille_type: Optional[int] = Field( - None, description="Type de famille : 0=Détail, 1=Total (F_FAMILLE.FA_Type)" - ) - famille_unite_vente: Optional[str] = Field( - None, description="Unité de vente de la famille (F_FAMILLE.FA_UniteVen)" - ) - famille_coef: Optional[float] = Field( - None, description="Coefficient de la famille (F_FAMILLE.FA_Coef)" - ) - famille_suivi_stock: Optional[bool] = Field( - None, description="Suivi stock de la famille (F_FAMILLE.FA_SuiviStock)" - ) - famille_garantie: Optional[int] = Field( - None, description="Garantie de la famille (F_FAMILLE.FA_Garantie)" - ) - famille_unite_poids: Optional[str] = Field( - None, description="Unité de poids de la famille (F_FAMILLE.FA_UnitePoids)" - ) - famille_delai: Optional[int] = Field( - None, description="Délai de la famille (F_FAMILLE.FA_Delai)" - ) - famille_nb_colis: Optional[int] = Field( - None, description="Nombre de colis de la famille (F_FAMILLE.FA_NbColis)" - ) - famille_code_fiscal: Optional[str] = Field( - None, description="Code fiscal de la famille (F_FAMILLE.FA_CodeFiscal)" - ) - famille_escompte: Optional[bool] = Field( - None, description="Escompte de la famille (F_FAMILLE.FA_Escompte)" - ) - famille_centrale: Optional[bool] = Field( - None, description="Famille centrale (F_FAMILLE.FA_Central)" - ) - famille_nature: Optional[int] = Field( - None, description="Nature de la famille (F_FAMILLE.FA_Nature)" - ) - famille_hors_stat: Optional[bool] = Field( - None, description="Hors statistique famille (F_FAMILLE.FA_HorsStat)" - ) - famille_pays: Optional[str] = Field( - None, description="Pays de la famille (F_FAMILLE.FA_Pays)" - ) + famille_code: Optional[str] = Field(None, max_length=20, description="Code famille (FA_CodeFamille)") + famille_libelle: Optional[str] = Field(None, description="Libellé de la famille (F_FAMILLE.FA_Intitule)") + famille_type: Optional[int] = Field(None, description="Type de famille : 0=Détail, 1=Total (F_FAMILLE.FA_Type)") + famille_unite_vente: Optional[str] = Field(None, description="Unité de vente de la famille (F_FAMILLE.FA_UniteVen)") + famille_coef: Optional[float] = Field(None, description="Coefficient de la famille (F_FAMILLE.FA_Coef)") + famille_suivi_stock: Optional[bool] = Field(None, description="Suivi stock de la famille (F_FAMILLE.FA_SuiviStock)") + famille_garantie: Optional[int] = Field(None, description="Garantie de la famille (F_FAMILLE.FA_Garantie)") + famille_unite_poids: Optional[str] = Field(None, description="Unité de poids de la famille (F_FAMILLE.FA_UnitePoids)") + famille_delai: Optional[int] = Field(None, description="Délai de la famille (F_FAMILLE.FA_Delai)") + famille_nb_colis: Optional[int] = Field(None, description="Nombre de colis de la famille (F_FAMILLE.FA_NbColis)") + famille_code_fiscal: Optional[str] = Field(None, description="Code fiscal de la famille (F_FAMILLE.FA_CodeFiscal)") + famille_escompte: Optional[bool] = Field(None, description="Escompte de la famille (F_FAMILLE.FA_Escompte)") + famille_centrale: Optional[bool] = Field(None, description="Famille centrale (F_FAMILLE.FA_Central)") + famille_nature: Optional[int] = Field(None, description="Nature de la famille (F_FAMILLE.FA_Nature)") + famille_hors_stat: Optional[bool] = Field(None, description="Hors statistique famille (F_FAMILLE.FA_HorsStat)") + famille_pays: Optional[str] = Field(None, description="Pays de la famille (F_FAMILLE.FA_Pays)") - nature: Optional[int] = Field( - None, description="Nature de l'article (AR_Nature)" - ) - garantie: Optional[int] = Field( - None, description="Durée de garantie en mois (AR_Garantie)" - ) - code_fiscal: Optional[str] = Field( - None, max_length=10, description="Code fiscal/TVA (AR_CodeFiscal)" - ) - pays: Optional[str] = Field( - None, description="Pays d'origine (AR_Pays)" - ) + nature: Optional[int] = Field(None, description="Nature de l'article (AR_Nature)") + garantie: Optional[int] = Field(None, description="Durée de garantie en mois (AR_Garantie)") + code_fiscal: Optional[str] = Field(None, max_length=10, description="Code fiscal/TVA (AR_CodeFiscal)") + pays: Optional[str] = Field(None, description="Pays d'origine (AR_Pays)") - fournisseur_principal: Optional[int] = Field( - None, description="N° compte du fournisseur principal (CO_No → F_COMPTET.CT_Num)" - ) - fournisseur_nom: Optional[str] = Field( - None, description="Nom du fournisseur principal (F_COMPTET.CT_Intitule)" - ) - conditionnement: Optional[str] = Field( - None, description="Conditionnement d'achat (AR_Condition)" - ) - nb_colis: Optional[int] = Field( - None, description="Nombre de colis par unité (AR_NbColis)" - ) - prevision: Optional[bool] = Field( - None, description="Gestion en prévision (AR_Prevision)" - ) + fournisseur_principal: Optional[int] = Field(None, description="N° compte du fournisseur principal (CO_No → F_COMPTET.CT_Num)") + fournisseur_nom: Optional[str] = Field(None, description="Nom du fournisseur principal (F_COMPTET.CT_Intitule)") - est_actif: bool = Field( - default=True, description="Article actif (AR_Sommeil = 0)" - ) - en_sommeil: bool = Field( - default=False, description="Article en sommeil (AR_Sommeil = 1)" - ) - article_substitut: Optional[str] = Field( - None, description="Référence article de substitution (AR_Substitut)" - ) - soumis_escompte: Optional[bool] = Field( - None, description="Soumis à escompte (AR_Escompte)" - ) - delai: Optional[int] = Field( - None, description="Délai de livraison en jours (AR_Delai)" - ) + conditionnement: Optional[str] = Field(None, description="Conditionnement d'achat (AR_Condition)") + conditionnement_qte: Optional[float] = Field(None, description="Quantité conditionnement (F_ENUMCOND.EC_Quantite)") + conditionnement_edi: Optional[str] = Field(None, description="Code EDI conditionnement (F_ENUMCOND.EC_EdiCode)") - publie: Optional[bool] = Field( - None, description="Publié sur web/catalogue (AR_Publie)" - ) - hors_statistique: Optional[bool] = Field( - None, description="Exclus des statistiques (AR_HorsStat)" - ) - vente_debit: Optional[bool] = Field( - None, description="Vente au débit (AR_VteDebit)" - ) - non_imprimable: Optional[bool] = Field( - None, description="Non imprimable sur documents (AR_NotImp)" - ) - transfere: Optional[bool] = Field( - None, description="Article transféré (AR_Transfere)" - ) - contremarque: Optional[bool] = Field( - None, description="Article en contremarque (AR_Contremarque)" - ) - fact_poids: Optional[bool] = Field( - None, description="Facturation au poids (AR_FactPoids)" - ) - fact_forfait: Optional[bool] = Field( - None, description="Facturation au forfait (AR_FactForfait)" - ) - saisie_variable: Optional[bool] = Field( - None, description="Saisie variable (AR_SaisieVar)" - ) - fictif: Optional[bool] = Field( - None, description="Article fictif (AR_Fictif)" - ) - sous_traitance: Optional[bool] = Field( - None, description="Article en sous-traitance (AR_SousTraitance)" - ) - criticite: Optional[int] = Field( - None, description="Niveau de criticité (AR_Criticite)" - ) + nb_colis: Optional[int] = Field(None, description="Nombre de colis par unité (AR_NbColis)") + prevision: Optional[bool] = Field(None, description="Gestion en prévision (AR_Prevision)") - reprise_code_defaut: Optional[str] = Field( - None, description="Code reprise par défaut (RP_CodeDefaut)" - ) - delai_fabrication: Optional[int] = Field( - None, description="Délai de fabrication (AR_DelaiFabrication)" - ) - delai_peremption: Optional[int] = Field( - None, description="Délai de péremption (AR_DelaiPeremption)" - ) - delai_securite: Optional[int] = Field( - None, description="Délai de sécurité (AR_DelaiSecurite)" - ) - type_lancement: Optional[int] = Field( - None, description="Type de lancement production (AR_TypeLancement)" - ) - cycle: Optional[int] = Field( - None, description="Cycle de production (AR_Cycle)" - ) + est_actif: bool = Field(default=True, description="Article actif (AR_Sommeil = 0)") + en_sommeil: bool = Field(default=False, description="Article en sommeil (AR_Sommeil = 1)") + article_substitut: Optional[str] = Field(None, description="Référence article de substitution (AR_Substitut)") + soumis_escompte: Optional[bool] = Field(None, description="Soumis à escompte (AR_Escompte)") + delai: Optional[int] = Field(None, description="Délai de livraison en jours (AR_Delai)") - photo: Optional[str] = Field( - None, description="Chemin/nom du fichier photo (AR_Photo)" - ) - langue_1: Optional[str] = Field( - None, description="Texte en langue 1 (AR_Langue1)" - ) - langue_2: Optional[str] = Field( - None, description="Texte en langue 2 (AR_Langue2)" - ) + publie: Optional[bool] = Field(None, description="Publié sur web/catalogue (AR_Publie)") + hors_statistique: Optional[bool] = Field(None, description="Exclus des statistiques (AR_HorsStat)") + vente_debit: Optional[bool] = Field(None, description="Vente au débit (AR_VteDebit)") + non_imprimable: Optional[bool] = Field(None, description="Non imprimable sur documents (AR_NotImp)") + transfere: Optional[bool] = Field(None, description="Article transféré (AR_Transfere)") + contremarque: Optional[bool] = Field(None, description="Article en contremarque (AR_Contremarque)") + fact_poids: Optional[bool] = Field(None, description="Facturation au poids (AR_FactPoids)") + fact_forfait: Optional[bool] = Field(None, description="Facturation au forfait (AR_FactForfait)") + saisie_variable: Optional[bool] = Field(None, description="Saisie variable (AR_SaisieVar)") + fictif: Optional[bool] = Field(None, description="Article fictif (AR_Fictif)") + sous_traitance: Optional[bool] = Field(None, description="Article en sous-traitance (AR_SousTraitance)") + criticite: Optional[int] = Field(None, description="Niveau de criticité (AR_Criticite)") - frais_01_denomination: Optional[str] = Field( - None, description="Dénomination frais 1 (AR_Frais01FR_Denomination)" - ) - frais_02_denomination: Optional[str] = Field( - None, description="Dénomination frais 2 (AR_Frais02FR_Denomination)" - ) - frais_03_denomination: Optional[str] = Field( - None, description="Dénomination frais 3 (AR_Frais03FR_Denomination)" - ) + reprise_code_defaut: Optional[str] = Field(None, description="Code reprise par défaut (RP_CodeDefaut)") + delai_fabrication: Optional[int] = Field(None, description="Délai de fabrication (AR_DelaiFabrication)") + delai_peremption: Optional[int] = Field(None, description="Délai de péremption (AR_DelaiPeremption)") + delai_securite: Optional[int] = Field(None, description="Délai de sécurité (AR_DelaiSecurite)") + type_lancement: Optional[int] = Field(None, description="Type de lancement production (AR_TypeLancement)") + cycle: Optional[int] = Field(None, description="Cycle de production (AR_Cycle)") - tva_code: Optional[str] = Field( - None, description="Code TVA (F_TAXE.TA_Code)" - ) - tva_taux: Optional[float] = Field( - None, description="Taux de TVA en % (F_TAXE.TA_Taux)" - ) + photo: Optional[str] = Field(None, description="Chemin/nom du fichier photo (AR_Photo)") + langue_1: Optional[str] = Field(None, description="Texte en langue 1 (AR_Langue1)") + langue_2: Optional[str] = Field(None, description="Texte en langue 2 (AR_Langue2)") + + frais_01_denomination: Optional[str] = Field(None, description="Dénomination frais 1 (AR_Frais01FR_Denomination)") + frais_02_denomination: Optional[str] = Field(None, description="Dénomination frais 2 (AR_Frais02FR_Denomination)") + frais_03_denomination: Optional[str] = Field(None, description="Dénomination frais 3 (AR_Frais03FR_Denomination)") + + tva_code: Optional[str] = Field(None, description="Code TVA (F_TAXE.TA_Code)") + tva_taux: Optional[float] = Field(None, description="Taux de TVA en % (F_TAXE.TA_Taux)") stat_01: Optional[str] = Field(None, description="Statistique 1 (AR_Stat01)") stat_02: Optional[str] = Field(None, description="Statistique 2 (AR_Stat02)") @@ -821,149 +1023,74 @@ class ArticleResponse(BaseModel): stat_04: Optional[str] = Field(None, description="Statistique 4 (AR_Stat04)") stat_05: Optional[str] = Field(None, description="Statistique 5 (AR_Stat05)") - categorie_1: Optional[int] = Field( - None, description="Catégorie comptable 1 (CL_No1)" - ) - categorie_2: Optional[int] = Field( - None, description="Catégorie comptable 2 (CL_No2)" - ) - categorie_3: Optional[int] = Field( - None, description="Catégorie comptable 3 (CL_No3)" - ) - categorie_4: Optional[int] = Field( - None, description="Catégorie comptable 4 (CL_No4)" - ) + categorie_1: Optional[int] = Field(None, description="Catégorie comptable 1 (CL_No1)") + categorie_2: Optional[int] = Field(None, description="Catégorie comptable 2 (CL_No2)") + categorie_3: Optional[int] = Field(None, description="Catégorie comptable 3 (CL_No3)") + categorie_4: Optional[int] = Field(None, description="Catégorie comptable 4 (CL_No4)") - date_modification: Optional[str] = Field( - None, description="Date de dernière modification (AR_DateModif)" - ) + date_modification: Optional[str] = Field(None, description="Date de dernière modification (AR_DateModif)") - marque_commerciale: Optional[str] = Field( - None, description="Marque commerciale (champ personnalisé)" - ) - objectif_qtes_vendues: Optional[str] = Field( - None, description="Objectif / Quantités vendues (champ personnalisé)" - ) - pourcentage_or: Optional[str] = Field( - None, description="Pourcentage teneur en or (champ personnalisé)" - ) - premiere_commercialisation: Optional[str] = Field( - None, description="Date de 1ère commercialisation (champ personnalisé)" - ) - interdire_commande: Optional[bool] = Field( - None, description="Interdire la commande (champ personnalisé)" - ) - exclure: Optional[bool] = Field( - None, description="Exclure de certains traitements (champ personnalisé)" - ) + marque_commerciale: Optional[str] = Field(None, description="Marque commerciale (champ personnalisé)") + objectif_qtes_vendues: Optional[str] = Field(None, description="Objectif / Quantités vendues (champ personnalisé)") + pourcentage_or: Optional[str] = Field(None, description="Pourcentage teneur en or (champ personnalisé)") + premiere_commercialisation: Optional[str] = Field(None, description="Date de 1ère commercialisation (champ personnalisé)") + interdire_commande: Optional[bool] = Field(None, description="Interdire la commande (champ personnalisé)") + exclure: Optional[bool] = Field(None, description="Exclure de certains traitements (champ personnalisé)") class Config: json_schema_extra = { "example": { "reference": "BAGUE-001", "designation": "Bague Or 18K Diamant", - "code_ean": "3760123456789", - "edi_code": "EAN13", - "raccourci": "BAG001", "prix_vente": 1299.00, - "prix_achat": 850.00, - "coef": 1.53, - "prix_net": 1199.00, - "prix_achat_nouveau": 875.00, - "date_application_prix": "2025-01-01", - "cout_standard": 860.00, "stock_reel": 15.0, - "stock_mini": 3.0, - "stock_maxi": 30.0, - "stock_reserve": 2.0, - "stock_commande": 10.0, "stock_disponible": 13.0, - "suivi_stock": True, - "nomenclature": False, - "unite_vente": "PCE", - "unite_poids": "GR", - "poids_net": 3.5, - "poids_brut": 3.8, - "gamme_1": "Or", - "gamme_2": "18K", - "type_article": 0, - "type_article_libelle": "Article", - "famille_code": "BAGUE", - "famille_libelle": "Bagues", - "famille_type": 0, - "famille_unite_vente": "PCE", - "famille_coef": 1.5, - "famille_suivi_stock": True, - "famille_garantie": 24, - "nature": 1, - "garantie": 24, - "code_fiscal": "TVA20", - "pays": "FR", - "fournisseur_principal": 150, - "fournisseur_nom": "Bijoux & Co SAS", - "conditionnement": "Écrin", - "nb_colis": 1, - "prevision": False, - "est_actif": True, - "en_sommeil": False, - "soumis_escompte": True, - "delai": 7, - "publie": True, - "hors_statistique": False, - "vente_debit": False, - "non_imprimable": False, - "transfere": False, - "contremarque": False, - "fact_poids": False, - "fact_forfait": False, - "fictif": False, - "sous_traitance": False, - "criticite": 2, - "delai_fabrication": 5, - "photo": "bague001.jpg", - "langue_1": "Gold Ring with Diamond", - "langue_2": "Anillo de Oro con Diamante", - "tva_code": "T20", - "tva_taux": 20.0, - "stat_01": "Luxe", - "stat_02": "Féminin", - "categorie_1": 1, - "categorie_2": 5, - "date_modification": "2024-12-15", - "marque_commerciale": "Elite Collection", - "objectif_qtes_vendues": "50", - "pourcentage_or": "75.0", - "premiere_commercialisation": "2024-01-01", - "interdire_commande": False, - "exclure": False + "nb_emplacements": 2, + "nb_gammes": 2, + "nb_tarifs_clients": 3, + "nb_fournisseurs": 2, + "nb_medias": 2, + "emplacements": [ + { + "depot": "01", + "emplacement": "A1-01", + "qte_stockee": 10.0, + "depot_nom": "Dépôt principal" + } + ], + "gammes": [ + { + "numero_gamme": 1, + "enumere": "001", + "gamme_nom": "Taille" + } + ] } } - + class ArticleListResponse(BaseModel): """Réponse pour une liste d'articles""" total: int = Field(..., description="Nombre total d'articles") - articles: list[ArticleResponse] = Field(..., description="Liste des articles") - filtre_applique: Optional[str] = Field( - None, description="Filtre de recherche appliqué" - ) - avec_stock: bool = Field( - True, description="Indique si les stocks ont été chargés" - ) - avec_famille: bool = Field( - True, description="Indique si les familles ont été enrichies" + articles: List[ArticleResponse] = Field(..., description="Liste des articles") + filtre_applique: Optional[str] = Field(None, description="Filtre de recherche appliqué") + avec_stock: bool = Field(True, description="Indique si les stocks ont été chargés") + avec_famille: bool = Field(True, description="Indique si les familles ont été enrichies") + avec_enrichissements_complets: bool = Field( + False, + description="Indique si tous les enrichissements sont activés" ) class Config: json_schema_extra = { "example": { "total": 1250, - "filtre_applique": "ordinateur", + "filtre_applique": "bague", "avec_stock": True, "avec_famille": True, - "articles": [ - ] + "avec_enrichissements_complets": True, + "articles": [] } } @@ -2138,7 +2265,6 @@ class FamilleCreateRequest(BaseModel): class FamilleResponse(BaseModel): """Modèle complet d'une famille avec données comptables et fournisseur""" - # ========== IDENTIFICATION ========== code: str = Field(..., description="Code famille") intitule: str = Field(..., description="Intitulé") type: int = Field(..., description="Type (0=Détail, 1=Total)") @@ -2146,33 +2272,27 @@ class FamilleResponse(BaseModel): est_total: bool = Field(..., description="True si type Total") est_detail: bool = Field(..., description="True si type Détail") - # ========== UNITÉS ET COEFFICIENTS ========== unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") unite_poids: Optional[str] = Field(None, description="Unité de poids") coef: Optional[float] = Field(None, description="Coefficient multiplicateur") - # ========== GESTION STOCK ========== suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé") garantie: Optional[int] = Field(None, description="Durée de garantie (mois)") delai: Optional[int] = Field(None, description="Délai de livraison (jours)") nb_colis: Optional[int] = Field(None, description="Nombre de colis") - # ========== FISCAL ET COMPTABLE ========== code_fiscal: Optional[str] = Field(None, description="Code fiscal par défaut") escompte: Optional[bool] = Field(None, description="Escompte autorisé") - # ========== CARACTÉRISTIQUES ========== est_centrale: Optional[bool] = Field(None, description="Famille d'achat centralisé") nature: Optional[int] = Field(None, description="Nature de la famille") pays: Optional[str] = Field(None, description="Pays d'origine") - # ========== CATÉGORIES COMPTABLES ========== categorie_1: Optional[int] = Field(None, description="Catégorie comptable 1 (CL_No1)") categorie_2: Optional[int] = Field(None, description="Catégorie comptable 2 (CL_No2)") categorie_3: Optional[int] = Field(None, description="Catégorie comptable 3 (CL_No3)") categorie_4: Optional[int] = Field(None, description="Catégorie comptable 4 (CL_No4)") - # ========== STATISTIQUES ========== stat_01: Optional[str] = Field(None, description="Statistique libre 1") stat_02: Optional[str] = Field(None, description="Statistique libre 2") stat_03: Optional[str] = Field(None, description="Statistique libre 3") @@ -2180,7 +2300,6 @@ class FamilleResponse(BaseModel): stat_05: Optional[str] = Field(None, description="Statistique libre 5") hors_statistique: Optional[bool] = Field(None, description="Exclue des statistiques") - # ========== OPTIONS DIVERSES ========== vente_debit: Optional[bool] = Field(None, description="Vente au débit") non_imprimable: Optional[bool] = Field(None, description="Non imprimable sur documents") contremarque: Optional[bool] = Field(None, description="Article en contremarque") @@ -2188,17 +2307,14 @@ class FamilleResponse(BaseModel): fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire") publie: Optional[bool] = Field(None, description="Publié (e-commerce)") - # ========== RÉFÉRENCES ET RACCOURCIS ========== racine_reference: Optional[str] = Field(None, description="Racine pour génération auto de références") racine_code_barre: Optional[str] = Field(None, description="Racine pour génération auto de codes-barres") raccourci: Optional[str] = Field(None, description="Raccourci clavier") - # ========== GESTION AVANCÉE ========== sous_traitance: Optional[bool] = Field(None, description="Famille en sous-traitance") fictif: Optional[bool] = Field(None, description="Famille fictive (nomenclature)") criticite: Optional[int] = Field(None, description="Niveau de criticité (0-5)") - # ========== COMPTABILITÉ VENTE (F_FAMCOMPTA Type=0) ========== compte_vente: Optional[str] = Field(None, description="Compte général de vente") compte_auxiliaire_vente: Optional[str] = Field(None, description="Compte auxiliaire de vente") tva_vente_1: Optional[str] = Field(None, description="Code TVA vente principal") @@ -2206,7 +2322,6 @@ class FamilleResponse(BaseModel): tva_vente_3: Optional[str] = Field(None, description="Code TVA vente tertiaire") type_facture_vente: Optional[int] = Field(None, description="Type de facture vente") - # ========== COMPTABILITÉ ACHAT (F_FAMCOMPTA Type=1) ========== compte_achat: Optional[str] = Field(None, description="Compte général d'achat") compte_auxiliaire_achat: Optional[str] = Field(None, description="Compte auxiliaire d'achat") tva_achat_1: Optional[str] = Field(None, description="Code TVA achat principal") @@ -2214,11 +2329,9 @@ class FamilleResponse(BaseModel): tva_achat_3: Optional[str] = Field(None, description="Code TVA achat tertiaire") type_facture_achat: Optional[int] = Field(None, description="Type de facture achat") - # ========== COMPTABILITÉ STOCK (F_FAMCOMPTA Type=2) ========== compte_stock: Optional[str] = Field(None, description="Compte de stock") compte_auxiliaire_stock: Optional[str] = Field(None, description="Compte auxiliaire de stock") - # ========== FOURNISSEUR PRINCIPAL (F_FAMFOURNISS) ========== fournisseur_principal: Optional[str] = Field(None, description="N° compte fournisseur principal") fournisseur_unite: Optional[str] = Field(None, description="Unité d'achat fournisseur") fournisseur_conversion: Optional[float] = Field(None, description="Coefficient de conversion") @@ -2231,10 +2344,8 @@ class FamilleResponse(BaseModel): fournisseur_remise: Optional[float] = Field(None, description="Remise fournisseur (%)") fournisseur_type_remise: Optional[int] = Field(None, description="Type de remise (0=%, 1=Montant)") - # ========== INFORMATIONS COMPLÉMENTAIRES ========== nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille") - # ========== CHAMPS LEGACY (rétrocompatibilité) ========== FA_CodeFamille: Optional[str] = Field(None, description="[Legacy] Code famille") FA_Intitule: Optional[str] = Field(None, description="[Legacy] Intitulé") FA_Type: Optional[int] = Field(None, description="[Legacy] Type") From 18699a86736f6ce79746c02a12462f01b628d922 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sun, 28 Dec 2025 21:20:15 +0300 Subject: [PATCH 121/199] Added contact handling --- api.py | 167 +++++++++++++++++++++++++++++++++++++++++++++++-- sage_client.py | 58 +++++++++++++++++ 2 files changed, 220 insertions(+), 5 deletions(-) diff --git a/api.py b/api.py index 764cc97..f04ccd2 100644 --- a/api.py +++ b/api.py @@ -130,8 +130,8 @@ class Contact(BaseModel): Tous les champs de F_CONTACTT """ - ct_num: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)") - ct_no: Optional[int] = Field(None, description="Numéro unique du contact (CT_No)") + numero: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)") + contact_numero: Optional[int] = Field(None, description="Numéro unique du contact (CT_No)") n_contact: Optional[int] = Field(None, description="Numéro de référence contact (N_Contact)") civilite: Optional[str] = Field(None, description="Civilité : M., Mme, Mlle (CT_Civilite)") @@ -150,6 +150,11 @@ class Contact(BaseModel): linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") skype: Optional[str] = Field(None, description="Identifiant Skype (CT_Skype)") + est_defaut: Optional[bool] = Field( + False, + description="True si ce contact est le contact par défaut du client" + ) + civilite_map: ClassVar[dict] = { 0: "M.", 1: "Mme", @@ -168,8 +173,8 @@ class Contact(BaseModel): class Config: json_schema_extra = { "example": { - "ct_num": "CLI000001", - "ct_no": 1, + "numero": "CLI000001", + "contact_numero": 1, "n_contact": 1, "civilite": "M.", "nom": "Dupont", @@ -2578,6 +2583,67 @@ class MouvementStockResponse(BaseModel): nb_lignes: int = Field(..., description="Nombre de lignes") +class ContactCreate(BaseModel): + """Données pour créer ou modifier un contact""" + numero: str = Field(..., description="Code du client parent (obligatoire)") + + civilite: Optional[str] = Field(None, description="M., Mme, Mlle, Société") + nom: str = Field(..., description="Nom de famille (obligatoire)") + prenom: Optional[str] = Field(None, description="Prénom") + fonction: Optional[str] = Field(None, description="Fonction/Titre") + + est_defaut: Optional[bool] = Field(False, description="Définir comme contact par défaut du client") + + service_code: Optional[int] = Field(None, description="Code du service") + + 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") + + facebook: Optional[str] = Field(None, description="URL Facebook") + linkedin: Optional[str] = Field(None, description="URL LinkedIn") + skype: Optional[str] = Field(None, description="Identifiant Skype") + + @validator("civilite") + def validate_civilite(cls, v): + if v and v not in ["M.", "Mme", "Mlle", "Société"]: + raise ValueError("Civilité doit être: M., Mme, Mlle ou Société") + return v + + class Config: + json_schema_extra = { + "example": { + "numero": "CLI000001", + "civilite": "M.", + "nom": "Dupont", + "prenom": "Jean", + "fonction": "Directeur Commercial", + "telephone": "0123456789", + "portable": "0612345678", + "email": "j.dupont@exemple.fr", + "linkedin": "https://linkedin.com/in/jeandupont", + "est_defaut": True + } + } + + +class ContactUpdate(BaseModel): + """Données pour modifier un contact (tous champs optionnels)""" + civilite: Optional[str] = None + nom: Optional[str] = None + prenom: Optional[str] = None + fonction: Optional[str] = None + service_code: Optional[int] = None + telephone: Optional[str] = None + portable: Optional[str] = None + telecopie: Optional[str] = None + email: Optional[str] = None + facebook: Optional[str] = None + linkedin: Optional[str] = None + skype: Optional[str] = None + est_defaut: Optional[bool] = None + templates_signature_email = { "demande_signature": { "id": "demande_signature", @@ -3218,7 +3284,7 @@ app.include_router(auth_router) @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) -async def rechercher_clients(query: Optional[str] = Query(None)): +async def obtenir_clients(query: Optional[str] = Query(None)): try: clients = sage_client.lister_clients(filtre=query or "") return [ClientDetails(**c) for c in clients] @@ -5926,6 +5992,97 @@ async def get_document_pdf( +@app.post("/clients/{numero}/contacts", response_model=Contact, tags=["Contacts"]) +async def creer_contact(numero: str, contact: ContactCreate): + try: + try: + sage_client.obtenir_client(numero) + except: + raise HTTPException(404, f"Client {numero} non trouvé") + + if contact.numero != numero: + contact.numero = numero + + resultat = sage_client.creer_contact(contact.dict()) + return Contact(**resultat) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création contact: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/clients/{numero}/contacts", response_model=List[Contact], tags=["Contacts"]) +async def lister_contacts_client(numero: str): + try: + contacts = sage_client.lister_contacts(numero) + return [Contact(**c) for c in contacts] + except Exception as e: + logger.error(f"Erreur liste contacts: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/clients/{numero}/contacts/{contact_numero}", response_model=Contact, tags=["Contacts"]) +async def obtenir_contact(numero: str, contact_numero: int): + try: + contact = sage_client.obtenir_contact(numero, contact_numero) + if not contact: + raise HTTPException(404, f"Contact {contact_numero} non trouvé pour client {numero}") + return Contact(**contact) + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur récupération contact: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/clients/{numero}/contacts/{contact_numero}", response_model=Contact, tags=["Contacts"]) +async def modifier_contact(numero: str, contact_numero: int, contact: ContactUpdate): + try: + contact_existant = sage_client.obtenir_contact(numero, contact_numero) + if not contact_existant: + raise HTTPException(404, f"Contact {contact_numero} non trouvé") + + updates = {k: v for k, v in contact.dict().items() if v is not None} + + if not updates: + raise HTTPException(400, "Aucune modification fournie") + + resultat = sage_client.modifier_contact(numero, contact_numero, updates) + return Contact(**resultat) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification contact: {e}") + raise HTTPException(500, str(e)) + + +@app.delete("/clients/{numero}/contacts/{contact_numero}", tags=["Contacts"]) +async def supprimer_contact(numero: str, contact_numero: int): + try: + resultat = sage_client.supprimer_contact(numero, contact_numero) + return {"success": True, "message": f"Contact {contact_numero} supprimé"} + except Exception as e: + logger.error(f"Erreur suppression contact: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/clients/{numero}/contacts/{contact_numero}/definir-defaut", tags=["Contacts"]) +async def definir_contact_defaut(numero: str, contact_numero: int): + try: + resultat = sage_client.definir_contact_defaut(numero, contact_numero) + return { + "success": True, + "message": f"Contact {contact_numero} défini comme contact par défaut", + "data": resultat + } + except Exception as e: + logger.error(f"Erreur définition contact par défaut: {e}") + raise HTTPException(500, str(e)) + + if __name__ == "__main__": uvicorn.run( "api:app", diff --git a/sage_client.py b/sage_client.py index 631669d..aba8338 100644 --- a/sage_client.py +++ b/sage_client.py @@ -450,4 +450,62 @@ class SageGatewayClient: raise + + def creer_contact(self, contact_data: Dict) -> Dict: + return self._post("/sage/contacts/create", contact_data) + + + def lister_contacts(self, numero: str) -> List[Dict]: + return self._post("/sage/contacts/list", {"numero": numero}).get("data", []) + + + def obtenir_contact(self, numero: str, contact_numero: int) -> Dict: + result = self._post("/sage/contacts/get", { + "numero": numero, + "contact_numero": contact_numero + }) + return result.get("data") if result.get("success") else None + + + def modifier_contact(self, numero: str, contact_numero: int, updates: Dict) -> Dict: + return self._post("/sage/contacts/update", { + "numero": numero, + "contact_numero": contact_numero, + "updates": updates + }) + + + def supprimer_contact(self, numero: str, contact_numero: int) -> Dict: + """ + Supprime un contact + + Args: + numero: Code du client + contact_numero: Numéro unique du contact + + Returns: + Dictionnaire avec le statut de la suppression + """ + return self._post("/sage/contacts/delete", { + "numero": numero, + "contact_numero": contact_numero + }) + + + def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict: + """ + Définit un contact comme contact par défaut du client + + Args: + numero: Code du client + contact_numero: Numéro unique du contact à définir comme par défaut + + Returns: + Dictionnaire avec les données du client mis à jour + """ + return self._post("/sage/contacts/set-default", { + "numero": numero, + "contact_numero": contact_numero + }) + sage_client = SageGatewayClient() From 8859152379c4a405c1eae89fa50a0a06c1c340b0 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Sun, 28 Dec 2025 21:34:13 +0300 Subject: [PATCH 122/199] Modified client's retrieving method on creating a new contact --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index f04ccd2..a7bbacb 100644 --- a/api.py +++ b/api.py @@ -5996,7 +5996,7 @@ async def get_document_pdf( async def creer_contact(numero: str, contact: ContactCreate): try: try: - sage_client.obtenir_client(numero) + sage_client.lire_client(numero) except: raise HTTPException(404, f"Client {numero} non trouvé") From 1d78c6b46b551d019c04f98c12c1dbbd95cc7668 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 29 Dec 2025 11:20:55 +0300 Subject: [PATCH 123/199] feat(tiers): add tiers schemas and API endpoints --- Dockerfile | 2 +- api.py | 56 +++++++++++++++---- core/dependencies.py | 12 +---- create_admin.py | 28 +++++----- database/db_config.py | 10 ++-- email_queue.py | 34 ++++++------ init_db.py | 8 +-- routes/auth.py | 59 ++++++-------------- sage_client.py | 51 ++++++++---------- schemas/__init__.py | 9 ++++ schemas/tiers/contact.py | 41 ++++++++++++++ schemas/tiers/tiers.py | 104 ++++++++++++++++++++++++++++++++++++ schemas/tiers/type_tiers.py | 6 +++ services/email_service.py | 20 +++---- 14 files changed, 295 insertions(+), 145 deletions(-) create mode 100644 schemas/__init__.py create mode 100644 schemas/tiers/contact.py create mode 100644 schemas/tiers/tiers.py create mode 100644 schemas/tiers/type_tiers.py diff --git a/Dockerfile b/Dockerfile index 7e49ad0..0348090 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN pip install --no-cache-dir --upgrade pip \ # Copier le reste du projet COPY . . -# ✅ Créer dossier persistant pour SQLite avec bonnes permissions +# Créer dossier persistant pour SQLite avec bonnes permissions RUN mkdir -p /app/data && chmod 777 /app/data # Exposer le port diff --git a/api.py b/api.py index a7bbacb..bff3fe0 100644 --- a/api.py +++ b/api.py @@ -30,6 +30,8 @@ from database import ( from email_queue import email_queue from sage_client import sage_client +from schemas import TiersDetails, TypeTiers + logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", @@ -1582,7 +1584,7 @@ class ClientCreateRequest(BaseModel): def to_sage_dict(self) -> dict: """ Convertit le modèle en dictionnaire compatible avec creer_client() - ✅ Mapping 1:1 avec les paramètres réels de la fonction + Mapping 1:1 avec les paramètres réels de la fonction """ stat01 = self.statistique01 or self.secteur @@ -1701,8 +1703,8 @@ class ClientCreateRequest(BaseModel): class ClientUpdateRequest(BaseModel): """ Modèle pour modification d'un client existant - ✅ TOUS les champs de ClientCreateRequest sont modifiables - ✅ TOUS optionnels (seuls les champs fournis sont modifiés) + TOUS les champs de ClientCreateRequest sont modifiables + TOUS optionnels (seuls les champs fournis sont modifiés) """ intitule: Optional[str] = Field(None, max_length=69) @@ -2842,7 +2844,7 @@ templates_signature_email = {

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

@@ -3004,7 +3006,7 @@ async def universign_envoyer( api_url = settings.universign_api_url auth = (api_key, "") - logger.info(f"🔐 Démarrage processus Universign pour {email}") + logger.info(f" Démarrage processus Universign pour {email}") logger.info(f"📌 Document: {doc_id} ({doc_data.get('type_label')})") if not pdf_bytes or len(pdf_bytes) == 0: @@ -3089,7 +3091,7 @@ async def universign_envoyer( field_id = response.json().get("id") logger.info(f"Champ créé: {field_id}") - logger.info("👤 ÉTAPE 5/6 : Liaison signataire au champ") + logger.info(" ÉTAPE 5/6 : Liaison signataire au champ") response = requests.post( f"{api_url}/transactions/{transaction_id}/signatures", # /signatures pas /signers @@ -3144,7 +3146,7 @@ async def universign_envoyer( logger.info("URL récupérée") - logger.info("📧 Préparation email") + logger.info(" Préparation email") template = templates_signature_email["demande_signature"] @@ -4137,7 +4139,7 @@ async def webhook_universign( email_queue.enqueue(email_log.id) logger.info( - f"📧 Email de confirmation envoyé: {signature_log.email_signataire}" + f" Email de confirmation envoyé: {signature_log.email_signataire}" ) elif event_type == "transaction.refused": @@ -4238,7 +4240,7 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se nb_relances += 1 logger.info( - f"📧 Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)" + f" Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)" ) except Exception as e: @@ -6083,6 +6085,42 @@ async def definir_contact_defaut(numero: str, contact_numero: int): raise HTTPException(500, str(e)) +@app.get("/tiers", response_model=List[TiersDetails], tags=["Tiers"]) +async def obtenir_tiers( + type_tiers: Optional[TypeTiers] = Query( + None, + description="Filtre par type: client, fournisseur, prospect, ou all" + ), + query: Optional[str] = Query( + None, + description="Recherche sur code ou intitulé" + ) +): + try: + tiers = sage_client.lister_tiers( + type_tiers=type_tiers.value if type_tiers else None, + filtre=query or "" + ) + return [TiersDetails(**t) for t in tiers] + except Exception as e: + logger.error(f" Erreur recherche tiers: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/tiers/{code}", response_model=TiersDetails, tags=["Tiers"]) +async def lire_tiers_detail(code: str): + try: + tiers = sage_client.lire_tiers(code) + if not tiers: + raise HTTPException(404, f"Tiers {code} introuvable") + return TiersDetails(**tiers) + except HTTPException: + raise + except Exception as e: + logger.error(f" Erreur lecture tiers {code}: {e}") + raise HTTPException(500, str(e)) + + if __name__ == "__main__": uvicorn.run( "api:app", diff --git a/core/dependencies.py b/core/dependencies.py index 7f8a5f9..69b6751 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -5,7 +5,7 @@ from sqlalchemy import select from database import get_session, User from security.auth import decode_token from typing import Optional -from datetime import datetime # ✅ AJOUT MANQUANT - C'ÉTAIT LE BUG ! +from datetime import datetime # AJOUT MANQUANT - C'ÉTAIT LE BUG ! security = HTTPBearer() @@ -14,14 +14,6 @@ async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), session: AsyncSession = Depends(get_session), ) -> User: - """ - Dépendance FastAPI pour extraire l'utilisateur du JWT - - Usage dans un endpoint: - @app.get("/protected") - async def protected_route(user: User = Depends(get_current_user)): - return {"user_id": user.id} - """ token = credentials.credentials # Décoder le token @@ -73,7 +65,7 @@ async def get_current_user( detail="Email non vérifié. Consultez votre boîte de réception.", ) - # ✅ FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!) + # FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!) if user.locked_until and user.locked_until > datetime.now(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/create_admin.py b/create_admin.py index 41f11b7..41b3da3 100644 --- a/create_admin.py +++ b/create_admin.py @@ -27,20 +27,20 @@ async def create_admin(): """Crée un utilisateur admin""" print("\n" + "=" * 60) - print("🔐 Création d'un compte administrateur") + print(" Création d'un compte administrateur") print("=" * 60 + "\n") # Saisie des informations email = input("Email de l'admin: ").strip().lower() if not email or "@" not in email: - print("❌ Email invalide") + print(" Email invalide") return False prenom = input("Prénom: ").strip() nom = input("Nom: ").strip() if not prenom or not nom: - print("❌ Prénom et nom requis") + print(" Prénom et nom requis") return False # Mot de passe avec validation @@ -55,9 +55,9 @@ async def create_admin(): if password == confirm: break else: - print("❌ Les mots de passe ne correspondent pas\n") + print(" Les mots de passe ne correspondent pas\n") else: - print(f"❌ {error_msg}\n") + print(f" {error_msg}\n") # Vérifier si l'email existe déjà async with async_session_factory() as session: @@ -67,7 +67,7 @@ async def create_admin(): existing = result.scalar_one_or_none() if existing: - print(f"\n❌ Un utilisateur avec l'email {email} existe déjà") + print(f"\n Un utilisateur avec l'email {email} existe déjà") return False # Créer l'admin @@ -86,12 +86,12 @@ async def create_admin(): session.add(admin) await session.commit() - print("\n✅ Administrateur créé avec succès!") - print(f"📧 Email: {email}") - print(f"👤 Nom: {prenom} {nom}") - print(f"🔑 Rôle: admin") - print(f"🆔 ID: {admin.id}") - print("\n💡 Vous pouvez maintenant vous connecter à l'API\n") + print("\n Administrateur créé avec succès!") + print(f" Email: {email}") + print(f" Nom: {prenom} {nom}") + print(f" Rôle: admin") + print(f" ID: {admin.id}") + print("\n Vous pouvez maintenant vous connecter à l'API\n") return True @@ -101,9 +101,9 @@ if __name__ == "__main__": result = asyncio.run(create_admin()) sys.exit(0 if result else 1) except KeyboardInterrupt: - print("\n\n❌ Création annulée") + print("\n\n Création annulée") sys.exit(1) except Exception as e: - print(f"\n❌ Erreur: {e}") + print(f"\n Erreur: {e}") logger.exception("Détails:") sys.exit(1) diff --git a/database/db_config.py b/database/db_config.py index f5bc0b4..eb7d347 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -27,17 +27,17 @@ async_session_factory = async_sessionmaker( async def init_db(): """ Crée toutes les tables dans la base de données - ⚠️ Utilise create_all qui ne crée QUE les tables manquantes + Utilise create_all qui ne crée QUE les tables manquantes """ try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - logger.info("✅ Base de données initialisée avec succès") - logger.info(f"📍 Fichier DB: {DATABASE_URL}") + logger.info(" Base de données initialisée avec succès") + logger.info(f" Fichier DB: {DATABASE_URL}") except Exception as e: - logger.error(f"❌ Erreur initialisation DB: {e}") + logger.error(f" Erreur initialisation DB: {e}") raise @@ -53,4 +53,4 @@ async def get_session() -> AsyncSession: async def close_db(): """Ferme proprement toutes les connexions""" await engine.dispose() - logger.info("🔌 Connexions DB fermées") + logger.info(" Connexions DB fermées") diff --git a/email_queue.py b/email_queue.py index 344f986..94e975e 100644 --- a/email_queue.py +++ b/email_queue.py @@ -20,9 +20,6 @@ logger = logging.getLogger(__name__) class EmailQueue: - """ - Queue d'emails avec workers threadés et retry automatique - """ def __init__(self): self.queue = queue.Queue() @@ -45,23 +42,23 @@ class EmailQueue: worker.start() self.workers.append(worker) - logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)") + logger.info(f" Queue email démarrée avec {num_workers} worker(s)") def stop(self): """Arrête les workers proprement""" - logger.info("🛑 Arrêt de la queue email...") + logger.info(" Arrêt de la queue email...") self.running = False try: self.queue.join() - logger.info("✅ Queue email arrêtée proprement") + logger.info(" Queue email arrêtée proprement") except: - logger.warning("⚠️ Timeout lors de l'arrêt de la queue") + logger.warning(" Timeout lors de l'arrêt de la queue") def enqueue(self, email_log_id: str): """Ajoute un email dans la queue""" self.queue.put(email_log_id) - logger.debug(f"📨 Email {email_log_id} ajouté à la queue") + logger.debug(f" Email {email_log_id} ajouté à la queue") def _worker(self): """Worker qui traite les emails dans un thread""" @@ -80,7 +77,7 @@ class EmailQueue: except queue.Empty: continue except Exception as e: - logger.error(f"❌ Erreur worker: {e}", exc_info=True) + logger.error(f" Erreur worker: {e}", exc_info=True) try: self.queue.task_done() except: @@ -94,7 +91,7 @@ class EmailQueue: from sqlalchemy import select if not self.session_factory: - logger.error("❌ session_factory non configuré") + logger.error(" session_factory non configuré") return async with self.session_factory() as session: @@ -104,7 +101,7 @@ class EmailQueue: email_log = result.scalar_one_or_none() if not email_log: - logger.error(f"❌ Email log {email_log_id} introuvable") + logger.error(f" Email log {email_log_id} introuvable") return email_log.statut = StatutEmail.EN_COURS @@ -117,7 +114,7 @@ class EmailQueue: email_log.statut = StatutEmail.ENVOYE email_log.date_envoi = datetime.now() email_log.derniere_erreur = None - logger.info(f"✅ Email envoyé: {email_log.destinataire}") + logger.info(f" Email envoyé: {email_log.destinataire}") except Exception as e: email_log.statut = StatutEmail.ERREUR @@ -134,10 +131,10 @@ class EmailQueue: timer.start() logger.warning( - f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}" + f" Retry prévu dans {delay}s pour {email_log.destinataire}" ) else: - logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}") + logger.error(f" Échec définitif: {email_log.destinataire} - {e}") await session.commit() @@ -176,20 +173,20 @@ class EmailQueue: logger.info(f"📎 PDF attaché: {doc_id}.pdf") except Exception as e: - logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") + logger.error(f" Erreur génération PDF {doc_id}: {e}") await asyncio.to_thread(self._send_smtp, msg) def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: if not self.sage_client: - logger.error("❌ sage_client non configuré") + logger.error(" sage_client non configuré") raise Exception("sage_client non disponible") try: doc = self.sage_client.lire_document(doc_id, type_doc) except Exception as e: - logger.error(f"❌ Erreur récupération document {doc_id}: {e}") + logger.error(f" Erreur récupération document {doc_id}: {e}") raise Exception(f"Document {doc_id} inaccessible") if not doc: @@ -284,11 +281,10 @@ class EmailQueue: pdf.save() buffer.seek(0) - logger.info(f"✅ PDF généré: {doc_id}.pdf") + logger.info(f" PDF généré: {doc_id}.pdf") return buffer.read() def _send_smtp(self, msg): - """Envoi SMTP bloquant (appelé depuis asyncio.to_thread)""" try: with smtplib.SMTP( settings.smtp_host, settings.smtp_port, timeout=30 diff --git a/init_db.py b/init_db.py index 7f5c174..2f1d61a 100644 --- a/init_db.py +++ b/init_db.py @@ -14,7 +14,7 @@ from pathlib import Path # Ajouter le répertoire parent au path pour les imports sys.path.insert(0, str(Path(__file__).parent)) -from database import init_db # ✅ Import depuis database/__init__.py +from database import init_db # Import depuis database/__init__.py import logging logging.basicConfig(level=logging.INFO) @@ -32,8 +32,8 @@ async def main(): # Créer les tables await init_db() - print("\n✅ Base de données créée avec succès!") - print(f"📍 Fichier: sage_dataven.db") + print("\n Base de données créée avec succès!") + print(f" Fichier: sage_dataven.db") print("\n📊 Tables créées:") print(" ├─ email_logs (Journalisation emails)") @@ -53,7 +53,7 @@ async def main(): return True except Exception as e: - print(f"\n❌ Erreur lors de l'initialisation: {e}") + print(f"\n Erreur lors de l'initialisation: {e}") logger.exception("Détails de l'erreur:") return False diff --git a/routes/auth.py b/routes/auth.py index 54406c1..c59bd20 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -95,12 +95,7 @@ async def log_login_attempt( async def check_rate_limit( session: AsyncSession, email: str, ip: str ) -> tuple[bool, str]: - """ - Vérifie si l'utilisateur/IP est rate limité - Returns: - (is_allowed, error_message) - """ time_window = datetime.now() - timedelta(minutes=15) result = await session.execute( @@ -126,13 +121,7 @@ async def register( request: Request, session: AsyncSession = Depends(get_session), ): - """ - 📝 Inscription d'un nouvel utilisateur - - Valide le mot de passe - - Crée le compte (non vérifié) - - Envoie email de vérification - """ result = await session.execute(select(User).where(User.email == data.email)) existing_user = result.scalar_one_or_none() @@ -170,7 +159,7 @@ async def register( if not email_sent: logger.warning(f"Échec envoi email vérification pour {data.email}") - logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") + logger.info(f" Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") return { "success": True, @@ -183,7 +172,7 @@ async def register( @router.get("/verify-email") async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): """ - ✅ Vérification de l'email via lien cliquable (GET) + Vérification de l'email via lien cliquable (GET) Utilisé quand l'utilisateur clique sur le lien dans l'email """ result = await session.execute(select(User).where(User.verification_token == token)) @@ -207,11 +196,11 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi user.verification_token_expires = None await session.commit() - logger.info(f"✅ Email vérifié: {user.email}") + logger.info(f" Email vérifié: {user.email}") return { "success": True, - "message": "✅ Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", + "message": " Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", "email": user.email, } @@ -221,7 +210,7 @@ async def verify_email_post( data: VerifyEmailRequest, session: AsyncSession = Depends(get_session) ): """ - ✅ Vérification de l'email via API (POST) + Vérification de l'email via API (POST) Utilisé pour les appels programmatiques depuis le frontend """ result = await session.execute( @@ -246,7 +235,7 @@ async def verify_email_post( user.verification_token_expires = None await session.commit() - logger.info(f"✅ Email vérifié: {user.email}") + logger.info(f" Email vérifié: {user.email}") return { "success": True, @@ -260,9 +249,6 @@ async def resend_verification( request: Request, session: AsyncSession = Depends(get_session), ): - """ - 🔄 Renvoyer l'email de vérification - """ result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() @@ -292,11 +278,6 @@ async def resend_verification( async def login( data: LoginRequest, request: Request, session: AsyncSession = Depends(get_session) ): - """ - 🔐 Connexion utilisateur - - Retourne access_token (30min) et refresh_token (7 jours) - """ ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") @@ -388,7 +369,7 @@ async def login( await log_login_attempt(session, data.email.lower(), ip, user_agent, True) - logger.info(f"✅ Connexion réussie: {user.email}") + logger.info(f" Connexion réussie: {user.email}") return TokenResponse( access_token=access_token, @@ -401,9 +382,7 @@ async def login( async def refresh_access_token( data: RefreshTokenRequest, session: AsyncSession = Depends(get_session) ): - """ - 🔄 Renouvellement du access_token via refresh_token - """ + payload = decode_token(data.refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException( @@ -446,7 +425,7 @@ async def refresh_access_token( {"sub": user.id, "email": user.email, "role": user.role} ) - logger.info(f"🔄 Token rafraîchi: {user.email}") + logger.info(f" Token rafraîchi: {user.email}") return TokenResponse( access_token=new_access_token, @@ -461,9 +440,7 @@ async def forgot_password( request: Request, session: AsyncSession = Depends(get_session), ): - """ - 🔑 Demande de réinitialisation de mot de passe - """ + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() @@ -485,7 +462,7 @@ async def forgot_password( ) AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url) - logger.info(f"📧 Reset password demandé: {user.email}") + logger.info(f" Reset password demandé: {user.email}") return { "success": True, @@ -497,9 +474,7 @@ async def forgot_password( async def reset_password( data: ResetPasswordRequest, session: AsyncSession = Depends(get_session) ): - """ - 🔐 Réinitialisation du mot de passe avec token - """ + result = await session.execute(select(User).where(User.reset_token == data.token)) user = result.scalar_one_or_none() @@ -528,7 +503,7 @@ async def reset_password( AuthEmailService.send_password_changed_notification(user.email) - logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") + logger.info(f" Mot de passe réinitialisé: {user.email}") return { "success": True, @@ -542,9 +517,7 @@ async def logout( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """ - 🚪 Déconnexion (révocation du refresh token) - """ + token_hash = hash_token(data.refresh_token) result = await session.execute( @@ -566,9 +539,7 @@ async def logout( @router.get("/me") async def get_current_user_info(user: User = Depends(get_current_user)): - """ - 👤 Récupération du profil utilisateur - """ + return { "id": user.id, "email": user.email, diff --git a/sage_client.py b/sage_client.py index aba8338..0384be1 100644 --- a/sage_client.py +++ b/sage_client.py @@ -32,7 +32,7 @@ class SageGatewayClient: except requests.exceptions.RequestException as e: if attempt == retries - 1: logger.error( - f"❌ Échec après {retries} tentatives sur {endpoint}: {e}" + f" Échec après {retries} tentatives sur {endpoint}: {e}" ) raise time.sleep(2**attempt) @@ -54,7 +54,7 @@ class SageGatewayClient: except requests.exceptions.RequestException as e: if attempt == retries - 1: logger.error( - f"❌ Échec GET après {retries} tentatives sur {endpoint}: {e}" + f" Échec GET après {retries} tentatives sur {endpoint}: {e}" ) raise time.sleep(2**attempt) @@ -108,7 +108,7 @@ class SageGatewayClient: r.raise_for_status() return r.json().get("data", {}) except requests.exceptions.RequestException as e: - logger.error(f"❌ Erreur changement statut: {e}") + logger.error(f" Erreur changement statut: {e}") raise def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: @@ -134,7 +134,7 @@ class SageGatewayClient: r.raise_for_status() return r.json().get("data", {}) except requests.exceptions.RequestException as e: - logger.error(f"❌ Erreur transformation: {e}") + logger.error(f" Erreur transformation: {e}") raise def mettre_a_jour_champ_libre( @@ -352,7 +352,7 @@ class SageGatewayClient: pdf_bytes = base64.b64decode(pdf_base64) - logger.info(f"✅ PDF décodé: {len(pdf_bytes)} octets") + logger.info(f" PDF décodé: {len(pdf_bytes)} octets") return pdf_bytes @@ -364,11 +364,11 @@ class SageGatewayClient: ) except requests.exceptions.RequestException as e: - logger.error(f"❌ Erreur HTTP génération PDF: {e}") + logger.error(f" Erreur HTTP génération PDF: {e}") raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}") except Exception as e: - logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) + logger.error(f" Erreur génération PDF: {e}", exc_info=True) raise def creer_article(self, article_data: Dict) -> Dict: @@ -420,7 +420,7 @@ class SageGatewayClient: r.raise_for_status() return r.json().get("data", {}) except requests.exceptions.RequestException as e: - logger.error(f"❌ Erreur listage modèles: {e}") + logger.error(f" Erreur listage modèles: {e}") raise def generer_pdf_document( @@ -446,7 +446,7 @@ class SageGatewayClient: return r.content except requests.exceptions.RequestException as e: - logger.error(f"❌ Erreur génération PDF: {e}") + logger.error(f" Erreur génération PDF: {e}") raise @@ -476,16 +476,6 @@ class SageGatewayClient: def supprimer_contact(self, numero: str, contact_numero: int) -> Dict: - """ - Supprime un contact - - Args: - numero: Code du client - contact_numero: Numéro unique du contact - - Returns: - Dictionnaire avec le statut de la suppression - """ return self._post("/sage/contacts/delete", { "numero": numero, "contact_numero": contact_numero @@ -493,19 +483,22 @@ class SageGatewayClient: def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict: - """ - Définit un contact comme contact par défaut du client - - Args: - numero: Code du client - contact_numero: Numéro unique du contact à définir comme par défaut - - Returns: - Dictionnaire avec les données du client mis à jour - """ return self._post("/sage/contacts/set-default", { "numero": numero, "contact_numero": contact_numero }) + + + def lister_tiers(self, type_tiers: Optional[str] = None, filtre: str = "") -> List[Dict]: + return self._post("/sage/tiers/list", { + "type_tiers": type_tiers, + "filtre": filtre + }).get("data", []) + + + def lire_tiers(self, code: str) -> Optional[Dict]: + return self._post("/sage/tiers/get", { + "code": code + }).get("data") sage_client = SageGatewayClient() diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..c987039 --- /dev/null +++ b/schemas/__init__.py @@ -0,0 +1,9 @@ +from schemas.tiers.tiers import (TiersDetails,) +from schemas.tiers.type_tiers import (TypeTiers,) +from schemas.tiers.contact import (Contact,) + +__all__ = [ + "TiersDetails", + "Contact", + "TypeTiers", +] \ No newline at end of file diff --git a/schemas/tiers/contact.py b/schemas/tiers/contact.py new file mode 100644 index 0000000..8c8bcc1 --- /dev/null +++ b/schemas/tiers/contact.py @@ -0,0 +1,41 @@ +from typing import Optional, ClassVar +from pydantic import BaseModel, Field, validator + +class Contact(BaseModel): + """Contact associé à un tiers""" + numero: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)") + contact_numero: Optional[int] = Field(None, description="Numéro unique du contact (CT_No)") + n_contact: Optional[int] = Field(None, description="Numéro de référence contact (N_Contact)") + + civilite: Optional[str] = Field(None, description="Civilité : 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)") + fonction: Optional[str] = Field(None, description="Fonction/Titre (CT_Fonction)") + + service_code: Optional[int] = Field(None, description="Code du service (N_Service)") + + telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") + portable: Optional[str] = Field(None, description="Téléphone mobile (CT_TelPortable)") + telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") + email: Optional[str] = Field(None, description="Adresse email (CT_EMail)") + + facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") + linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") + skype: Optional[str] = Field(None, description="Identifiant Skype (CT_Skype)") + + est_defaut: Optional[bool] = Field(False, description="Contact par défaut") + + civilite_map: ClassVar[dict] = { + 0: "M.", + 1: "Mme", + 2: "Mlle", + 3: "Société", + } + + @validator("civilite", pre=True, always=True) + def convert_civilite(cls, v): + if v is None: + return v + if isinstance(v, int): + return cls.civilite_map.get(v, str(v)) + return v \ No newline at end of file diff --git a/schemas/tiers/tiers.py b/schemas/tiers/tiers.py new file mode 100644 index 0000000..69bfa79 --- /dev/null +++ b/schemas/tiers/tiers.py @@ -0,0 +1,104 @@ +from typing import List, Optional +from pydantic import BaseModel, Field +from schemas import Contact + +class TiersDetails(BaseModel): + # IDENTIFICATION + numero: Optional[str] = Field(None, description="Code tiers (CT_Num)") + intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") + type_tiers: Optional[int] = Field(None, description="Type : 0=Client, 1=Fournisseur (CT_Type)") + qualite: Optional[str] = Field(None, description="Qualité Sage : CLI, FOU, PRO (CT_Qualite)") + classement: Optional[str] = Field(None, description="Code de classement (CT_Classement)") + raccourci: Optional[str] = Field(None, description="Code raccourci 7 car. (CT_Raccourci)") + siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)") + tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire (CT_Identifiant)") + code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") + + # ADRESSE + contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") + adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)") + complement: Optional[str] = Field(None, description="Complément d'adresse (CT_Complement)") + code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)") + ville: Optional[str] = Field(None, description="Ville (CT_Ville)") + region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") + pays: Optional[str] = Field(None, description="Pays (CT_Pays)") + + # TELECOM + telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") + telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") + email: Optional[str] = Field(None, description="Email principal (CT_EMail)") + site_web: Optional[str] = Field(None, description="Site web (CT_Site)") + facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") + linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") + + # TAUX + taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") + taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") + taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") + taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") + + # STATISTIQUES + statistique01: Optional[str] = Field(None, description="Statistique 1 (CT_Statistique01)") + statistique02: Optional[str] = Field(None, description="Statistique 2 (CT_Statistique02)") + statistique03: Optional[str] = Field(None, description="Statistique 3 (CT_Statistique03)") + statistique04: Optional[str] = Field(None, description="Statistique 4 (CT_Statistique04)") + statistique05: Optional[str] = Field(None, description="Statistique 5 (CT_Statistique05)") + statistique06: Optional[str] = Field(None, description="Statistique 6 (CT_Statistique06)") + statistique07: Optional[str] = Field(None, description="Statistique 7 (CT_Statistique07)") + statistique08: Optional[str] = Field(None, description="Statistique 8 (CT_Statistique08)") + statistique09: Optional[str] = Field(None, description="Statistique 9 (CT_Statistique09)") + statistique10: Optional[str] = Field(None, description="Statistique 10 (CT_Statistique10)") + + # COMMERCIAL + encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé (CT_Encours)") + assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit (CT_Assurance)") + langue: Optional[int] = Field(None, description="Code langue 0=FR, 1=EN (CT_Langue)") + commercial_code: Optional[int] = Field(None, description="Code du commercial (CO_No)") + + # FACTURATION + lettrage_auto: Optional[bool] = Field(None, description="Lettrage automatique (CT_Lettrage)") + est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") + type_facture: Optional[int] = Field(None, description="Type facture 0=Facture, 1=BL (CT_Facture)") + est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") + bl_en_facture: Optional[int] = Field(None, description="Imprimer BL en facture (CT_BLFact)") + saut_page: Optional[int] = Field(None, description="Saut de page sur documents (CT_Saut)") + validation_echeance: Optional[int] = Field(None, description="Valider les échéances (CT_ValidEch)") + controle_encours: Optional[int] = Field(None, description="Contrôler l'encours (CT_ControlEnc)") + exclure_relance: Optional[bool] = Field(None, description="Exclure des relances (CT_NotRappel)") + exclure_penalites: Optional[bool] = Field(None, description="Exclure des pénalités (CT_NotPenal)") + bon_a_payer: Optional[int] = Field(None, description="Bon à payer obligatoire (CT_BonAPayer)") + + # LOGISTIQUE + priorite_livraison: Optional[int] = Field(None, description="Priorité livraison (CT_PrioriteLivr)") + livraison_partielle: Optional[int] = Field(None, description="Livraison partielle (CT_LivrPartielle)") + delai_transport: Optional[int] = Field(None, description="Délai transport jours (CT_DelaiTransport)") + delai_appro: Optional[int] = Field(None, description="Délai appro jours (CT_DelaiAppro)") + + # COMMENTAIRE + commentaire: Optional[str] = Field(None, description="Commentaire libre (CT_Commentaire)") + + # ANALYTIQUE + section_analytique: Optional[str] = Field(None, description="Section analytique (CA_Num)") + + # ORGANISATION / SURVEILLANCE + mode_reglement_code: Optional[int] = Field(None, description="Code mode règlement (MR_No)") + surveillance_active: Optional[bool] = Field(None, description="Surveillance financière (CT_Surveillance)") + coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)") + forme_juridique: Optional[str] = Field(None, description="Forme juridique SA, SARL (CT_SvFormeJuri)") + effectif: Optional[str] = Field(None, description="Nombre d'employés (CT_SvEffectif)") + sv_regularite: Optional[str] = Field(None, description="Régularité paiements (CT_SvRegul)") + sv_cotation: Optional[str] = Field(None, description="Cotation crédit (CT_SvCotation)") + sv_objet_maj: Optional[str] = Field(None, description="Objet dernière MAJ (CT_SvObjetMaj)") + sv_chiffre_affaires: Optional[float] = Field(None, description="Chiffre d'affaires (CT_SvCA)") + sv_resultat: Optional[float] = Field(None, description="Résultat financier (CT_SvResultat)") + + # COMPTE GENERAL ET CATEGORIES + compte_general: Optional[str] = Field(None, description="Compte général principal (CG_NumPrinc)") + categorie_tarif: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") + categorie_compta: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") + + # CONTACTS + contacts: Optional[List[Contact]] = Field( + default_factory=list, + description="Liste des contacts du tiers" + ) \ No newline at end of file diff --git a/schemas/tiers/type_tiers.py b/schemas/tiers/type_tiers.py new file mode 100644 index 0000000..a14b3e7 --- /dev/null +++ b/schemas/tiers/type_tiers.py @@ -0,0 +1,6 @@ +class TypeTiers(str, Enum): + """Types de tiers possibles""" + ALL = "all" + CLIENT = "client" + FOURNISSEUR = "fournisseur" + PROSPECT = "prospect" \ No newline at end of file diff --git a/services/email_service.py b/services/email_service.py index 7bb7661..b234a5f 100644 --- a/services/email_service.py +++ b/services/email_service.py @@ -32,11 +32,11 @@ class AuthEmailService: server.send_message(msg) - logger.info(f"✅ Email envoyé: {subject} → {to}") + logger.info(f" Email envoyé: {subject} → {to}") return True except Exception as e: - logger.error(f"❌ Erreur envoi email: {e}") + logger.error(f" Erreur envoi email: {e}") return False @staticmethod @@ -91,7 +91,7 @@ class AuthEmailService:

- ⚠️ Ce lien expire dans 24 heures + Ce lien expire dans 24 heures

@@ -107,7 +107,7 @@ class AuthEmailService: """ return AuthEmailService._send_email( - email, "🔐 Vérifiez votre adresse email - Sage Dataven", html_body + email, " Vérifiez votre adresse email - Sage Dataven", html_body ) @staticmethod @@ -146,7 +146,7 @@ class AuthEmailService:

-

🔑 Réinitialisation de mot de passe

+

Réinitialisation de mot de passe

Demande de réinitialisation

@@ -162,7 +162,7 @@ class AuthEmailService:

- ⚠️ Ce lien expire dans 1 heure + Ce lien expire dans 1 heure

@@ -178,7 +178,7 @@ class AuthEmailService: """ return AuthEmailService._send_email( - email, "🔐 Réinitialisation de votre mot de passe - Sage Dataven", html_body + email, " Réinitialisation de votre mot de passe - Sage Dataven", html_body ) @staticmethod @@ -199,14 +199,14 @@ class AuthEmailService:

-

✅ Mot de passe modifié

+

Mot de passe modifié

Votre mot de passe a été changé avec succès

Ce message confirme que le mot de passe de votre compte Sage Dataven a été modifié.

- ⚠️ Si vous n'êtes pas à l'origine de ce changement, contactez immédiatement notre support. + Si vous n'êtes pas à l'origine de ce changement, contactez immédiatement notre support.