MERGING branch develop INTO MAIN

This commit is contained in:
Fanilo-Nantenaina 2026-01-08 16:58:43 +03:00
parent 307105b8ad
commit e990cbdc08
68 changed files with 12602 additions and 1028 deletions

View file

@ -7,7 +7,7 @@ SAGE_GATEWAY_URL=http://192.168.1.50:8100
SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
# === Base de données === # === Base de données ===
DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db
# === SMTP === # === SMTP ===
SMTP_HOST=smtp.office365.com SMTP_HOST=smtp.office365.com

9
.trunk/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
*out
*logs
*actions
*notifications
*tools
plugins
user_trunk.yaml
user.yaml
tmp

32
.trunk/trunk.yaml Normal file
View file

@ -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

View file

@ -1,21 +1,78 @@
# Backend Dockerfile # ================================
FROM python:3.12-slim # Base
# ================================
FROM python:3.12-slim AS base
WORKDIR /app WORKDIR /app
# Copier et installer les dépendances # Installer dépendances système si nécessaire
COPY requirements.txt . RUN apt-get update && apt-get install -y --no-install-recommends \
RUN pip install --no-cache-dir --upgrade pip \ curl \
&& pip install --no-cache-dir -r requirements.txt && 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 . . COPY . .
# ✅ Créer dossier persistant pour SQLite avec bonnes permissions
RUN mkdir -p /app/data && chmod 777 /app/data
# Exposer le port
EXPOSE 8000 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"] # 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"]

3100
api.py

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,33 @@
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List from typing import List
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
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 (Windows) ===
sage_gateway_url: str sage_gateway_url: str
sage_gateway_token: str sage_gateway_token: str
frontend_url: str
# === Base de données === # === Base de données ===
database_url: str = "sqlite+aiosqlite:///./sage_dataven.db" database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db"
# === SMTP === # === SMTP ===
smtp_host: str smtp_host: str
@ -22,6 +35,7 @@ class Settings(BaseSettings):
smtp_user: str smtp_user: str
smtp_password: str smtp_password: str
smtp_from: str smtp_from: str
smtp_use_tls: bool = True
# === Universign === # === Universign ===
universign_api_key: str universign_api_key: str
@ -31,13 +45,14 @@ class Settings(BaseSettings):
api_host: str api_host: str
api_port: int api_port: int
api_reload: bool = False api_reload: bool = False
# === Email Queue === # === Email Queue ===
max_email_workers: int = 3 max_email_workers: int = 3
max_retry_attempts: int = 3 max_retry_attempts: int = 3
retry_delay_seconds: int = 60 retry_delay_seconds: int = 3
# === CORS === # === CORS ===
cors_origins: List[str] = ["*"] cors_origins: List[str] = ["*"]
settings = Settings()
settings = Settings()

94
core/dependencies.py Normal file
View file

@ -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

77
core/sage_context.py Normal file
View file

@ -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()

97
create_admin.py Normal file
View file

@ -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)

405
data/data.py Normal file
View file

