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 :
+
+
+
+
+
+
+
+
+ |
+
+ ⏰ 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 :
+
+
+
+
+
+
+ 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"""
+
+
+
+
+
+
+
+
+
+
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"""
+
+
+
+
+
+
+
+
+
+
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 = """
+
+
+
+
+
+
+
+
+
+
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}"