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/.trunk/.gitignore b/.trunk/.gitignore new file mode 100644 index 0000000..15966d0 --- /dev/null +++ b/.trunk/.gitignore @@ -0,0 +1,9 @@ +*out +*logs +*actions +*notifications +*tools +plugins +user_trunk.yaml +user.yaml +tmp diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml new file mode 100644 index 0000000..1e4ac82 --- /dev/null +++ b/.trunk/trunk.yaml @@ -0,0 +1,32 @@ +# This file controls the behavior of Trunk: https://docs.trunk.io/cli +# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml +version: 0.1 +cli: + version: 1.25.0 +# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) +plugins: + sources: + - id: trunk + ref: v1.7.4 + uri: https://github.com/trunk-io/plugins +# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) +runtimes: + enabled: + - node@22.16.0 + - python@3.10.8 +# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) +lint: + enabled: + - git-diff-check + - hadolint@2.14.0 + - markdownlint@0.47.0 + - osv-scanner@2.3.1 + - prettier@3.7.4 + - trufflehog@3.92.4 +actions: + disabled: + - trunk-announce + - trunk-check-pre-push + - trunk-fmt-pre-commit + enabled: + - trunk-upgrade-available diff --git a/Dockerfile b/Dockerfile index 7e49ad0..55d43e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,78 @@ -# Backend Dockerfile -FROM python:3.12-slim - +# ================================ +# Base +# ================================ +FROM python:3.12-slim AS base WORKDIR /app -# Copier et installer les dépendances -COPY requirements.txt . -RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -r requirements.txt +# Installer dépendances système si nécessaire +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip + +# ================================ +# DEV +# ================================ +FROM base AS dev +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + ENV=development + +# Installer dépendances dev (si vous avez un requirements.dev.txt) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Créer dossiers +RUN mkdir -p /app/data /app/logs && chmod -R 777 /app/data /app/logs -# Copier le reste du projet COPY . . -# ✅ Créer dossier persistant pour SQLite avec bonnes permissions -RUN mkdir -p /app/data && chmod 777 /app/data - -# Exposer le port EXPOSE 8000 +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] -# Lancer l'API et initialiser la DB au démarrage -CMD ["sh", "-c", "python init_db.py && uvicorn api:app --host 0.0.0.0 --port 8000 --reload"] \ No newline at end of file +# ================================ +# STAGING +# ================================ +FROM base AS staging +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + ENV=staging + +RUN pip install --no-cache-dir -r requirements.txt + +RUN mkdir -p /app/data /app/logs && chmod -R 755 /app/data /app/logs + +COPY . . + +# Initialiser la DB au build +RUN python init_db.py || true + +EXPOSE 8002 +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8002", "--log-level", "info"] + +# ================================ +# PROD +# ================================ +FROM base AS prod +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + ENV=production + +RUN pip install --no-cache-dir -r requirements.txt + +# Créer utilisateur non-root pour la sécurité +RUN useradd -m -u 1000 appuser && \ + mkdir -p /app/data /app/logs && \ + chown -R appuser:appuser /app + +COPY --chown=appuser:appuser . . + +# Initialiser la DB au build +RUN python init_db.py || true + +USER appuser + +EXPOSE 8004 +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8004", "--workers", "4"] \ No newline at end of file diff --git a/api.py b/api.py index 368edb1..d37e7f0 100644 --- a/api.py +++ b/api.py @@ -1,11 +1,12 @@ -from fastapi import FastAPI, HTTPException, 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 fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field, EmailStr -from typing import List, Optional, Dict -from datetime import date, datetime -from enum import Enum +from typing import List, Optional +from datetime import datetime import uvicorn +import asyncio from contextlib import asynccontextmanager import uuid import csv @@ -13,260 +14,149 @@ import io import logging from sqlalchemy.ext.asyncio import AsyncSession 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() - ] -) -logger = logging.getLogger(__name__) - -# Imports locaux -from config import settings +import os +from pathlib import Path as FilePath +from data.data import TAGS_METADATA, templates_signature_email +from routes.auth import router as auth_router +from config.config import settings from database import ( init_db, async_session_factory, get_session, EmailLog, - StatutEmail as StatutEmailEnum, + StatutEmail as StatutEmailDB, WorkflowLog, SignatureLog, - StatutSignature as StatutSignatureEnum + StatutSignature as StatutSignatureDB, ) from email_queue import email_queue -from sage_client import sage_client +from sage_client import sage_client, SageGatewayClient -# ===================================================== -# ENUMS -# ===================================================== -class TypeDocument(int, Enum): - DEVIS = 0 - BON_LIVRAISON = 1 - BON_RETOUR = 2 - COMMANDE = 3 - PREPARATION = 4 - FACTURE = 5 +from schemas import ( + TiersDetails, + BaremeRemiseResponse, + Users, + ClientCreate, + ClientDetails, + ClientUpdate, + FournisseurCreate, + FournisseurDetails, + FournisseurUpdate, + Contact, + AvoirCreate, + AvoirUpdate, + CommandeCreate, + CommandeUpdate, + DevisRequest, + Devis, + DevisUpdate, + TypeDocument, + TypeDocumentSQL, + StatutEmail, + EmailEnvoi, + FactureCreate, + FactureUpdate, + LivraisonCreate, + LivraisonUpdate, + StatutSignature, + ArticleCreate, + Article, + ArticleUpdate, + EntreeStock, + SortieStock, + MouvementStock, + RelanceDevis, + Familles, + FamilleCreate, + ContactCreate, + ContactUpdate, +) +from schemas.tiers.commercial import ( + CollaborateurCreate, + CollaborateurDetails, + CollaborateurUpdate, +) +from utils.normalization import normaliser_type_tiers +from routes.sage_gateway import router as sage_gateway_router +from routes.universign import router as universign_router -class StatutSignature(str, Enum): - EN_ATTENTE = "EN_ATTENTE" - ENVOYE = "ENVOYE" - SIGNE = "SIGNE" - REFUSE = "REFUSE" - EXPIRE = "EXPIRE" +from services.universign_sync import UniversignSyncService, UniversignSyncScheduler -class StatutEmail(str, Enum): - EN_ATTENTE = "EN_ATTENTE" - EN_COURS = "EN_COURS" - ENVOYE = "ENVOYE" - OUVERT = "OUVERT" - ERREUR = "ERREUR" - BOUNCE = "BOUNCE" +from core.sage_context import ( + get_sage_client_for_user, + get_gateway_context_for_user, + GatewayContext, +) +from utils.generic_functions import ( + _preparer_lignes_document, + universign_envoyer, + universign_statut, +) -# ===================================================== -# MODÈLES PYDANTIC -# ===================================================== -class ClientResponse(BaseModel): - numero: str - intitule: str - adresse: Optional[str] = None - code_postal: Optional[str] = None - ville: Optional[str] = None - email: Optional[str] = None - telephone: Optional[str] = None +if os.path.exists("/app"): + LOGS_DIR = FilePath("/app/logs") +else: + LOGS_DIR = FilePath(__file__).resolve().parent / "logs" -class ArticleResponse(BaseModel): - reference: str - designation: str - prix_vente: float - stock_reel: float +LOGS_DIR.mkdir(parents=True, exist_ok=True) -class LigneDevis(BaseModel): - article_code: str - quantite: float - prix_unitaire_ht: Optional[float] = None - remise_pourcentage: Optional[float] = 0.0 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler(LOGS_DIR / "sage_api.log", encoding="utf-8"), + logging.StreamHandler(), + ], +) -class DevisRequest(BaseModel): - client_id: str - date_devis: Optional[date] = None - lignes: List[LigneDevis] +logger = logging.getLogger(__name__) -class DevisResponse(BaseModel): - id: str - client_id: str - date_devis: str - montant_total_ht: float - montant_total_ttc: float - nb_lignes: int -class SignatureRequest(BaseModel): - doc_id: str - type_doc: TypeDocument - email_signataire: EmailStr - nom_signataire: str - -class EmailEnvoiRequest(BaseModel): - destinataire: EmailStr - cc: Optional[List[EmailStr]] = [] - cci: Optional[List[EmailStr]] = [] - sujet: str - corps_html: str - document_ids: Optional[List[str]] = None - type_document: Optional[TypeDocument] = None - -class RelanceDevisRequest(BaseModel): - doc_id: str - message_personnalise: Optional[str] = None - -# ===================================================== -# SERVICES EXTERNES (Universign) -# ===================================================== -async def universign_envoyer(doc_id: str, pdf_bytes: bytes, email: str, nom: str) -> Dict: - """Envoi signature via API Universign""" - import requests - - try: - api_key = settings.universign_api_key - api_url = settings.universign_api_url - auth = (api_key, "") - - # Étape 1: Créer transaction - response = requests.post( - f"{api_url}/transactions", - auth=auth, - json={"name": f"Devis {doc_id}", "language": "fr"}, - timeout=30 - ) - response.raise_for_status() - transaction_id = response.json().get("id") - - # Étape 2: Upload PDF - files = {'file': (f'Devis_{doc_id}.pdf', pdf_bytes, 'application/pdf')} - response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=30) - response.raise_for_status() - file_id = response.json().get("id") - - # Étape 3: Ajouter document - response = requests.post( - f"{api_url}/transactions/{transaction_id}/documents", - auth=auth, - data={"document": file_id}, - timeout=30 - ) - response.raise_for_status() - document_id = response.json().get("id") - - # Étape 4: Créer champ signature - response = requests.post( - f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", - auth=auth, - data={"type": "signature"}, - timeout=30 - ) - response.raise_for_status() - field_id = response.json().get("id") - - # Étape 5: Assigner signataire - response = requests.post( - f"{api_url}/transactions/{transaction_id}/signatures", - auth=auth, - data={"signer": email, "field": field_id}, - timeout=30 - ) - response.raise_for_status() - - # Étape 6: Démarrer transaction - response = requests.post( - f"{api_url}/transactions/{transaction_id}/start", - auth=auth, - timeout=30 - ) - response.raise_for_status() - - final_data = response.json() - signer_url = final_data.get("actions", [{}])[0].get("url", "") if final_data.get("actions") else "" - - logger.info(f"✅ Signature Universign envoyée: {transaction_id}") - - return { - "transaction_id": transaction_id, - "signer_url": signer_url, - "statut": "ENVOYE" - } - - except Exception as e: - logger.error(f"❌ Erreur Universign: {e}") - return {"error": str(e), "statut": "ERREUR"} - -async def universign_statut(transaction_id: str) -> Dict: - """Récupération statut signature""" - import requests - - try: - response = requests.get( - f"{settings.universign_api_url}/transactions/{transaction_id}", - auth=(settings.universign_api_key, ""), - timeout=10 - ) - - if response.status_code == 200: - data = response.json() - statut_map = { - "draft": "EN_ATTENTE", - "started": "EN_ATTENTE", - "completed": "SIGNE", - "refused": "REFUSE", - "expired": "EXPIRE", - "canceled": "REFUSE" - } - return { - "statut": statut_map.get(data.get("state"), "EN_ATTENTE"), - "date_signature": data.get("completed_at") - } - else: - return {"statut": "ERREUR"} - - except Exception as e: - logger.error(f"Erreur statut Universign: {e}") - return {"statut": "ERREUR", "error": str(e)} - -# ===================================================== -# CYCLE DE VIE -# ===================================================== @asynccontextmanager async def lifespan(app: FastAPI): - # Init base de données await init_db() - logger.info("✅ Base de données initialisée") - - # 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") + 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") + + email_queue.start(num_workers=settings.max_email_workers) + logger.info("Email queue démarrée") + + sync_service = UniversignSyncService( + api_url=settings.universign_api_url, api_key=settings.universign_api_key + ) + + # Configuration du service avec les dépendances + sync_service.configure( + sage_client=sage_client, email_queue=email_queue, settings=settings + ) + + scheduler = UniversignSyncScheduler( + sync_service=sync_service, + interval_minutes=5, + ) + + sync_task = asyncio.create_task(scheduler.start(async_session_factory)) + + logger.info("Synchronisation Universign démarrée (5min)") + + yield + + scheduler.stop() + sync_task.cancel() + email_queue.stop() + logger.info("Services arrêtés") + -# ===================================================== -# APPLICATION -# ===================================================== app = FastAPI( - title="API Sage 100c Dataven", - version="2.0.0", - description="API de gestion commerciale - VPS Linux", - lifespan=lifespan + title="Sage Gateways", + version="3.0.0", + description="Configuration multi-tenant des connexions Sage Gateway", + lifespan=lifespan, + openapi_tags=TAGS_METADATA, ) app.add_middleware( @@ -274,117 +164,503 @@ 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) -# ===================================================== -@app.get("/clients", response_model=List[ClientResponse], tags=["US-A1"]) -async def rechercher_clients(query: Optional[str] = Query(None)): - """🔍 Recherche clients via gateway Windows""" + +app.include_router(auth_router) +app.include_router(sage_gateway_router) +app.include_router(universign_router) + + +@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) +async def obtenir_clients( + query: Optional[str] = Query(None), + #sage: SageGatewayClient = Depends(get_sage_client_for_user), +): 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)) -@app.get("/articles", response_model=List[ArticleResponse], tags=["US-A1"]) + +@app.get("/clients/{code}", response_model=ClientDetails, tags=["Clients"]) +async def lire_client_detail(code: str): + try: + client = sage_client.lire_client(code) + + if not client: + raise HTTPException(404, f"Client {code} introuvable") + + return ClientDetails(**client) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture client {code}: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/clients/{code}", tags=["Clients"]) +async def modifier_client( + code: str, + client_update: ClientUpdate, + session: AsyncSession = Depends(get_session), +): + try: + 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: + logger.warning(f"Erreur métier modification client {code}: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + 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: ClientCreate, session: AsyncSession = Depends(get_session) +): + try: + 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, + "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}") + status = 400 if "existe déjà" in str(e) else 500 + raise HTTPException(status, str(e)) + + +@app.get("/articles", response_model=List[Article], 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] + return [Article(**a) for a in articles] except Exception as e: logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) -@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["US-A1"]) -async def creer_devis(devis: DevisRequest): - """📝 Création de devis via gateway Windows""" + +@app.post( + "/articles", + response_model=Article, + status_code=status.HTTP_201_CREATED, + tags=["Articles"], +) +async def creer_article(article: ArticleCreate): + try: + 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", + ) + + article_data = article.dict(exclude_unset=True) + + logger.info(f"Création article: {article.reference} - {article.designation}") + + resultat = sage_client.creer_article(article_data) + + logger.info( + f"Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" + ) + + return Article(**resultat) + + except ValueError as 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: + raise + + except Exception as e: + 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}", response_model=Article, tags=["Articles"]) +async def modifier_article( + reference: str = Path(..., description="Référence de l'article à modifier"), + article: ArticleUpdate = Body(...), +): + try: + 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.", + ) + + logger.info(f"Modification article {reference}: {list(article_data.keys())}") + + resultat = sage_client.modifier_article(reference, article_data) + + 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 Article(**resultat) + + except ValueError as e: + 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 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}", response_model=Article, tags=["Articles"]) +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", + ) + + logger.info(f"Article {reference} lu: {article.get('designation', '')}") + + return Article(**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)}", + ) + + +@app.post("/devis", response_model=Devis, 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, - "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 - ] + "date_livraison": ( + devis.date_livraison.isoformat() if devis.date_livraison else None + ), + "reference": devis.reference, + "lignes": _preparer_lignes_document(devis.lignes), } - - # Appel HTTP vers Windows + resultat = sage_client.creer_devis(devis_data) - - logger.info(f"✅ Devis créé: {resultat.get('numero_devis')}") - - return DevisResponse( + + logger.info( + f"Devis créé: {resultat.get('numero_devis')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + return Devis( 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/{id}", tags=["US-A1"]) + +@app.put("/devis/{id}", tags=["Devis"]) +async def modifier_devis( + id: str, + devis_update: DevisUpdate, + session: AsyncSession = Depends(get_session), +): + try: + 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": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne in devis_update.lignes + ] + + 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 + + resultat = sage_client.modifier_devis(id, update_data) + + logger.info(f"Devis {id} modifié avec succès") + + return { + "success": True, + "message": f"Devis {id} modifié avec succès", + "devis": resultat, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification devis {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/commandes", status_code=201, tags=["Commandes"]) +async def creer_commande( + commande: CommandeCreate, session: AsyncSession = Depends(get_session) +): + try: + 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 + ), + "reference": commande.reference, + "lignes": _preparer_lignes_document(commande.lignes), + } + + resultat = sage_client.creer_commande(commande_data) + + logger.info( + f"Commande créée: {resultat.get('numero_commande')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + 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": resultat.get("reference"), + "date_livraison": resultat.get("date_livraison"), + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création commande: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/commandes/{id}", tags=["Commandes"]) +async def modifier_commande( + id: str, + commande_update: CommandeUpdate, + session: AsyncSession = Depends(get_session), +): + try: + 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": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne 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 + + resultat = sage_client.modifier_commande(id, update_data) + + logger.info(f"Commande {id} modifiée avec succès") + + return { + "success": True, + "message": f"Commande {id} modifiée avec succès", + "commande": resultat, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification commande {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/devis", tags=["Devis"]) +async def lister_devis( + limit: int = Query(100, le=1000), + statut: Optional[int] = Query(None), + inclure_lignes: bool = Query( + True, description="Inclure les lignes de chaque devis" + ), +): + try: + devis_list = sage_client.lister_devis( + limit=limit, statut=statut, inclure_lignes=inclure_lignes + ) + return devis_list + + except Exception as e: + logger.error(f"Erreur liste devis: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): - """📄 Lecture d'un devis via gateway Windows""" try: devis = sage_client.lire_devis(id) + if not devis: raise HTTPException(404, f"Devis {id} introuvable") - return devis + + return {"success": True, "data": devis} + except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture devis: {e}") raise HTTPException(500, str(e)) -@app.get("/devis/{id}/pdf", tags=["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: - # 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) + +@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"), +): + try: + types_labels = { + 0: "Devis", + 10: "Commande", + 20: "Preparation", + 30: "BonLivraison", + 40: "BonRetour", + 50: "Avoir", + 60: "Facture", + } + + 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})") + + 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") + + filename = f"{label}_{numero}.pdf" + + return StreamingResponse( + iter([pdf_bytes]), + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)), + }, + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True + ) + raise HTTPException(500, f"Erreur génération PDF: {str(e)}") + + +@app.post("/devis/{id}/envoyer", tags=["Devis"]) +async def envoyer_devis_email( + id: str, request: EmailEnvoi, session: AsyncSession = Depends(get_session) ): - """📧 Envoi devis par email""" try: - # Vérifier que le devis existe - devis = sage_client.lire_devis(id) - if not devis: - raise HTTPException(404, f"Devis {id} introuvable") - - # Créer logs email pour chaque destinataire tous_destinataires = [request.destinataire] + request.cc + request.cci email_logs = [] - + for dest in tous_destinataires: email_log = EmailLog( id=str(uuid.uuid4()), @@ -393,210 +669,639 @@ async def envoyer_devis_email( corps_html=request.corps_html, document_ids=id, type_document=TypeDocument.DEVIS, - statut=StatutEmailEnum.EN_ATTENTE, + statut=StatutEmailDB.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)) -# ===================================================== -# 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.put("/document/{type_doc}/{numero}/statut", tags=["Documents"]) +async def changer_statut_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"), + nouveau_statut: int = Query( + ..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté" + ), ): - """🔧 Transformation Devis → Commande via gateway Windows""" + document_type_sql = None + document_type_code = None + type_doc_normalized = None + + try: + match type_doc: + case 0: + document_type_sql = TypeDocumentSQL.DEVIS + document_type_code = TypeDocument.DEVIS + type_doc_normalized = 0 + case 10 | 1: + document_type_sql = TypeDocumentSQL.BON_COMMANDE + document_type_code = TypeDocument.BON_COMMANDE + type_doc_normalized = 10 + case 20 | 2: + document_type_sql = TypeDocumentSQL.PREPARATION + document_type_code = TypeDocument.PREPARATION + type_doc_normalized = 20 + case 30 | 3: + document_type_sql = TypeDocumentSQL.BON_LIVRAISON + document_type_code = TypeDocument.BON_LIVRAISON + type_doc_normalized = 30 + case 40 | 4: + document_type_sql = TypeDocumentSQL.BON_RETOUR + document_type_code = TypeDocument.BON_RETOUR + type_doc_normalized = 40 + case 50 | 5: + document_type_sql = TypeDocumentSQL.BON_AVOIR + document_type_code = TypeDocument.BON_AVOIR + type_doc_normalized = 50 + case 60 | 6: + document_type_sql = TypeDocumentSQL.FACTURE + document_type_code = TypeDocument.FACTURE + type_doc_normalized = 60 + case _: + raise HTTPException( + 400, + f"Type de document invalide: {type_doc}", + ) + + document_existant = sage_client.lire_document(numero, document_type_sql) + if not document_existant: + raise HTTPException(404, f"Document {numero} introuvable") + + statut_actuel = document_existant.get("statut", 0) + + match type_doc: + case 0: + if statut_actuel >= 2: + statuts_devis = {2: "accepté", 3: "perdu", 4: "archivé"} + raise HTTPException( + 400, + f"Le devis {numero} est {statuts_devis.get(statut_actuel, 'verrouillé')} " + f"et ne peut plus changer de statut", + ) + + case 10 | 1 | 20 | 2 | 30 | 3 | 40 | 4 | 50 | 5 | 60 | 6: + if statut_actuel >= 2: + type_names = { + 10: "la commande", + 1: "la commande", + 20: "la préparation", + 2: "la préparation", + 30: "la livraison", + 3: "la livraison", + 40: "le retour", + 4: "le retour", + 50: "l'avoir", + 5: "l'avoir", + 60: "la facture", + 6: "la facture", + } + raise HTTPException( + 400, + f"Le document {numero} ({type_names.get(type_doc, 'document')}) " + f"ne peut plus changer de statut (statut actuel ≥ 2)", + ) + + document_type_int = ( + document_type_code.value + if hasattr(document_type_code, "value") + else type_doc_normalized + ) + + resultat = sage_client.changer_statut_document( + document_type_code=document_type_int, + numero=numero, + nouveau_statut=nouveau_statut, + ) + + logger.info( + f"Statut document {numero} changé: {statut_actuel} → {nouveau_statut}" + ) + + return { + "success": True, + "document_id": numero, + "type_document_code": document_type_int, + "type_document_sql": str(document_type_sql.value), + "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 document {numero}: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/commandes/{id}", tags=["Commandes"]) +async def lire_commande(id: str): + try: + commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) + if not commande: + raise HTTPException(404, f"Commande {id} introuvable") + return commande + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture commande: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/commandes", tags=["Commandes"]) +async def lister_commandes( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + try: + commandes = sage_client.lister_commandes(limit=limit, statut=statut) + return commandes + + except Exception as e: + logger.error(f"Erreur liste commandes: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) +async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): try: - # Appel HTTP vers Windows resultat = sage_client.transformer_document( numero_source=id, - type_source=TypeDocument.DEVIS, - type_cible=TypeDocument.COMMANDE + 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 + 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"], + "statut_devis_mis_a_jour": True, } - + except Exception as e: logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) -async def commande_vers_facture( - id: str, - session: AsyncSession = Depends(get_session) -): - """🔧 Transformation Commande → Facture via gateway Windows""" + +@app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) +async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): try: resultat = sage_client.transformer_document( numero_source=id, - type_source=TypeDocument.COMMANDE, - type_cible=TypeDocument.FACTURE + 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, + type_source=TypeDocument.BON_COMMANDE, document_cible=resultat.get("document_cible", ""), type_cible=TypeDocument.FACTURE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), - succes=True + succes=True, ) - + 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}") 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) -): - """✍️ Envoi document pour signature Universign""" + +@app.get("/admin/signatures/relances-auto", tags=["Admin"]) +async def relancer_signatures_automatique(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 + from datetime import timedelta + + date_limite = datetime.now() - timedelta(days=7) + + query = select(SignatureLog).where( + SignatureLog.statut.in_( + [StatutSignatureDB.EN_ATTENTE, StatutSignatureDB.ENVOYE] + ), + SignatureLog.date_envoi < date_limite, + SignatureLog.nb_relances < 3, # Max 3 relances ) - - if "error" in resultat: - raise HTTPException(500, resultat["error"]) - - # Logger en DB - signature_log = SignatureLog( - id=str(uuid.uuid4()), - document_id=demande.doc_id, - type_document=demande.type_doc, - transaction_id=resultat["transaction_id"], - signer_url=resultat["signer_url"], - email_signataire=demande.email_signataire, - nom_signataire=demande.nom_signataire, - statut=StatutSignatureEnum.ENVOYE, - date_envoi=datetime.now() - ) - - session.add(signature_log) + + result = await session.execute(query) + signatures_a_relancer = result.scalars().all() + + nb_relances = 0 + + for signature in signatures_a_relancer: + try: + nb_jours = (datetime.now() - signature.date_envoi).days + jours_restants = 30 - nb_jours # Lien expire après 30 jours + + if jours_restants <= 0: + signature.statut = StatutSignatureDB.EXPIRE + continue + + 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)) + + 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=StatutEmailDB.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + email_queue.enqueue(email_log.id) + + 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() - - # MAJ champ libre Sage via gateway Windows - sage_client.mettre_a_jour_champ_libre( - demande.doc_id, - demande.type_doc, - "UniversignID", - resultat["transaction_id"] - ) - - logger.info(f"✅ Signature envoyée: {demande.doc_id}") - + return { "success": True, - "transaction_id": resultat["transaction_id"], - "signer_url": resultat["signer_url"] + "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(...)): + 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") + + 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 signature: {e}") + logger.error(f"Erreur statut signature: {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) + +@app.get("/signatures", tags=["Signatures"]) +async def lister_signatures( + statut: Optional[StatutSignature] = Query(None), + limit: int = Query(100, le=1000), + session: AsyncSession = Depends(get_session), +): + query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc()) + + if statut: + statut_db = StatutSignatureDB[statut.value] + query = query.where(SignatureLog.statut == statut_db) + + query = query.limit(limit) + result = await session.execute(query) + signatures = result.scalars().all() + + return [ + { + "id": sig.id, + "document_id": sig.document_id, + "type_document": sig.type_document.value, + "transaction_id": sig.transaction_id, + "signer_url": sig.signer_url, + "email_signataire": sig.email_signataire, + "nom_signataire": sig.nom_signataire, + "statut": sig.statut.value, + "date_envoi": sig.date_envoi.isoformat() if sig.date_envoi else None, + "date_signature": ( + sig.date_signature.isoformat() if sig.date_signature else None + ), + "est_relance": sig.est_relance, + "nb_relances": sig.nb_relances or 0, + } + for sig in signatures + ] + + +@app.get("/signatures/{transaction_id}/status", tags=["Signatures"]) +async def statut_signature_detail( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + 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") + + statut_universign = await universign_statut(transaction_id) + + if statut_universign.get("statut") != "ERREUR": + statut_map = { + "EN_ATTENTE": StatutSignatureDB.EN_ATTENTE, + "ENVOYE": StatutSignatureDB.ENVOYE, + "SIGNE": StatutSignatureDB.SIGNE, + "REFUSE": StatutSignatureDB.REFUSE, + "EXPIRE": StatutSignatureDB.EXPIRE, + } + + nouveau_statut = statut_map.get( + statut_universign["statut"], StatutSignatureDB.EN_ATTENTE + ) + + signature_log.statut = nouveau_statut + + if statut_universign.get("date_signature"): + signature_log.date_signature = datetime.fromisoformat( + statut_universign["date_signature"].replace("Z", "+00:00") + ) + + await session.commit() + + return { + "transaction_id": transaction_id, + "document_id": signature_log.document_id, + "statut": signature_log.statut.value, + "email_signataire": signature_log.email_signataire, + "date_envoi": ( + signature_log.date_envoi.isoformat() if signature_log.date_envoi else None + ), + "date_signature": ( + signature_log.date_signature.isoformat() + if signature_log.date_signature + else None + ), + "signer_url": signature_log.signer_url, + } + + +@app.post("/signatures/refresh-all", tags=["Signatures"]) +async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): + query = select(SignatureLog).where( + SignatureLog.statut.in_( + [StatutSignatureDB.EN_ATTENTE, StatutSignatureDB.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": StatutSignatureDB.SIGNE, + "REFUSE": StatutSignatureDB.REFUSE, + "EXPIRE": StatutSignatureDB.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, + } + + +class EmailBatch(BaseModel): + destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100) + sujet: str = Field(..., min_length=1, max_length=500) + corps_html: str = Field(..., min_length=1) + document_ids: Optional[List[str]] = None + type_document: Optional[TypeDocument] = None + + +@app.post("/emails/send-batch", tags=["Emails"]) +async def envoyer_emails_lot( + batch: EmailBatch, session: AsyncSession = Depends(get_session) +): + 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=StatutEmailDB.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, + } + + +@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), +): + try: + 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)) + + +@app.post("/devis/{id}/relancer-signature", tags=["Devis"]) +async def relancer_devis_signature( + id: str, relance: RelanceDevis, session: AsyncSession = Depends(get_session) ): - """📧 Relance devis via Universign""" try: - # Lire devis via gateway devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") - - # Récupérer contact via gateway + contact = sage_client.lire_contact_client(devis["client_code"]) if not contact or not contact.get("email"): raise HTTPException(400, "Aucun email trouvé pour ce client") - - # Générer PDF + pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) - - # Envoi Universign + resultat = await universign_envoyer( id, pdf_bytes, contact["email"], - contact["nom"] or contact["client_intitule"] + contact["nom"] or contact["client_intitule"], ) - + if "error" in resultat: raise HTTPException(500, resultat["error"]) - - # Logger en DB + signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=id, @@ -605,64 +1310,1603 @@ async def relancer_devis_signature( signer_url=resultat["signer_url"], email_signataire=contact["email"], nom_signataire=contact["nom"] or contact["client_intitule"], - statut=StatutSignatureEnum.ENVOYE, + statut=StatutSignatureDB.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 - HEALTH -# ===================================================== + +class ContactClientResponse(BaseModel): + client_code: str + client_intitule: str + email: Optional[str] + nom: Optional[str] + telephone: Optional[str] + peut_etre_relance: bool + + +@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) +async def recuperer_contact_devis(id: str): + try: + devis = sage_client.lire_devis(id) + if not devis: + raise HTTPException(404, f"Devis {id} introuvable") + + 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)) + + +@app.get("/factures", tags=["Factures"]) +async def lister_factures( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + try: + factures = sage_client.lister_factures(limit=limit, statut=statut) + return factures + + except Exception as e: + logger.error(f"Erreur liste factures: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/factures/{numero}", tags=["Factures"]) +async def lire_facture_detail(numero: str): + try: + facture = sage_client.lire_document(numero, TypeDocumentSQL.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 RelanceFacture(BaseModel): + doc_id: str + message_personnalise: Optional[str] = None + + +@app.post("/factures", status_code=201, tags=["Factures"]) +async def creer_facture( + facture: FactureCreate, session: AsyncSession = Depends(get_session) +): + try: + 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 + ), + "reference": facture.reference, + "lignes": _preparer_lignes_document(facture.lignes), + } + + resultat = sage_client.creer_facture(facture_data) + + logger.info( + f"Facture créée: {resultat.get('numero_facture')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + 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": resultat.get("reference"), + "date_livraison": resultat.get("date_livraison"), + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création facture: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/factures/{id}", tags=["Factures"]) +async def modifier_facture( + id: str, + facture_update: FactureUpdate, + session: AsyncSession = Depends(get_session), +): + try: + 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": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne 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 + + 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_db = { + "relance_facture": { + "id": "relance_facture", + "nom": "Relance Facture", + "sujet": "Rappel - Facture {{DO_Piece}}", + "corps_html": """ +