@ -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": """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Arial, sans-serif; background-color: #f4f7fa;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f7fa; padding: 40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #000; margin: 0; font-size: 24px; font-weight: 600;">
Signature Électronique Requise
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px 30px;">
<p style="color: #2d3748; font-size: 16px; line-height: 1.6; margin: 0 0 20px;">
Bonjour <strong>{{NOM_SIGNATAIRE}}</strong>,
</p>
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 25px;">
Nous vous invitons à signer électroniquement le document suivant :
</p>
<!-- Document Info Box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f7fafc; border-left: 4px solid #667eea; border-radius: 4px; margin-bottom: 30px;">
<tr>
<td style="padding: 20px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color: #718096; font-size: 13px; padding: 5px 0;">Type de document</td>
<td style="color: #2d3748; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{TYPE_DOC}}</td>
</tr>
<tr>
<td style="color: #718096; font-size: 13px; padding: 5px 0;">Numéro</td>
<td style="color: #2d3748; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{NUMERO}}</td>
</tr>
<tr>
<td style="color: #718096; font-size: 13px; padding: 5px 0;">Date</td>
<td style="color: #2d3748; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{DATE}}</td>
</tr>
<tr>
<td style="color: #718096; font-size: 13px; padding: 5px 0;">Montant TTC</td>
<td style="color: #2d3748; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{MONTANT_TTC}} </td>
</tr>
</table>
</td>
</tr>
</table>
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 30px;">
Cliquez sur le bouton ci-dessous pour accéder au document et apposer votre signature électronique sécurisée :
</p>
<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 10px 0 30px;">
<a href="{{SIGNER_URL}}" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #000; text-decoration: none; padding: 16px 40px; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);">
Signer le document
</a>
</td>
</tr>
</table>
<!-- Info Box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #fffaf0; border: 1px solid #fbd38d; border-radius: 4px; margin-bottom: 20px;">
<tr>
<td style="padding: 15px;">
<p style="color: #744210; font-size: 13px; line-height: 1.5; margin: 0;">
<strong>Important :</strong> Ce lien de signature est valable pendant <strong>30 jours</strong>.
Nous vous recommandons de signer ce document dès que possible.
</p>
</td>
</tr>
</table>
<p style="color: #718096; font-size: 13px; line-height: 1.5; margin: 0;">
<strong>🔒 Signature électronique sécurisée</strong><br>
Votre signature est protégée par notre partenaire de confiance <strong>Universign</strong>,
certifié eIDAS et conforme au RGPD. Votre identité sera vérifiée et le document sera
horodaté de manière infalsifiable.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f7fafc; padding: 25px 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e2e8f0;">
<p style="color: #718096; font-size: 12px; line-height: 1.5; margin: 0 0 10px;">
Vous avez des questions ? Contactez-nous à <a href="mailto:{{CONTACT_EMAIL}}" style="color: #667eea; text-decoration: none;">{{CONTACT_EMAIL}}</a>
</p>
<p style="color: #a0aec0; font-size: 11px; line-height: 1.4; margin: 0;">
Cet email a été envoyé automatiquement par le système Sage 100c Dataven.<br>
Si vous avez reçu cet email par erreur, veuillez nous en informer.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
""",
"variables_disponibles": [
"NOM_SIGNATAIRE",
"TYPE_DOC",
"NUMERO",
"DATE",
"MONTANT_TTC",
"SIGNER_URL",
"CONTACT_EMAIL",
],
},
"signature_confirmee": {
"id": "signature_confirmee",
"nom": "Confirmation de Signature",
"sujet": "Document signé - {{TYPE_DOC}} {{NUMERO}}",
"corps_html": """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Arial, sans-serif; background-color: #f4f7fa;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f7fa; padding: 40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">
Document Signé avec Succès
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px 30px;">
<p style="color: #2d3748; font-size: 16px; line-height: 1.6; margin: 0 0 20px;">
Bonjour <strong>{{NOM_SIGNATAIRE}}</strong>,
</p>
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 25px;">
Nous confirmons la signature électronique du document suivant :
</p>
<!-- Success Box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f0fff4; border-left: 4px solid #48bb78; border-radius: 4px; margin-bottom: 30px;">
<tr>
<td style="padding: 20px;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td style="color: #2f855a; font-size: 13px; padding: 5px 0;">Document</td>
<td style="color: #22543d; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{TYPE_DOC}} {{NUMERO}}</td>
</tr>
<tr>
<td style="color: #2f855a; font-size: 13px; padding: 5px 0;">Signé le</td>
<td style="color: #22543d; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{DATE_SIGNATURE}}</td>
</tr>
<tr>
<td style="color: #2f855a; font-size: 13px; padding: 5px 0;">ID Transaction</td>
<td style="color: #22543d; font-size: 13px; font-family: monospace; text-align: right; padding: 5px 0;">{{TRANSACTION_ID}}</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 25px;">
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é.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ebf8ff; border: 1px solid #90cdf4; border-radius: 4px; margin-bottom: 20px;">
<tr>
<td style="padding: 15px;">
<p style="color: #2c5282; font-size: 13px; line-height: 1.5; margin: 0;">
<strong>Signature certifiée :</strong> 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.
</p>
</td>
</tr>
</table>
<p style="color: #718096; font-size: 14px; line-height: 1.6; margin: 0;">
Merci pour votre confiance. Notre équipe reste à votre disposition pour toute question.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f7fafc; padding: 25px 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e2e8f0;">
<p style="color: #718096; font-size: 12px; line-height: 1.5; margin: 0 0 10px;">
Contact : <a href="mailto:{{CONTACT_EMAIL}}" style="color: #48bb78; text-decoration: none;">{{CONTACT_EMAIL}}</a>
</p>
<p style="color: #a0aec0; font-size: 11px; line-height: 1.4; margin: 0;">
Sage 100c Dataven - Système de signature électronique sécurisée
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
""",
"variables_disponibles": [
"NOM_SIGNATAIRE",
"TYPE_DOC",
"NUMERO",
"DATE_SIGNATURE",
"TRANSACTION_ID",
"CONTACT_EMAIL",
],
},
"relance_signature": {
"id": "relance_signature",
"nom": "Relance Signature en Attente",
"sujet": "⏰ Rappel - Signature en attente {{TYPE_DOC}} {{NUMERO}}",
"corps_html": """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Arial, sans-serif; background-color: #f4f7fa;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f7fa; padding: 40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%); padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">
Signature en Attente
</h1>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px 30px;">
<p style="color: #2d3748; font-size: 16px; line-height: 1.6; margin: 0 0 20px;">
Bonjour <strong>{{NOM_SIGNATAIRE}}</strong>,
</p>
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 25px;">
Nous vous avons envoyé il y a <strong>{{NB_JOURS}}</strong> jours un document à signer électroniquement.
Nous constatons que celui-ci n'a pas encore été signé.
</p>
<!-- Warning Box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #fffaf0; border-left: 4px solid #ed8936; border-radius: 4px; margin-bottom: 30px;">
<tr>
<td style="padding: 20px;">
<p style="color: #744210; font-size: 14px; line-height: 1.5; margin: 0 0 10px;">
<strong>Document en attente :</strong> {{TYPE_DOC}} {{NUMERO}}
</p>
<p style="color: #744210; font-size: 13px; line-height: 1.5; margin: 0;">
Le lien de signature expirera dans <strong>{{JOURS_RESTANTS}}</strong> jours
</p>
</td>
</tr>
</table>
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 30px;">
Pour éviter tout retard dans le traitement de votre dossier, nous vous invitons à signer ce document dès maintenant :
</p>
<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 10px 0 30px;">
<a href="{{SIGNER_URL}}" style="display: inline-block; background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 12px rgba(237, 137, 54, 0.4);">
Signer maintenant
</a>
</td>
</tr>
</table>
<p style="color: #718096; font-size: 13px; line-height: 1.5; margin: 0;">
Si vous rencontrez des difficultés ou avez des questions, n'hésitez pas à nous contacter.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f7fafc; padding: 25px 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e2e8f0;">
<p style="color: #718096; font-size: 12px; line-height: 1.5; margin: 0 0 10px;">
Contact : <a href="mailto:{{CONTACT_EMAIL}}" style="color: #ed8936; text-decoration: none;">{{CONTACT_EMAIL}}</a>
</p>
<p style="color: #a0aec0; font-size: 11px; line-height: 1.4; margin: 0;">
Sage 100c Dataven - Relance automatique
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
""",
"variables_disponibles": [
"NOM_SIGNATAIRE",
"TYPE_DOC",
"NUMERO",
"NB_JOURS",
"JOURS_RESTANTS",
"SIGNER_URL",
"CONTACT_EMAIL",
],
},
}

BIN
data/sage_dataven.db Normal file

Binary file not shown.

View file

@ -3,37 +3,56 @@ from database.db_config import (
async_session_factory, async_session_factory,
init_db, init_db,
get_session, get_session,
close_db close_db,
) )
from database.models.generic_model import (
from database.models import (
Base,
EmailLog,
SignatureLog,
WorkflowLog,
CacheMetadata, CacheMetadata,
AuditLog, 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, StatutEmail,
StatutSignature StatutSignature,
)
from database.models.workflow import WorkflowLog
from database.models.universign import (
UniversignTransaction,
UniversignSigner,
UniversignSyncLog,
UniversignTransactionStatus,
LocalDocumentStatus,
UniversignSignerStatus,
SageDocumentType
) )
__all__ = [ __all__ = [
# Config "engine",
'engine', "async_session_factory",
'async_session_factory', "init_db",
'init_db', "get_session",
'get_session', "close_db",
'close_db', "Base",
"EmailLog",
# Models "SignatureLog",
'Base', "WorkflowLog",
'EmailLog', "CacheMetadata",
'SignatureLog', "AuditLog",
'WorkflowLog', "StatutEmail",
'CacheMetadata', "StatutSignature",
'AuditLog', "User",
"RefreshToken",
# Enums "LoginAttempt",
'StatutEmail', "SageGatewayConfig",
'StatutSignature', "UniversignTransaction",
] "UniversignSigner",
"UniversignSyncLog",
"UniversignTransactionStatus",
"LocalDocumentStatus",
"UniversignSignerStatus",
"SageDocumentType"
]

View file

@ -1,19 +1,19 @@
import os import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import NullPool
from database.models import Base
import logging import logging
from database.models.generic_model import Base
logger = logging.getLogger(__name__) 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( engine = create_async_engine(
DATABASE_URL, DATABASE_URL,
echo=False, echo=False,
future=True, future=True,
connect_args={"check_same_thread": False}, poolclass=NullPool,
poolclass=StaticPool,
) )
async_session_factory = async_sessionmaker( async_session_factory = async_sessionmaker(
@ -25,32 +25,27 @@ async_session_factory = async_sessionmaker(
async def init_db(): async def init_db():
""" logger.info("Debut init_db")
Crée toutes les tables dans la base de données
Utilise create_all qui ne crée QUE les tables manquantes
"""
try: try:
logger.info("Tentative de connexion")
async with engine.begin() as conn: async with engine.begin() as conn:
logger.info("Connexion etablie")
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
logger.info("create_all execute")
logger.info("✅ Base de données initialisée avec succès")
logger.info(f"📍 Fichier DB: {DATABASE_URL}") logger.info("Base de données initialisée avec succès")
logger.info(f"Fichier DB: {DATABASE_URL}")
except Exception as e: except Exception as e:
logger.error(f"Erreur initialisation DB: {e}") logger.error(f"Erreur initialisation DB: {e}")
raise raise
async def get_session() -> AsyncSession: async def get_session() -> AsyncSession:
"""Dependency FastAPI pour obtenir une session DB"""
async with async_session_factory() as session: async with async_session_factory() as session:
try: yield session
yield session
finally:
await session.close()
async def close_db(): async def close_db():
"""Ferme proprement toutes les connexions"""
await engine.dispose() await engine.dispose()
logger.info("🔌 Connexions DB fermées") logger.info("Connexions DB fermées")

18
database/enum/status.py Normal file
View file

@ -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"

View file

@ -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"<EmailLog {self.id} to={self.destinataire} status={self.statut.value}>"
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"<SignatureLog {self.id} doc={self.document_id} status={self.statut.value}>"
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"<WorkflowLog {self.document_source}{self.document_cible}>"
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"<CacheMetadata type={self.cache_type} items={self.item_count}>"
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"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"

43
database/models/email.py Normal file
View file

@ -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"<EmailLog {self.id} to={self.destinataire} status={self.statut.value}>"

View file

@ -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"<CacheMetadata type={self.cache_type} items={self.item_count}>"
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"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"
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"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
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"<LoginAttempt {self.email} success={self.success}>"

View file

@ -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"<SageGatewayConfig {self.name} user={self.user_id} active={self.is_active}>"

View file

@ -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"<SignatureLog {self.id} doc={self.document_id} status={self.statut.value}>"

View file

@ -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"<UniversignTransaction {self.transaction_id} "
f"sage={self.sage_document_id} "
f"status={self.universign_status.value}>"
)
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"<UniversignSigner {self.email} status={self.status.value}>"
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"<SyncLog {self.sync_type} at {self.sync_timestamp}>"
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"<UniversignConfig {self.environment}>"

39
database/models/user.py Normal file
View file

@ -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"<User {self.email} verified={self.is_verified}>"

View file

@ -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"<WorkflowLog {self.document_source}{self.document_cible}>"

24
docker-compose.dev.yml Normal file
View file

@ -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

23
docker-compose.prod.yml Normal file
View file

@ -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

View file

@ -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

View file

@ -1,13 +1,4 @@
version: "3.9"
services: services:
vps-sage-api: backend:
build: . build:
container_name: vps-sage-api context: .
env_file: .env
volumes:
# ✅ Monter un DOSSIER entier au lieu d'un fichier
- ./data:/app/data
ports:
- "8000:8000"
restart: unless-stopped

View file

@ -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 threading
import queue import queue
import time
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional
from tenacity import retry, stop_after_attempt, wait_exponential
import smtplib import smtplib
import ssl
import socket
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
from config import settings from config.config import settings
import logging 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__) logger = logging.getLogger(__name__)
class EmailQueue: class EmailQueue:
"""
Queue d'emails avec workers threadés et retry automatique
"""
def __init__(self): def __init__(self):
self.queue = queue.Queue() self.queue = queue.Queue()
self.workers = [] self.workers = []
self.running = False self.running = False
self.session_factory = None self.session_factory = None
self.sage_client = None # Sera injecté depuis api.py self.sage_client = None
def start(self, num_workers: int = 3): def start(self, num_workers: int = 3):
"""Démarre les workers"""
if self.running: if self.running:
logger.warning("Queue déjà démarrée")
return return
self.running = True self.running = True
for i in range(num_workers): for i in range(num_workers):
worker = threading.Thread( worker = threading.Thread(
target=self._worker, target=self._worker, name=f"EmailWorker-{i}", daemon=True
name=f"EmailWorker-{i}",
daemon=True
) )
worker.start() worker.start()
self.workers.append(worker) 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): def stop(self):
"""Arrête les workers proprement"""
logger.info("🛑 Arrêt de la queue email...")
self.running = False self.running = False
# Attendre que la queue soit vide (max 30s)
try: try:
self.queue.join() self.queue.join()
logger.info("✅ Queue email arrêtée proprement") except Exception:
except: pass
logger.warning("⚠️ Timeout lors de l'arrêt de la queue")
def enqueue(self, email_log_id: str): def enqueue(self, email_log_id: str):
"""Ajoute un email dans la queue"""
self.queue.put(email_log_id) self.queue.put(email_log_id)
logger.debug(f"📨 Email {email_log_id} ajouté à la queue")
def _worker(self): def _worker(self):
"""Worker qui traite les emails dans un thread"""
# Créer une event loop pour ce thread
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
while self.running: while self.running:
try: try:
# Récupérer un email de la queue (timeout 1s)
email_log_id = self.queue.get(timeout=1) email_log_id = self.queue.get(timeout=1)
# Traiter l'email
loop.run_until_complete(self._process_email(email_log_id)) loop.run_until_complete(self._process_email(email_log_id))
# Marquer comme traité
self.queue.task_done() self.queue.task_done()
except queue.Empty: except queue.Empty:
continue continue
except Exception as e: except Exception as e:
logger.error(f"Erreur worker: {e}", exc_info=True) logger.error(f"Erreur worker: {e}")
try: try:
self.queue.task_done() self.queue.task_done()
except: except Exception:
pass pass
finally: finally:
loop.close() loop.close()
async def _process_email(self, email_log_id: str): async def _process_email(self, email_log_id: str):
"""Traite un email avec retry automatique"""
from database import EmailLog, StatutEmail from database import EmailLog, StatutEmail
from sqlalchemy import select from sqlalchemy import select
if not self.session_factory: if not self.session_factory:
logger.error("session_factory non configuré") logger.error("session_factory non configuré")
return return
async with self.session_factory() as session: async with self.session_factory() as session:
# Charger l'email log
result = await session.execute( result = await session.execute(
select(EmailLog).where(EmailLog.id == email_log_id) select(EmailLog).where(EmailLog.id == email_log_id)
) )
email_log = result.scalar_one_or_none() email_log = result.scalar_one_or_none()
if not email_log: if not email_log:
logger.error(f"Email log {email_log_id} introuvable") logger.error(f"Email log {email_log_id} introuvable")
return return
# Marquer comme en cours
email_log.statut = StatutEmail.EN_COURS email_log.statut = StatutEmail.EN_COURS
email_log.nb_tentatives += 1 email_log.nb_tentatives += 1
await session.commit() await session.commit()
try: try:
# Envoi avec retry automatique
await self._send_with_retry(email_log) await self._send_with_retry(email_log)
# Succès
email_log.statut = StatutEmail.ENVOYE email_log.statut = StatutEmail.ENVOYE
email_log.date_envoi = datetime.now() email_log.date_envoi = datetime.now()
email_log.derniere_erreur = None email_log.derniere_erreur = None
logger.info(f"✅ Email envoyé: {email_log.destinataire}")
except Exception as e: except Exception as e:
# Échec error_msg = str(e)
email_log.statut = StatutEmail.ERREUR email_log.statut = StatutEmail.ERREUR
email_log.derniere_erreur = str(e)[:1000] # Limiter la taille email_log.derniere_erreur = error_msg[:1000]
# Programmer un retry si < max attempts
if email_log.nb_tentatives < settings.max_retry_attempts: if email_log.nb_tentatives < settings.max_retry_attempts:
delay = settings.retry_delay_seconds * (2 ** (email_log.nb_tentatives - 1)) delay = settings.retry_delay_seconds * (
2 ** (email_log.nb_tentatives - 1)
)
email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay)
# Programmer le retry
timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer = threading.Timer(delay, self.enqueue, args=[email_log_id])
timer.daemon = True timer.daemon = True
timer.start() 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() 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): async def _send_with_retry(self, email_log):
"""Envoi SMTP avec retry Tenacity + génération PDF"""
# Préparer le message
msg = MIMEMultipart() msg = MIMEMultipart()
msg['From'] = settings.smtp_from msg["From"] = settings.smtp_from
msg['To'] = email_log.destinataire msg["To"] = email_log.destinataire
msg['Subject'] = email_log.sujet msg["Subject"] = email_log.sujet
msg.attach(MIMEText(email_log.corps_html, "html"))
# Corps HTML
msg.attach(MIMEText(email_log.corps_html, 'html')) # Attachement des PDFs
# 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs
if email_log.document_ids: 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 type_doc = email_log.type_document
for doc_id in document_ids: for doc_id in document_ids:
doc_id = doc_id.strip() doc_id = doc_id.strip()
if not doc_id: if not doc_id:
continue continue
try: try:
# Générer PDF (appel bloquant dans thread séparé)
pdf_bytes = await asyncio.to_thread( pdf_bytes = await asyncio.to_thread(
self._generate_pdf, self._generate_pdf, doc_id, type_doc
doc_id,
type_doc
) )
if pdf_bytes: if pdf_bytes:
# Attacher PDF
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.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) msg.attach(part)
logger.info(f"📎 PDF attaché: {doc_id}.pdf")
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") logger.error(f"Erreur génération PDF {doc_id}: {e}")
# Continuer avec les autres PDFs
# Envoi SMTP
# Envoi SMTP (bloquant mais dans thread séparé)
await asyncio.to_thread(self._send_smtp, msg) 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: 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: if not self.sage_client:
logger.error("❌ sage_client non configuré")
raise Exception("sage_client non disponible") raise Exception("sage_client non disponible")
# 📡 Récupérer document depuis gateway Windows via HTTP
try: try:
doc = self.sage_client.lire_document(doc_id, type_doc) doc = self.sage_client.lire_document(doc_id, type_doc)
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur récupération document {doc_id}: {e}") raise Exception(f"Document {doc_id} inaccessible : {e}")
raise Exception(f"Document {doc_id} inaccessible")
if not doc: if not doc:
raise Exception(f"Document {doc_id} introuvable") raise Exception(f"Document {doc_id} introuvable")
# 📄 Créer PDF avec ReportLab
buffer = BytesIO() buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4) pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = 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.setFont("Helvetica-Bold", 20)
pdf.drawString(2*cm, height - 3*cm, f"Document N° {doc_id}") numero = doc.get("numero") or "BROUILLON"
pdf.drawRightString(width - margin, y, numero.upper())
# Type de document
type_labels = { y -= 7 * mm
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
pdf.setFont("Helvetica", 9) pdf.setFont("Helvetica", 9)
pdf.setFillColor(gray_600)
for ligne in doc.get('lignes', []):
# Nouvelle page si nécessaire date_str = doc.get("date") or datetime.now().strftime("%d/%m/%Y")
if y < 3*cm: pdf.drawRightString(width - margin, y, f"Date : {date_str}")
pdf.showPage()
y = height - 3*cm y -= 5 * mm
pdf.setFont("Helvetica", 9) date_livraison = (
doc.get("date_livraison") or doc.get("date_echeance") or date_str
designation = ligne.get('designation', '')[:50] )
pdf.drawString(2*cm, y, designation) pdf.drawRightString(width - margin, y, f"Validité : {date_livraison}")
pdf.drawString(10*cm, y, str(ligne.get('quantite', 0)))
pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}") y -= 5 * mm
pdf.drawString(15*cm, y, f"{ligne.get('montant_ht', 0):.2f}") reference = doc.get("reference") or ""
y -= 0.6*cm pdf.drawRightString(width - margin, y, f"Réf : {reference}")
# === TOTAUX === # ===== ADDRESSES =====
y -= 1*cm y -= 20 * mm
pdf.line(12*cm, y, width - 2*cm, y)
# Émetteur (gauche)
y -= 0.8*cm col1_x = margin
pdf.setFont("Helvetica-Bold", 11) col2_x = margin + content_width / 2 + 6 * mm
pdf.drawString(12*cm, y, "Total HT:") col_width = content_width / 2 - 6 * mm
pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}")
pdf.setFont("Helvetica-Bold", 8)
y -= 0.6*cm pdf.setFillColor(gray_400)
pdf.drawString(12*cm, y, "TVA (20%):") pdf.drawString(col1_x, y, "ÉMETTEUR")
tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0)
pdf.drawString(15*cm, y, f"{tva:.2f}") y_emetteur = y - 5 * mm
pdf.setFont("Helvetica-Bold", 10)
y -= 0.6*cm pdf.setFillColor(gray_800)
pdf.setFont("Helvetica-Bold", 14) pdf.drawString(col1_x, y_emetteur, "Bijou S.A.S")
pdf.drawString(12*cm, y, "Total TTC:")
pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}") y_emetteur -= 5 * mm
pdf.setFont("Helvetica", 9)
# === PIED DE PAGE === 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.setFont("Helvetica", 8)
pdf.drawString(2*cm, 2*cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}") lignes = doc.get("lignes", [])
pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven")
for ligne in lignes:
# Finaliser 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() pdf.save()
buffer.seek(0) buffer.seek(0)
logger.info(f"✅ PDF généré: {doc_id}.pdf")
return buffer.read() 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()
email_queue = EmailQueue()

View file

@ -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 asyncio
import sys import sys
from pathlib import Path from pathlib import Path
# Ajouter le répertoire parent au path pour les imports
sys.path.insert(0, str(Path(__file__).parent)) 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 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__) logger = logging.getLogger(__name__)
async def main(): 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: try:
# Créer les tables logger.info("Debut de l'initialisation")
await init_db() await init_db()
logger.info("Initialisation terminee")
print("\n✅ Base de données créée avec succès!") print("\nInitialisation terminee")
print(f"📍 Fichier: sage_dataven.db")
print("\nBase de données créée avec succès !")
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")
return True return True
except Exception as e: except Exception as e:
print(f"\nErreur lors de l'initialisation: {e}") print(f"\nErreur lors de l'initialisation: {e}")
logger.exception("Détails de l'erreur:") logger.exception("Détails de l'erreur:")
return False return False
if __name__ == "__main__": if __name__ == "__main__":
result = asyncio.run(main()) result = asyncio.run(main())
sys.exit(0 if result else 1) sys.exit(0 if result else 1)

View file

@ -5,10 +5,17 @@ pydantic-settings
reportlab reportlab
requests requests
msal msal
python-multipart python-multipart
email-validator email-validator
python-dotenv python-dotenv
python-jose[cryptography]
passlib[bcrypt]
bcrypt==4.2.0
sqlalchemy sqlalchemy
aiosqlite aiosqlite
tenacity tenacity
httpx

529
routes/auth.py Normal file
View file

@ -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,
}

323
routes/sage_gateway.py Normal file
View file

@ -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",
}

1615
routes/universign.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,99 +1,444 @@
# sage_client.py
import requests import requests
from typing import Dict, List, Optional from typing import Dict, List, Optional
from config import settings from config.config import settings
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SageGatewayClient: class SageGatewayClient:
""" def __init__(
Client HTTP pour communiquer avec la gateway Sage Windows self,
""" gateway_url: Optional[str] = None,
gateway_token: Optional[str] = None,
def __init__(self): gateway_id: Optional[str] = None,
self.url = settings.sage_gateway_url.rstrip("/") ):
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 = { self.headers = {
"X-Sage-Token": settings.sage_gateway_token, "X-Sage-Token": self.token,
"Content-Type": "application/json" "Content-Type": "application/json",
} }
self.timeout = 30 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: def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict:
"""POST avec retry automatique"""
import time import time
for attempt in range(retries): for attempt in range(retries):
try: try:
r = requests.post( r = requests.post(
f"{self.url}{endpoint}", f"{self.url}{endpoint}",
json=data or {}, json=data or {},
headers=self.headers, headers=self.headers,
timeout=self.timeout timeout=self.timeout,
) )
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
if attempt == retries - 1: 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 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]: def lister_clients(self, filtre: str = "") -> List[Dict]:
return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
def lire_client(self, code: str) -> Optional[Dict]: def lire_client(self, code: str) -> Optional[Dict]:
return self._post("/sage/clients/get", {"code": code}).get("data") 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]: def lister_articles(self, filtre: str = "") -> List[Dict]:
return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
def lire_article(self, ref: str) -> Optional[Dict]: def lire_article(self, ref: str) -> Optional[Dict]:
return self._post("/sage/articles/get", {"code": ref}).get("data") 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: def creer_devis(self, devis_data: Dict) -> Dict:
return self._post("/sage/devis/create", devis_data).get("data", {}) return self._post("/sage/devis/create", devis_data).get("data", {})
def lire_devis(self, numero: str) -> Optional[Dict]: def lire_devis(self, numero: str) -> Optional[Dict]:
return self._post("/sage/devis/get", {"code": numero}).get("data") 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]: 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: def changer_statut_document(
return self._post("/sage/documents/transform", { self, document_type_code: int, numero: str, nouveau_statut: int
"numero_source": numero_source, ) -> Dict:
"type_source": type_source, try:
"type_cible": type_cible r = requests.post(
}).get("data", {}) 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: def transformer_document(
resp = self._post("/sage/documents/champ-libre", { self, numero_source: str, type_source: int, type_cible: int
"doc_id": doc_id, ) -> Dict:
"type_doc": type_doc, try:
"nom_champ": nom_champ, r = requests.post(
"valeur": valeur 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) 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]: def lire_contact_client(self, code_client: str) -> Optional[Dict]:
return self._post("/sage/contact/read", {"code": code_client}).get("data") 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: def refresh_cache(self) -> Dict:
return self._post("/sage/cache/refresh") 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: def health(self) -> dict:
try: try:
r = requests.get(f"{self.url}/health", timeout=5) r = requests.get(f"{self.url}/health", timeout=5)
return r.json() return r.json()
except: except Exception:
return {"status": "down"} return {"status": "down"}
# Instance globale
sage_client = SageGatewayClient() sage_client = SageGatewayClient()

108
schemas/__init__.py Normal file
View file

@ -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",
]

View file

@ -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")

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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,
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
}
}

View file

@ -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

View file

@ -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,
}
}

View file

@ -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

View file

@ -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

9
schemas/schema_mixte.py Normal file
View file

@ -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

View file

576
schemas/tiers/clients.py Normal file
View file

@ -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",
}
}

116
schemas/tiers/commercial.py Normal file
View file

@ -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,
}
}

111
schemas/tiers/contact.py Normal file
View file

@ -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

View file

@ -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",
}
}

217
schemas/tiers/tiers.py Normal file
View file

@ -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"
)

View file

@ -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,
}
}

View file

@ -0,0 +1,8 @@
from enum import Enum
class TypeTiers(str, Enum):
ALL = "all"
CLIENT = "client"
FOURNISSEUR = "fournisseur"
PROSPECT = "prospect"

18
schemas/user.py Normal file
View file

@ -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

92
security/auth.py Normal file
View file

@ -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, ""

202
services/email_service.py Normal file
View file

@ -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"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #4F46E5; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{
display: inline-block;
background: #4F46E5;
color: white;
padding: 12px 30px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}}
.footer {{ text-align: center; margin-top: 20px; font-size: 12px; color: #6b7280; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 Bienvenue sur Sage Dataven</h1>
</div>
<div class="content">
<h2>Vérifiez votre adresse email</h2>
<p>Merci de vous être inscrit ! Pour activer votre compte, veuillez cliquer sur le bouton ci-dessous :</p>
<div style="text-align: center;">
<a href="{verification_link}" class="button">Vérifier mon email</a>
</div>
<p style="margin-top: 30px;">Ou copiez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; background: #e5e7eb; padding: 10px; border-radius: 4px;">
{verification_link}
</p>
<p style="margin-top: 30px; color: #ef4444;">
Ce lien expire dans <strong>24 heures</strong>
</p>
<p style="margin-top: 30px; font-size: 14px; color: #6b7280;">
Si vous n'avez pas créé de compte, ignorez cet email.
</p>
</div>
<div class="footer">
<p>© 2024 Sage Dataven - API de gestion commerciale</p>
</div>
</div>
</body>
</html>
"""
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"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #EF4444; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{
display: inline-block;
background: #EF4444;
color: white;
padding: 12px 30px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}}
.footer {{ text-align: center; margin-top: 20px; font-size: 12px; color: #6b7280; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1> Réinitialisation de mot de passe</h1>
</div>
<div class="content">
<h2>Demande de réinitialisation</h2>
<p>Vous avez demandé à réinitialiser votre mot de passe. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :</p>
<div style="text-align: center;">
<a href="{reset_link}" class="button">Réinitialiser mon mot de passe</a>
</div>
<p style="margin-top: 30px;">Ou copiez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; background: #e5e7eb; padding: 10px; border-radius: 4px;">
{reset_link}
</p>
<p style="margin-top: 30px; color: #ef4444;">
Ce lien expire dans <strong>1 heure</strong>
</p>
<p style="margin-top: 30px; font-size: 14px; color: #6b7280;">
Si vous n'avez pas demandé cette réinitialisation, ignorez cet email. Votre mot de passe actuel reste inchangé.
</p>
</div>
<div class="footer">
<p>© 2024 Sage Dataven - API de gestion commerciale</p>
</div>
</div>
</body>
</html>
"""
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 = """
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #10B981; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #6b7280; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1> Mot de passe modifié</h1>
</div>
<div class="content">
<h2>Votre mot de passe a été changé avec succès</h2>
<p>Ce message confirme que le mot de passe de votre compte Sage Dataven a été modifié.</p>
<p style="margin-top: 30px; padding: 15px; background: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 4px;">
Si vous n'êtes pas à l'origine de ce changement, contactez immédiatement notre support.
</p>
</div>
<div class="footer">
<p>© 2024 Sage Dataven - API de gestion commerciale</p>
</div>
</div>
</body>
</html>
"""
return AuthEmailService._send_email(
email, " Votre mot de passe a été modifié - Sage Dataven", html_body
)

400
services/sage_gateway.py Normal file
View file

@ -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,
}

View file

@ -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)

695
services/universign_sync.py Normal file
View file

@ -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")

15
tools/cleaner.py Normal file
View file

@ -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)

View file

@ -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")

27
utils/__init__.py Normal file
View file

@ -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",
]

129
utils/enums.py Normal file
View file

@ -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)

468
utils/generic_functions.py Normal file
View file

@ -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"]

16
utils/normalization.py Normal file
View file

@ -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

View file

@ -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}"