Bonjour {{CT_Intitule}},

+

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

+

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

+

Cordialement,

+ """, + "variables_disponibles": [ + "DO_Piece", + "DO_Date", + "CT_Intitule", + "DO_TotalHT", + "DO_TotalTTC", + ], + } +} + + +@app.post("/factures/{id}/relancer", tags=["Factures"]) +async def relancer_facture( + id: str, + relance: RelanceFacture, + session: AsyncSession = Depends(get_session), +): + try: + facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) + if not facture: + raise HTTPException(404, f"Facture {id} introuvable") + + 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") + + 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) + + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=contact["email"], + sujet=sujet, + corps_html=corps, + document_ids=id, + type_document=TypeDocument.FACTURE, + statut=StatutEmailDB.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + await session.flush() + + email_queue.enqueue(email_log.id) + + 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)) + + +@app.get("/emails/logs", tags=["Emails"]) +async def journal_emails( + statut: Optional[StatutEmail] = Query(None), + destinataire: Optional[str] = Query(None), + limit: int = Query(100, le=1000), + session: AsyncSession = Depends(get_session), +): + query = select(EmailLog) + + if statut: + query = query.where(EmailLog.statut == StatutEmailDB[statut.value]) + + if destinataire: + query = query.where(EmailLog.destinataire.contains(destinataire)) + + query = query.order_by(EmailLog.date_creation.desc()).limit(limit) + + result = await session.execute(query) + logs = result.scalars().all() + + return [ + { + "id": log.id, + "destinataire": log.destinataire, + "sujet": log.sujet, + "statut": log.statut.value, + "date_creation": log.date_creation.isoformat(), + "date_envoi": log.date_envoi.isoformat() if log.date_envoi else None, + "nb_tentatives": log.nb_tentatives, + "derniere_erreur": log.derniere_erreur, + "document_ids": log.document_ids, + } + for log in logs + ] + + +@app.get("/emails/logs/export", tags=["Emails"]) +async def exporter_logs_csv( + statut: Optional[StatutEmail] = Query(None), + session: AsyncSession = Depends(get_session), +): + query = select(EmailLog) + if statut: + query = query.where(EmailLog.statut == StatutEmailDB[statut.value]) + + query = query.order_by(EmailLog.date_creation.desc()) + + result = await session.execute(query) + logs = result.scalars().all() + + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow( + [ + "ID", + "Destinataire", + "Sujet", + "Statut", + "Date Création", + "Date Envoi", + "Nb Tentatives", + "Erreur", + "Documents", + ] + ) + + 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" + }, + ) + + +class TemplateEmail(BaseModel): + id: Optional[str] = None + nom: str + sujet: str + corps_html: str + variables_disponibles: List[str] = [] + + +class TemplatePreview(BaseModel): + template_id: str + document_id: str + type_document: TypeDocument + + +@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) +async def lister_templates(): + return [TemplateEmail(**template) for template in templates_email_db.values()] + + +@app.get( + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] +) +async def lire_template(template_id: str): + if template_id not in templates_email_db: + raise HTTPException(404, f"Template {template_id} introuvable") + + return TemplateEmail(**templates_email_db[template_id]) + + +@app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) +async def creer_template(template: TemplateEmail): + template_id = str(uuid.uuid4()) + + templates_email_db[template_id] = { + "id": template_id, + "nom": template.nom, + "sujet": template.sujet, + "corps_html": template.corps_html, + "variables_disponibles": template.variables_disponibles, + } + + logger.info(f"Template créé: {template_id}") + + return TemplateEmail(id=template_id, **template.dict()) + + +@app.put( + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] +) +async def modifier_template(template_id: str, template: TemplateEmail): + 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 modifiés") + + templates_email_db[template_id] = { + "id": template_id, + "nom": template.nom, + "sujet": template.sujet, + "corps_html": template.corps_html, + "variables_disponibles": template.variables_disponibles, + } + + logger.info(f"Template modifié: {template_id}") + + return TemplateEmail(id=template_id, **template.dict()) + + +@app.delete("/templates/emails/{template_id}", tags=["Emails"]) +async def supprimer_template(template_id: str): + if template_id not in templates_email_db: + raise HTTPException(404, f"Template {template_id} introuvable") + + if template_id in ["relance_devis", "relance_facture"]: + raise HTTPException(400, "Les templates système ne peuvent pas être supprimés") + + del templates_email_db[template_id] + + logger.info(f"Template supprimé: {template_id}") + + return {"success": True, "message": f"Template {template_id} supprimé"} + + +@app.post("/templates/emails/preview", tags=["Emails"]) +async def previsualiser_email(preview: TemplatePreview): + 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] + + doc = sage_client.lire_document(preview.document_id, preview.type_document) + if not doc: + raise HTTPException(404, f"Document {preview.document_id} introuvable") + + 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}", + } + + 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, + } + + +@app.get("/prospects", tags=["Prospects"]) +async def rechercher_prospects(query: Optional[str] = Query(None)): + 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): + 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)) + + +@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 "") + + logger.info(f"{len(fournisseurs)} fournisseurs") + + if len(fournisseurs) == 0: + logger.warning("Aucun fournisseur retourné - vérifier la gateway Windows") + + return [FournisseurDetails(**f) for f in 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: FournisseurCreate, + session: AsyncSession = Depends(get_session), +): + try: + 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: + logger.warning(f"Erreur métier création fournisseur: {e}") + raise HTTPException(400, str(e)) + + except Exception as e: + logger.error(f"Erreur technique création fournisseur: {e}") + raise HTTPException(500, str(e)) + + +@app.put( + "/fournisseurs/{code}", response_model=FournisseurDetails, tags=["Fournisseurs"] +) +async def modifier_fournisseur( + code: str, + fournisseur_update: FournisseurUpdate, + session: AsyncSession = Depends(get_session), +): + try: + resultat = sage_client.modifier_fournisseur( + code, fournisseur_update.dict(exclude_none=True) + ) + + logger.info(f"Fournisseur {code} modifié avec succès") + + return FournisseurDetails(**resultat) + + except ValueError as e: + logger.warning(f"Erreur métier modification fournisseur {code}: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + 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): + 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)) + + +@app.get("/avoirs", tags=["Avoirs"]) +async def lister_avoirs( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + 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): + try: + avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR) + if not avoir: + raise HTTPException(404, f"Avoir {numero} introuvable") + return avoir + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture avoir: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/avoirs", status_code=201, tags=["Avoirs"]) +async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_session)): + try: + avoir_data = { + "client_id": avoir.client_id, + "date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None), + "date_livraison": ( + avoir.date_livraison.isoformat() if avoir.date_livraison else None + ), + "reference": avoir.reference, + "lignes": _preparer_lignes_document(avoir.lignes), + } + + resultat = sage_client.creer_avoir(avoir_data) + + logger.info( + f"Avoir créé: {resultat.get('numero_avoir')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + 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": resultat.get("reference"), + "date_livraison": resultat.get("date_livraison"), + }, + } + + 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: AvoirUpdate, + session: AsyncSession = Depends(get_session), +): + try: + 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": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne 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 + + 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)) + + +@app.get("/livraisons", tags=["Livraisons"]) +async def lister_livraisons( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + 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): + try: + livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) + if not livraison: + raise HTTPException(404, f"Livraison {numero} introuvable") + return livraison + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture livraison: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/livraisons", status_code=201, tags=["Livraisons"]) +async def creer_livraison( + livraison: LivraisonCreate, session: AsyncSession = Depends(get_session) +): + """ + Crée un bon de livraison dans Sage 100 + + - Le prix_unitaire_ht est optionnel (utilise le prix Sage si non fourni) + - La remise_pourcentage est appliquée après le prix + """ + try: + livraison_data = { + "client_id": livraison.client_id, + "date_livraison": ( + livraison.date_livraison.isoformat() + if livraison.date_livraison + else None + ), + "date_livraison_prevue": ( + livraison.date_livraison_prevue.isoformat() + if livraison.date_livraison_prevue + else None + ), + "reference": livraison.reference, + "lignes": _preparer_lignes_document(livraison.lignes), + } + + resultat = sage_client.creer_livraison(livraison_data) + + logger.info( + f"Livraison créée: {resultat.get('numero_livraison')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + 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"], + "date_livraison_prevue": resultat.get("date_livraison_prevue"), + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": resultat.get("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: LivraisonUpdate, + session: AsyncSession = Depends(get_session), +): + try: + 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": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne 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 + + resultat = sage_client.modifier_livraison(id, update_data) + + logger.info(f"Livraison {id} modifiée avec succès") + + return { + "success": True, + "message": f"Livraison {id} modifiée avec succès", + "livraison": resultat, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification livraison {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"]) +async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): + try: + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 + type_cible=settings.SAGE_TYPE_FACTURE, # = 60 + ) + + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.BON_LIVRAISON, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.FACTURE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"Transformation: Livraison {id} → Facture {resultat['document_cible']}" + ) + + return { + "success": True, + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + } + + except Exception as e: + logger.error(f"Erreur transformation: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/workflow/devis/{id}/to-facture", tags=["Workflows"]) +async def devis_vers_facture_direct( + id: str, session: AsyncSession = Depends(get_session) +): + try: + 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.", + ) + + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_DEVIS, # = 0 + type_cible=settings.SAGE_TYPE_FACTURE, # = 60 + ) + + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.DEVIS, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.FACTURE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"Transformation DIRECTE: Devis {id} → Facture {resultat['document_cible']}" + ) + + return { + "success": True, + "workflow": "devis_to_facture_direct", + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + "statut_devis_mis_a_jour": True, + "message": f"Facture {resultat['document_cible']} créée directement depuis le devis {id}", + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur transformation devis→facture: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/workflow/commande/{id}/to-livraison", tags=["Workflows"]) +async def commande_vers_livraison( + id: str, session: AsyncSession = Depends(get_session) +): + try: + commande_existante = sage_client.lire_document(id, TypeDocumentSQL.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.", + ) + + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 + type_cible=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 + ) + + 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( + "/familles", + response_model=List[Familles], + 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 [Familles(**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=Familles, + 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 Familles(**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=Familles, + status_code=status.HTTP_201_CREATED, + tags=["Familles"], + summary="Création d'une famille d'articles", +) +async def creer_famille(famille: FamilleCreate): + try: + 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}") + + resultat = sage_client.creer_famille(famille_data) + + logger.info(f"Famille créée: {resultat.get('code')}") + + return Familles(**resultat) + + except ValueError as 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: + raise + + except Exception as e: + 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=MouvementStock, + 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: EntreeStock): + try: + 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)") + + resultat = sage_client.creer_entree_stock(entree_data) + + logger.info(f"Entrée stock créée: {resultat.get('numero')}") + + return MouvementStock(**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=MouvementStock, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="SORTIE DE STOCK : Retire des articles du stock", +) +async def creer_sortie_stock(sortie: SortieStock): + try: + 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)") + + resultat = sage_client.creer_sortie_stock(sortie_data) + + logger.info(f"Sortie stock créée: {resultat.get('numero')}") + + return MouvementStock(**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=MouvementStock, + 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 MouvementStock(**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[Users], 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), +): + from database import User + from sqlalchemy import select + + try: + query = select(User) + + if role: + query = query.where(User.role == role) + + if verified_only: + query = query.where(User.is_verified) + + query = query.order_by(User.created_at.desc()).limit(limit) + + result = await session.execute(query) + users = result.scalars().all() + + users_response = [] + for user in users: + users_response.append( + Users( + 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)): + from database import User + from sqlalchemy import select, func + + try: + total_query = select(func.count(User.id)) + total_result = await session.execute(total_query) + total = total_result.scalar() + + verified_query = select(func.count(User.id)).where(User.is_verified) + verified_result = await session.execute(verified_query) + verified = verified_result.scalar() + + active_query = select(func.count(User.id)).where(User.is_active) + active_result = await session.execute(active_query) + active = active_result.scalar() + + 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.post("/tiers/{numero}/contacts", response_model=Contact, tags=["Contacts"]) +async def creer_contact(numero: str, contact: ContactCreate): + try: + try: + sage_client.lire_tiers(numero) + except HTTPException: + raise + except Exception: + raise HTTPException(404, f"Tiers {numero} non trouvé") + + if contact.numero != numero: + contact.numero = numero + + resultat = sage_client.creer_contact(contact.dict()) + + if isinstance(resultat, dict) and "data" in resultat: + contact_data = resultat["data"] + else: + contact_data = resultat + + return Contact(**contact_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création contact: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get("/tiers/{numero}/contacts", response_model=List[Contact], tags=["Contacts"]) +async def lister_contacts(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( + "/tiers/{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( + "/tiers/{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) + if isinstance(resultat, dict) and "data" in resultat: + contact_data = resultat["data"] + else: + contact_data = resultat + + return Contact(**contact_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification contact: {e}") + raise HTTPException(500, str(e)) + + +@app.delete("/tiers/{numero}/contacts/{contact_numero}", tags=["Contacts"]) +async def supprimer_contact(numero: str, contact_numero: int): + try: + 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("/tiers/{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)) + + +@app.get("/tiers", response_model=List[TiersDetails], tags=["Tiers"]) +async def obtenir_tiers( + type_tiers: Optional[str] = Query( + None, + description="Filtre par type: 0/client, 1/fournisseur, 2/prospect, 3/all ou strings", + ), + query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"), +): + try: + type_normalise = normaliser_type_tiers(type_tiers) + tiers = sage_client.lister_tiers(type_tiers=type_normalise, 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)) + + +@app.get("/sage/current-config", tags=["System"]) +async def get_current_sage_config( + ctx: GatewayContext = Depends(get_gateway_context_for_user), +): + return { + "source": "user_gateway" if not ctx.is_fallback else "fallback_env", + "gateway_id": ctx.gateway_id, + "gateway_name": ctx.gateway_name, + "gateway_url": ctx.url, + "user_id": ctx.user_id, + } + + +# Routes Collaborateurs +@app.get( + "/collaborateurs", + response_model=List[CollaborateurDetails], + tags=["Collaborateurs"], +) +async def lister_collaborateurs( + filtre: Optional[str] = Query(None, description="Filtre sur nom/prénom"), + actifs_seulement: bool = Query( + True, description="Exclure les collaborateurs en sommeil" + ), +): + """Liste tous les collaborateurs""" + try: + collaborateurs = sage_client.lister_collaborateurs(filtre, actifs_seulement) + return [CollaborateurDetails(**c) for c in collaborateurs] + except Exception as e: + logger.error(f"Erreur liste collaborateurs: {e}") + raise HTTPException(500, str(e)) + + +@app.get( + "/collaborateurs/{numero}", + response_model=CollaborateurDetails, + tags=["Collaborateurs"], +) +async def lire_collaborateur_detail(numero: int): + """Lit un collaborateur par son numéro""" + try: + collaborateur = sage_client.lire_collaborateur(numero) + + if not collaborateur: + raise HTTPException(404, f"Collaborateur {numero} introuvable") + + return CollaborateurDetails(**collaborateur) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture collaborateur {numero}: {e}") + raise HTTPException(500, str(e)) + + +@app.post( + "/collaborateurs", + response_model=CollaborateurDetails, + tags=["Collaborateurs"], + status_code=201, +) +async def creer_collaborateur(collaborateur: CollaborateurCreate): + """Crée un nouveau collaborateur""" + try: + nouveau = sage_client.creer_collaborateur(collaborateur.model_dump()) + + if not nouveau: + raise HTTPException(500, "Échec création collaborateur") + + return CollaborateurDetails(**nouveau) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création collaborateur: {e}") + raise HTTPException(500, str(e)) + + +@app.put( + "/collaborateurs/{numero}", + response_model=CollaborateurDetails, + tags=["Collaborateurs"], +) +async def modifier_collaborateur(numero: int, collaborateur: CollaborateurUpdate): + """Modifie un collaborateur existant""" + try: + modifie = sage_client.modifier_collaborateur( + numero, collaborateur.model_dump(exclude_unset=True) + ) + + if not modifie: + raise HTTPException(404, f"Collaborateur {numero} introuvable") + + return CollaborateurDetails(**modifie) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification collaborateur {numero}: {e}") + raise HTTPException(500, str(e)) + + @app.get("/health", tags=["System"]) -async def health_check(): - """🏥 Health check""" - gateway_health = sage_client.health() - +async def health_check( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): + gateway_health = sage.health() + return { "status": "healthy", "sage_gateway": gateway_health, + "using_gateway_id": sage.gateway_id, "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""" return { "api": "Sage 100c Dataven - VPS Linux", "version": "2.0.0", "documentation": "/docs", - "health": "/health" + "health": "/health", } -# ===================================================== -# LANCEMENT -# ===================================================== + +@app.get("/admin/cache/info", tags=["Admin"]) +async def info_cache(): + try: + 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)) + + +@app.get("/admin/queue/status", tags=["Admin"]) +async def statut_queue(): + return { + "queue_size": email_queue.queue.qsize(), + "workers": len(email_queue.workers), + "running": email_queue.running, + } + + if __name__ == "__main__": uvicorn.run( "api:app", host=settings.api_host, port=settings.api_port, - reload=settings.api_reload - ) \ No newline at end of file + reload=settings.api_reload, + ) diff --git a/config.py b/config/config.py similarity index 52% rename from config.py rename to config/config.py index 2ed4336..63bf99b 100644 --- a/config.py +++ b/config/config.py @@ -1,20 +1,33 @@ 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" ) + # === 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 + 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 + 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 @@ -22,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 @@ -31,13 +45,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 - + retry_delay_seconds: int = 3 + # === CORS === cors_origins: List[str] = ["*"] -settings = Settings() \ No newline at end of file + +settings = Settings() diff --git a/core/dependencies.py b/core/dependencies.py new file mode 100644 index 0000000..039081c --- /dev/null +++ b/core/dependencies.py @@ -0,0 +1,94 @@ +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 +from datetime import datetime + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session), +) -> User: + token = credentials.credentials + + payload = decode_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide ou expiré", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Type de token incorrect", + headers={"WWW-Authenticate": "Bearer"}, + ) + + 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"}, + ) + + 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"}, + ) + + 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.", + ) + + 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]: + if not credentials: + return None + + try: + return await get_current_user(credentials, session) + except HTTPException: + return None + + +def require_role(*allowed_roles: str): + 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 diff --git a/core/sage_context.py b/core/sage_context.py new file mode 100644 index 0000000..cd092a4 --- /dev/null +++ b/core/sage_context.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass +from typing import Optional +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session, User +from core.dependencies import get_current_user +from sage_client import SageGatewayClient +from config.config import settings +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class GatewayContext: + + url: str + token: str + gateway_id: Optional[str] = None + gateway_name: Optional[str] = None + user_id: Optional[str] = None + is_fallback: bool = False + + +async def get_sage_client_for_user( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +) -> SageGatewayClient: + from services.sage_gateway import SageGatewayService + + service = SageGatewayService(session) + active_gateway = await service.get_active_gateway(user.id) + + if active_gateway: + logger.debug(f"Gateway active: {active_gateway.name} pour {user.email}") + return SageGatewayClient( + gateway_url=active_gateway.gateway_url, + gateway_token=active_gateway.gateway_token, + gateway_id=active_gateway.id, + ) + + logger.debug(f"Fallback .env pour {user.email}") + return SageGatewayClient() + + +async def get_gateway_context_for_user( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +) -> GatewayContext: + from services.sage_gateway import SageGatewayService + + service = SageGatewayService(session) + active_gateway = await service.get_active_gateway(user.id) + + if active_gateway: + return GatewayContext( + url=active_gateway.gateway_url, + token=active_gateway.gateway_token, + gateway_id=active_gateway.id, + gateway_name=active_gateway.name, + user_id=user.id, + is_fallback=False, + ) + + return GatewayContext( + url=settings.sage_gateway_url, + token=settings.sage_gateway_token, + gateway_id=None, + gateway_name="Fallback (.env)", + user_id=user.id, + is_fallback=True, + ) + + +async def get_sage_client_public() -> SageGatewayClient: + return SageGatewayClient() diff --git a/create_admin.py b/create_admin.py new file mode 100644 index 0000000..d3cb786 --- /dev/null +++ b/create_admin.py @@ -0,0 +1,97 @@ +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(): + 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") + + 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, + 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(" 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) diff --git a/data/data.py b/data/data.py new file mode 100644 index 0000000..c49c5d5 --- /dev/null +++ b/data/data.py @@ -0,0 +1,405 @@ +TAGS_METADATA = [ + {"name": "System", "description": "Health checks et informations système"}, + {"name": "Admin", "description": "Administration système (cache, queue)"}, + {"name": "Debug", "description": "Routes de debug et diagnostics"}, + { + "name": "Authentication", + "description": "Authentification, gestion des sessions et contrôle d'accès", + }, + { + "name": "Sage Gateways", + "description": "Passerelles de communication avec Sage (API, synchronisation, échanges)", + }, + { + "name": "Tiers", + "description": "Gestion des tiers (clients, fournisseurs et prospects)", + }, + { + "name": "Clients", + "description": "Gestion des clients (recherche, création, modification)", + }, + {"name": "Fournisseurs", "description": "Gestion des fournisseurs"}, + {"name": "Prospects", "description": "Gestion des prospects"}, + { + "name": "Contacts", + "description": "Gestion des contacts rattachés aux tiers", + }, + { + "name": "Familles", + "description": "Gestion des familles et catégories d'articles", + }, + {"name": "Articles", "description": "Gestion des articles et produits"}, + { + "name": "Stock", + "description": "Consultation et gestion des stocks d'articles", + }, + {"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": "Documents", + "description": "Gestion des documents liés aux tiers (devis, commandes, factures, avoirs)", + }, + { + "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.)"}, +] + +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", + ], + }, +} diff --git a/data/sage_dataven.db b/data/sage_dataven.db new file mode 100644 index 0000000..925d98b Binary files /dev/null and b/data/sage_dataven.db differ diff --git a/database/__init__.py b/database/__init__.py index 1912d5d..96eb874 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -3,37 +3,56 @@ from database.db_config import ( async_session_factory, init_db, get_session, - close_db + close_db, ) - -from database.models import ( - Base, - EmailLog, - SignatureLog, - WorkflowLog, +from database.models.generic_model import ( CacheMetadata, AuditLog, + RefreshToken, + LoginAttempt, +) +from database.models.user import User +from database.models.email import EmailLog +from database.models.signature import SignatureLog +from database.models.sage_config import SageGatewayConfig +from database.enum.status import ( StatutEmail, - StatutSignature + StatutSignature, +) +from database.models.workflow import WorkflowLog +from database.models.universign import ( + UniversignTransaction, + UniversignSigner, + UniversignSyncLog, + UniversignTransactionStatus, + LocalDocumentStatus, + UniversignSignerStatus, + SageDocumentType ) __all__ = [ - # Config - 'engine', - 'async_session_factory', - 'init_db', - 'get_session', - 'close_db', - - # Models - 'Base', - 'EmailLog', - 'SignatureLog', - 'WorkflowLog', - 'CacheMetadata', - 'AuditLog', - - # Enums - 'StatutEmail', - 'StatutSignature', -] \ No newline at end of file + "engine", + "async_session_factory", + "init_db", + "get_session", + "close_db", + "Base", + "EmailLog", + "SignatureLog", + "WorkflowLog", + "CacheMetadata", + "AuditLog", + "StatutEmail", + "StatutSignature", + "User", + "RefreshToken", + "LoginAttempt", + "SageGatewayConfig", + "UniversignTransaction", + "UniversignSigner", + "UniversignSyncLog", + "UniversignTransactionStatus", + "LocalDocumentStatus", + "UniversignSignerStatus", + "SageDocumentType" +] diff --git a/database/db_config.py b/database/db_config.py index 0bbba98..ab89bbb 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -1,19 +1,19 @@ import os from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import StaticPool -from database.models import Base +from sqlalchemy.pool import NullPool import logging +from database.models.generic_model import Base + logger = logging.getLogger(__name__) -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./sage_dataven.db") +DATABASE_URL = os.getenv("DATABASE_URL") engine = create_async_engine( DATABASE_URL, echo=False, future=True, - connect_args={"check_same_thread": False}, - poolclass=StaticPool, + poolclass=NullPool, ) async_session_factory = async_sessionmaker( @@ -25,32 +25,27 @@ 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 - """ + logger.info("Debut init_db") try: + logger.info("Tentative de connexion") async with engine.begin() as conn: + logger.info("Connexion etablie") 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("create_all execute") + + 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 async def get_session() -> AsyncSession: - """Dependency FastAPI pour obtenir une session DB""" async with async_session_factory() as session: - try: - yield session - finally: - await session.close() + yield session 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/enum/status.py b/database/enum/status.py new file mode 100644 index 0000000..c452f70 --- /dev/null +++ b/database/enum/status.py @@ -0,0 +1,18 @@ +import enum + + +class StatutEmail(str, enum.Enum): + EN_ATTENTE = "EN_ATTENTE" + EN_COURS = "EN_COURS" + ENVOYE = "ENVOYE" + OUVERT = "OUVERT" + ERREUR = "ERREUR" + BOUNCE = "BOUNCE" + + +class StatutSignature(str, enum.Enum): + EN_ATTENTE = "EN_ATTENTE" + ENVOYE = "ENVOYE" + SIGNE = "SIGNE" + REFUSE = "REFUSE" + EXPIRE = "EXPIRE" diff --git a/database/models.py b/database/models.py deleted file mode 100644 index f147305..0000000 --- a/database/models.py +++ /dev/null @@ -1,204 +0,0 @@ -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 - -Base = declarative_base() - -# ============================================================================ -# Enums -# ============================================================================ - -class StatutEmail(str, enum.Enum): - """Statuts possibles d'un email""" - EN_ATTENTE = "EN_ATTENTE" - EN_COURS = "EN_COURS" - ENVOYE = "ENVOYE" - OUVERT = "OUVERT" - 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) - - def __repr__(self): - return f"" - - -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) - 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) - - def __repr__(self): - return f"" - - -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) - duree_ms = Column(Integer, nullable=True) # Durée en millisecondes - - def __repr__(self): - return f"" - - -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' - - # 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) - - def __repr__(self): - return f"" - - -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. - 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"" \ No newline at end of file diff --git a/database/models/email.py b/database/models/email.py new file mode 100644 index 0000000..8ba4177 --- /dev/null +++ b/database/models/email.py @@ -0,0 +1,43 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Text, + Enum as SQLEnum, +) +from datetime import datetime +from database.models.generic_model import Base +from database.enum.status import StatutEmail + + +class EmailLog(Base): + __tablename__ = "email_logs" + + id = Column(String(36), primary_key=True) + + destinataire = Column(String(255), nullable=False, index=True) + cc = Column(Text, nullable=True) + cci = Column(Text, nullable=True) + + sujet = Column(String(500), nullable=False) + corps_html = Column(Text, nullable=False) + + document_ids = Column(Text, nullable=True) + type_document = Column(Integer, nullable=True) + + statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) + + date_creation = Column(DateTime, default=datetime.now, nullable=False) + date_envoi = Column(DateTime, nullable=True) + date_ouverture = Column(DateTime, nullable=True) + + nb_tentatives = Column(Integer, default=0) + derniere_erreur = Column(Text, nullable=True) + prochain_retry = Column(DateTime, nullable=True) + + ip_envoi = Column(String(45), nullable=True) + user_agent = Column(String(500), nullable=True) + + def __repr__(self): + return f"" diff --git a/database/models/generic_model.py b/database/models/generic_model.py new file mode 100644 index 0000000..840b614 --- /dev/null +++ b/database/models/generic_model.py @@ -0,0 +1,91 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Float, + Text, + Boolean, +) +from sqlalchemy.ext.declarative import declarative_base +from datetime import datetime + +Base = declarative_base() + + +class CacheMetadata(Base): + __tablename__ = "cache_metadata" + + id = Column(Integer, primary_key=True, autoincrement=True) + + cache_type = Column(String(50), unique=True, nullable=False) + + last_refresh = Column(DateTime, default=datetime.now) + item_count = Column(Integer, default=0) + refresh_duration_ms = Column(Float, nullable=True) + + last_error = Column(Text, nullable=True) + error_count = Column(Integer, default=0) + + def __repr__(self): + return f"" + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + + action = Column(String(100), nullable=False, index=True) + ressource_type = Column(String(50), nullable=True) + ressource_id = Column(String(100), nullable=True, index=True) + + utilisateur = Column(String(100), nullable=True) + ip_address = Column(String(45), nullable=True) + + succes = Column(Boolean, default=True) + details = Column(Text, nullable=True) + erreur = Column(Text, nullable=True) + + date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) + + def __repr__(self): + return f"" + + +class RefreshToken(Base): + __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) + + device_info = Column(String(500), nullable=True) + ip_address = Column(String(45), nullable=True) + + expires_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=datetime.now, nullable=False) + + is_revoked = Column(Boolean, default=False) + revoked_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class LoginAttempt(Base): + __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"" diff --git a/database/models/sage_config.py b/database/models/sage_config.py new file mode 100644 index 0000000..f6ed363 --- /dev/null +++ b/database/models/sage_config.py @@ -0,0 +1,54 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Text, + Boolean, +) +from datetime import datetime +from database.models.generic_model import Base + + +class SageGatewayConfig(Base): + __tablename__ = "sage_gateway_configs" + + id = Column(String(36), primary_key=True) + user_id = Column(String(36), nullable=False, index=True) + + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + + gateway_url = Column(String(500), nullable=False) + gateway_token = Column(String(255), nullable=False) + + sage_database = Column(String(255), nullable=True) + sage_company = Column(String(255), nullable=True) + + is_active = Column(Boolean, default=False, index=True) + is_default = Column(Boolean, default=False) + priority = Column(Integer, default=0) + + last_health_check = Column(DateTime, nullable=True) + last_health_status = Column(Boolean, nullable=True) + last_error = Column(Text, nullable=True) + + total_requests = Column(Integer, default=0) + successful_requests = Column(Integer, default=0) + failed_requests = Column(Integer, default=0) + last_used_at = Column(DateTime, nullable=True) + + extra_config = Column(Text, nullable=True) + + is_encrypted = Column(Boolean, default=False) + allowed_ips = Column(Text, nullable=True) + + created_at = Column(DateTime, default=datetime.now, nullable=False) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + created_by = Column(String(36), nullable=True) + + is_deleted = Column(Boolean, default=False, index=True) + deleted_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" diff --git a/database/models/signature.py b/database/models/signature.py new file mode 100644 index 0000000..c84abe9 --- /dev/null +++ b/database/models/signature.py @@ -0,0 +1,44 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Text, + Boolean, + Enum as SQLEnum, +) +from datetime import datetime +from database.models.generic_model import Base +from database.enum.status import StatutSignature + + +class SignatureLog(Base): + __tablename__ = "signature_logs" + + id = Column(String(36), primary_key=True) + + document_id = Column(String(100), nullable=False, index=True) + type_document = Column(Integer, nullable=False) + + transaction_id = Column(String(100), unique=True, index=True, nullable=True) + signer_url = Column(String(500), nullable=True) + + email_signataire = Column(String(255), nullable=False, index=True) + nom_signataire = Column(String(255), nullable=False) + + 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) + + est_relance = Column(Boolean, default=False) + nb_relances = Column(Integer, default=0) + derniere_relance = Column(DateTime, nullable=True) + + raison_refus = Column(Text, nullable=True) + ip_signature = Column(String(45), nullable=True) + + def __repr__(self): + return f"" diff --git a/database/models/universign.py b/database/models/universign.py new file mode 100644 index 0000000..e4ad3a3 --- /dev/null +++ b/database/models/universign.py @@ -0,0 +1,303 @@ +from sqlalchemy import ( + Column, + String, + DateTime, + Boolean, + Integer, + Text, + Enum as SQLEnum, + ForeignKey, + Index, +) +from sqlalchemy.orm import relationship +from datetime import datetime +from enum import Enum +from database.models.generic_model import Base + + +class UniversignTransactionStatus(str, Enum): + DRAFT = "draft" + READY = "ready" + STARTED = "started" + COMPLETED = "completed" + CLOSED = "closed" + REFUSED = "refused" + EXPIRED = "expired" + CANCELED = "canceled" + FAILED = "failed" + + +class UniversignSignerStatus(str, Enum): + WAITING = "waiting" + OPEN = "open" + VIEWED = "viewed" + SIGNED = "signed" + COMPLETED = "completed" + REFUSED = "refused" + EXPIRED = "expired" + STALLED = "stalled" + UNKNOWN = "unknown" + + +class LocalDocumentStatus(str, Enum): + PENDING = "EN_ATTENTE" + IN_PROGRESS = "EN_COURS" + SIGNED = "SIGNE" + REJECTED = "REFUSE" + EXPIRED = "EXPIRE" + ERROR = "ERREUR" + + +class SageDocumentType(int, Enum): + DEVIS = 0 + BON_COMMANDE = 10 + PREPARATION = 20 + BON_LIVRAISON = 30 + BON_RETOUR = 40 + BON_AVOIR = 50 + FACTURE = 60 + + +class UniversignTransaction(Base): + __tablename__ = "universign_transactions" + + # === IDENTIFIANTS === + id = Column(String(36), primary_key=True) # UUID local + transaction_id = Column( + String(255), + unique=True, + nullable=False, + index=True, + comment="ID Universign (ex: tr_abc123)", + ) + + # === LIEN AVEC LE DOCUMENT SAGE === + sage_document_id = Column( + String(50), + nullable=False, + index=True, + comment="Numéro du document Sage (ex: DE00123)", + ) + sage_document_type = Column( + SQLEnum(SageDocumentType), nullable=False, comment="Type de document Sage" + ) + + # === STATUTS UNIVERSIGN (SOURCE DE VÉRITÉ) === + universign_status = Column( + SQLEnum(UniversignTransactionStatus), + nullable=False, + default=UniversignTransactionStatus.DRAFT, + index=True, + comment="Statut brut Universign", + ) + universign_status_updated_at = Column( + DateTime, nullable=True, comment="Dernière MAJ du statut Universign" + ) + + # === STATUT LOCAL (DÉDUIT) === + local_status = Column( + SQLEnum(LocalDocumentStatus), + nullable=False, + default=LocalDocumentStatus.PENDING, + index=True, + comment="Statut métier simplifié pour l'UI", + ) + + # === URLS ET MÉTADONNÉES UNIVERSIGN === + signer_url = Column(Text, nullable=True, comment="URL de signature") + document_url = Column(Text, nullable=True, comment="URL du document signé") + + signed_document_path = Column( + Text, nullable=True, comment="Chemin local du PDF signé" + ) + signed_document_downloaded_at = Column( + DateTime, nullable=True, comment="Date de téléchargement du document" + ) + signed_document_size_bytes = Column( + Integer, nullable=True, comment="Taille du fichier en octets" + ) + download_attempts = Column( + Integer, default=0, comment="Nombre de tentatives de téléchargement" + ) + download_error = Column( + Text, nullable=True, comment="Dernière erreur de téléchargement" + ) + + certificate_url = Column(Text, nullable=True, comment="URL du certificat") + + # === SIGNATAIRES === + signers_data = Column( + Text, nullable=True, comment="JSON des signataires (snapshot)" + ) + + # === INFORMATIONS MÉTIER === + requester_email = Column(String(255), nullable=True) + requester_name = Column(String(255), nullable=True) + document_name = Column(String(500), nullable=True) + + # === DATES CLÉS === + created_at = Column( + DateTime, + default=datetime.now, + nullable=False, + comment="Date de création locale", + ) + sent_at = Column( + DateTime, nullable=True, comment="Date d'envoi Universign (started)" + ) + signed_at = Column(DateTime, nullable=True, comment="Date de signature complète") + refused_at = Column(DateTime, nullable=True) + expired_at = Column(DateTime, nullable=True) + canceled_at = Column(DateTime, nullable=True) + + # === SYNCHRONISATION === + last_synced_at = Column( + DateTime, nullable=True, comment="Dernière sync réussie avec Universign" + ) + sync_attempts = Column(Integer, default=0, comment="Nombre de tentatives de sync") + sync_error = Column(Text, nullable=True) + + # === FLAGS === + is_test = Column( + Boolean, default=False, comment="Transaction en environnement .alpha" + ) + needs_sync = Column( + Boolean, default=True, index=True, comment="À synchroniser avec Universign" + ) + webhook_received = Column(Boolean, default=False, comment="Webhook Universign reçu") + + # === RELATION === + signers = relationship( + "UniversignSigner", back_populates="transaction", cascade="all, delete-orphan" + ) + sync_logs = relationship( + "UniversignSyncLog", back_populates="transaction", cascade="all, delete-orphan" + ) + + # === INDEXES COMPOSITES === + __table_args__ = ( + Index("idx_sage_doc", "sage_document_id", "sage_document_type"), + Index("idx_sync_status", "needs_sync", "universign_status"), + Index("idx_dates", "created_at", "signed_at"), + ) + + def __repr__(self): + return ( + f"" + ) + + +class UniversignSigner(Base): + """ + Détail de chaque signataire d'une transaction + """ + + __tablename__ = "universign_signers" + + id = Column(String(36), primary_key=True) + transaction_id = Column( + String(36), + ForeignKey("universign_transactions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # === DONNÉES SIGNATAIRE === + email = Column(String(255), nullable=False, index=True) + name = Column(String(255), nullable=True) + phone = Column(String(50), nullable=True) + + # === STATUT === + status = Column( + SQLEnum(UniversignSignerStatus), + default=UniversignSignerStatus.WAITING, + nullable=False, + ) + + # === ACTIONS === + viewed_at = Column(DateTime, nullable=True) + signed_at = Column(DateTime, nullable=True) + refused_at = Column(DateTime, nullable=True) + refusal_reason = Column(Text, nullable=True) + + # === MÉTADONNÉES === + ip_address = Column(String(45), nullable=True) + user_agent = Column(Text, nullable=True) + signature_method = Column(String(50), nullable=True) + + # === ORDRE === + order_index = Column(Integer, default=0) + + # === RELATION === + transaction = relationship("UniversignTransaction", back_populates="signers") + + def __repr__(self): + return f"" + + +class UniversignSyncLog(Base): + """ + Journal de toutes les synchronisations (audit trail) + """ + + __tablename__ = "universign_sync_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + transaction_id = Column( + String(36), + ForeignKey("universign_transactions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # === SYNC INFO === + sync_type = Column(String(50), nullable=False, comment="webhook, polling, manual") + sync_timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True) + + # === CHANGEMENTS DÉTECTÉS === + previous_status = Column(String(50), nullable=True) + new_status = Column(String(50), nullable=True) + changes_detected = Column(Text, nullable=True, comment="JSON des changements") + + # === RÉSULTAT === + success = Column(Boolean, default=True) + error_message = Column(Text, nullable=True) + http_status_code = Column(Integer, nullable=True) + response_time_ms = Column(Integer, nullable=True) + + # === RELATION === + transaction = relationship("UniversignTransaction", back_populates="sync_logs") + + def __repr__(self): + return f"" + + +class UniversignConfig(Base): + __tablename__ = "universign_configs" + + id = Column(String(36), primary_key=True) + user_id = Column(String(36), nullable=True, index=True) + + environment = Column( + String(50), nullable=False, default="alpha", comment="alpha, prod" + ) + + api_url = Column(String(500), nullable=False) + api_key = Column(String(500), nullable=False, comment="À chiffrer") + + # === OPTIONS === + webhook_url = Column(String(500), nullable=True) + webhook_secret = Column(String(255), nullable=True) + + auto_sync_enabled = Column(Boolean, default=True) + sync_interval_minutes = Column(Integer, default=5) + + signature_expiry_days = Column(Integer, default=30) + + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.now) + + def __repr__(self): + return f"" diff --git a/database/models/user.py b/database/models/user.py new file mode 100644 index 0000000..7c73cd0 --- /dev/null +++ b/database/models/user.py @@ -0,0 +1,39 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Boolean, +) +from datetime import datetime +from database.models.generic_model import Base + + +class User(Base): + __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) + + nom = Column(String(100), nullable=False) + prenom = Column(String(100), nullable=False) + role = Column(String(50), default="user") + + is_verified = Column(Boolean, default=False) + verification_token = Column(String(255), nullable=True, unique=True, index=True) + verification_token_expires = Column(DateTime, nullable=True) + + is_active = Column(Boolean, default=True) + failed_login_attempts = Column(Integer, default=0) + locked_until = Column(DateTime, nullable=True) + + reset_token = Column(String(255), nullable=True, unique=True, index=True) + reset_token_expires = Column(DateTime, nullable=True) + + 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"" diff --git a/database/models/workflow.py b/database/models/workflow.py new file mode 100644 index 0000000..018aba2 --- /dev/null +++ b/database/models/workflow.py @@ -0,0 +1,37 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Float, + Text, + Boolean, +) +from datetime import datetime +from database.models.generic_model import Base + + +class WorkflowLog(Base): + __tablename__ = "workflow_logs" + + id = Column(String(36), primary_key=True) + + 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) + + nb_lignes = Column(Integer, nullable=True) + montant_ht = Column(Float, nullable=True) + montant_ttc = Column(Float, nullable=True) + + date_transformation = Column(DateTime, default=datetime.now, nullable=False) + utilisateur = Column(String(100), nullable=True) + + succes = Column(Boolean, default=True) + erreur = Column(Text, nullable=True) + duree_ms = Column(Integer, nullable=True) # Durée en millisecondes + + def __repr__(self): + return f"" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..7ee11c7 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,24 @@ +services: + backend: + container_name: dev-sage-api + build: + context: . + target: dev + env_file: .env + volumes: + - .:/app + - /app/__pycache__ + - ./data:/app/data + - ./logs:/app/logs + ports: + - "8000:8000" + environment: + ENV: development + DEBUG: "true" + DATABASE_URL: "sqlite+aiosqlite:///./data/sage_dataven.db" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..027eaf7 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,23 @@ +services: + backend: + container_name: prod_sage_api + build: + context: . + target: prod + env_file: .env.production + volumes: + - ./data:/app/data + - ./logs:/app/logs + ports: + - "8004:8004" + environment: + ENV: production + DEBUG: "false" + DATABASE_URL: "sqlite+aiosqlite:///./data/sage_prod.db" + restart: always + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8004/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s \ No newline at end of file diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..81f9215 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,22 @@ +services: + backend: + container_name: staging_sage_api + build: + context: . + target: staging + env_file: .env.staging + volumes: + - ./data:/app/data + - ./logs:/app/logs + ports: + - "8002:8002" + environment: + ENV: staging + DEBUG: "false" + DATABASE_URL: "sqlite+aiosqlite:///./data/sage_staging.db" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8002/health"] + interval: 30s + timeout: 10s + retries: 3 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3787019..9989985 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,4 @@ -version: "3.9" - services: - vps-sage-api: - build: . - container_name: vps-sage-api - env_file: .env - volumes: - # ✅ Monter un DOSSIER entier au lieu d'un fichier - - ./data:/app/data - ports: - - "8000:8000" - restart: unless-stopped \ No newline at end of file + backend: + build: + context: . \ No newline at end of file diff --git a/email_queue.py b/email_queue.py index 9c8451d..6079ae8 100644 --- a/email_queue.py +++ b/email_queue.py @@ -1,346 +1,456 @@ -# -*- 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 import asyncio from datetime import datetime, timedelta -from typing import Optional -from tenacity import retry, stop_after_attempt, wait_exponential import smtplib +import ssl +import socket from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.application import MIMEApplication -from config import settings +from config.config import settings import logging +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen import canvas +from io import BytesIO + +from reportlab.lib.units import mm +from reportlab.lib.colors import HexColor logger = logging.getLogger(__name__) 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 # Sera injecté depuis api.py - + 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)") - + + 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") - + except Exception: + pass + 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: - logger.error(f"❌ Erreur worker: {e}", exc_info=True) + logger.error(f"Erreur worker: {e}") try: self.queue.task_done() - except: + except Exception: 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é") + 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") + 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 + error_msg = str(e) email_log.statut = StatutEmail.ERREUR - email_log.derniere_erreur = str(e)[:1000] # Limiter la taille - - # Programmer un retry si < max attempts + email_log.derniere_erreur = error_msg[:1000] + 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}") - 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) - ) + 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 + msg["From"] = settings.smtp_from + msg["To"] = email_log.destinataire + msg["Subject"] = email_log.sujet + msg.attach(MIMEText(email_log.corps_html, "html")) + + # 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é) + logger.error(f"Erreur génération PDF {doc_id}: {e}") + + # Envoi SMTP await asyncio.to_thread(self._send_smtp, msg) - + + def _send_smtp(self, msg): + server = None + + try: + # Résolution DNS + socket.getaddrinfo(settings.smtp_host, settings.smtp_port) + + # Connexion + server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) + + # EHLO + server.ehlo() + + # STARTTLS + if settings.smtp_use_tls: + if server.has_extn("STARTTLS"): + context = ssl.create_default_context() + server.starttls(context=context) + server.ehlo() + + # Authentification + if settings.smtp_user and settings.smtp_password: + server.login(settings.smtp_user, settings.smtp_password) + + # Envoi + refused = server.send_message(msg) + if refused: + raise Exception(f"Destinataires refusés: {refused}") + + # Fermeture + server.quit() + + except Exception as e: + if server: + try: + server.quit() + except Exception: + pass + raise Exception(f"Erreur SMTP: {str(e)}") + 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") - + raise Exception(f"Document {doc_id} inaccessible : {e}") + 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 === + + # Couleurs + green_color = HexColor("#2A6F4F") + gray_400 = HexColor("#9CA3AF") + gray_600 = HexColor("#4B5563") + gray_800 = HexColor("#1F2937") + + # Marges + margin = 8 * mm + content_width = width - 2 * margin + + y = height - margin + + # ===== HEADER ===== + y -= 20 * mm + + # Logo/Nom entreprise à gauche + pdf.setFont("Helvetica-Bold", 18) + pdf.setFillColor(green_color) + pdf.drawString(margin, y, "Bijou S.A.S") + + # Informations document à droite + pdf.setFillColor(gray_800) 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", - 2: "BON DE RETOUR", - 3: "COMMANDE", - 4: "PRÉPARATION", - 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}") - - # === INFORMATIONS CLIENT === - y = height - 5*cm - pdf.setFont("Helvetica-Bold", 14) - 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', '')}") - - # === LIGNES === - y -= 1.5*cm - pdf.setFont("Helvetica-Bold", 14) - 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 + numero = doc.get("numero") or "BROUILLON" + pdf.drawRightString(width - margin, y, numero.upper()) + + y -= 7 * mm 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 - 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 - - # === TOTAUX === - 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.setFont("Helvetica-Bold", 14) - pdf.drawString(12*cm, y, "Total TTC:") - pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}€") - - # === PIED DE PAGE === + pdf.setFillColor(gray_600) + + date_str = doc.get("date") or datetime.now().strftime("%d/%m/%Y") + pdf.drawRightString(width - margin, y, f"Date : {date_str}") + + y -= 5 * mm + date_livraison = ( + doc.get("date_livraison") or doc.get("date_echeance") or date_str + ) + pdf.drawRightString(width - margin, y, f"Validité : {date_livraison}") + + y -= 5 * mm + reference = doc.get("reference") or "—" + pdf.drawRightString(width - margin, y, f"Réf : {reference}") + + # ===== ADDRESSES ===== + y -= 20 * mm + + # Émetteur (gauche) + col1_x = margin + col2_x = margin + content_width / 2 + 6 * mm + col_width = content_width / 2 - 6 * mm + + pdf.setFont("Helvetica-Bold", 8) + pdf.setFillColor(gray_400) + pdf.drawString(col1_x, y, "ÉMETTEUR") + + y_emetteur = y - 5 * mm + pdf.setFont("Helvetica-Bold", 10) + pdf.setFillColor(gray_800) + pdf.drawString(col1_x, y_emetteur, "Bijou S.A.S") + + y_emetteur -= 5 * mm + pdf.setFont("Helvetica", 9) + pdf.setFillColor(gray_600) + pdf.drawString(col1_x, y_emetteur, "123 Avenue de la République") + + y_emetteur -= 4 * mm + pdf.drawString(col1_x, y_emetteur, "75011 Paris, France") + + y_emetteur -= 5 * mm + pdf.drawString(col1_x, y_emetteur, "contact@bijou.com") + + # Destinataire (droite, avec fond gris) + box_y = y - 4 * mm + box_height = 28 * mm + pdf.setFillColorRGB(0.97, 0.97, 0.97) # bg-gray-50 + pdf.roundRect( + col2_x, box_y - box_height, col_width, box_height, 3 * mm, fill=1, stroke=0 + ) + + pdf.setFillColor(gray_400) + pdf.setFont("Helvetica-Bold", 8) + pdf.drawString(col2_x + 4 * mm, y, "DESTINATAIRE") + + y_dest = y - 5 * mm + pdf.setFont("Helvetica-Bold", 10) + pdf.setFillColor(gray_800) + client_name = doc.get("client_intitule") or "Client" + pdf.drawString(col2_x + 4 * mm, y_dest, client_name) + + y_dest -= 5 * mm + pdf.setFont("Helvetica", 9) + pdf.setFillColor(gray_600) + pdf.drawString(col2_x + 4 * mm, y_dest, "10 rue des Clients") + + y_dest -= 4 * mm + pdf.drawString(col2_x + 4 * mm, y_dest, "75001 Paris") + + # ===== LIGNES D'ARTICLES ===== + y = min(y_emetteur, y_dest) - 20 * mm + + # En-têtes des colonnes + col_designation = margin + col_quantite = width - margin - 80 * mm + col_prix_unit = width - margin - 64 * mm + col_taux_taxe = width - margin - 40 * mm + col_montant = width - margin - 24 * mm + + pdf.setFont("Helvetica-Bold", 9) + pdf.setFillColor(gray_800) + pdf.drawString(col_designation, y, "Désignation") + pdf.drawRightString(col_quantite, y, "Qté") + pdf.drawRightString(col_prix_unit, y, "Prix Unit. HT") + pdf.drawRightString(col_taux_taxe, y, "TVA") + pdf.drawRightString(col_montant, y, "Montant HT") + + y -= 7 * mm + + # Lignes d'articles 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 + lignes = doc.get("lignes", []) + + for ligne in lignes: + if y < 60 * mm: # Nouvelle page si nécessaire + pdf.showPage() + y = height - margin - 20 * mm + pdf.setFont("Helvetica", 8) + + designation = ( + ligne.get("designation") or ligne.get("designation_article") or "" + ) + if len(designation) > 60: + designation = designation[:57] + "..." + + pdf.setFillColor(gray_800) + pdf.setFont("Helvetica-Bold", 8) + pdf.drawString(col_designation, y, designation) + + y -= 4 * mm + + # Description (si différente) + description = ligne.get("description", "") + if description and description != designation: + pdf.setFont("Helvetica", 7) + pdf.setFillColor(gray_600) + if len(description) > 70: + description = description[:67] + "..." + pdf.drawString(col_designation, y, description) + y -= 4 * mm + + # Valeurs + y += 4 * mm # Remonter pour aligner avec la désignation + pdf.setFont("Helvetica", 8) + pdf.setFillColor(gray_800) + + quantite = ligne.get("quantite") or 0 + pdf.drawRightString(col_quantite, y, str(quantite)) + + prix_unit = ligne.get("prix_unitaire_ht") or ligne.get("prix_unitaire", 0) + pdf.drawRightString(col_prix_unit, y, f"{prix_unit:.2f} €") + + taux_taxe = ligne.get("taux_taxe1") or 20 + pdf.drawRightString(col_taux_taxe, y, f"{taux_taxe}%") + + montant = ligne.get("montant_ligne_ht") or ligne.get("montant_ht", 0) + pdf.setFont("Helvetica-Bold", 8) + pdf.drawRightString(col_montant, y, f"{montant:.2f} €") + + y -= 8 * mm + + # Si aucune ligne + if not lignes: + pdf.setFont("Helvetica-Oblique", 9) + pdf.setFillColor(gray_400) + pdf.drawCentredString(width / 2, y, "Aucune ligne") + y -= 15 * mm + + # ===== TOTAUX ===== + y -= 10 * mm + + totals_x = width - margin - 64 * mm + totals_label_width = 40 * mm + + pdf.setFont("Helvetica", 9) + pdf.setFillColor(gray_600) + + # Total HT + pdf.drawString(totals_x, y, "Total HT") + total_ht = doc.get("total_ht_net") or doc.get("total_ht") or 0 + pdf.drawRightString(width - margin, y, f"{total_ht:.2f} €") + + y -= 6 * mm + + # TVA + pdf.drawString(totals_x, y, "TVA") + total_ttc = doc.get("total_ttc") or 0 + tva = total_ttc - total_ht + pdf.drawRightString(width - margin, y, f"{tva:.2f} €") + + y -= 8 * mm + + # Ligne de séparation + pdf.setStrokeColor(gray_400) + pdf.line(totals_x, y + 2 * mm, width - margin, y + 2 * mm) + + # Net à payer + pdf.setFont("Helvetica-Bold", 12) + pdf.setFillColor(green_color) + pdf.drawString(totals_x, y, "Net à payer") + pdf.drawRightString(width - margin, y, f"{total_ttc:.2f} €") + + # ===== NOTES ===== + notes = doc.get("notes_publique") or doc.get("notes") + if notes: + y -= 15 * mm + pdf.setStrokeColor(gray_400) + pdf.line(margin, y + 5 * mm, width - margin, y + 5 * mm) + + y -= 5 * mm + pdf.setFont("Helvetica-Bold", 8) + pdf.setFillColor(gray_400) + pdf.drawString(margin, y, "NOTES & CONDITIONS") + + y -= 5 * mm + pdf.setFont("Helvetica", 8) + pdf.setFillColor(gray_600) + + # Gérer les sauts de ligne dans les notes + for line in notes.split("\n"): + if y < 25 * mm: + break + pdf.drawString(margin, y, line[:100]) + y -= 4 * mm + + # ===== FOOTER ===== + pdf.setFont("Helvetica", 7) + pdf.setFillColor(gray_400) + pdf.drawCentredString(width / 2, 15 * mm, "Page 1 / 1") + 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: - 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: - raise Exception(f"Erreur envoi: {str(e)}") -# 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..0534362 100644 --- a/init_db.py +++ b/init_db.py @@ -1,63 +1,35 @@ -# -*- coding: utf-8 -*- -""" -Script d'initialisation de la base de données SQLite -Lance ce script avant le premier démarrage de l'API - -Usage: - python init_db.py -""" - import asyncio import sys 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 logging -logging.basicConfig(level=logging.INFO) +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) logger = logging.getLogger(__name__) async def main(): - """Crée toutes les tables dans sage_dataven.db""" - - print("\n" + "="*60) - print("🚀 Initialisation de la base de données Sage Dataven") - print("="*60 + "\n") - try: - # Créer les tables + logger.info("Debut de l'initialisation") 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") + logger.info("Initialisation terminee") + print("\nInitialisation terminee") + + print("\nBase de données créée avec succès !") + return True - + except Exception as e: - print(f"\n❌ Erreur lors de l'initialisation: {e}") + print(f"\nErreur lors de l'initialisation: {e}") logger.exception("Détails de l'erreur:") return False 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/requirements.txt b/requirements.txt index 6138d38..2ece0f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,10 +5,17 @@ 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 +tenacity + +httpx \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..0d18349 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,529 @@ +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 services.email_service import AuthEmailService +from core.dependencies import get_current_user +from config.config import settings +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +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 Login(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int = 86400 + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + + +class ForgotPassword(BaseModel): + email: EmailStr + + +class ResetPassword(BaseModel): + token: str + new_password: str = Field(..., min_length=8) + + +class VerifyEmail(BaseModel): + token: str + + +class ResendVerification(BaseModel): + email: EmailStr + + +async def log_login_attempt( + session: AsyncSession, + email: str, + ip: str, + user_agent: str, + success: bool, + failure_reason: Optional[str] = None, +): + 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]: + time_window = datetime.now() - timedelta(minutes=15) + + result = await session.execute( + select(LoginAttempt).where( + LoginAttempt.email == email, + LoginAttempt.success, + 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, "" + + +@router.post("/register", status_code=status.HTTP_201_CREATED) +async def register( + data: RegisterRequest, + request: Request, + session: AsyncSession = Depends(get_session), +): + 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é" + ) + + 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) + + verification_token = generate_verification_token() + + 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() + + 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.get("/verify-email") +async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): + 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é.", + } + + if user.verification_token_expires < datetime.now(): + return { + "success": False, + "message": "Token expiré. Veuillez demander un nouvel email de vérification.", + "expired": True, + } + + 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_post( + data: VerifyEmail, session: AsyncSession = Depends(get_session) +): + 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", + ) + + 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.", + ) + + 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: ResendVerification, + request: Request, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(User).where(User.email == data.email.lower())) + user = result.scalar_one_or_none() + + if not user: + 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é" + ) + + verification_token = generate_verification_token() + user.verification_token = verification_token + user.verification_token_expires = datetime.now() + timedelta(hours=24) + await session.commit() + + 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: Login, request: Request, session: AsyncSession = Depends(get_session) +): + ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent", "unknown") + + 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 + ) + + result = await session.execute(select(User).where(User.email == data.email.lower())) + user = result.scalar_one_or_none() + + 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", + ) + + if user: + user.failed_login_attempts += 1 + + 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", + ) + + 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.", + ) + + 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é", + ) + + user.failed_login_attempts = 0 + user.locked_until = None + user.last_login = datetime.now() + + access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) + refresh_token_jwt = create_refresh_token(user.id) + + 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() + + 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, + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_access_token( + data: RefreshTokenRequest, session: AsyncSession = Depends(get_session) +): + 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) + + result = await session.execute( + select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.token_hash == token_hash, + not RefreshToken.is_revoked, + ) + ) + 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", + ) + + if token_record.expires_at < datetime.now(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré" + ) + + 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é", + ) + + 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, + expires_in=86400, + ) + + +@router.post("/forgot-password") +async def forgot_password( + data: ForgotPassword, + request: Request, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(User).where(User.email == data.email.lower())) + user = result.scalar_one_or_none() + + if not user: + return { + "success": True, + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", + } + + reset_token = generate_reset_token() + user.reset_token = reset_token + user.reset_token_expires = datetime.now() + timedelta(hours=1) + await session.commit() + + 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: ResetPassword, session: AsyncSession = Depends(get_session) +): + 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", + ) + + 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.", + ) + + 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) + + 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() + + 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), +): + 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)): + 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, + } diff --git a/routes/sage_gateway.py b/routes/sage_gateway.py new file mode 100644 index 0000000..6b9db87 --- /dev/null +++ b/routes/sage_gateway.py @@ -0,0 +1,323 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +import logging + +from database import get_session, User +from core.dependencies import get_current_user +from services.sage_gateway import ( + SageGatewayService, + gateway_response_from_model, +) +from schemas import ( + SageGatewayCreate, + SageGatewayUpdate, + SageGatewayResponse, + SageGatewayList, + SageGatewayHealthCheck, + SageGatewayTest, + SageGatewayStatsResponse, + CurrentGatewayInfo, +) +from config.config import settings + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/sage-gateways", tags=["Sage Gateways"]) + + +@router.post( + "", response_model=SageGatewayResponse, status_code=status.HTTP_201_CREATED +) +async def create_gateway( + data: SageGatewayCreate, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateway = await service.create(user.id, data.model_dump()) + + logger.info(f"Gateway créée: {gateway.name} par {user.email}") + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.get("", response_model=SageGatewayList) +async def list_gateways( + include_deleted: bool = Query(False, description="Inclure les gateways supprimées"), + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateways = await service.list_for_user(user.id, include_deleted) + active = await service.get_active_gateway(user.id) + + items = [SageGatewayResponse(**gateway_response_from_model(g)) for g in gateways] + + return SageGatewayList( + items=items, + total=len(items), + active_gateway=SageGatewayResponse(**gateway_response_from_model(active)) + if active + else None, + using_fallback=active is None, + ) + + +@router.get("/current", response_model=CurrentGatewayInfo) +async def get_current_gateway( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + url, token, gateway_id = await service.get_effective_gateway_config(user.id) + + if gateway_id: + gateway = await service.get_by_id(gateway_id, user.id) + return CurrentGatewayInfo( + source="user_config", + gateway_id=gateway_id, + gateway_name=gateway.name if gateway else None, + gateway_url=url, + is_healthy=gateway.last_health_status if gateway else None, + user_id=user.id, + ) + else: + return CurrentGatewayInfo( + source="fallback", + gateway_id=None, + gateway_name="Configuration .env (défaut)", + gateway_url=url, + is_healthy=None, + user_id=user.id, + ) + + +@router.get("/stats", response_model=SageGatewayStatsResponse) +async def get_gateway_stats( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + stats = await service.get_stats(user.id) + return SageGatewayStatsResponse(**stats) + + +@router.get("/{gateway_id}", response_model=SageGatewayResponse) +async def get_gateway( + gateway_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateway = await service.get_by_id(gateway_id, user.id) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.put("/{gateway_id}", response_model=SageGatewayResponse) +async def update_gateway( + gateway_id: str, + data: SageGatewayUpdate, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + update_data = {k: v for k, v in data.model_dump().items() if v is not None} + + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Aucun champ à modifier" + ) + + gateway = await service.update(gateway_id, user.id, update_data) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + logger.info(f"Gateway mise à jour: {gateway.name} par {user.email}") + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.delete("/{gateway_id}") +async def delete_gateway( + gateway_id: str, + hard_delete: bool = Query(False, description="Suppression définitive"), + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + success = await service.delete(gateway_id, user.id, hard_delete) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + logger.info( + f"Gateway supprimée: {gateway_id} par {user.email} (hard={hard_delete})" + ) + + return { + "success": True, + "message": f"Gateway supprimée {'définitivement' if hard_delete else '(soft delete)'}", + } + + +@router.post("/{gateway_id}/activate", response_model=SageGatewayResponse) +async def activate_gateway( + gateway_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateway = await service.activate(gateway_id, user.id) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + logger.info(f"Gateway activée: {gateway.name} par {user.email}") + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.post("/{gateway_id}/deactivate", response_model=SageGatewayResponse) +async def deactivate_gateway( + gateway_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateway = await service.deactivate(gateway_id, user.id) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + logger.info(f"Gateway désactivée: {gateway.name} - fallback actif") + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.post("/deactivate-all") +async def deactivate_all_gateways( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + await service._deactivate_all_for_user(user.id) + await session.commit() + + logger.info( + f"Toutes les gateways désactivées pour {user.email} - fallback .env actif" + ) + + return { + "success": True, + "message": "Toutes les gateways désactivées. Le fallback .env est maintenant utilisé.", + "fallback_url": settings.sage_gateway_url, + } + + +@router.post("/{gateway_id}/health-check", response_model=SageGatewayHealthCheck) +async def check_gateway_health( + gateway_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + from datetime import datetime + + service = SageGatewayService(session) + + gateway = await service.get_by_id(gateway_id, user.id) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + result = await service.health_check(gateway_id, user.id) + + return SageGatewayHealthCheck( + gateway_id=gateway_id, + gateway_name=gateway.name, + status=result.get("status", "unknown"), + response_time_ms=result.get("response_time_ms"), + sage_version=result.get("sage_version"), + error=result.get("error"), + checked_at=datetime.now(), + ) + + +@router.post("/test", response_model=dict) +async def test_gateway_config( + data: SageGatewayTest, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + service = SageGatewayService(session) + result = await service.test_gateway(data.gateway_url, data.gateway_token) + + return {"tested_url": data.gateway_url, "result": result} + + +@router.post("/health-check-all") +async def check_all_gateways_health( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + gateways = await service.list_for_user(user.id) + + results = [] + for gateway in gateways: + result = await service.health_check(gateway.id, user.id) + results.append( + { + "gateway_id": gateway.id, + "gateway_name": gateway.name, + "is_active": gateway.is_active, + **result, + } + ) + + healthy_count = sum(1 for r in results if r.get("status") == "healthy") + + return { + "total": len(results), + "healthy": healthy_count, + "unhealthy": len(results) - healthy_count, + "results": results, + } + + +@router.get("/fallback/info") +async def get_fallback_info( + user: User = Depends(get_current_user), +): + return { + "source": ".env", + "gateway_url": settings.sage_gateway_url, + "token_configured": bool(settings.sage_gateway_token), + "token_preview": f"****{settings.sage_gateway_token[-4:]}" + if settings.sage_gateway_token + else None, + "description": "Configuration par défaut utilisée quand aucune gateway utilisateur n'est active", + } diff --git a/routes/universign.py b/routes/universign.py new file mode 100644 index 0000000..9752755 --- /dev/null +++ b/routes/universign.py @@ -0,0 +1,1615 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import false, select, func, or_, and_, true +from sqlalchemy.orm import selectinload +from typing import List, Optional +from datetime import datetime, timedelta +from pydantic import BaseModel, EmailStr +import logging +from data.data import templates_signature_email +from email_queue import email_queue +from database import UniversignSignerStatus, UniversignTransactionStatus, get_session +from database import ( + UniversignTransaction, + UniversignSigner, + UniversignSyncLog, + LocalDocumentStatus, + SageDocumentType, +) +import os +from pathlib import Path +import json +from services.universign_document import UniversignDocumentService +from services.universign_sync import UniversignSyncService +from config.config import settings +from utils.generic_functions import normaliser_type_doc +from utils.universign_status_mapping import get_status_message, map_universign_to_local + +from database.models.email import EmailLog +from database.enum.status import StatutEmail + + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/universign", tags=["Universign"]) + +sync_service = UniversignSyncService( + api_url=settings.universign_api_url, api_key=settings.universign_api_key +) + + +class CreateSignatureRequest(BaseModel): + """Demande de création d'une signature""" + + sage_document_id: str + sage_document_type: SageDocumentType + signer_email: EmailStr + signer_name: str + document_name: Optional[str] = None + + +class TransactionResponse(BaseModel): + """Réponse détaillée d'une transaction""" + + id: str + transaction_id: str + sage_document_id: str + sage_document_type: str + universign_status: str + local_status: str + local_status_label: str + signer_url: Optional[str] + document_url: Optional[str] + created_at: datetime + sent_at: Optional[datetime] + signed_at: Optional[datetime] + last_synced_at: Optional[datetime] + needs_sync: bool + signers: List[dict] + + signed_document_available: bool = False + signed_document_downloaded_at: Optional[datetime] = None + signed_document_size_kb: Optional[float] = None + + +class SyncStatsResponse(BaseModel): + """Statistiques de synchronisation""" + + total_transactions: int + pending_sync: int + signed: int + in_progress: int + refused: int + expired: int + last_sync_at: Optional[datetime] + + +@router.post("/signatures/create", response_model=TransactionResponse) +async def create_signature( + request: CreateSignatureRequest, session: AsyncSession = Depends(get_session) +): + try: + # === VÉRIFICATION DOUBLON RENFORCÉE === + logger.info( + f"🔍 Vérification doublon pour: {request.sage_document_id} " + f"(type: {request.sage_document_type.name})" + ) + + existing_query = select(UniversignTransaction).where( + UniversignTransaction.sage_document_id == request.sage_document_id, + UniversignTransaction.sage_document_type == request.sage_document_type, + ) + existing_result = await session.execute(existing_query) + all_existing = existing_result.scalars().all() + + if all_existing: + logger.warning( + f"{len(all_existing)} transaction(s) existante(s) trouvée(s)" + ) + + # Filtrer les transactions non-finales + active_txs = [ + tx + for tx in all_existing + if tx.local_status + not in [ + LocalDocumentStatus.SIGNED, + LocalDocumentStatus.REJECTED, + LocalDocumentStatus.EXPIRED, + LocalDocumentStatus.ERROR, + ] + ] + + if active_txs: + active_tx = active_txs[0] + logger.error( + f"Transaction active existante: {active_tx.transaction_id} " + f"(statut: {active_tx.local_status.value})" + ) + raise HTTPException( + 400, + f"Une demande de signature est déjà en cours pour {request.sage_document_id} " + f"(transaction: {active_tx.transaction_id}, statut: {active_tx.local_status.value}). " + f"Utilisez GET /universign/documents/{request.sage_document_id}/signatures pour voir toutes les transactions.", + ) + + logger.info( + "Toutes les transactions existantes sont finales, création autorisée" + ) + + # Génération PDF + logger.info(f"📄 Génération PDF: {request.sage_document_id}") + pdf_bytes = email_queue._generate_pdf( + request.sage_document_id, normaliser_type_doc(request.sage_document_type) + ) + + if not pdf_bytes: + raise HTTPException(400, "Échec génération PDF") + + logger.info(f"PDF généré: {len(pdf_bytes)} octets") + + # === CRÉATION TRANSACTION UNIVERSIGN === + import requests + import uuid + + auth = (settings.universign_api_key, "") + + logger.info("🔄 Création transaction Universign...") + + resp = requests.post( + f"{settings.universign_api_url}/transactions", + auth=auth, + json={ + "name": request.document_name + or f"{request.sage_document_type.name} {request.sage_document_id}", + "language": "fr", + }, + timeout=30, + ) + + if resp.status_code != 200: + logger.error(f"Erreur Universign (création): {resp.text}") + raise HTTPException(500, f"Erreur Universign: {resp.status_code}") + + universign_tx_id = resp.json().get("id") + logger.info(f"Transaction Universign créée: {universign_tx_id}") + + # Upload PDF + logger.info("📤 Upload PDF...") + files = { + "file": (f"{request.sage_document_id}.pdf", pdf_bytes, "application/pdf") + } + resp = requests.post( + f"{settings.universign_api_url}/files", auth=auth, files=files, timeout=60 + ) + + if resp.status_code not in [200, 201]: + logger.error(f"Erreur upload: {resp.text}") + raise HTTPException(500, "Erreur upload PDF") + + file_id = resp.json().get("id") + logger.info(f"PDF uploadé: {file_id}") + + # Attachement document + logger.info("🔗 Attachement document...") + resp = requests.post( + f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents", + auth=auth, + data={"document": file_id}, + timeout=30, + ) + + if resp.status_code not in [200, 201]: + raise HTTPException(500, "Erreur attachement document") + + document_id = resp.json().get("id") + + # Création champ signature + logger.info("✍️ Création champ signature...") + resp = requests.post( + f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents/{document_id}/fields", + auth=auth, + data={"type": "signature"}, + timeout=30, + ) + + if resp.status_code not in [200, 201]: + raise HTTPException(500, "Erreur création champ signature") + + field_id = resp.json().get("id") + + # Liaison signataire + logger.info(f"👤 Liaison signataire: {request.signer_email}") + resp = requests.post( + f"{settings.universign_api_url}/transactions/{universign_tx_id}/signatures", + auth=auth, + data={"signer": request.signer_email, "field": field_id}, + timeout=30, + ) + + if resp.status_code not in [200, 201]: + raise HTTPException(500, "Erreur liaison signataire") + + # Démarrage transaction + logger.info("🚀 Démarrage transaction...") + resp = requests.post( + f"{settings.universign_api_url}/transactions/{universign_tx_id}/start", + auth=auth, + timeout=30, + ) + + if resp.status_code not in [200, 201]: + raise HTTPException(500, "Erreur démarrage transaction") + + final_data = resp.json() + + # Extraction URL de signature + signer_url = "" + if final_data.get("actions"): + for action in final_data["actions"]: + if action.get("url"): + signer_url = action["url"] + break + + if not signer_url: + raise HTTPException(500, "URL de signature non retournée") + + logger.info("URL de signature obtenue") + + # === ENREGISTREMENT LOCAL === + local_id = str(uuid.uuid4()) + + transaction = UniversignTransaction( + id=local_id, + transaction_id=universign_tx_id, # Utiliser l'ID Universign, ne jamais le changer + sage_document_id=request.sage_document_id, + sage_document_type=request.sage_document_type, + universign_status=UniversignTransactionStatus.STARTED, + local_status=LocalDocumentStatus.IN_PROGRESS, + signer_url=signer_url, + requester_email=request.signer_email, + requester_name=request.signer_name, + document_name=request.document_name, + created_at=datetime.now(), + sent_at=datetime.now(), + is_test=True, + needs_sync=True, + ) + + session.add(transaction) + + signer = UniversignSigner( + id=f"{local_id}_signer_0", + transaction_id=local_id, + email=request.signer_email, + name=request.signer_name, + status=UniversignSignerStatus.WAITING, + order_index=0, + ) + + session.add(signer) + await session.commit() + + logger.info( + f"💾 Transaction sauvegardée: {local_id} (Universign: {universign_tx_id})" + ) + + # === ENVOI EMAIL AVEC TEMPLATE === + template = templates_signature_email["demande_signature"] + + type_labels = { + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", + } + + doc_info = email_queue.sage_client.lire_document( + request.sage_document_id, request.sage_document_type.value + ) + montant_ttc = f"{doc_info.get('total_ttc', 0):.2f}" if doc_info else "0.00" + date_doc = ( + doc_info.get("date", datetime.now().strftime("%d/%m/%Y")) + if doc_info + else datetime.now().strftime("%d/%m/%Y") + ) + + variables = { + "NOM_SIGNATAIRE": request.signer_name, + "TYPE_DOC": type_labels.get(request.sage_document_type.value, "Document"), + "NUMERO": request.sage_document_id, + "DATE": date_doc, + "MONTANT_TTC": montant_ttc, + "SIGNER_URL": 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)) + + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=request.signer_email, + sujet=sujet, + corps_html=corps, + document_ids=request.sage_document_id, + type_document=request.sage_document_type.value, + statut=StatutEmail.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + await session.commit() + + email_queue.enqueue(email_log.id) + + # === MISE À JOUR STATUT SAGE (Confirmé = 1) === + try: + from sage_client import sage_client + + sage_client.changer_statut_document( + document_type_code=request.sage_document_type.value, + numero=request.sage_document_id, + nouveau_statut=1, + ) + logger.info( + f"Statut Sage mis à jour: {request.sage_document_id} → Confirmé (1)" + ) + except Exception as e: + logger.warning(f"Impossible de mettre à jour le statut Sage: {e}") + + # === RÉPONSE === + return TransactionResponse( + id=transaction.id, + transaction_id=transaction.transaction_id, + sage_document_id=transaction.sage_document_id, + sage_document_type=transaction.sage_document_type.name, + universign_status=transaction.universign_status.value, + local_status=transaction.local_status.value, + local_status_label=get_status_message(transaction.local_status.value), + signer_url=transaction.signer_url, + document_url=None, + created_at=transaction.created_at, + sent_at=transaction.sent_at, + signed_at=None, + last_synced_at=None, + needs_sync=True, + signers=[ + { + "email": signer.email, + "name": signer.name, + "status": signer.status.value, + } + ], + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création signature: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@router.get("/transactions", response_model=List[TransactionResponse]) +async def list_transactions( + status: Optional[LocalDocumentStatus] = None, + sage_document_id: Optional[str] = None, + limit: int = Query(100, le=1000), + session: AsyncSession = Depends(get_session), +): + """Liste toutes les transactions""" + query = select(UniversignTransaction).options( + selectinload(UniversignTransaction.signers) + ) + + if status: + query = query.where(UniversignTransaction.local_status == status) + + if sage_document_id: + query = query.where(UniversignTransaction.sage_document_id == sage_document_id) + + query = query.order_by(UniversignTransaction.created_at.desc()).limit(limit) + + result = await session.execute(query) + transactions = result.scalars().all() + + return [ + TransactionResponse( + id=tx.id, + transaction_id=tx.transaction_id, + sage_document_id=tx.sage_document_id, + sage_document_type=tx.sage_document_type.name, + universign_status=tx.universign_status.value, + local_status=tx.local_status.value, + local_status_label=get_status_message(tx.local_status.value), + signer_url=tx.signer_url, + document_url=tx.document_url, + created_at=tx.created_at, + sent_at=tx.sent_at, + signed_at=tx.signed_at, + last_synced_at=tx.last_synced_at, + needs_sync=tx.needs_sync, + signers=[ + { + "email": s.email, + "name": s.name, + "status": s.status.value, + "signed_at": s.signed_at.isoformat() if s.signed_at else None, + } + for s in tx.signers + ], + # ✅ NOUVEAUX CHAMPS + signed_document_available=bool( + tx.signed_document_path and Path(tx.signed_document_path).exists() + ), + signed_document_downloaded_at=tx.signed_document_downloaded_at, + signed_document_size_kb=( + tx.signed_document_size_bytes / 1024 + if tx.signed_document_size_bytes + else None + ), + ) + for tx in transactions + ] + + +@router.get("/transactions/{transaction_id}", response_model=TransactionResponse) +async def get_transaction( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """Récupère une transaction par son ID""" + query = ( + select(UniversignTransaction) + .where(UniversignTransaction.transaction_id == transaction_id) + .options(selectinload(UniversignTransaction.signers)) + ) + + result = await session.execute(query) + tx = result.scalar_one_or_none() + + if not tx: + raise HTTPException(404, "Transaction introuvable") + + return TransactionResponse( + id=tx.id, + transaction_id=tx.transaction_id, + sage_document_id=tx.sage_document_id, + sage_document_type=tx.sage_document_type.name, + universign_status=tx.universign_status.value, + local_status=tx.local_status.value, + local_status_label=get_status_message(tx.local_status.value), + signer_url=tx.signer_url, + document_url=tx.document_url, + created_at=tx.created_at, + sent_at=tx.sent_at, + signed_at=tx.signed_at, + last_synced_at=tx.last_synced_at, + needs_sync=tx.needs_sync, + signers=[ + { + "email": s.email, + "name": s.name, + "status": s.status.value, + "signed_at": s.signed_at.isoformat() if s.signed_at else None, + } + for s in tx.signers + ], + # ✅ NOUVEAUX CHAMPS + signed_document_available=bool( + tx.signed_document_path and Path(tx.signed_document_path).exists() + ), + signed_document_downloaded_at=tx.signed_document_downloaded_at, + signed_document_size_kb=( + tx.signed_document_size_bytes / 1024 + if tx.signed_document_size_bytes + else None + ), + ) + + +@router.post("/transactions/{transaction_id}/sync") +async def sync_single_transaction( + transaction_id: str, + force: bool = Query(False), + session: AsyncSession = Depends(get_session), +): + """Force la synchronisation d'une transaction""" + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, "Transaction introuvable") + + success, error = await sync_service.sync_transaction( + session, transaction, force=force + ) + + if not success: + raise HTTPException(500, error or "Échec synchronisation") + + return { + "success": True, + "transaction_id": transaction_id, + "new_status": transaction.local_status.value, + "synced_at": transaction.last_synced_at.isoformat(), + } + + +@router.post("/sync/all") +async def sync_all_transactions( + max_transactions: int = Query(50, le=500), + session: AsyncSession = Depends(get_session), +): + """Synchronise toutes les transactions en attente""" + stats = await sync_service.sync_all_pending(session, max_transactions) + + return {"success": True, "stats": stats, "timestamp": datetime.now().isoformat()} + + +@router.post("/webhook") +@router.post("/webhook/") +async def webhook_universign( + request: Request, session: AsyncSession = Depends(get_session) +): + """ + CORRECTION : Extraction correcte du transaction_id selon la structure réelle d'Universign + """ + try: + payload = await request.json() + + # 📋 LOG COMPLET du payload pour débogage + logger.info( + f"📥 Webhook Universign reçu - Type: {payload.get('type', 'unknown')}" + ) + logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}") + + # EXTRACTION CORRECTE DU TRANSACTION_ID + transaction_id = None + + # 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante) + # Exemple : transaction.lifecycle.created, transaction.lifecycle.started, etc. + if payload.get("type", "").startswith("transaction.") and "payload" in payload: + # Le transaction_id est dans payload.object.id + nested_object = payload.get("payload", {}).get("object", {}) + if nested_object.get("object") == "transaction": + transaction_id = nested_object.get("id") + logger.info( + f"Transaction ID extrait de payload.object.id: {transaction_id}" + ) + + # 🔍 Structure 2 : Action événements (action.opened, action.completed) + elif payload.get("type", "").startswith("action."): + # Le transaction_id est directement dans payload.object.transaction_id + transaction_id = ( + payload.get("payload", {}).get("object", {}).get("transaction_id") + ) + logger.info( + f"Transaction ID extrait de payload.object.transaction_id: {transaction_id}" + ) + + # 🔍 Structure 3 : Transaction directe (fallback) + elif payload.get("object") == "transaction": + transaction_id = payload.get("id") + logger.info(f"Transaction ID extrait direct: {transaction_id}") + + # 🔍 Structure 4 : Ancien format (pour rétro-compatibilité) + elif "transaction" in payload: + transaction_id = payload.get("transaction", {}).get("id") + logger.info(f"Transaction ID extrait de transaction.id: {transaction_id}") + + # Échec d'extraction + if not transaction_id: + logger.error( + f"Transaction ID introuvable dans webhook\n" + f"Type d'événement: {payload.get('type', 'unknown')}\n" + f"Clés racine: {list(payload.keys())}\n" + f"Payload simplifié: {json.dumps({k: v if k != 'payload' else '...' for k, v in payload.items()})}" + ) + return { + "status": "error", + "message": "Transaction ID manquant dans webhook", + "event_type": payload.get("type"), + "event_id": payload.get("id"), + }, 400 + + logger.info(f"🎯 Transaction ID identifié: {transaction_id}") + + # Vérifier si la transaction existe localement + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + tx = result.scalar_one_or_none() + + if not tx: + logger.warning( + f"Transaction {transaction_id} inconnue en local\n" + f"Type d'événement: {payload.get('type')}\n" + f"Elle sera synchronisée au prochain polling" + ) + return { + "status": "accepted", + "message": f"Transaction {transaction_id} non trouvée localement, sera synchronisée au prochain polling", + "transaction_id": transaction_id, + "event_type": payload.get("type"), + } + + # Traiter le webhook + success, error = await sync_service.process_webhook( + session, payload, transaction_id + ) + + if not success: + logger.error(f"Erreur traitement webhook: {error}") + return { + "status": "error", + "message": error, + "transaction_id": transaction_id, + }, 500 + + # Succès + logger.info( + f"Webhook traité avec succès\n" + f"Transaction: {transaction_id}\n" + f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n" + f"Type d'événement: {payload.get('type')}" + ) + + return { + "status": "processed", + "transaction_id": transaction_id, + "local_status": tx.local_status.value if tx else None, + "event_type": payload.get("type"), + "event_id": payload.get("id"), + } + + except Exception as e: + logger.error(f"💥 Erreur critique webhook: {e}", exc_info=True) + return {"status": "error", "message": str(e)}, 500 + + +@router.get("/stats", response_model=SyncStatsResponse) +async def get_sync_stats(session: AsyncSession = Depends(get_session)): + """Statistiques globales de synchronisation""" + + # Total + total_query = select(func.count(UniversignTransaction.id)) + total = (await session.execute(total_query)).scalar() + + # En attente de sync + pending_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.needs_sync + ) + pending = (await session.execute(pending_query)).scalar() + + # Par statut + signed_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.local_status == LocalDocumentStatus.SIGNED + ) + signed = (await session.execute(signed_query)).scalar() + + in_progress_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.local_status == LocalDocumentStatus.IN_PROGRESS + ) + in_progress = (await session.execute(in_progress_query)).scalar() + + refused_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.local_status == LocalDocumentStatus.REJECTED + ) + refused = (await session.execute(refused_query)).scalar() + + expired_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.local_status == LocalDocumentStatus.EXPIRED + ) + expired = (await session.execute(expired_query)).scalar() + + # Dernière sync + last_sync_query = select(func.max(UniversignTransaction.last_synced_at)) + last_sync = (await session.execute(last_sync_query)).scalar() + + return SyncStatsResponse( + total_transactions=total, + pending_sync=pending, + signed=signed, + in_progress=in_progress, + refused=refused, + expired=expired, + last_sync_at=last_sync, + ) + + +@router.get("/transactions/{transaction_id}/logs") +async def get_transaction_logs( + transaction_id: str, + limit: int = Query(50, le=500), + session: AsyncSession = Depends(get_session), +): + # Trouver la transaction + tx_query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + tx_result = await session.execute(tx_query) + tx = tx_result.scalar_one_or_none() + + if not tx: + raise HTTPException(404, "Transaction introuvable") + + # Logs + logs_query = ( + select(UniversignSyncLog) + .where(UniversignSyncLog.transaction_id == tx.id) + .order_by(UniversignSyncLog.sync_timestamp.desc()) + .limit(limit) + ) + + logs_result = await session.execute(logs_query) + logs = logs_result.scalars().all() + + return { + "transaction_id": transaction_id, + "total_syncs": len(logs), + "logs": [ + { + "sync_type": log.sync_type, + "timestamp": log.sync_timestamp.isoformat(), + "success": log.success, + "previous_status": log.previous_status, + "new_status": log.new_status, + "error_message": log.error_message, + "response_time_ms": log.response_time_ms, + } + for log in logs + ], + } + + +# Ajouter ces routes dans universign.py + + +@router.get("/documents/{sage_document_id}/signatures") +async def get_signatures_for_document( + sage_document_id: str, + session: AsyncSession = Depends(get_session), +): + """Liste toutes les transactions de signature pour un document Sage""" + query = ( + select(UniversignTransaction) + .options(selectinload(UniversignTransaction.signers)) + .where(UniversignTransaction.sage_document_id == sage_document_id) + .order_by(UniversignTransaction.created_at.desc()) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + return [ + { + "id": tx.id, + "transaction_id": tx.transaction_id, + "local_status": tx.local_status.value, + "universign_status": tx.universign_status.value + if tx.universign_status + else None, + "created_at": tx.created_at.isoformat(), + "signed_at": tx.signed_at.isoformat() if tx.signed_at else None, + "signer_url": tx.signer_url, + "signers_count": len(tx.signers), + } + for tx in transactions + ] + + +@router.delete("/documents/{sage_document_id}/duplicates") +async def cleanup_duplicate_signatures( + sage_document_id: str, + keep_latest: bool = Query( + True, description="Garder la plus récente (True) ou la plus ancienne (False)" + ), + session: AsyncSession = Depends(get_session), +): + """ + Supprime les doublons de signatures pour un document. + Garde une seule transaction (la plus récente ou ancienne selon le paramètre). + """ + query = ( + select(UniversignTransaction) + .where(UniversignTransaction.sage_document_id == sage_document_id) + .order_by( + UniversignTransaction.created_at.desc() + if keep_latest + else UniversignTransaction.created_at.asc() + ) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + if len(transactions) <= 1: + return { + "success": True, + "message": "Aucun doublon trouvé", + "kept": transactions[0].transaction_id if transactions else None, + "deleted_count": 0, + } + + # Garder la première (selon l'ordre), supprimer les autres + to_keep = transactions[0] + to_delete = transactions[1:] + + deleted_ids = [] + for tx in to_delete: + deleted_ids.append(tx.transaction_id) + await session.delete(tx) + + await session.commit() + + logger.info( + f"Nettoyage doublons {sage_document_id}: gardé {to_keep.transaction_id}, supprimé {deleted_ids}" + ) + + return { + "success": True, + "document_id": sage_document_id, + "kept": { + "id": to_keep.id, + "transaction_id": to_keep.transaction_id, + "status": to_keep.local_status.value, + "created_at": to_keep.created_at.isoformat(), + }, + "deleted_count": len(deleted_ids), + "deleted_transaction_ids": deleted_ids, + } + + +@router.delete("/transactions/{transaction_id}") +async def delete_transaction( + transaction_id: str, + session: AsyncSession = Depends(get_session), +): + """Supprime une transaction spécifique par son ID Universign""" + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + tx = result.scalar_one_or_none() + + if not tx: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + await session.delete(tx) + await session.commit() + + logger.info(f"Transaction {transaction_id} supprimée") + + return { + "success": True, + "deleted_transaction_id": transaction_id, + "document_id": tx.sage_document_id, + } + + +@router.post("/cleanup/all-duplicates") +async def cleanup_all_duplicates( + session: AsyncSession = Depends(get_session), +): + """ + Nettoie tous les doublons dans la base. + Pour chaque document avec plusieurs transactions, garde la plus récente non-erreur ou la plus récente. + """ + from sqlalchemy import func + + # Trouver les documents avec plusieurs transactions + subquery = ( + select( + UniversignTransaction.sage_document_id, + func.count(UniversignTransaction.id).label("count"), + ) + .group_by(UniversignTransaction.sage_document_id) + .having(func.count(UniversignTransaction.id) > 1) + ).subquery() + + duplicates_query = select(subquery.c.sage_document_id) + duplicates_result = await session.execute(duplicates_query) + duplicate_docs = [row[0] for row in duplicates_result.fetchall()] + + total_deleted = 0 + cleanup_details = [] + + for doc_id in duplicate_docs: + # Récupérer toutes les transactions pour ce document + tx_query = ( + select(UniversignTransaction) + .where(UniversignTransaction.sage_document_id == doc_id) + .order_by(UniversignTransaction.created_at.desc()) + ) + tx_result = await session.execute(tx_query) + transactions = tx_result.scalars().all() + + # Priorité: SIGNE > EN_COURS > EN_ATTENTE > autres + priority = {"SIGNE": 0, "EN_COURS": 1, "EN_ATTENTE": 2} + + def sort_key(tx): + status_priority = priority.get(tx.local_status.value, 99) + return (status_priority, -tx.created_at.timestamp()) + + sorted_txs = sorted(transactions, key=sort_key) + to_keep = sorted_txs[0] + to_delete = sorted_txs[1:] + + for tx in to_delete: + await session.delete(tx) + total_deleted += 1 + + cleanup_details.append( + { + "document_id": doc_id, + "kept": to_keep.transaction_id, + "kept_status": to_keep.local_status.value, + "deleted_count": len(to_delete), + } + ) + + await session.commit() + + logger.info( + f"Nettoyage global: {total_deleted} doublons supprimés sur {len(duplicate_docs)} documents" + ) + + return { + "success": True, + "documents_processed": len(duplicate_docs), + "total_deleted": total_deleted, + "details": cleanup_details, + } + + +@router.get("/admin/diagnostic", tags=["Admin"]) +async def diagnostic_complet(session: AsyncSession = Depends(get_session)): + """ + Diagnostic complet de l'état des transactions Universign + """ + try: + # Statistiques générales + total_query = select(func.count(UniversignTransaction.id)) + total = (await session.execute(total_query)).scalar() + + # Par statut local + statuts_query = select( + UniversignTransaction.local_status, func.count(UniversignTransaction.id) + ).group_by(UniversignTransaction.local_status) + statuts_result = await session.execute(statuts_query) + statuts = {status.value: count for status, count in statuts_result.all()} + + # Transactions sans sync récente + date_limite = datetime.now() - timedelta(hours=1) + sans_sync_query = select(func.count(UniversignTransaction.id)).where( + and_( + UniversignTransaction.needs_sync, + or_( + UniversignTransaction.last_synced_at < date_limite, + UniversignTransaction.last_synced_at.is_(None), + ), + ) + ) + sans_sync = (await session.execute(sans_sync_query)).scalar() + + # Doublons potentiels + doublons_query = ( + select( + UniversignTransaction.sage_document_id, + func.count(UniversignTransaction.id).label("count"), + ) + .group_by(UniversignTransaction.sage_document_id) + .having(func.count(UniversignTransaction.id) > 1) + ) + doublons_result = await session.execute(doublons_query) + doublons = doublons_result.fetchall() + + # Transactions avec erreurs de sync + erreurs_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.sync_error.isnot(None) + ) + erreurs = (await session.execute(erreurs_query)).scalar() + + # Transactions sans webhook reçu + sans_webhook_query = select(func.count(UniversignTransaction.id)).where( + and_( + not UniversignTransaction.webhook_received, + UniversignTransaction.local_status != LocalDocumentStatus.PENDING, + ) + ) + sans_webhook = (await session.execute(sans_webhook_query)).scalar() + + diagnostic = { + "timestamp": datetime.now().isoformat(), + "total_transactions": total, + "repartition_statuts": statuts, + "problemes_detectes": { + "sans_sync_recente": sans_sync, + "doublons_possibles": len(doublons), + "erreurs_sync": erreurs, + "sans_webhook": sans_webhook, + }, + "documents_avec_doublons": [ + {"document_id": doc_id, "nombre_transactions": count} + for doc_id, count in doublons + ], + "recommandations": [], + } + + # Recommandations + if sans_sync > 0: + diagnostic["recommandations"].append( + f"🔄 {sans_sync} transaction(s) à synchroniser. " + f"Utilisez POST /universign/sync/all" + ) + + if len(doublons) > 0: + diagnostic["recommandations"].append( + f"{len(doublons)} document(s) avec doublons. " + f"Utilisez POST /universign/cleanup/all-duplicates" + ) + + if erreurs > 0: + diagnostic["recommandations"].append( + f"{erreurs} transaction(s) en erreur. " + f"Vérifiez les logs avec GET /universign/transactions?status=ERREUR" + ) + + return diagnostic + + except Exception as e: + logger.error(f"Erreur diagnostic: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/admin/force-sync-all", tags=["Admin"]) +async def forcer_sync_toutes_transactions( + max_transactions: int = Query(200, le=500), + session: AsyncSession = Depends(get_session), +): + """ + Force la synchronisation de TOUTES les transactions (même finales) + À utiliser pour réparer les incohérences + """ + try: + query = ( + select(UniversignTransaction) + .options(selectinload(UniversignTransaction.signers)) + .order_by(UniversignTransaction.created_at.desc()) + .limit(max_transactions) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + stats = { + "total_verifie": len(transactions), + "success": 0, + "failed": 0, + "status_changes": 0, + "details": [], + } + + for transaction in transactions: + try: + previous_status = transaction.local_status.value + + logger.info( + f"🔄 Force sync: {transaction.transaction_id} (statut: {previous_status})" + ) + + success, error = await sync_service.sync_transaction( + session, transaction, force=True + ) + + new_status = transaction.local_status.value + + if success: + stats["success"] += 1 + if new_status != previous_status: + stats["status_changes"] += 1 + stats["details"].append( + { + "transaction_id": transaction.transaction_id, + "document_id": transaction.sage_document_id, + "changement": f"{previous_status} → {new_status}", + } + ) + else: + stats["failed"] += 1 + stats["details"].append( + { + "transaction_id": transaction.transaction_id, + "document_id": transaction.sage_document_id, + "erreur": error, + } + ) + + except Exception as e: + logger.error(f"Erreur sync {transaction.transaction_id}: {e}") + stats["failed"] += 1 + + return { + "success": True, + "stats": stats, + "timestamp": datetime.now().isoformat(), + } + + except Exception as e: + logger.error(f"Erreur force sync: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/admin/repair-transaction/{transaction_id}", tags=["Admin"]) +async def reparer_transaction( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Répare une transaction spécifique en la re-synchronisant depuis Universign + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + old_status = transaction.local_status.value + old_universign_status = ( + transaction.universign_status.value + if transaction.universign_status + else None + ) + + # Force sync + success, error = await sync_service.sync_transaction( + session, transaction, force=True + ) + + if not success: + return { + "success": False, + "transaction_id": transaction_id, + "erreur": error, + "ancien_statut": old_status, + } + + return { + "success": True, + "transaction_id": transaction_id, + "reparation": { + "ancien_statut_local": old_status, + "nouveau_statut_local": transaction.local_status.value, + "ancien_statut_universign": old_universign_status, + "nouveau_statut_universign": transaction.universign_status.value, + "statut_change": old_status != transaction.local_status.value, + }, + "derniere_sync": transaction.last_synced_at.isoformat(), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur réparation: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/admin/transactions-inconsistantes", tags=["Admin"]) +async def trouver_transactions_inconsistantes( + session: AsyncSession = Depends(get_session), +): + """ + Trouve les transactions dont le statut local ne correspond pas au statut Universign + """ + try: + # Toutes les transactions non-finales + query = select(UniversignTransaction).where( + UniversignTransaction.local_status.in_( + [LocalDocumentStatus.PENDING, LocalDocumentStatus.IN_PROGRESS] + ) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + inconsistantes = [] + + for tx in transactions: + try: + # Récupérer le statut depuis Universign + universign_data = sync_service.fetch_transaction_status( + tx.transaction_id + ) + + if not universign_data: + inconsistantes.append( + { + "transaction_id": tx.transaction_id, + "document_id": tx.sage_document_id, + "probleme": "Impossible de récupérer depuis Universign", + "statut_local": tx.local_status.value, + "statut_universign": None, + } + ) + continue + + universign_status = universign_data["transaction"].get("state") + expected_local_status = map_universign_to_local(universign_status) + + if expected_local_status != tx.local_status.value: + inconsistantes.append( + { + "transaction_id": tx.transaction_id, + "document_id": tx.sage_document_id, + "probleme": "Statut incohérent", + "statut_local": tx.local_status.value, + "statut_universign": universign_status, + "statut_attendu": expected_local_status, + "derniere_sync": tx.last_synced_at.isoformat() + if tx.last_synced_at + else None, + } + ) + + except Exception as e: + logger.error(f"Erreur vérification {tx.transaction_id}: {e}") + inconsistantes.append( + { + "transaction_id": tx.transaction_id, + "document_id": tx.sage_document_id, + "probleme": f"Erreur: {str(e)}", + "statut_local": tx.local_status.value, + } + ) + + return { + "total_verifie": len(transactions), + "inconsistantes": len(inconsistantes), + "details": inconsistantes, + "recommandation": ( + "Utilisez POST /universign/admin/force-sync-all pour corriger" + if inconsistantes + else "Aucune incohérence détectée" + ), + } + + except Exception as e: + logger.error(f"Erreur recherche incohérences: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/admin/nettoyer-transactions-erreur", tags=["Admin"]) +async def nettoyer_transactions_erreur( + age_jours: int = Query( + 7, description="Supprimer les transactions en erreur de plus de X jours" + ), + session: AsyncSession = Depends(get_session), +): + """ + Nettoie les transactions en erreur anciennes + """ + try: + date_limite = datetime.now() - timedelta(days=age_jours) + + query = select(UniversignTransaction).where( + and_( + UniversignTransaction.local_status == LocalDocumentStatus.ERROR, + UniversignTransaction.created_at < date_limite, + ) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + supprimees = [] + for tx in transactions: + supprimees.append( + { + "transaction_id": tx.transaction_id, + "document_id": tx.sage_document_id, + "date_creation": tx.created_at.isoformat(), + "erreur": tx.sync_error, + } + ) + await session.delete(tx) + + await session.commit() + + return { + "success": True, + "transactions_supprimees": len(supprimees), + "age_limite_jours": age_jours, + "details": supprimees, + } + + except Exception as e: + logger.error(f"Erreur nettoyage: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/debug/webhook-payload/{transaction_id}", tags=["Debug"]) +async def voir_dernier_webhook( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Affiche le dernier payload webhook reçu pour une transaction + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + tx = result.scalar_one_or_none() + + if not tx: + raise HTTPException(404, "Transaction introuvable") + + # Récupérer le dernier log de type webhook + logs_query = ( + select(UniversignSyncLog) + .where( + and_( + UniversignSyncLog.transaction_id == tx.id, + UniversignSyncLog.sync_type.like("webhook:%"), + ) + ) + .order_by(UniversignSyncLog.sync_timestamp.desc()) + .limit(1) + ) + + logs_result = await session.execute(logs_query) + last_webhook_log = logs_result.scalar_one_or_none() + + if not last_webhook_log: + return { + "transaction_id": transaction_id, + "webhook_recu": tx.webhook_received, + "dernier_payload": None, + "message": "Aucun webhook reçu pour cette transaction", + } + + return { + "transaction_id": transaction_id, + "webhook_recu": tx.webhook_received, + "dernier_webhook": { + "timestamp": last_webhook_log.sync_timestamp.isoformat(), + "type": last_webhook_log.sync_type, + "success": last_webhook_log.success, + "payload": json.loads(last_webhook_log.changes_detected) + if last_webhook_log.changes_detected + else None, + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur debug webhook: {e}") + raise HTTPException(500, str(e)) + + +@router.get( + "/transactions/{transaction_id}/document/download", tags=["Documents Signés"] +) +async def telecharger_document_signe( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Télécharge le document signé localement stocké + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + if not transaction.signed_document_path: + raise HTTPException( + 404, + "Document signé non disponible localement. " + "Utilisez POST /admin/download-missing-documents pour le récupérer.", + ) + + file_path = Path(transaction.signed_document_path) + + if not file_path.exists(): + # Document perdu, on peut tenter de le retélécharger + logger.warning(f"Fichier perdu : {file_path}") + raise HTTPException( + 404, + "Fichier introuvable sur le serveur. " + "Utilisez POST /admin/download-missing-documents pour le récupérer.", + ) + + # Génération du nom de fichier pour le téléchargement + download_name = ( + f"{transaction.sage_document_id}_" + f"{transaction.sage_document_type.name}_" + f"signe.pdf" + ) + + return FileResponse( + path=str(file_path), media_type="application/pdf", filename=download_name + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur téléchargement document : {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@router.get("/transactions/{transaction_id}/document/info", tags=["Documents Signés"]) +async def info_document_signe( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Informations sur le document signé + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + file_exists = False + file_size_mb = None + + if transaction.signed_document_path: + file_path = Path(transaction.signed_document_path) + file_exists = file_path.exists() + + if file_exists: + file_size_mb = os.path.getsize(file_path) / (1024 * 1024) + + return { + "transaction_id": transaction_id, + "document_available_locally": file_exists, + "document_url_universign": transaction.document_url, + "downloaded_at": ( + transaction.signed_document_downloaded_at.isoformat() + if transaction.signed_document_downloaded_at + else None + ), + "file_size_mb": round(file_size_mb, 2) if file_size_mb else None, + "download_attempts": transaction.download_attempts, + "last_download_error": transaction.download_error, + "local_path": transaction.signed_document_path if file_exists else None, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur info document : {e}") + raise HTTPException(500, str(e)) + + +@router.post("/admin/download-missing-documents", tags=["Admin"]) +async def telecharger_documents_manquants( + force_redownload: bool = Query( + False, description="Forcer le retéléchargement même si déjà présent" + ), + session: AsyncSession = Depends(get_session), +): + """ + Télécharge tous les documents signés manquants pour les transactions SIGNE + """ + try: + # Transactions signées sans document local + query = select(UniversignTransaction).where( + UniversignTransaction.local_status == LocalDocumentStatus.SIGNED, + or_( + UniversignTransaction.signed_document_path.is_(None), + force_redownload, + ), + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + logger.info(f"📥 {len(transactions)} document(s) à télécharger") + + document_service = UniversignDocumentService( + api_key=settings.universign_api_key, timeout=60 + ) + + results = {"total": len(transactions), "success": 0, "failed": 0, "details": []} + + for transaction in transactions: + try: + ( + success, + error, + ) = await document_service.download_and_store_signed_document( + session=session, transaction=transaction, force=force_redownload + ) + + if success: + results["success"] += 1 + results["details"].append( + { + "transaction_id": transaction.transaction_id, + "sage_document_id": transaction.sage_document_id, + "status": "success", + } + ) + else: + results["failed"] += 1 + results["details"].append( + { + "transaction_id": transaction.transaction_id, + "sage_document_id": transaction.sage_document_id, + "status": "failed", + "error": error, + } + ) + + except Exception as e: + logger.error(f"Erreur téléchargement {transaction.transaction_id}: {e}") + results["failed"] += 1 + results["details"].append( + {"transaction_id": transaction.transaction_id, "error": str(e)} + ) + + await session.commit() + + logger.info( + f"Téléchargement terminé : {results['success']}/{results['total']} réussis" + ) + + return results + + except Exception as e: + logger.error(f"Erreur téléchargement batch : {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@router.post("/admin/cleanup-old-documents", tags=["Admin"]) +async def nettoyer_anciens_documents( + days_to_keep: int = Query( + 90, ge=7, le=365, description="Nombre de jours à conserver" + ), +): + """ + Supprime les documents signés de plus de X jours (par défaut 90) + """ + try: + document_service = UniversignDocumentService( + api_key=settings.universign_api_key + ) + + deleted, size_freed_mb = await document_service.cleanup_old_documents( + days_to_keep=days_to_keep + ) + + return { + "success": True, + "files_deleted": deleted, + "space_freed_mb": size_freed_mb, + "days_kept": days_to_keep, + } + + except Exception as e: + logger.error(f"Erreur nettoyage : {e}") + raise HTTPException(500, str(e)) diff --git a/sage_client.py b/sage_client.py index a0b92fe..8caef65 100644 --- a/sage_client.py +++ b/sage_client.py @@ -1,99 +1,444 @@ +# sage_client.py import requests from typing import Dict, List, Optional -from config import settings +from config.config import settings 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("/") + def __init__( + self, + gateway_url: Optional[str] = None, + gateway_token: Optional[str] = None, + gateway_id: Optional[str] = None, + ): + self.url = (gateway_url or settings.sage_gateway_url).rstrip("/") + self.token = gateway_token or settings.sage_gateway_token + self.gateway_id = gateway_id + self.headers = { - "X-Sage-Token": settings.sage_gateway_token, - "Content-Type": "application/json" + "X-Sage-Token": self.token, + "Content-Type": "application/json", } self.timeout = 30 + @classmethod + def from_context( + cls, url: str, token: str, gateway_id: Optional[str] = None + ) -> "SageGatewayClient": + return cls(gateway_url=url, gateway_token=token, gateway_id=gateway_id) + 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: + 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]: return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) def lire_client(self, code: str) -> Optional[Dict]: return self._post("/sage/clients/get", {"code": code}).get("data") - # === ARTICLES === + def creer_client(self, client_data: Dict) -> Dict: + return self._post("/sage/clients/create", client_data).get("data", {}) + + def modifier_client(self, code: str, client_data: Dict) -> Dict: + return self._post( + "/sage/clients/update", {"code": code, "client_data": client_data} + ).get("data", {}) + def lister_articles(self, filtre: str = "") -> List[Dict]: return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) def lire_article(self, ref: str) -> Optional[Dict]: return self._post("/sage/articles/get", {"code": ref}).get("data") - # === DEVIS === + def creer_article(self, article_data: Dict) -> Dict: + return self._post("/sage/articles/create", article_data).get("data", {}) + + def modifier_article(self, reference: str, article_data: Dict) -> Dict: + return self._post( + "/sage/articles/update", + {"reference": reference, "article_data": article_data}, + ).get("data", {}) + def creer_devis(self, devis_data: Dict) -> Dict: return self._post("/sage/devis/create", devis_data).get("data", {}) def lire_devis(self, numero: str) -> Optional[Dict]: return self._post("/sage/devis/get", {"code": numero}).get("data") - # === DOCUMENTS === + def lister_devis( + self, + limit: int = 100, + statut: Optional[int] = None, + inclure_lignes: bool = True, + ) -> List[Dict]: + 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 modifier_devis(self, numero: str, devis_data: Dict) -> Dict: + return self._post( + "/sage/devis/update", {"numero": numero, "devis_data": devis_data} + ).get("data", {}) + + def lister_commandes( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/commandes/list", payload).get("data", []) + + def creer_commande(self, commande_data: Dict) -> Dict: + return self._post("/sage/commandes/create", commande_data).get("data", {}) + + def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: + return self._post( + "/sage/commandes/update", {"numero": numero, "commande_data": commande_data} + ).get("data", {}) + + def lister_factures( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/factures/list", payload).get("data", []) + + def creer_facture(self, facture_data: Dict) -> Dict: + return self._post("/sage/factures/create", facture_data).get("data", {}) + + def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: + return self._post( + "/sage/factures/update", {"numero": numero, "facture_data": facture_data} + ).get("data", {}) + + def lister_livraisons( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/livraisons/list", payload).get("data", []) + + def creer_livraison(self, livraison_data: Dict) -> Dict: + return self._post("/sage/livraisons/create", livraison_data).get("data", {}) + + def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: + return self._post( + "/sage/livraisons/update", + {"numero": numero, "livraison_data": livraison_data}, + ).get("data", {}) + + def lister_avoirs( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/avoirs/list", payload).get("data", []) + + def creer_avoir(self, avoir_data: Dict) -> Dict: + return self._post("/sage/avoirs/create", avoir_data).get("data", {}) + + def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: + return self._post( + "/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data} + ).get("data", {}) + 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 changer_statut_document( + self, document_type_code: int, numero: str, nouveau_statut: int + ) -> Dict: + try: + r = requests.post( + f"{self.url}/sage/document/statut", + params={ + "numero": numero, + "type_doc": document_type_code, + "nouveau_statut": nouveau_statut, + }, + 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 - 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 transformer_document( + self, numero_source: str, type_source: int, type_cible: int + ) -> Dict: + try: + r = requests.post( + f"{self.url}/sage/documents/transform", + params={ + "numero_source": numero_source, + "type_source": type_source, + "type_cible": type_cible, + }, + headers=self.headers, + timeout=60, + ) + 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 + ) -> 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) - # === CONTACTS === + def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool: + resp = self._post( + "/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc} + ) + return resp.get("success", False) + + def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: + try: + logger.info(f"Demande génération PDF: doc_id={doc_id}, type={type_doc}") + + r = requests.post( + f"{self.url}/sage/documents/generate-pdf", + json={"doc_id": doc_id, "type_doc": type_doc}, + headers=self.headers, + timeout=60, + ) + + r.raise_for_status() + + import base64 + + response_data = r.json() + + 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})" + ) + + 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 + + def lister_prospects(self, filtre: str = "") -> List[Dict]: + return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", []) + + def lire_prospect(self, code: str) -> Optional[Dict]: + return self._post("/sage/prospects/get", {"code": code}).get("data") + + def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: + return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", []) + + def lire_fournisseur(self, code: str) -> Optional[Dict]: + return self._post("/sage/fournisseurs/get", {"code": code}).get("data") + + def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: + return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) + + def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: + return self._post( + "/sage/fournisseurs/update", + {"code": code, "fournisseur_data": fournisseur_data}, + ).get("data", {}) + + 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") + def lire_contact_client(self, code_client: str) -> Optional[Dict]: return self._post("/sage/contact/read", {"code": code_client}).get("data") - # === CACHE === + 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: + return self._post( + "/sage/contacts/delete", + {"numero": numero, "contact_numero": contact_numero}, + ) + + def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict: + return self._post( + "/sage/contacts/set-default", + {"numero": numero, "contact_numero": contact_numero}, + ) + + 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 + + def lire_remise_max_client(self, code_client: str) -> float: + result = self._post("/sage/client/remise-max", {"code": code_client}) + return result.get("data", {}).get("remise_max", 10.0) + + def lister_collaborateurs( + self, filtre: Optional[str] = None, actifs_seulement: bool = True + ) -> List[Dict]: + """Liste tous les collaborateurs""" + return self._post( + "/sage/collaborateurs/list", + { + "filtre": filtre or "", # Convertir None en "" + "actifs_seulement": actifs_seulement, + }, + ).get("data", []) + + def lire_collaborateur(self, numero: int) -> Optional[Dict]: + """Lit un collaborateur par numéro""" + return self._post("/sage/collaborateurs/get", {"numero": numero}).get("data") + + def creer_collaborateur(self, data: Dict) -> Optional[Dict]: + """Crée un nouveau collaborateur""" + return self._post("/sage/collaborateurs/create", data).get("data") + + def modifier_collaborateur(self, numero: int, data: Dict) -> Optional[Dict]: + """Modifie un collaborateur existant""" + return self._post( + "/sage/collaborateurs/update", {"numero": numero, **data} + ).get("data") + def refresh_cache(self) -> Dict: return self._post("/sage/cache/refresh") - # === HEALTH === + def get_cache_info(self) -> Dict: + return self._get("/sage/cache/info").get("data", {}) + def health(self) -> dict: try: r = requests.get(f"{self.url}/health", timeout=5) return r.json() - except: + except Exception: return {"status": "down"} -# Instance globale -sage_client = SageGatewayClient() \ No newline at end of file + +sage_client = SageGatewayClient() diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..ececf1d --- /dev/null +++ b/schemas/__init__.py @@ -0,0 +1,108 @@ +from schemas.tiers.tiers import TiersDetails, TypeTiersInt +from schemas.tiers.type_tiers import TypeTiers +from schemas.schema_mixte import BaremeRemiseResponse +from schemas.user import Users +from schemas.tiers.clients import ( + ClientCreate, + ClientDetails, + ClientResponse, + ClientUpdate, +) +from schemas.tiers.contact import Contact, ContactCreate, ContactUpdate +from schemas.tiers.fournisseurs import ( + FournisseurCreate, + FournisseurDetails, + FournisseurUpdate, +) +from schemas.documents.avoirs import AvoirCreate, AvoirUpdate +from schemas.documents.commandes import CommandeCreate, CommandeUpdate +from schemas.documents.devis import ( + DevisRequest, + Devis, + DevisUpdate, + RelanceDevis, +) +from schemas.documents.documents import TypeDocument, TypeDocumentSQL +from schemas.documents.email import StatutEmail, EmailEnvoi +from schemas.documents.factures import FactureCreate, FactureUpdate +from schemas.documents.livraisons import LivraisonCreate, LivraisonUpdate +from schemas.documents.universign import Signature, StatutSignature +from schemas.articles.articles import ( + ArticleCreate, + Article, + ArticleUpdate, + ArticleList, + EntreeStock, + SortieStock, + MouvementStock, +) +from schemas.articles.famille_article import ( + Familles, + FamilleCreate, + FamilleList, +) + +from schemas.sage.sage_gateway import ( + SageGatewayCreate, + SageGatewayUpdate, + SageGatewayResponse, + SageGatewayList, + SageGatewayHealthCheck, + SageGatewayTest, + SageGatewayStatsResponse, + CurrentGatewayInfo, +) + +__all__ = [ + "TiersDetails", + "TypeTiers", + "BaremeRemiseResponse", + "Users", + "ClientCreate", + "ClientDetails", + "ClientResponse", + "ClientUpdate", + "FournisseurCreate", + "FournisseurDetails", + "FournisseurUpdate", + "Contact", + "AvoirCreate", + "AvoirUpdate", + "CommandeCreate", + "CommandeUpdate", + "DevisRequest", + "Devis", + "DevisUpdate", + "TypeDocument", + "TypeDocumentSQL", + "StatutEmail", + "EmailEnvoi", + "FactureCreate", + "FactureUpdate", + "LivraisonCreate", + "LivraisonUpdate", + "Signature", + "StatutSignature", + "TypeTiersInt", + "ArticleCreate", + "Article", + "ArticleUpdate", + "ArticleList", + "EntreeStock", + "SortieStock", + "MouvementStock", + "RelanceDevis", + "Familles", + "FamilleCreate", + "FamilleList", + "ContactCreate", + "ContactUpdate", + "SageGatewayCreate", + "SageGatewayUpdate", + "SageGatewayResponse", + "SageGatewayList", + "SageGatewayHealthCheck", + "SageGatewayTest", + "SageGatewayStatsResponse", + "CurrentGatewayInfo", +] diff --git a/schemas/articles/articles.py b/schemas/articles/articles.py new file mode 100644 index 0000000..79b2d62 --- /dev/null +++ b/schemas/articles/articles.py @@ -0,0 +1,650 @@ +from pydantic import BaseModel, Field, validator, field_validator +from typing import List, Optional +from datetime import date + +from utils import ( + NomenclatureType, + SuiviStockType, + TypeArticle, + normalize_enum_to_int, + normalize_string_field, +) + + +class Article(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_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[dict] = Field( + default_factory=list, description="Détail du stock par emplacement" + ) + nb_emplacements: int = Field(0, description="Nombre d'emplacements") + + # Champs énumérés normalisés + suivi_stock: Optional[int] = Field( + None, + description="Type de suivi de stock (AR_SuiviStock): 0=Aucun, 1=CMUP, 2=FIFO/LIFO, 3=Sérialisé", + ) + suivi_stock_libelle: Optional[str] = Field( + None, description="Libellé du type de suivi de stock" + ) + + nomenclature: Optional[int] = Field( + None, + description="Type de nomenclature (AR_Nomencl): 0=Non, 1=Fabrication, 2=Commerciale", + ) + nomenclature_libelle: Optional[str] = Field( + None, description="Libellé du type de nomenclature" + ) + + 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[dict] = Field(default_factory=list, description="Détail des gammes") + nb_gammes: int = Field(0, description="Nombre de gammes") + + tarifs_clients: List[dict] = Field( + default_factory=list, description="Tarifs spécifiques par client/catégorie" + ) + nb_tarifs_clients: int = Field(0, description="Nombre de tarifs clients") + + composants: List[dict] = Field( + default_factory=list, description="Composants/Opérations de production" + ) + nb_composants: int = Field(0, description="Nombre de composants") + + compta_vente: List[dict] = Field( + default_factory=list, description="Comptabilité vente" + ) + compta_achat: List[dict] = Field( + default_factory=list, description="Comptabilité achat" + ) + compta_stock: List[dict] = Field( + default_factory=list, description="Comptabilité stock" + ) + + fournisseurs: List[dict] = Field( + default_factory=list, description="Tous les fournisseurs de l'article" + ) + nb_fournisseurs: int = Field(0, description="Nombre de fournisseurs") + + refs_enumerees: List[dict] = Field( + default_factory=list, description="Références énumérées" + ) + nb_refs_enumerees: int = Field(0, description="Nombre de références énumérées") + + medias: List[dict] = Field(default_factory=list, description="Médias attachés") + nb_medias: int = Field(0, description="Nombre de médias") + + prix_gammes: List[dict] = Field( + default_factory=list, description="Prix par combinaison de gammes" + ) + nb_prix_gammes: int = Field(0, description="Nombre de prix par gammes") + + type_article: Optional[int] = Field( + 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" + ) + + 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") + famille_type: Optional[int] = Field( + None, description="Type de famille : 0=Détail, 1=Total" + ) + famille_unite_vente: Optional[str] = Field( + None, description="Unité de vente de la famille" + ) + famille_coef: Optional[float] = Field(None, description="Coefficient de la famille") + famille_suivi_stock: Optional[bool] = Field( + None, description="Suivi stock de la famille" + ) + famille_garantie: Optional[int] = Field(None, description="Garantie de la famille") + famille_unite_poids: Optional[str] = Field( + None, description="Unité de poids de la famille" + ) + famille_delai: Optional[int] = Field(None, description="Délai de la famille") + famille_nb_colis: Optional[int] = Field( + None, description="Nombre de colis de la famille" + ) + famille_code_fiscal: Optional[str] = Field( + None, description="Code fiscal de la famille" + ) + famille_escompte: Optional[bool] = Field(None, description="Escompte de la famille") + famille_centrale: Optional[bool] = Field(None, description="Famille centrale") + famille_nature: Optional[int] = Field(None, description="Nature de la famille") + famille_hors_stat: Optional[bool] = Field( + None, description="Hors statistique famille" + ) + famille_pays: Optional[str] = Field(None, description="Pays de la famille") + + 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" + ) + fournisseur_nom: Optional[str] = Field( + None, description="Nom du fournisseur principal" + ) + + conditionnement: Optional[str] = Field( + None, description="Conditionnement d'achat (AR_Condition)" + ) + conditionnement_qte: Optional[float] = Field( + None, description="Quantité conditionnement" + ) + conditionnement_edi: Optional[str] = Field( + None, description="Code EDI conditionnement" + ) + + 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)" + ) + + 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)" + ) + + 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)" + ) + + 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" + ) + frais_02_denomination: Optional[str] = Field( + None, description="Dénomination frais 2" + ) + frais_03_denomination: Optional[str] = Field( + None, description="Dénomination frais 3" + ) + + 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)") + 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_modification: Optional[str] = Field( + None, description="Date de dernière modification (AR_DateModif)" + ) + + marque_commerciale: Optional[str] = Field(None, description="Marque commerciale") + objectif_qtes_vendues: Optional[str] = Field( + None, description="Objectif / Quantités vendues" + ) + pourcentage_or: Optional[str] = Field(None, description="Pourcentage teneur en or") + premiere_commercialisation: Optional[str] = Field( + None, description="Date de 1ère commercialisation" + ) + interdire_commande: Optional[bool] = Field( + None, description="Interdire la commande" + ) + exclure: Optional[bool] = Field(None, description="Exclure de certains traitements") + + @field_validator("fournisseur_principal", mode="before") + @classmethod + def convert_fournisseur_principal(cls, v): + if v in (None, "", " ", " "): + return None + if isinstance(v, str): + v = v.strip() + if not v: + return None + try: + return int(v) + except (ValueError, TypeError): + return None + return v + + @field_validator( + "unite_vente", + "unite_poids", + "gamme_1", + "gamme_2", + "conditionnement", + "code_fiscal", + "pays", + "article_substitut", + "reprise_code_defaut", + mode="before", + ) + @classmethod + def convert_string_fields(cls, v): + """Convertit les champs string qui peuvent venir comme int depuis la DB""" + return normalize_string_field(v) + + @field_validator("suivi_stock", "nomenclature", mode="before") + @classmethod + def convert_enum_fields(cls, v): + """Convertit les champs énumérés en int""" + return normalize_enum_to_int(v) + + def model_post_init(self, __context): + """Génère automatiquement les libellés après l'initialisation""" + if self.suivi_stock is not None: + self.suivi_stock_libelle = SuiviStockType.get_label(self.suivi_stock) + + if self.nomenclature is not None: + self.nomenclature_libelle = NomenclatureType.get_label(self.nomenclature) + + if self.type_article is not None: + self.type_article_libelle = TypeArticle.get_label(self.type_article) + + class Config: + json_schema_extra = { + "example": { + "reference": "BAGUE-001", + "designation": "Bague Or 18K Diamant", + "prix_vente": 1299.00, + "stock_reel": 15.0, + "suivi_stock": 1, + "suivi_stock_libelle": "CMUP", + "nomenclature": 0, + "nomenclature_libelle": "Non", + } + } + + +class ArticleList(BaseModel): + """Réponse pour une liste d'articles""" + + total: int = Field(..., description="Nombre total d'articles") + articles: List[Article] = 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 ArticleCreate(BaseModel): + 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") + coef: Optional[float] = Field(None, ge=0, description="Coefficient") + + stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial") + stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum") + stock_maxi: Optional[float] = Field(None, ge=0, description="Stock maximum") + + code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres EAN") + unite_vente: Optional[str] = Field("UN", max_length=10, description="Unité vente") + tva_code: Optional[str] = Field(None, max_length=10, description="Code TVA") + code_fiscal: Optional[str] = Field(None, max_length=10, description="Code fiscal") + + description: Optional[str] = Field( + None, max_length=255, description="Description/Commentaire" + ) + + pays: Optional[str] = Field(None, max_length=3, description="Pays d'origine") + garantie: Optional[int] = Field(None, ge=0, description="Garantie en mois") + delai: Optional[int] = Field(None, ge=0, description="Délai livraison jours") + + poids_net: Optional[float] = Field(None, ge=0, description="Poids net kg") + poids_brut: Optional[float] = Field(None, ge=0, description="Poids brut kg") + + stat_01: Optional[str] = Field(None, max_length=20, description="Statistique 1") + stat_02: Optional[str] = Field(None, max_length=20, description="Statistique 2") + stat_03: Optional[str] = Field(None, max_length=20, description="Statistique 3") + stat_04: Optional[str] = Field(None, max_length=20, description="Statistique 4") + stat_05: Optional[str] = Field(None, max_length=20, description="Statistique 5") + + soumis_escompte: Optional[bool] = Field(None, description="Soumis à escompte") + publie: Optional[bool] = Field(None, description="Publié web/catalogue") + en_sommeil: Optional[bool] = Field(None, description="Article en sommeil") + + +class ArticleUpdate(BaseModel): + designation: Optional[str] = Field(None, 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") + coef: Optional[float] = Field(None, ge=0, description="Coefficient") + + stock_reel: Optional[float] = Field(None, ge=0, description="Stock réel") + stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum") + stock_maxi: Optional[float] = Field(None, ge=0, description="Stock maximum") + + code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres EAN") + unite_vente: Optional[str] = Field(None, max_length=10, description="Unité vente") + code_fiscal: Optional[str] = Field(None, max_length=10, description="Code fiscal") + + description: Optional[str] = Field(None, max_length=255, description="Description") + + pays: Optional[str] = Field(None, max_length=3, description="Pays d'origine") + garantie: Optional[int] = Field(None, ge=0, description="Garantie en mois") + delai: Optional[int] = Field(None, ge=0, description="Délai livraison jours") + + poids_net: Optional[float] = Field(None, ge=0, description="Poids net kg") + poids_brut: Optional[float] = Field(None, ge=0, description="Poids brut kg") + + stat_01: Optional[str] = Field(None, max_length=20, description="Statistique 1") + stat_02: Optional[str] = Field(None, max_length=20, description="Statistique 2") + stat_03: Optional[str] = Field(None, max_length=20, description="Statistique 3") + stat_04: Optional[str] = Field(None, max_length=20, description="Statistique 4") + stat_05: Optional[str] = Field(None, max_length=20, description="Statistique 5") + + soumis_escompte: Optional[bool] = Field(None, description="Soumis à escompte") + publie: Optional[bool] = Field(None, description="Publié web/catalogue") + en_sommeil: Optional[bool] = Field(None, description="Article en sommeil") + + +class MouvementStockLigne(BaseModel): + 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") + 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: + json_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 EntreeStock(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[MouvementStockLigne] = 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 SortieStock(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[MouvementStockLigne] = 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 MouvementStock(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") + 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") diff --git a/schemas/articles/famille_article.py b/schemas/articles/famille_article.py new file mode 100644 index 0000000..59b6c31 --- /dev/null +++ b/schemas/articles/famille_article.py @@ -0,0 +1,255 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class FamilleCreate(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 Familles(BaseModel): + """Modèle complet d'une famille avec données comptables et fournisseur""" + + 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: bool = Field(..., description="True si type Détail") + + 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") + + 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") + + 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 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 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 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" + ) + + 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") + 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é (e-commerce)") + + 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="Famille en sous-traitance" + ) + fictif: Optional[bool] = Field(None, description="Famille fictive (nomenclature)") + criticite: Optional[int] = Field(None, description="Niveau de criticité (0-5)") + + 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") + + 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") + + compte_stock: Optional[str] = Field(None, description="Compte de stock") + compte_auxiliaire_stock: Optional[str] = Field( + None, description="Compte auxiliaire de stock" + ) + + 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)" + ) + + nb_articles: Optional[int] = Field( + None, description="Nombre d'articles dans la famille" + ) + + 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": "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.5, + "suivi_stock": True, + "garantie": 24, + "delai": 5, + "nb_colis": 1, + "code_fiscal": "C19", + "escompte": True, + "est_centrale": False, + "nature": 0, + "pays": "FR", + "categorie_1": 1, + "categorie_2": 0, + "categorie_3": 0, + "categorie_4": 0, + "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 FamilleList(BaseModel): + """Réponse pour la liste des familles""" + + familles: list[Familles] + total: int + filtre: Optional[str] = None + inclure_totaux: bool = True + + class Config: + json_schema_extra = { + "example": { + "familles": [], + "total": 42, + "filtre": "ELECT", + "inclure_totaux": False, + } + } diff --git a/schemas/documents/avoirs.py b/schemas/documents/avoirs.py new file mode 100644 index 0000000..66f363e --- /dev/null +++ b/schemas/documents/avoirs.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + +class AvoirCreate(BaseModel): + client_id: str + date_avoir: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: List[LigneDocument] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_avoir": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "reference": "AV-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 5.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 0.0, + } + ], + } + } + + +class AvoirUpdate(BaseModel): + date_avoir: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "date_avoir": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "reference": "AV-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 45.0, + } + ], + "statut": 2, + } + } diff --git a/schemas/documents/commandes.py b/schemas/documents/commandes.py new file mode 100644 index 0000000..5c920dc --- /dev/null +++ b/schemas/documents/commandes.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + +class CommandeCreate(BaseModel): + client_id: str + date_commande: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: List[LigneDocument] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_commande": "2024-01-15T10:00:00", + "reference": "CMD-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0, + } + ], + } + } + + +class CommandeUpdate(BaseModel): + date_commande: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "date_commande": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "reference": "CMD-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0, + } + ], + "statut": 2, + } + } diff --git a/schemas/documents/devis.py b/schemas/documents/devis.py new file mode 100644 index 0000000..d43ad40 --- /dev/null +++ b/schemas/documents/devis.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + + +class DevisRequest(BaseModel): + client_id: str + date_devis: Optional[datetime] = None + date_livraison: Optional[datetime] = None + reference: Optional[str] = None + lignes: List[LigneDocument] + + +class Devis(BaseModel): + id: str + client_id: str + date_devis: str + montant_total_ht: float + montant_total_ttc: float + nb_lignes: int + + +class DevisUpdate(BaseModel): + """Modèle pour modification d'un devis existant""" + + date_devis: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + reference: Optional[str] = None + statut: Optional[int] = Field(None, ge=0, le=6) + + class Config: + json_schema_extra = { + "example": { + "date_devis": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "reference": "DEV-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 5.0, + "prix_unitaire_ht": 100.0, + "remise_pourcentage": 10.0, + } + ], + "statut": 2, + } + } + + +class RelanceDevis(BaseModel): + doc_id: str + message_personnalise: Optional[str] = None diff --git a/schemas/documents/documents.py b/schemas/documents/documents.py new file mode 100644 index 0000000..509d2ad --- /dev/null +++ b/schemas/documents/documents.py @@ -0,0 +1,22 @@ +from config.config import settings +from enum import Enum + + +class TypeDocument(int, Enum): + DEVIS = settings.SAGE_TYPE_DEVIS + BON_COMMANDE = settings.SAGE_TYPE_BON_COMMANDE + PREPARATION = settings.SAGE_TYPE_PREPARATION + BON_LIVRAISON = settings.SAGE_TYPE_BON_LIVRAISON + BON_RETOUR = settings.SAGE_TYPE_BON_RETOUR + BON_AVOIR = settings.SAGE_TYPE_BON_AVOIR + FACTURE = settings.SAGE_TYPE_FACTURE + + +class TypeDocumentSQL(int, Enum): + DEVIS = settings.SAGE_TYPE_DEVIS + BON_COMMANDE = 1 + PREPARATION = 2 + BON_LIVRAISON = 3 + BON_RETOUR = 4 + BON_AVOIR = 5 + FACTURE = 6 diff --git a/schemas/documents/email.py b/schemas/documents/email.py new file mode 100644 index 0000000..49229ac --- /dev/null +++ b/schemas/documents/email.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, EmailStr +from typing import List, Optional +from enum import Enum +from schemas.documents.documents import TypeDocument + + +class StatutEmail(str, Enum): + EN_ATTENTE = "EN_ATTENTE" + EN_COURS = "EN_COURS" + ENVOYE = "ENVOYE" + OUVERT = "OUVERT" + ERREUR = "ERREUR" + BOUNCE = "BOUNCE" + + +class EmailEnvoi(BaseModel): + destinataire: EmailStr + cc: Optional[List[EmailStr]] = [] + cci: Optional[List[EmailStr]] = [] + sujet: str + corps_html: str + document_ids: Optional[List[str]] = None + type_document: Optional[TypeDocument] = None diff --git a/schemas/documents/factures.py b/schemas/documents/factures.py new file mode 100644 index 0000000..0ab6e21 --- /dev/null +++ b/schemas/documents/factures.py @@ -0,0 +1,53 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + +class FactureCreate(BaseModel): + client_id: str + date_facture: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: List[LigneDocument] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_facture": "2024-01-15T10:00:00", + "reference": "FA-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0, + } + ], + } + } + + +class FactureUpdate(BaseModel): + date_facture: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "date_facture": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0, + } + ], + "statut": 2, + } + } diff --git a/schemas/documents/ligne_document.py b/schemas/documents/ligne_document.py new file mode 100644 index 0000000..4666ace --- /dev/null +++ b/schemas/documents/ligne_document.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, field_validator +from typing import Optional + + +class LigneDocument(BaseModel): + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + @field_validator("quantite") + def validate_quantite(cls, v): + if v <= 0: + raise ValueError("La quantité doit être positive") + return v + + @field_validator("remise_pourcentage") + def validate_remise(cls, v): + if v is not None and (v < 0 or v > 100): + raise ValueError("La remise doit être entre 0 et 100") + return v diff --git a/schemas/documents/livraisons.py b/schemas/documents/livraisons.py new file mode 100644 index 0000000..3dc9eb9 --- /dev/null +++ b/schemas/documents/livraisons.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + + +class LivraisonCreate(BaseModel): + client_id: str + date_livraison: Optional[datetime] = None + date_livraison_prevue: Optional[datetime] = None + lignes: List[LigneDocument] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_livraison": "2024-01-15T10:00:00", + "reference": "BL-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0, + } + ], + } + } + + +class LivraisonUpdate(BaseModel): + date_livraison: Optional[datetime] = None + date_livraison_prevue: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "date_livraison": "2024-01-15T10:00:00", + "date_livraison_prevue": "2024-01-15T10:00:00", + "reference": "BL-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0, + } + ], + "statut": 2, + } + } diff --git a/schemas/documents/universign.py b/schemas/documents/universign.py new file mode 100644 index 0000000..ba866ac --- /dev/null +++ b/schemas/documents/universign.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, EmailStr +from enum import Enum +from schemas.documents.documents import TypeDocument + + +class StatutSignature(str, Enum): + EN_ATTENTE = "EN_ATTENTE" + ENVOYE = "ENVOYE" + SIGNE = "SIGNE" + REFUSE = "REFUSE" + EXPIRE = "EXPIRE" + + +class Signature(BaseModel): + doc_id: str + type_doc: TypeDocument + email_signataire: EmailStr + nom_signataire: str diff --git a/schemas/sage/sage_gateway.py b/schemas/sage/sage_gateway.py new file mode 100644 index 0000000..e503641 --- /dev/null +++ b/schemas/sage/sage_gateway.py @@ -0,0 +1,164 @@ +from pydantic import BaseModel, Field, field_validator +from typing import Optional, List, Dict, Any +from datetime import datetime +from enum import Enum + + +class GatewayHealthStatus(str, Enum): + HEALTHY = "healthy" + UNHEALTHY = "unhealthy" + UNKNOWN = "unknown" + + +# === CREATE === +class SageGatewayCreate(BaseModel): + + name: str = Field( + ..., min_length=2, max_length=100, description="Nom de la gateway" + ) + description: Optional[str] = Field(None, max_length=500) + + gateway_url: str = Field( + ..., description="URL de la gateway Sage (ex: http://192.168.1.50:8100)" + ) + gateway_token: str = Field( + ..., min_length=10, description="Token d'authentification" + ) + + sage_database: Optional[str] = Field(None, max_length=255) + sage_company: Optional[str] = Field(None, max_length=255) + + is_active: bool = Field(False, description="Activer immédiatement cette gateway") + is_default: bool = Field(False, description="Définir comme gateway par défaut") + priority: int = Field(0, ge=0, le=100) + + extra_config: Optional[Dict[str, Any]] = Field( + None, description="Configuration JSON additionnelle" + ) + allowed_ips: Optional[List[str]] = Field( + None, description="Liste des IPs autorisées" + ) + + @field_validator("gateway_url") + @classmethod + def validate_url(cls, v): + if not v.startswith(("http://", "https://")): + raise ValueError("L'URL doit commencer par http:// ou https://") + return v.rstrip("/") + + +class SageGatewayUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=2, max_length=100) + description: Optional[str] = Field(None, max_length=500) + + gateway_url: Optional[str] = None + gateway_token: Optional[str] = Field(None, min_length=10) + + sage_database: Optional[str] = None + sage_company: Optional[str] = None + + is_default: Optional[bool] = None + priority: Optional[int] = Field(None, ge=0, le=100) + + extra_config: Optional[Dict[str, Any]] = None + allowed_ips: Optional[List[str]] = None + + @field_validator("gateway_url") + @classmethod + def validate_url(cls, v): + if v and not v.startswith(("http://", "https://")): + raise ValueError("L'URL doit commencer par http:// ou https://") + return v.rstrip("/") if v else v + + +# === RESPONSE === +class SageGatewayResponse(BaseModel): + + id: str + user_id: str + + name: str + description: Optional[str] = None + + gateway_url: str + token_preview: str + + sage_database: Optional[str] = None + sage_company: Optional[str] = None + + is_active: bool + is_default: bool + priority: int + + health_status: GatewayHealthStatus + last_health_check: Optional[datetime] = None + last_error: Optional[str] = None + + total_requests: int + successful_requests: int + failed_requests: int + success_rate: float + last_used_at: Optional[datetime] = None + + extra_config: Optional[Dict[str, Any]] = None + allowed_ips: Optional[List[str]] = None + + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class SageGatewayList(BaseModel): + + items: List[SageGatewayResponse] + total: int + active_gateway: Optional[SageGatewayResponse] = None + using_fallback: bool = False + + +class SageGatewayHealthCheck(BaseModel): + gateway_id: str + gateway_name: str + status: GatewayHealthStatus + response_time_ms: Optional[float] = None + sage_version: Optional[str] = None + error: Optional[str] = None + checked_at: datetime + + +class SageGatewayActivateRequest(BaseModel): + gateway_id: str + + +class SageGatewayTest(BaseModel): + gateway_url: str + gateway_token: str + + @field_validator("gateway_url") + @classmethod + def validate_url(cls, v): + if not v.startswith(("http://", "https://")): + raise ValueError("L'URL doit commencer par http:// ou https://") + return v.rstrip("/") + + +class SageGatewayStatsResponse(BaseModel): + total_gateways: int + active_gateways: int + total_requests: int + successful_requests: int + failed_requests: int + average_success_rate: float + most_used_gateway: Optional[str] = None + last_activity: Optional[datetime] = None + + +class CurrentGatewayInfo(BaseModel): + source: str + gateway_id: Optional[str] = None + gateway_name: Optional[str] = None + gateway_url: str + is_healthy: Optional[bool] = None + user_id: Optional[str] = None diff --git a/schemas/schema_mixte.py b/schemas/schema_mixte.py new file mode 100644 index 0000000..8a69976 --- /dev/null +++ b/schemas/schema_mixte.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class BaremeRemiseResponse(BaseModel): + client_id: str + remise_max_autorisee: float + remise_demandee: float + autorisee: bool + message: str diff --git a/schemas/tiers/__init__.py b/schemas/tiers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schemas/tiers/clients.py b/schemas/tiers/clients.py new file mode 100644 index 0000000..8085375 --- /dev/null +++ b/schemas/tiers/clients.py @@ -0,0 +1,576 @@ +from pydantic import BaseModel, Field, field_validator +from typing import Optional +from schemas.tiers.tiers import TiersDetails + + +class ClientResponse(BaseModel): + 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 + + +class ClientDetails(TiersDetails): + class Config: + json_schema_extra = { + "example": { + "numero": "CLI000001", + "intitule": "SARL EXEMPLE", + "type_tiers": 0, + "commercial_code": 1, + "commercial": { + "numero": 1, + "nom": "DUPONT", + "prenom": "Jean", + "email": "j.dupont@entreprise.fr", + }, + } + } + + +class ClientCreate(BaseModel): + intitule: str = Field( + ..., max_length=69, description="Nom du client (CT_Intitule) - OBLIGATOIRE" + ) + + numero: str = Field( + ..., max_length=17, description="Numéro client CT_Num (auto si None)" + ) + + 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)" + ) + + 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" + ) + + encours_autorise: Optional[float] = Field( + None, description="CT_Encours (montant max autorisé)" + ) + + assurance_credit: Optional[float] = Field( + None, description="CT_Assurance (montant assurance crédit)" + ) + + langue: Optional[int] = Field( + None, ge=0, description="CT_Langue (0=Français, 1=Anglais, etc.)" + ) + + commercial_code: Optional[int] = Field( + None, description="CO_No (ID du collaborateur commercial)" + ) + + lettrage_auto: Optional[bool] = Field( + True, description="CT_Lettrage (1=oui, 0=non)" + ) + + est_actif: Optional[bool] = Field( + True, description="Inverse de CT_Sommeil (True=actif, False=en sommeil)" + ) + + type_facture: Optional[int] = Field( + 1, ge=0, le=2, description="CT_Facture: 0=aucune, 1=normale, 2=regroupée" + ) + + est_prospect: Optional[bool] = Field( + False, description="CT_Prospect (1=oui, 0=non)" + ) + + bl_en_facture: Optional[int] = Field( + None, ge=0, le=1, description="CT_BLFact (impression BL sur facture)" + ) + + saut_page: Optional[int] = Field( + None, ge=0, le=1, description="CT_Saut (saut de page après impression)" + ) + + 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") + + 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") + + @field_validator("siret") + @classmethod + def validate_siret(cls, v): + """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("email") + @classmethod + def validate_email(cls, v): + """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 + return None + + @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'/'null'/'' en None""" + if isinstance(v, str) and v.lower() in ("none", "null", ""): + return None + return v + + 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 + """ + stat01 = self.statistique01 or self.secteur + + ca = self.ca_annuel or self.sv_chiffre_affaires + + return { + "intitule": self.intitule, + "numero": self.numero, + "type_tiers": self.type_tiers, + "qualite": self.qualite, + "classement": self.classement, + "raccourci": self.raccourci, + "siret": self.siret, + "tva_intra": self.tva_intra, + "code_naf": self.code_naf, + "contact": self.contact, + "adresse": self.adresse, + "complement": self.complement, + "code_postal": self.code_postal, + "ville": self.ville, + "region": self.region, + "pays": self.pays, + "telephone": self.telephone, + "telecopie": self.telecopie, + "email": self.email, + "site_web": self.site_web, + "portable": self.portable, + "facebook": self.facebook, + "linkedin": self.linkedin, + "compte_general": self.compte_general, + "categorie_tarifaire": self.categorie_tarifaire, + "categorie_comptable": self.categorie_comptable, + "taux01": self.taux01, + "taux02": self.taux02, + "taux03": self.taux03, + "taux04": self.taux04, + "statistique01": stat01, + "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, + "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": self.commentaire, + "section_analytique": self.section_analytique, + "mode_reglement_code": self.mode_reglement_code, + "surveillance_active": self.surveillance_active, + "coface": self.coface, + "forme_juridique": self.forme_juridique, + "effectif": self.effectif, + "sv_regularite": self.sv_regularite, + "sv_cotation": self.sv_cotation, + "sv_objet_maj": self.sv_objet_maj, + "ca_annuel": ca, + "sv_chiffre_affaires": self.sv_chiffre_affaires, + "sv_resultat": self.sv_resultat, + } + + class Config: + json_schema_extra = { + "example": { + "intitule": "ENTREPRISE EXEMPLE SARL", + "numero": "CLI00123", + "type_tiers": 0, + "qualite": "CLI", + "compte_general": "411000", + "est_prospect": False, + "est_actif": True, + "email": "contact@exemple.fr", + "telephone": "0123456789", + "adresse": "123 Rue de la Paix", + "code_postal": "75001", + "ville": "Paris", + "pays": "France", + } + } + + +class ClientUpdate(BaseModel): + 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) + + 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) + 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", + } + } diff --git a/schemas/tiers/commercial.py b/schemas/tiers/commercial.py new file mode 100644 index 0000000..5a4685b --- /dev/null +++ b/schemas/tiers/commercial.py @@ -0,0 +1,116 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional + + +class CollaborateurBase(BaseModel): + """Champs communs collaborateur""" + + nom: str = Field(..., max_length=50) + prenom: Optional[str] = Field(None, max_length=50) + fonction: Optional[str] = Field(None, max_length=50) + + # Adresse + adresse: Optional[str] = Field(None, max_length=100) + complement: Optional[str] = Field(None, max_length=100) + code_postal: Optional[str] = Field(None, max_length=10) + ville: Optional[str] = Field(None, max_length=50) + code_region: Optional[str] = Field(None, max_length=50) + pays: Optional[str] = Field(None, max_length=50) + + # Services + service: Optional[str] = Field(None, max_length=50) + vendeur: bool = Field(default=False) + caissier: bool = Field(default=False) + acheteur: bool = Field(default=False) + chef_ventes: bool = Field(default=False) + numero_chef_ventes: Optional[int] = None + + # Contact + telephone: Optional[str] = Field(None, max_length=20) + telecopie: Optional[str] = Field(None, max_length=20) + email: Optional[EmailStr] = None + tel_portable: Optional[str] = Field(None, max_length=20) + + # Réseaux sociaux + facebook: Optional[str] = Field(None, max_length=100) + linkedin: Optional[str] = Field(None, max_length=100) + skype: Optional[str] = Field(None, max_length=100) + + # Autres + matricule: Optional[str] = Field(None, max_length=20) + sommeil: bool = Field(default=False) + + +class CollaborateurCreate(CollaborateurBase): + """Création d'un collaborateur""" + + pass + + +class CollaborateurUpdate(BaseModel): + """Modification d'un collaborateur (tous champs optionnels)""" + + nom: Optional[str] = Field(None, max_length=50) + prenom: Optional[str] = Field(None, max_length=50) + fonction: Optional[str] = Field(None, max_length=50) + + adresse: Optional[str] = Field(None, max_length=100) + complement: Optional[str] = Field(None, max_length=100) + code_postal: Optional[str] = Field(None, max_length=10) + ville: Optional[str] = Field(None, max_length=50) + code_region: Optional[str] = Field(None, max_length=50) + pays: Optional[str] = Field(None, max_length=50) + + service: Optional[str] = Field(None, max_length=50) + vendeur: Optional[bool] = None + caissier: Optional[bool] = None + acheteur: Optional[bool] = None + chef_ventes: Optional[bool] = None + numero_chef_ventes: Optional[int] = None + + telephone: Optional[str] = Field(None, max_length=20) + telecopie: Optional[str] = Field(None, max_length=20) + email: Optional[EmailStr] = None + tel_portable: Optional[str] = Field(None, max_length=20) + + facebook: Optional[str] = Field(None, max_length=100) + linkedin: Optional[str] = Field(None, max_length=100) + skype: Optional[str] = Field(None, max_length=100) + + matricule: Optional[str] = Field(None, max_length=20) + sommeil: Optional[bool] = None + + +class CollaborateurListe(BaseModel): + """Vue liste simplifiée""" + + numero: int + nom: str + prenom: Optional[str] + fonction: Optional[str] + service: Optional[str] + email: Optional[str] + telephone: Optional[str] + vendeur: bool + sommeil: bool + + +class CollaborateurDetails(CollaborateurBase): + """Détails complets d'un collaborateur""" + + numero: int + + class Config: + json_schema_extra = { + "example": { + "numero": 1, + "nom": "DUPONT", + "prenom": "Jean", + "fonction": "Directeur Commercial", + "service": "Commercial", + "vendeur": True, + "email": "j.dupont@entreprise.fr", + "telephone": "0123456789", + "sommeil": False, + } + } diff --git a/schemas/tiers/contact.py b/schemas/tiers/contact.py new file mode 100644 index 0000000..b5393d8 --- /dev/null +++ b/schemas/tiers/contact.py @@ -0,0 +1,111 @@ +from pydantic import BaseModel, Field, validator +from typing import Optional, ClassVar + + +class Contact(BaseModel): + 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 + + +class ContactCreate(BaseModel): + 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): + 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 diff --git a/schemas/tiers/fournisseurs.py b/schemas/tiers/fournisseurs.py new file mode 100644 index 0000000..6807560 --- /dev/null +++ b/schemas/tiers/fournisseurs.py @@ -0,0 +1,79 @@ +from pydantic import BaseModel, Field, EmailStr +from typing import Optional +from schemas.tiers.tiers import TiersDetails + + +class FournisseurDetails(TiersDetails): + class Config: + json_schema_extra = { + "example": { + "numero": "FOU000001", + "intitule": "SARL FOURNISSEUR", + "type_tiers": 1, + "commercial_code": 1, + "commercial": { + "numero": 1, + "nom": "MARTIN", + "prenom": "Sophie", + "email": "s.martin@entreprise.fr", + }, + } + } + + +class FournisseurCreate(BaseModel): + intitule: str = Field( + ..., min_length=1, max_length=69, description="Raison sociale du fournisseur" + ) + compte_collectif: str = Field( + "401000", description="Compte comptable fournisseur (ex: 401000)" + ) + num: Optional[str] = Field( + None, max_length=17, description="Code fournisseur souhaité (optionnel)" + ) + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "ACME SUPPLIES SARL", + "compte_collectif": "401000", + "num": "FOUR001", + "adresse": "15 Rue du Commerce", + "code_postal": "75001", + "ville": "Paris", + "pays": "France", + "email": "contact@acmesupplies.fr", + "telephone": "0145678901", + "siret": "12345678901234", + "tva_intra": "FR12345678901", + } + } + + +class FournisseurUpdate(BaseModel): + 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", + } + } diff --git a/schemas/tiers/tiers.py b/schemas/tiers/tiers.py new file mode 100644 index 0000000..58166a1 --- /dev/null +++ b/schemas/tiers/tiers.py @@ -0,0 +1,217 @@ +from typing import List, Optional +from pydantic import BaseModel, Field +from schemas.tiers.contact import Contact +from enum import IntEnum + +from schemas.tiers.tiers_collab import Collaborateur + + +class TypeTiersInt(IntEnum): + CLIENT = 0 + FOURNISSEUR = 1 + SALARIE = 2 + AUTRE = 3 + + +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)" + ) + commercial: Optional[Collaborateur] = Field( + None, description="Détails du commercial/collaborateur" + ) + + # 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" + ) diff --git a/schemas/tiers/tiers_collab.py b/schemas/tiers/tiers_collab.py new file mode 100644 index 0000000..3d727ed --- /dev/null +++ b/schemas/tiers/tiers_collab.py @@ -0,0 +1,54 @@ +from typing import Optional +from pydantic import BaseModel, Field + + +class Collaborateur(BaseModel): + """Modèle pour un collaborateur/commercial""" + + numero: Optional[int] = Field(None, description="Numéro du collaborateur (CO_No)") + nom: Optional[str] = Field(None, description="Nom (CO_Nom)") + prenom: Optional[str] = Field(None, description="Prénom (CO_Prenom)") + fonction: Optional[str] = Field(None, description="Fonction (CO_Fonction)") + adresse: Optional[str] = Field(None, description="Adresse (CO_Adresse)") + complement: Optional[str] = Field( + None, description="Complément adresse (CO_Complement)" + ) + code_postal: Optional[str] = Field(None, description="Code postal (CO_CodePostal)") + ville: Optional[str] = Field(None, description="Ville (CO_Ville)") + region: Optional[str] = Field(None, description="Région (CO_CodeRegion)") + pays: Optional[str] = Field(None, description="Pays (CO_Pays)") + service: Optional[str] = Field(None, description="Service (CO_Service)") + est_vendeur: Optional[bool] = Field(None, description="Est vendeur (CO_Vendeur)") + est_caissier: Optional[bool] = Field(None, description="Est caissier (CO_Caissier)") + est_acheteur: Optional[bool] = Field(None, description="Est acheteur (CO_Acheteur)") + telephone: Optional[str] = Field(None, description="Téléphone (CO_Telephone)") + telecopie: Optional[str] = Field(None, description="Fax (CO_Telecopie)") + email: Optional[str] = Field(None, description="Email (CO_EMail)") + tel_portable: Optional[str] = Field(None, description="Portable (CO_TelPortable)") + matricule: Optional[str] = Field(None, description="Matricule (CO_Matricule)") + facebook: Optional[str] = Field(None, description="Facebook (CO_Facebook)") + linkedin: Optional[str] = Field(None, description="LinkedIn (CO_LinkedIn)") + skype: Optional[str] = Field(None, description="Skype (CO_Skype)") + est_actif: Optional[bool] = Field(None, description="Est actif (CO_Sommeil=0)") + est_chef_ventes: Optional[bool] = Field( + None, description="Est chef des ventes (CO_ChefVentes)" + ) + chef_ventes_numero: Optional[int] = Field( + None, description="N° chef des ventes (CO_NoChefVentes)" + ) + + class Config: + json_schema_extra = { + "example": { + "numero": 1, + "nom": "DUPONT", + "prenom": "Jean", + "fonction": "Commercial", + "service": "Ventes", + "est_vendeur": True, + "telephone": "0123456789", + "email": "j.dupont@entreprise.fr", + "tel_portable": "0612345678", + "est_actif": True, + } + } diff --git a/schemas/tiers/type_tiers.py b/schemas/tiers/type_tiers.py new file mode 100644 index 0000000..809005f --- /dev/null +++ b/schemas/tiers/type_tiers.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class TypeTiers(str, Enum): + ALL = "all" + CLIENT = "client" + FOURNISSEUR = "fournisseur" + PROSPECT = "prospect" diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000..28150bb --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from typing import Optional + + +class Users(BaseModel): + 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 diff --git a/security/auth.py b/security/auth.py new file mode 100644 index 0000000..970a90f --- /dev/null +++ b/security/auth.py @@ -0,0 +1,92 @@ +from passlib.context import CryptContext +from datetime import datetime, timedelta +from typing import Optional, Dict +import jwt +import secrets +import hashlib + +SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 10080 +REFRESH_TOKEN_EXPIRE_DAYS = 7 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def generate_verification_token() -> str: + return secrets.token_urlsafe(32) + + +def generate_reset_token() -> str: + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: + 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: + 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]: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.JWTError: + return None + + +def validate_password_strength(password: str) -> tuple[bool, str]: + 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, "" diff --git a/services/email_service.py b/services/email_service.py new file mode 100644 index 0000000..2a6e9e3 --- /dev/null +++ b/services/email_service.py @@ -0,0 +1,202 @@ +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from config.config import settings +import logging + +logger = logging.getLogger(__name__) + + +class AuthEmailService: + @staticmethod + def _send_email(to: str, subject: str, html_body: str) -> bool: + 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: + 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: + reset_link = f"{base_url}/reset?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: + 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 + ) diff --git a/services/sage_gateway.py b/services/sage_gateway.py new file mode 100644 index 0000000..feccaaf --- /dev/null +++ b/services/sage_gateway.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import uuid +import json +import httpx +from datetime import datetime +from typing import Optional, Tuple, List +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import false, select, true, update, and_ +import logging + +from config.config import settings +from database import SageGatewayConfig + +logger = logging.getLogger(__name__) + + +class SageGatewayService: + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, user_id: str, data: dict) -> SageGatewayConfig: + """Créer une nouvelle configuration gateway""" + + if data.get("is_active"): + await self._deactivate_all_for_user(user_id) + + if data.get("is_default"): + await self._unset_default_for_user(user_id) + + extra_config = data.pop("extra_config", None) + allowed_ips = data.pop("allowed_ips", None) + + gateway = SageGatewayConfig( + id=str(uuid.uuid4()), + user_id=user_id, + created_by=user_id, + extra_config=json.dumps(extra_config) if extra_config else None, + allowed_ips=json.dumps(allowed_ips) if allowed_ips else None, + **data, + ) + + self.session.add(gateway) + await self.session.commit() + await self.session.refresh(gateway) + + logger.info(f"Gateway créée: {gateway.name} pour user {user_id}") + return gateway + + async def get_by_id( + self, gateway_id: str, user_id: str + ) -> Optional[SageGatewayConfig]: + result = await self.session.execute( + select(SageGatewayConfig).where( + and_( + SageGatewayConfig.id == gateway_id, + SageGatewayConfig.user_id == user_id, + SageGatewayConfig.is_deleted == false(), + ) + ) + ) + return result.scalar_one_or_none() + + async def list_for_user( + self, user_id: str, include_deleted: bool = False + ) -> List[SageGatewayConfig]: + query = select(SageGatewayConfig).where(SageGatewayConfig.user_id == user_id) + + if not include_deleted: + query = query.where(SageGatewayConfig.is_deleted == false()) + + query = query.order_by( + SageGatewayConfig.is_active.desc(), + SageGatewayConfig.priority.desc(), + SageGatewayConfig.name, + ) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def update( + self, gateway_id: str, user_id: str, data: dict + ) -> Optional[SageGatewayConfig]: + """Mettre à jour une gateway""" + + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return None + + if data.get("is_default") and not gateway.is_default: + await self._unset_default_for_user(user_id) + + if "extra_config" in data: + data["extra_config"] = ( + json.dumps(data["extra_config"]) if data["extra_config"] else None + ) + if "allowed_ips" in data: + data["allowed_ips"] = ( + json.dumps(data["allowed_ips"]) if data["allowed_ips"] else None + ) + + for key, value in data.items(): + if value is not None and hasattr(gateway, key): + setattr(gateway, key, value) + + gateway.updated_at = datetime.now() + await self.session.commit() + await self.session.refresh(gateway) + + logger.info(f"Gateway mise à jour: {gateway.name}") + return gateway + + async def delete( + self, gateway_id: str, user_id: str, hard_delete: bool = False + ) -> bool: + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return False + + if hard_delete: + await self.session.delete(gateway) + else: + gateway.is_deleted = True + gateway.deleted_at = datetime.now() + gateway.is_active = False + + await self.session.commit() + logger.info(f"Gateway supprimée: {gateway.name} (hard={hard_delete})") + return True + + async def activate( + self, gateway_id: str, user_id: str + ) -> Optional[SageGatewayConfig]: + """Activer une gateway (désactive les autres)""" + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return None + + await self._deactivate_all_for_user(user_id) + + gateway.is_active = True + gateway.updated_at = datetime.now() + await self.session.commit() + await self.session.refresh(gateway) + + logger.info(f"Gateway activée: {gateway.name} pour user {user_id}") + return gateway + + async def deactivate( + self, gateway_id: str, user_id: str + ) -> Optional[SageGatewayConfig]: + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return None + + gateway.is_active = False + gateway.updated_at = datetime.now() + await self.session.commit() + await self.session.refresh(gateway) + + logger.info(f"Gateway désactivée: {gateway.name} - fallback .env actif") + return gateway + + async def get_active_gateway(self, user_id: str) -> Optional[SageGatewayConfig]: + result = await self.session.execute( + select(SageGatewayConfig).where( + and_( + SageGatewayConfig.user_id == user_id, + SageGatewayConfig.is_active, + SageGatewayConfig.is_deleted == false(), + ) + ) + ) + return result.scalar_one_or_none() + + async def get_effective_gateway_config( + self, user_id: Optional[str] + ) -> Tuple[str, str, Optional[str]]: + if user_id: + active = await self.get_active_gateway(user_id) + if active: + active.total_requests += 1 + active.last_used_at = datetime.now() + await self.session.commit() + + return (active.gateway_url, active.gateway_token, active.id) + + return (settings.sage_gateway_url, settings.sage_gateway_token, None) + + async def health_check(self, gateway_id: str, user_id: str) -> dict: + import time + + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return {"error": "Gateway introuvable"} + + start_time = time.time() + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + f"{gateway.gateway_url}/health", + headers={"Authorization": f"Bearer {gateway.gateway_token}"}, + ) + + response_time = (time.time() - start_time) * 1000 + + if response.status_code == 200: + data = response.json() + gateway.last_health_check = datetime.now() + gateway.last_health_status = True + gateway.last_error = None + await self.session.commit() + + return { + "status": "healthy", + "response_time_ms": round(response_time, 2), + "sage_version": data.get("sage_version"), + "details": data, + } + else: + raise Exception(f"HTTP {response.status_code}") + + except Exception as e: + gateway.last_health_check = datetime.now() + gateway.last_health_status = False + gateway.last_error = str(e) + await self.session.commit() + + return { + "status": "unhealthy", + "error": str(e), + "response_time_ms": round((time.time() - start_time) * 1000, 2), + } + + async def test_gateway(self, url: str, token: str) -> dict: + """Tester une configuration gateway avant création""" + import time + + start_time = time.time() + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + f"{url}/health", headers={"Authorization": f"Bearer {token}"} + ) + + response_time = (time.time() - start_time) * 1000 + + if response.status_code == 200: + return { + "success": True, + "status": "healthy", + "response_time_ms": round(response_time, 2), + "details": response.json(), + } + else: + return { + "success": False, + "status": "unhealthy", + "error": f"HTTP {response.status_code}: {response.text}", + } + + except httpx.TimeoutException: + return { + "success": False, + "status": "timeout", + "error": "Connexion timeout (10s)", + } + except httpx.ConnectError as e: + return { + "success": False, + "status": "unreachable", + "error": f"Impossible de se connecter: {e}", + } + except Exception as e: + return {"success": False, "status": "error", "error": str(e)} + + async def record_request(self, gateway_id: str, success: bool) -> None: + """Enregistrer une requête (succès/échec)""" + + if not gateway_id: + return + + result = await self.session.execute( + select(SageGatewayConfig).where(SageGatewayConfig.id == gateway_id) + ) + gateway = result.scalar_one_or_none() + + if gateway: + gateway.total_requests += 1 + if success: + gateway.successful_requests += 1 + else: + gateway.failed_requests += 1 + gateway.last_used_at = datetime.now() + await self.session.commit() + + async def get_stats(self, user_id: str) -> dict: + """Statistiques d'utilisation pour un utilisateur""" + gateways = await self.list_for_user(user_id) + + total_requests = sum(g.total_requests for g in gateways) + successful = sum(g.successful_requests for g in gateways) + failed = sum(g.failed_requests for g in gateways) + + most_used = max(gateways, key=lambda g: g.total_requests) if gateways else None + last_activity = max( + (g.last_used_at for g in gateways if g.last_used_at), default=None + ) + + return { + "total_gateways": len(gateways), + "active_gateways": sum(1 for g in gateways if g.is_active), + "total_requests": total_requests, + "successful_requests": successful, + "failed_requests": failed, + "average_success_rate": (successful / total_requests * 100) + if total_requests > 0 + else 0, + "most_used_gateway": most_used.name if most_used else None, + "last_activity": last_activity, + } + + async def _deactivate_all_for_user(self, user_id: str) -> None: + """Désactiver toutes les gateways d'un utilisateur""" + + await self.session.execute( + update(SageGatewayConfig) + .where(SageGatewayConfig.user_id == user_id) + .values(is_active=False) + ) + + async def _unset_default_for_user(self, user_id: str) -> None: + """Retirer le flag default de toutes les gateways""" + + await self.session.execute( + update(SageGatewayConfig) + .where(SageGatewayConfig.user_id == user_id) + .values(is_default=False) + ) + + +def gateway_response_from_model(gateway: SageGatewayConfig) -> dict: + """Convertir un model en réponse API (masque le token)""" + + token_preview = ( + f"****{gateway.gateway_token[-4:]}" if gateway.gateway_token else "****" + ) + + success_rate = 0.0 + if gateway.total_requests > 0: + success_rate = (gateway.successful_requests / gateway.total_requests) * 100 + + if gateway.last_health_status is None: + health_status = "unknown" + elif gateway.last_health_status: + health_status = "healthy" + else: + health_status = "unhealthy" + + extra_config = None + if gateway.extra_config: + try: + extra_config = json.loads(gateway.extra_config) + except json.JSONDecodeError: + pass + + allowed_ips = None + if gateway.allowed_ips: + try: + allowed_ips = json.loads(gateway.allowed_ips) + except json.JSONDecodeError: + pass + + return { + "id": gateway.id, + "user_id": gateway.user_id, + "name": gateway.name, + "description": gateway.description, + "gateway_url": gateway.gateway_url, + "token_preview": token_preview, + "sage_database": gateway.sage_database, + "sage_company": gateway.sage_company, + "is_active": gateway.is_active, + "is_default": gateway.is_default, + "priority": gateway.priority, + "health_status": health_status, + "last_health_check": gateway.last_health_check, + "last_error": gateway.last_error, + "total_requests": gateway.total_requests, + "successful_requests": gateway.successful_requests, + "failed_requests": gateway.failed_requests, + "success_rate": round(success_rate, 2), + "last_used_at": gateway.last_used_at, + "extra_config": extra_config, + "allowed_ips": allowed_ips, + "created_at": gateway.created_at, + "updated_at": gateway.updated_at, + } diff --git a/services/universign_document.py b/services/universign_document.py new file mode 100644 index 0000000..98baf68 --- /dev/null +++ b/services/universign_document.py @@ -0,0 +1,156 @@ +import os +import logging +import requests +from pathlib import Path +from datetime import datetime +from typing import Optional, Tuple +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +SIGNED_DOCS_DIR = Path(os.getenv("SIGNED_DOCS_PATH", "/app/data/signed_documents")) +SIGNED_DOCS_DIR.mkdir(parents=True, exist_ok=True) + + +class UniversignDocumentService: + """Service de gestion des documents signés Universign""" + + def __init__(self, api_key: str, timeout: int = 60): + self.api_key = api_key + self.timeout = timeout + self.auth = (api_key, "") + + async def download_and_store_signed_document( + self, session: AsyncSession, transaction, force: bool = False + ) -> Tuple[bool, Optional[str]]: + if not force and transaction.signed_document_path: + if os.path.exists(transaction.signed_document_path): + logger.debug(f"Document déjà téléchargé : {transaction.transaction_id}") + return True, None + + if not transaction.document_url: + error = "Aucune URL de document disponible" + logger.warning(f"{error} pour {transaction.transaction_id}") + transaction.download_error = error + await session.commit() + return False, error + + try: + logger.info(f"Téléchargement document signé : {transaction.transaction_id}") + + transaction.download_attempts += 1 + + response = requests.get( + transaction.document_url, + auth=self.auth, + timeout=self.timeout, + stream=True, + ) + + response.raise_for_status() + + content_type = response.headers.get("Content-Type", "") + if "pdf" not in content_type.lower(): + error = f"Type de contenu invalide : {content_type}" + logger.error(error) + transaction.download_error = error + await session.commit() + return False, error + + filename = self._generate_filename(transaction) + file_path = SIGNED_DOCS_DIR / filename + + with open(file_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + file_size = os.path.getsize(file_path) + + if file_size < 1024: # Moins de 1 KB = suspect + error = f"Fichier trop petit : {file_size} octets" + logger.error(error) + os.remove(file_path) + transaction.download_error = error + await session.commit() + return False, error + + transaction.signed_document_path = str(file_path) + transaction.signed_document_downloaded_at = datetime.now() + transaction.signed_document_size_bytes = file_size + transaction.download_error = None + + await session.commit() + + logger.info(f"Document téléchargé : {filename} ({file_size / 1024:.1f} KB)") + + return True, None + + except requests.exceptions.RequestException as e: + error = f"Erreur HTTP : {str(e)}" + logger.error(f"{error} pour {transaction.transaction_id}") + transaction.download_error = error + await session.commit() + return False, error + + except OSError as e: + error = f"Erreur filesystem : {str(e)}" + logger.error(f"{error}") + transaction.download_error = error + await session.commit() + return False, error + + except Exception as e: + error = f"Erreur inattendue : {str(e)}" + logger.error(f"{error}", exc_info=True) + transaction.download_error = error + await session.commit() + return False, error + + def _generate_filename(self, transaction) -> str: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + tx_id = transaction.transaction_id.replace("tr_", "") + + filename = f"{transaction.sage_document_id}_{tx_id}_{timestamp}.pdf" + + return filename + + def get_document_path(self, transaction) -> Optional[Path]: + if not transaction.signed_document_path: + return None + + path = Path(transaction.signed_document_path) + if path.exists(): + return path + + return None + + async def cleanup_old_documents(self, days_to_keep: int = 90) -> Tuple[int, int]: + from datetime import timedelta + + cutoff_date = datetime.now() - timedelta(days=days_to_keep) + + deleted = 0 + size_freed = 0 + + for file_path in SIGNED_DOCS_DIR.glob("*.pdf"): + try: + file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) + + if file_time < cutoff_date: + size_freed += os.path.getsize(file_path) + os.remove(file_path) + deleted += 1 + logger.info(f"🗑️ Supprimé : {file_path.name}") + + except Exception as e: + logger.error(f"Erreur suppression {file_path}: {e}") + + size_freed_mb = size_freed / (1024 * 1024) + + logger.info( + f"Nettoyage terminé : {deleted} fichiers supprimés " + f"({size_freed_mb:.2f} MB libérés)" + ) + + return deleted, int(size_freed_mb) diff --git a/services/universign_sync.py b/services/universign_sync.py new file mode 100644 index 0000000..bde966a --- /dev/null +++ b/services/universign_sync.py @@ -0,0 +1,695 @@ +import requests +import json +import logging +import uuid +from typing import Dict, Optional, Tuple +from datetime import datetime, timedelta +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_ +from sqlalchemy.orm import selectinload + +from database import ( + UniversignTransaction, + UniversignSigner, + UniversignSyncLog, + UniversignTransactionStatus, + LocalDocumentStatus, + UniversignSignerStatus, + EmailLog, + StatutEmail, +) +from data.data import templates_signature_email +from services.universign_document import UniversignDocumentService +from utils.universign_status_mapping import ( + map_universign_to_local, + is_transition_allowed, + get_status_actions, + is_final_status, + resolve_status_conflict, +) + +logger = logging.getLogger(__name__) + + +class UniversignSyncService: + def __init__(self, api_url: str, api_key: str, timeout: int = 30): + self.api_url = api_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.auth = (api_key, "") + self.sage_client = None + self.email_queue = None + self.settings = None + self.document_service = UniversignDocumentService(api_key=api_key, timeout=60) + + def configure(self, sage_client, email_queue, settings): + self.sage_client = sage_client + self.email_queue = email_queue + self.settings = settings + + def fetch_transaction_status(self, transaction_id: str) -> Optional[Dict]: + start_time = datetime.now() + + try: + response = requests.get( + f"{self.api_url}/transactions/{transaction_id}", + auth=self.auth, + timeout=self.timeout, + headers={"Accept": "application/json"}, + ) + + response_time_ms = int((datetime.now() - start_time).total_seconds() * 1000) + + if response.status_code == 200: + data = response.json() + logger.info( + f"Fetch OK: {transaction_id} status={data.get('state')} ({response_time_ms}ms)" + ) + return { + "transaction": data, + "http_status": 200, + "response_time_ms": response_time_ms, + "fetched_at": datetime.now(), + } + + elif response.status_code == 404: + logger.warning( + f"Transaction {transaction_id} introuvable sur Universign" + ) + return None + + else: + logger.error( + f"Erreur HTTP {response.status_code} pour {transaction_id}: {response.text}" + ) + return None + + except requests.exceptions.Timeout: + logger.error(f"Timeout récupération {transaction_id} (>{self.timeout}s)") + return None + + except Exception as e: + logger.error(f"Erreur fetch {transaction_id}: {e}", exc_info=True) + return None + + async def sync_all_pending( + self, session: AsyncSession, max_transactions: int = 50 + ) -> Dict[str, int]: + query = ( + select(UniversignTransaction) + .options(selectinload(UniversignTransaction.signers)) + .where( + and_( + UniversignTransaction.needs_sync, + or_( + ~UniversignTransaction.local_status.in_( + [ + LocalDocumentStatus.SIGNED, + LocalDocumentStatus.REJECTED, + LocalDocumentStatus.EXPIRED, + ] + ), + UniversignTransaction.last_synced_at + < (datetime.now() - timedelta(hours=1)), + UniversignTransaction.last_synced_at.is_(None), + ), + ) + ) + .order_by(UniversignTransaction.created_at.asc()) + .limit(max_transactions) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + stats = { + "total_found": len(transactions), + "success": 0, + "failed": 0, + "skipped": 0, + "status_changes": 0, + } + + for transaction in transactions: + try: + previous_status = transaction.local_status.value + + success, error = await self.sync_transaction( + session, transaction, force=False + ) + + if success: + stats["success"] += 1 + if transaction.local_status.value != previous_status: + stats["status_changes"] += 1 + else: + stats["failed"] += 1 + + except Exception as e: + logger.error( + f"Erreur sync {transaction.transaction_id}: {e}", exc_info=True + ) + stats["failed"] += 1 + + logger.info( + f"Polling terminé: {stats['success']}/{stats['total_found']} OK, {stats['status_changes']} changements détectés" + ) + + return stats + + # CORRECTION 1 : process_webhook dans universign_sync.py + async def process_webhook( + self, session: AsyncSession, payload: Dict, transaction_id: str = None + ) -> Tuple[bool, Optional[str]]: + """ + Traite un webhook Universign - CORRECTION : meilleure gestion des payloads + """ + try: + # Si transaction_id n'est pas fourni, essayer de l'extraire + if not transaction_id: + # Même logique que dans universign.py + if ( + payload.get("type", "").startswith("transaction.") + and "payload" in payload + ): + nested_object = payload.get("payload", {}).get("object", {}) + if nested_object.get("object") == "transaction": + transaction_id = nested_object.get("id") + elif payload.get("type", "").startswith("action."): + transaction_id = ( + payload.get("payload", {}) + .get("object", {}) + .get("transaction_id") + ) + elif payload.get("object") == "transaction": + transaction_id = payload.get("id") + + if not transaction_id: + return False, "Transaction ID manquant" + + event_type = payload.get("type", "webhook") + + logger.info( + f"📨 Traitement webhook: transaction={transaction_id}, event={event_type}" + ) + + # Récupérer la transaction locale + query = ( + select(UniversignTransaction) + .options(selectinload(UniversignTransaction.signers)) + .where(UniversignTransaction.transaction_id == transaction_id) + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + logger.warning(f"Transaction {transaction_id} inconnue localement") + return False, "Transaction inconnue" + + # Marquer comme webhook reçu + transaction.webhook_received = True + + # Stocker l'ancien statut pour comparaison + old_status = transaction.local_status.value + + # Force la synchronisation complète + success, error = await self.sync_transaction( + session, transaction, force=True + ) + + # Log du changement de statut + if success and transaction.local_status.value != old_status: + logger.info( + f"Webhook traité: {transaction_id} | " + f"{old_status} → {transaction.local_status.value}" + ) + + # Enregistrer le log du webhook + await self._log_sync_attempt( + session=session, + transaction=transaction, + sync_type=f"webhook:{event_type}", + success=success, + error_message=error, + previous_status=old_status, + new_status=transaction.local_status.value, + changes=json.dumps( + payload, default=str + ), # Ajout default=str pour éviter les erreurs JSON + ) + + await session.commit() + + return success, error + + except Exception as e: + logger.error(f"💥 Erreur traitement webhook: {e}", exc_info=True) + return False, str(e) + + # CORRECTION 2 : _sync_signers - Ne pas écraser les signers existants + async def _sync_signers( + self, + session: AsyncSession, + transaction: UniversignTransaction, + universign_data: Dict, + ): + signers_data = universign_data.get("participants", []) + if not signers_data: + signers_data = universign_data.get("signers", []) + + if not signers_data: + logger.debug("Aucun signataire dans les données Universign") + return + + existing_signers = {s.email: s for s in transaction.signers} + + for idx, signer_data in enumerate(signers_data): + email = signer_data.get("email", "") + if not email: + logger.warning(f"Signataire sans email à l'index {idx}, ignoré") + continue + + # PROTECTION : gérer les statuts inconnus + raw_status = signer_data.get("status") or signer_data.get( + "state", "waiting" + ) + try: + status = UniversignSignerStatus(raw_status) + except ValueError: + logger.warning( + f"Statut inconnu pour signer {email}: {raw_status}, utilisation de 'unknown'" + ) + status = UniversignSignerStatus.UNKNOWN + + if email in existing_signers: + signer = existing_signers[email] + signer.status = status + + viewed_at = self._parse_date(signer_data.get("viewed_at")) + if viewed_at and not signer.viewed_at: + signer.viewed_at = viewed_at + + signed_at = self._parse_date(signer_data.get("signed_at")) + if signed_at and not signer.signed_at: + signer.signed_at = signed_at + + refused_at = self._parse_date(signer_data.get("refused_at")) + if refused_at and not signer.refused_at: + signer.refused_at = refused_at + + if signer_data.get("name") and not signer.name: + signer.name = signer_data.get("name") + else: + # Nouveau signer avec gestion d'erreur intégrée + try: + signer = UniversignSigner( + id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", + transaction_id=transaction.id, + email=email, + name=signer_data.get("name"), + status=status, + order_index=idx, + viewed_at=self._parse_date(signer_data.get("viewed_at")), + signed_at=self._parse_date(signer_data.get("signed_at")), + refused_at=self._parse_date(signer_data.get("refused_at")), + ) + session.add(signer) + logger.info( + f"➕ Nouveau signataire ajouté: {email} (statut: {status.value})" + ) + except Exception as e: + logger.error(f"Erreur création signer {email}: {e}") + + # CORRECTION 3 : Amélioration du logging dans sync_transaction + async def sync_transaction( + self, + session: AsyncSession, + transaction: UniversignTransaction, + force: bool = False, + ) -> Tuple[bool, Optional[str]]: + """ + CORRECTION : Meilleur logging et gestion d'erreurs + """ + + # Si statut final et pas de force, skip + if is_final_status(transaction.local_status.value) and not force: + logger.debug( + f"⏭️ Skip {transaction.transaction_id}: statut final {transaction.local_status.value}" + ) + transaction.needs_sync = False + await session.commit() + return True, None + + # Récupération du statut distant + logger.info(f"🔄 Synchronisation: {transaction.transaction_id}") + + result = self.fetch_transaction_status(transaction.transaction_id) + + if not result: + error = "Échec récupération données Universign" + logger.error(f"{error}: {transaction.transaction_id}") + + # CORRECTION : Incrémenter les tentatives MÊME en cas d'échec + transaction.sync_attempts += 1 + transaction.sync_error = error + + await self._log_sync_attempt(session, transaction, "polling", False, error) + await session.commit() + return False, error + + try: + universign_data = result["transaction"] + universign_status_raw = universign_data.get("state", "draft") + + logger.info(f"📊 Statut Universign brut: {universign_status_raw}") + + # Convertir le statut + new_local_status = map_universign_to_local(universign_status_raw) + previous_local_status = transaction.local_status.value + + logger.info( + f"🔄 Mapping: {universign_status_raw} (Universign) → " + f"{new_local_status} (Local) | Actuel: {previous_local_status}" + ) + + # Vérifier la transition + if not is_transition_allowed(previous_local_status, new_local_status): + logger.warning( + f"Transition refusée: {previous_local_status} → {new_local_status}" + ) + new_local_status = resolve_status_conflict( + previous_local_status, new_local_status + ) + logger.info(f"Résolution conflit: statut résolu = {new_local_status}") + + status_changed = previous_local_status != new_local_status + + if status_changed: + logger.info( + f"🔔 CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" + ) + + # Mise à jour du statut Universign brut + try: + transaction.universign_status = UniversignTransactionStatus( + universign_status_raw + ) + except ValueError: + logger.warning(f"Statut Universign inconnu: {universign_status_raw}") + # Fallback intelligent + if new_local_status == "SIGNE": + transaction.universign_status = ( + UniversignTransactionStatus.COMPLETED + ) + elif new_local_status == "REFUSE": + transaction.universign_status = UniversignTransactionStatus.REFUSED + elif new_local_status == "EXPIRE": + transaction.universign_status = UniversignTransactionStatus.EXPIRED + else: + transaction.universign_status = UniversignTransactionStatus.STARTED + + # Mise à jour du statut local + transaction.local_status = LocalDocumentStatus(new_local_status) + transaction.universign_status_updated_at = datetime.now() + + # Mise à jour des dates + if new_local_status == "EN_COURS" and not transaction.sent_at: + transaction.sent_at = datetime.now() + logger.info("📅 Date d'envoi mise à jour") + + if new_local_status == "SIGNE" and not transaction.signed_at: + transaction.signed_at = datetime.now() + logger.info("Date de signature mise à jour") + + if new_local_status == "REFUSE" and not transaction.refused_at: + transaction.refused_at = datetime.now() + logger.info("Date de refus mise à jour") + + if new_local_status == "EXPIRE" and not transaction.expired_at: + transaction.expired_at = datetime.now() + logger.info("⏰ Date d'expiration mise à jour") + + # Mise à jour des URLs + if ( + universign_data.get("documents") + and len(universign_data["documents"]) > 0 + ): + first_doc = universign_data["documents"][0] + if first_doc.get("url"): + transaction.document_url = first_doc["url"] + + # NOUVEAU : Téléchargement automatique du document signé + if new_local_status == "SIGNE" and transaction.document_url: + if not transaction.signed_document_path: + logger.info("Déclenchement téléchargement document signé") + + ( + download_success, + download_error, + ) = await self.document_service.download_and_store_signed_document( + session=session, transaction=transaction, force=False + ) + + if download_success: + logger.info("Document signé téléchargé avec succès") + else: + logger.warning(f"Échec téléchargement : {download_error}") + + # Synchroniser les signataires + await self._sync_signers(session, transaction, universign_data) + + # Mise à jour des métadonnées de sync + transaction.last_synced_at = datetime.now() + transaction.sync_attempts += 1 + transaction.needs_sync = not is_final_status(new_local_status) + transaction.sync_error = None # Effacer l'erreur précédente + + # Log de la tentative + await self._log_sync_attempt( + session=session, + transaction=transaction, + sync_type="polling", + success=True, + error_message=None, + previous_status=previous_local_status, + new_status=new_local_status, + changes=json.dumps( + { + "status_changed": status_changed, + "universign_raw": universign_status_raw, + "response_time_ms": result.get("response_time_ms"), + }, + default=str, # Éviter les erreurs de sérialisation + ), + ) + + await session.commit() + + # Exécuter les actions post-changement + if status_changed: + logger.info(f"🎬 Exécution actions pour statut: {new_local_status}") + await self._execute_status_actions( + session, transaction, new_local_status + ) + + logger.info( + f"Sync terminée: {transaction.transaction_id} | " + f"{previous_local_status} → {new_local_status}" + ) + + return True, None + + except Exception as e: + error_msg = f"Erreur lors de la synchronisation: {str(e)}" + logger.error(f"{error_msg}", exc_info=True) + + transaction.sync_error = error_msg[:1000] # Tronquer si trop long + transaction.sync_attempts += 1 + + await self._log_sync_attempt( + session, transaction, "polling", False, error_msg + ) + await session.commit() + + return False, error_msg + + async def _log_sync_attempt( + self, + session: AsyncSession, + transaction: UniversignTransaction, + sync_type: str, + success: bool, + error_message: Optional[str] = None, + previous_status: Optional[str] = None, + new_status: Optional[str] = None, + changes: Optional[str] = None, + ): + log = UniversignSyncLog( + transaction_id=transaction.id, + sync_type=sync_type, + sync_timestamp=datetime.now(), + previous_status=previous_status, + new_status=new_status, + changes_detected=changes, + success=success, + error_message=error_message, + ) + session.add(log) + + async def _execute_status_actions( + self, session: AsyncSession, transaction: UniversignTransaction, new_status: str + ): + actions = get_status_actions(new_status) + if not actions: + return + + if actions.get("update_sage_status") and self.sage_client: + await self._update_sage_status(transaction, new_status) + elif actions.get("update_sage_status"): + logger.debug( + f"sage_client non configuré, skip MAJ Sage pour {transaction.sage_document_id}" + ) + + if actions.get("send_notification") and self.email_queue and self.settings: + await self._send_notification(session, transaction, new_status) + elif actions.get("send_notification"): + logger.debug( + f"email_queue/settings non configuré, skip notification pour {transaction.transaction_id}" + ) + + async def _update_sage_status( + self, transaction: UniversignTransaction, status: str + ): + if not self.sage_client: + logger.warning("sage_client non configuré pour mise à jour Sage") + return + + try: + type_doc = transaction.sage_document_type.value + doc_id = transaction.sage_document_id + + if status == "SIGNE": + self.sage_client.changer_statut_document( + document_type_code=type_doc, numero=doc_id, nouveau_statut=2 + ) + logger.info(f"Statut Sage mis à jour: {doc_id} → Accepté (2)") + + elif status == "EN_COURS": + self.sage_client.changer_statut_document( + document_type_code=type_doc, numero=doc_id, nouveau_statut=1 + ) + logger.info(f"Statut Sage mis à jour: {doc_id} → Confirmé (1)") + + except Exception as e: + logger.error( + f"Erreur mise à jour Sage pour {transaction.sage_document_id}: {e}" + ) + + async def _send_notification( + self, session: AsyncSession, transaction: UniversignTransaction, status: str + ): + if not self.email_queue or not self.settings: + logger.warning("email_queue ou settings non configuré") + return + + try: + if status == "SIGNE": + template = templates_signature_email["signature_confirmee"] + + type_labels = { + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", + } + + variables = { + "NOM_SIGNATAIRE": transaction.requester_name or "Client", + "TYPE_DOC": type_labels.get( + transaction.sage_document_type.value, "Document" + ), + "NUMERO": transaction.sage_document_id, + "DATE_SIGNATURE": transaction.signed_at.strftime("%d/%m/%Y à %H:%M") + if transaction.signed_at + else datetime.now().strftime("%d/%m/%Y à %H:%M"), + "TRANSACTION_ID": transaction.transaction_id, + "CONTACT_EMAIL": self.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)) + + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=transaction.requester_email, + sujet=sujet, + corps_html=corps, + document_ids=transaction.sage_document_id, + type_document=transaction.sage_document_type.value, + statut=StatutEmail.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + await session.flush() + + self.email_queue.enqueue(email_log.id) + + logger.info( + f"Email confirmation signature envoyé à {transaction.requester_email}" + ) + + except Exception as e: + logger.error( + f"Erreur envoi notification pour {transaction.transaction_id}: {e}" + ) + + @staticmethod + def _parse_date(date_str: Optional[str]) -> Optional[datetime]: + if not date_str: + return None + try: + return datetime.fromisoformat(date_str.replace("Z", "+00:00")) + except Exception: + return None + + +class UniversignSyncScheduler: + def __init__(self, sync_service: UniversignSyncService, interval_minutes: int = 5): + self.sync_service = sync_service + self.interval_minutes = interval_minutes + self.is_running = False + + async def start(self, session_factory): + import asyncio + + self.is_running = True + + logger.info( + f"Démarrage polling Universign (intervalle: {self.interval_minutes}min)" + ) + + while self.is_running: + try: + async with session_factory() as session: + stats = await self.sync_service.sync_all_pending(session) + + logger.info( + f"Polling: {stats['success']} transactions synchronisées, " + f"{stats['status_changes']} changements" + ) + + except Exception as e: + logger.error(f"Erreur polling: {e}", exc_info=True) + + await asyncio.sleep(self.interval_minutes * 60) + + def stop(self): + self.is_running = False + logger.info("Arrêt polling Universign") diff --git a/tools/cleaner.py b/tools/cleaner.py new file mode 100644 index 0000000..6da2e19 --- /dev/null +++ b/tools/cleaner.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +def supprimer_commentaires_ligne(fichier): + path = Path(fichier) + lignes = path.read_text(encoding="utf-8").splitlines() + lignes_sans_commentaires = [line for line in lignes if not line.lstrip().startswith("#")] + path.write_text("\n".join(lignes_sans_commentaires), encoding="utf-8") + + +if __name__ == "__main__": + base_dir = Path(__file__).resolve().parent.parent + fichier_api = base_dir / "data/data.py" + + supprimer_commentaires_ligne(fichier_api) diff --git a/tools/extract_pydantic_models.py b/tools/extract_pydantic_models.py new file mode 100644 index 0000000..595e15f --- /dev/null +++ b/tools/extract_pydantic_models.py @@ -0,0 +1,54 @@ +import ast +import os +import textwrap + +SOURCE_FILE = "main.py" +MODELS_DIR = "../models" + +os.makedirs(MODELS_DIR, exist_ok=True) + +with open(SOURCE_FILE, "r", encoding="utf-8") as f: + source_code = f.read() + +tree = ast.parse(source_code) + +pydantic_classes = [] +other_nodes = [] + +for node in tree.body: + if isinstance(node, ast.ClassDef): + if any( + isinstance(base, ast.Name) and base.id == "BaseModel" for base in node.bases + ): + pydantic_classes.append(node) + continue + other_nodes.append(node) + +# --- Extraction des classes --- +imports = """ +from pydantic import BaseModel, Field +from typing import Optional, List +""" + +for cls in pydantic_classes: + class_name = cls.name + file_name = f"{class_name.lower()}.py" + file_path = os.path.join(MODELS_DIR, file_name) + + class_code = ast.get_source_segment(source_code, cls) + class_code = textwrap.dedent(class_code) + + with open(file_path, "w", encoding="utf-8") as f: + f.write(imports.strip() + "\n\n") + f.write(class_code) + + print(f"✅ Modèle extrait : {class_name} → {file_path}") + +# --- Réécriture du fichier source sans les modèles --- +new_tree = ast.Module(body=other_nodes, type_ignores=[]) +new_source = ast.unparse(new_tree) + +with open(SOURCE_FILE, "w", encoding="utf-8") as f: + f.write(new_source) + +print("\n🎉 Extraction terminée") diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..562d73f --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,27 @@ +from .enums import ( + TypeArticle, + TypeCompta, + TypeRessource, + TypeTiers, + TypeEmplacement, + TypeFamille, + NomenclatureType, + SuiviStockType, + normalize_enum_to_string, + normalize_enum_to_int, + normalize_string_field, +) + +__all__ = [ + "TypeArticle", + "TypeCompta", + "TypeRessource", + "TypeTiers", + "TypeEmplacement", + "TypeFamille", + "NomenclatureType", + "SuiviStockType", + "normalize_enum_to_string", + "normalize_enum_to_int", + "normalize_string_field", +] diff --git a/utils/enums.py b/utils/enums.py new file mode 100644 index 0000000..646fd01 --- /dev/null +++ b/utils/enums.py @@ -0,0 +1,129 @@ +from enum import IntEnum +from typing import Optional + + +class SuiviStockType(IntEnum): + AUCUN = 0 + CMUP = 1 + FIFO_LIFO = 2 + SERIALISE = 3 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Aucun", 1: "CMUP", 2: "FIFO/LIFO", 3: "Sérialisé"} + return labels.get(value) if value is not None else None + + +class NomenclatureType(IntEnum): + NON = 0 + FABRICATION = 1 + COMMERCIALE = 2 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Non", 1: "Fabrication", 2: "Commerciale/Composé"} + return labels.get(value) if value is not None else None + + +class TypeArticle(IntEnum): + ARTICLE = 0 + PRESTATION = 1 + DIVERS = 2 + NOMENCLATURE = 3 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = { + 0: "Article", + 1: "Prestation de service", + 2: "Divers / Frais", + 3: "Nomenclature", + } + return labels.get(value) if value is not None else None + + +class TypeFamille(IntEnum): + DETAIL = 0 + TOTAL = 1 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Détail", 1: "Total"} + return labels.get(value) if value is not None else None + + +class TypeCompta(IntEnum): + VENTE = 0 + ACHAT = 1 + STOCK = 2 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Vente", 1: "Achat", 2: "Stock"} + return labels.get(value) if value is not None else None + + +class TypeRessource(IntEnum): + MAIN_OEUVRE = 0 + MACHINE = 1 + SOUS_TRAITANCE = 2 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Main d'œuvre", 1: "Machine", 2: "Sous-traitance"} + return labels.get(value) if value is not None else None + + +class TypeTiers(IntEnum): + CLIENT = 0 + FOURNISSEUR = 1 + SALARIE = 2 + AUTRE = 3 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Client", 1: "Fournisseur", 2: "Salarié", 3: "Autre"} + return labels.get(value) if value is not None else None + + +class TypeEmplacement(IntEnum): + NORMAL = 0 + QUARANTAINE = 1 + REBUT = 2 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Normal", 1: "Quarantaine", 2: "Rebut"} + return labels.get(value) if value is not None else None + + +def normalize_enum_to_string(value, default="0") -> Optional[str]: + if value is None: + return None + if value == 0: + return None + return str(value) + + +def normalize_enum_to_int(value, default=0) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (ValueError, TypeError): + return default + + +def normalize_string_field(value) -> Optional[str]: + if value is None: + return None + if isinstance(value, int): + if value == 0: + return None + return str(value) + if isinstance(value, str): + stripped = value.strip() + if stripped in ("", "0"): + return None + return stripped + return str(value) diff --git a/utils/generic_functions.py b/utils/generic_functions.py new file mode 100644 index 0000000..f09ee5f --- /dev/null +++ b/utils/generic_functions.py @@ -0,0 +1,468 @@ +from typing import Dict, List +from config.config import settings +import logging + +from datetime import datetime +import uuid +import requests + +from sqlalchemy.ext.asyncio import AsyncSession + +from data.data import templates_signature_email +from database import EmailLog, StatutEmail as StatutEmailEnum + +logger = logging.getLogger(__name__) + + +async def universign_envoyer( + doc_id: str, + pdf_bytes: bytes, + email: str, + nom: str, + doc_data: Dict, + session: AsyncSession, +) -> Dict: + from email_queue import email_queue + + try: + api_key = settings.universign_api_key + api_url = settings.universign_api_url + auth = (api_key, "") + + 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: + raise Exception("Le PDF généré est vide") + + logger.info(f"PDF valide : {len(pdf_bytes)} octets") + + logger.info("ÉTAPE 1/6 : Création transaction") + + response = requests.post( + f"{api_url}/transactions", + auth=auth, + json={ + "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", + "language": "fr", + }, + 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}") + + logger.info("ÉTAPE 2/6 : Upload 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=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") + logger.info(f"Fichier uploadé: {file_id}") + + logger.info("ÉTAPE 3/6 : Ajout document à transaction") + + response = requests.post( + f"{api_url}/transactions/{transaction_id}/documents", + auth=auth, + 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}") + + 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", + }, + 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}") + + logger.info(" É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}") + + logger.info("ÉTAPE 6/6 : Démarrage transaction") + + response = requests.post( + 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("Transaction démarrée") + + logger.info("Récupération URL de signature") + + signer_url = "" + + if final_data.get("actions"): + for action in final_data["actions"]: + if action.get("url"): + signer_url = action["url"] + break + + 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 + + if not signer_url: + logger.error(f"URL introuvable dans: {final_data}") + raise ValueError("URL de signature non retournée par Universign") + + logger.info("URL récupérée") + + logger.info(" Préparation email") + + template = templates_signature_email["demande_signature"] + + 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, + } + + 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)) + + 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() + + email_queue.enqueue(email_log.id) + + logger.info(f"Email mis en file pour {email}") + logger.info("🎉 Processus terminé avec succès") + + 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}", 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 + + try: + response = requests.get( + f"{settings.universign_api_url}/transactions/{transaction_id}", + auth=(settings.universign_api_key, ""), + timeout=10, + ) + + if response.status_code == 200: + data = response.json() + statut_map = { + "draft": "EN_ATTENTE", + "started": "EN_ATTENTE", + "completed": "SIGNE", + "refused": "REFUSE", + "expired": "EXPIRE", + "canceled": "REFUSE", + } + return { + "statut": statut_map.get(data.get("state"), "EN_ATTENTE"), + "date_signature": data.get("completed_at"), + } + else: + return {"statut": "ERREUR"} + + except Exception as e: + logger.error(f"Erreur statut Universign: {e}") + return {"statut": "ERREUR", "error": str(e)} + + +def normaliser_type_doc(type_doc: int) -> int: + TYPES_AUTORISES = {0, 10, 30, 50, 60} + + 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 + + +def _preparer_lignes_document(lignes: List) -> List[Dict]: + return [ + { + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "prix_unitaire_ht": ligne.prix_unitaire_ht, + "remise_pourcentage": ligne.remise_pourcentage or 0.0, + } + for ligne in lignes + ] + + +UNIVERSIGN_TO_LOCAL: Dict[str, str] = { + # États initiaux + "draft": "EN_ATTENTE", + "ready": "EN_ATTENTE", + # En cours + "started": "EN_COURS", + # États finaux (succès) + "completed": "SIGNE", + "closed": "SIGNE", + # États finaux (échec) + "refused": "REFUSE", + "expired": "EXPIRE", + "canceled": "REFUSE", + "failed": "ERREUR", +} + + +LOCAL_TO_SAGE_STATUS: Dict[str, int] = { + "EN_ATTENTE": 0, + "EN_COURS": 1, + "SIGNE": 2, + "REFUSE": 3, + "EXPIRE": 4, + "ERREUR": 5, +} + +STATUS_ACTIONS: Dict[str, Dict[str, any]] = { + """ + Actions automatiques à déclencher selon le statut + """ + "SIGNE": { + "update_sage_status": True, # Mettre à jour Sage + "trigger_workflow": True, # Déclencher transformation (devis→commande) + "send_notification": True, # Email de confirmation + "archive_document": True, # Archiver le PDF signé + "update_sage_field": "CB_DateSignature", # Champ libre Sage + }, + "REFUSE": { + "update_sage_status": True, + "trigger_workflow": False, + "send_notification": True, + "archive_document": False, + "alert_sales": True, # Alerter commercial + }, + "EXPIRE": { + "update_sage_status": True, + "trigger_workflow": False, + "send_notification": True, + "archive_document": False, + "schedule_reminder": True, # Programmer relance + }, + "ERREUR": { + "update_sage_status": False, + "trigger_workflow": False, + "send_notification": False, + "log_error": True, + "retry_sync": True, + }, +} + + +ALLOWED_TRANSITIONS: Dict[str, list] = { + """ + Transitions de statuts autorisées (validation) + """ + "EN_ATTENTE": ["EN_COURS", "ERREUR"], + "EN_COURS": ["SIGNE", "REFUSE", "EXPIRE", "ERREUR"], + "SIGNE": [], # État final, pas de retour + "REFUSE": [], # État final + "EXPIRE": [], # État final + "ERREUR": ["EN_ATTENTE", "EN_COURS"], # Retry possible +} + + +def map_universign_to_local(universign_status: str) -> str: + return UNIVERSIGN_TO_LOCAL.get( + universign_status.lower(), + "ERREUR", # Fallback si statut inconnu + ) + + +def get_sage_status_code(local_status: str) -> int: + return LOCAL_TO_SAGE_STATUS.get(local_status, 5) + + +def is_transition_allowed(from_status: str, to_status: str) -> bool: + if from_status == to_status: + return True # Même statut = OK (idempotence) + + allowed = ALLOWED_TRANSITIONS.get(from_status, []) + return to_status in allowed + + +def get_status_actions(local_status: str) -> Dict[str, any]: + return STATUS_ACTIONS.get(local_status, {}) + + +def is_final_status(local_status: str) -> bool: + return local_status in ["SIGNE", "REFUSE", "EXPIRE"] + + +STATUS_PRIORITY: Dict[str, int] = { + "ERREUR": 0, + "EN_ATTENTE": 1, + "EN_COURS": 2, + "EXPIRE": 3, + "REFUSE": 4, + "SIGNE": 5, +} + + +def resolve_status_conflict(status_a: str, status_b: str) -> str: + priority_a = STATUS_PRIORITY.get(status_a, 0) + priority_b = STATUS_PRIORITY.get(status_b, 0) + + return status_a if priority_a >= priority_b else status_b + + +STATUS_MESSAGES: Dict[str, Dict[str, str]] = { + "EN_ATTENTE": { + "fr": "Document en attente d'envoi", + "en": "Document pending", + "icon": "⏳", + "color": "gray", + }, + "EN_COURS": { + "fr": "En attente de signature", + "en": "Awaiting signature", + "icon": "✍️", + "color": "blue", + }, + "SIGNE": { + "fr": "Signé avec succès", + "en": "Successfully signed", + "icon": "✅", + "color": "green", + }, + "REFUSE": { + "fr": "Signature refusée", + "en": "Signature refused", + "icon": "❌", + "color": "red", + }, + "EXPIRE": { + "fr": "Délai de signature expiré", + "en": "Signature expired", + "icon": "⏰", + "color": "orange", + }, + "ERREUR": { + "fr": "Erreur technique", + "en": "Technical error", + "icon": "⚠️", + "color": "red", + }, +} + + +def get_status_message(local_status: str, lang: str = "fr") -> str: + """ + Obtient le message utilisateur pour un statut + + Args: + local_status: Statut local + lang: Langue (fr, en) + + Returns: + Message formaté + """ + status_info = STATUS_MESSAGES.get(local_status, {}) + icon = status_info.get("icon", "") + message = status_info.get(lang, local_status) + + return f"{icon} {message}" + + +__all__ = ["_preparer_lignes_document", "normaliser_type_doc"] diff --git a/utils/normalization.py b/utils/normalization.py new file mode 100644 index 0000000..a7750af --- /dev/null +++ b/utils/normalization.py @@ -0,0 +1,16 @@ +from typing import Optional, Union + + +def normaliser_type_tiers(type_tiers: Union[str, int, None]) -> Optional[str]: + if type_tiers is None: + return None + + mapping_int = {0: "client", 1: "fournisseur", 2: "prospect", 3: "all"} + + if isinstance(type_tiers, int): + return mapping_int.get(type_tiers, "all") + + if isinstance(type_tiers, str) and type_tiers.isdigit(): + return mapping_int.get(int(type_tiers), "all") + + return type_tiers.lower() if isinstance(type_tiers, str) else None diff --git a/utils/universign_status_mapping.py b/utils/universign_status_mapping.py new file mode 100644 index 0000000..90bb383 --- /dev/null +++ b/utils/universign_status_mapping.py @@ -0,0 +1,165 @@ +from typing import Dict, Any +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +UNIVERSIGN_TO_LOCAL: Dict[str, str] = { + "draft": "EN_ATTENTE", + "ready": "EN_ATTENTE", + "started": "EN_COURS", + "completed": "SIGNE", + "closed": "SIGNE", + "refused": "REFUSE", + "expired": "EXPIRE", + "canceled": "REFUSE", + "failed": "ERREUR", +} + +LOCAL_TO_SAGE_STATUS: Dict[str, int] = { + "EN_ATTENTE": 0, + "EN_COURS": 1, + "SIGNE": 2, + "REFUSE": 3, + "EXPIRE": 4, + "ERREUR": 5, +} + +STATUS_ACTIONS: Dict[str, Dict[str, Any]] = { + "SIGNE": { + "update_sage_status": True, + "trigger_workflow": True, + "send_notification": True, + "archive_document": True, + "update_sage_field": "CB_DateSignature", + }, + "REFUSE": { + "update_sage_status": True, + "trigger_workflow": False, + "send_notification": True, + "archive_document": False, + "alert_sales": True, + }, + "EXPIRE": { + "update_sage_status": True, + "trigger_workflow": False, + "send_notification": True, + "archive_document": False, + "schedule_reminder": True, + }, + "ERREUR": { + "update_sage_status": False, + "trigger_workflow": False, + "send_notification": False, + "log_error": True, + "retry_sync": True, + }, +} + +ALLOWED_TRANSITIONS: Dict[str, list] = { + "EN_ATTENTE": ["EN_COURS", "ERREUR"], + "EN_COURS": ["SIGNE", "REFUSE", "EXPIRE", "ERREUR"], + "SIGNE": [], + "REFUSE": [], + "EXPIRE": [], + "ERREUR": ["EN_ATTENTE", "EN_COURS"], +} + +STATUS_PRIORITY: Dict[str, int] = { + "ERREUR": 0, + "EN_ATTENTE": 1, + "EN_COURS": 2, + "EXPIRE": 3, + "REFUSE": 4, + "SIGNE": 5, +} + +STATUS_MESSAGES: Dict[str, Dict[str, str]] = { + "EN_ATTENTE": { + "fr": "Document en attente d'envoi", + "en": "Document pending", + "icon": "⏳", + "color": "gray", + }, + "EN_COURS": { + "fr": "En attente de signature", + "en": "Awaiting signature", + "icon": "✍️", + "color": "blue", + }, + "SIGNE": { + "fr": "Signé avec succès", + "en": "Successfully signed", + "icon": "✅", + "color": "green", + }, + "REFUSE": { + "fr": "Signature refusée", + "en": "Signature refused", + "icon": "❌", + "color": "red", + }, + "EXPIRE": { + "fr": "Délai de signature expiré", + "en": "Signature expired", + "icon": "⏰", + "color": "orange", + }, + "ERREUR": { + "fr": "Erreur technique", + "en": "Technical error", + "icon": "⚠️", + "color": "red", + }, +} + + +def map_universign_to_local(universign_status: str) -> str: + """Convertit un statut Universign en statut local avec fallback robuste.""" + normalized = universign_status.lower().strip() + mapped = UNIVERSIGN_TO_LOCAL.get(normalized) + + if not mapped: + logger.warning( + f"Statut Universign inconnu: '{universign_status}', mapping vers ERREUR" + ) + return "ERREUR" + + return mapped + + +def get_sage_status_code(local_status: str) -> int: + """Obtient le code numérique pour Sage.""" + return LOCAL_TO_SAGE_STATUS.get(local_status, 5) + + +def is_transition_allowed(from_status: str, to_status: str) -> bool: + """Vérifie si une transition de statut est valide.""" + if from_status == to_status: + return True + return to_status in ALLOWED_TRANSITIONS.get(from_status, []) + + +def get_status_actions(local_status: str) -> Dict[str, Any]: + """Obtient les actions à exécuter pour un statut.""" + return STATUS_ACTIONS.get(local_status, {}) + + +def is_final_status(local_status: str) -> bool: + """Détermine si le statut est final.""" + return local_status in ["SIGNE", "REFUSE", "EXPIRE"] + + +def resolve_status_conflict(status_a: str, status_b: str) -> str: + """Résout un conflit entre deux statuts (prend le plus prioritaire).""" + priority_a = STATUS_PRIORITY.get(status_a, 0) + priority_b = STATUS_PRIORITY.get(status_b, 0) + return status_a if priority_a >= priority_b else status_b + + +def get_status_message(local_status: str, lang: str = "fr") -> str: + """Obtient le message utilisateur pour un statut.""" + status_info = STATUS_MESSAGES.get(local_status, {}) + icon = status_info.get("icon", "") + message = status_info.get(lang, local_status) + return f"{icon} {message}"