MERGING branch develop INTO MAIN
This commit is contained in:
parent
307105b8ad
commit
e990cbdc08
68 changed files with 12602 additions and 1028 deletions
|
|
@ -7,7 +7,7 @@ SAGE_GATEWAY_URL=http://192.168.1.50:8100
|
|||
SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
|
||||
|
||||
# === Base de données ===
|
||||
DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db
|
||||
|
||||
# === SMTP ===
|
||||
SMTP_HOST=smtp.office365.com
|
||||
|
|
|
|||
9
.trunk/.gitignore
vendored
Normal file
9
.trunk/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
*out
|
||||
*logs
|
||||
*actions
|
||||
*notifications
|
||||
*tools
|
||||
plugins
|
||||
user_trunk.yaml
|
||||
user.yaml
|
||||
tmp
|
||||
32
.trunk/trunk.yaml
Normal file
32
.trunk/trunk.yaml
Normal 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
|
||||
85
Dockerfile
85
Dockerfile
|
|
@ -1,21 +1,78 @@
|
|||
# Backend Dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
# ================================
|
||||
# Base
|
||||
# ================================
|
||||
FROM python:3.12-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Copier et installer les dépendances
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
# Installer dépendances système si nécessaire
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
# ================================
|
||||
# DEV
|
||||
# ================================
|
||||
FROM base AS dev
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
ENV=development
|
||||
|
||||
# Installer dépendances dev (si vous avez un requirements.dev.txt)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Créer dossiers
|
||||
RUN mkdir -p /app/data /app/logs && chmod -R 777 /app/data /app/logs
|
||||
|
||||
# Copier le reste du projet
|
||||
COPY . .
|
||||
|
||||
# ✅ Créer dossier persistant pour SQLite avec bonnes permissions
|
||||
RUN mkdir -p /app/data && chmod 777 /app/data
|
||||
|
||||
# Exposer le port
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
# Lancer l'API et initialiser la DB au démarrage
|
||||
CMD ["sh", "-c", "python init_db.py && uvicorn api:app --host 0.0.0.0 --port 8000 --reload"]
|
||||
# ================================
|
||||
# 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"]
|
||||
|
|
@ -1,20 +1,33 @@
|
|||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import List
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore"
|
||||
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
||||
# === JWT & Auth ===
|
||||
jwt_secret: str
|
||||
jwt_algorithm: str
|
||||
access_token_expire_minutes: int
|
||||
refresh_token_expire_days: int
|
||||
|
||||
SAGE_TYPE_DEVIS: int = 0
|
||||
SAGE_TYPE_BON_COMMANDE: int = 10
|
||||
SAGE_TYPE_PREPARATION: int = 20
|
||||
SAGE_TYPE_BON_LIVRAISON: int = 30
|
||||
SAGE_TYPE_BON_RETOUR: int = 40
|
||||
SAGE_TYPE_BON_AVOIR: int = 50
|
||||
SAGE_TYPE_FACTURE: int = 60
|
||||
|
||||
# === Sage Gateway (Windows) ===
|
||||
sage_gateway_url: str
|
||||
sage_gateway_token: str
|
||||
frontend_url: str
|
||||
|
||||
# === Base de données ===
|
||||
database_url: str = "sqlite+aiosqlite:///./sage_dataven.db"
|
||||
database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db"
|
||||
|
||||
# === SMTP ===
|
||||
smtp_host: str
|
||||
|
|
@ -22,6 +35,7 @@ class Settings(BaseSettings):
|
|||
smtp_user: str
|
||||
smtp_password: str
|
||||
smtp_from: str
|
||||
smtp_use_tls: bool = True
|
||||
|
||||
# === Universign ===
|
||||
universign_api_key: str
|
||||
|
|
@ -35,9 +49,10 @@ class Settings(BaseSettings):
|
|||
# === Email Queue ===
|
||||
max_email_workers: int = 3
|
||||
max_retry_attempts: int = 3
|
||||
retry_delay_seconds: int = 60
|
||||
retry_delay_seconds: int = 3
|
||||
|
||||
# === CORS ===
|
||||
cors_origins: List[str] = ["*"]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
94
core/dependencies.py
Normal file
94
core/dependencies.py
Normal 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
77
core/sage_context.py
Normal 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
97
create_admin.py
Normal 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
405
data/data.py
Normal 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
BIN
data/sage_dataven.db
Normal file
Binary file not shown.
|
|
@ -3,37 +3,56 @@ from database.db_config import (
|
|||
async_session_factory,
|
||||
init_db,
|
||||
get_session,
|
||||
close_db
|
||||
close_db,
|
||||
)
|
||||
|
||||
from database.models import (
|
||||
Base,
|
||||
EmailLog,
|
||||
SignatureLog,
|
||||
WorkflowLog,
|
||||
from database.models.generic_model import (
|
||||
CacheMetadata,
|
||||
AuditLog,
|
||||
RefreshToken,
|
||||
LoginAttempt,
|
||||
)
|
||||
from database.models.user import User
|
||||
from database.models.email import EmailLog
|
||||
from database.models.signature import SignatureLog
|
||||
from database.models.sage_config import SageGatewayConfig
|
||||
from database.enum.status import (
|
||||
StatutEmail,
|
||||
StatutSignature
|
||||
StatutSignature,
|
||||
)
|
||||
from database.models.workflow import WorkflowLog
|
||||
from database.models.universign import (
|
||||
UniversignTransaction,
|
||||
UniversignSigner,
|
||||
UniversignSyncLog,
|
||||
UniversignTransactionStatus,
|
||||
LocalDocumentStatus,
|
||||
UniversignSignerStatus,
|
||||
SageDocumentType
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
'engine',
|
||||
'async_session_factory',
|
||||
'init_db',
|
||||
'get_session',
|
||||
'close_db',
|
||||
|
||||
# Models
|
||||
'Base',
|
||||
'EmailLog',
|
||||
'SignatureLog',
|
||||
'WorkflowLog',
|
||||
'CacheMetadata',
|
||||
'AuditLog',
|
||||
|
||||
# Enums
|
||||
'StatutEmail',
|
||||
'StatutSignature',
|
||||
"engine",
|
||||
"async_session_factory",
|
||||
"init_db",
|
||||
"get_session",
|
||||
"close_db",
|
||||
"Base",
|
||||
"EmailLog",
|
||||
"SignatureLog",
|
||||
"WorkflowLog",
|
||||
"CacheMetadata",
|
||||
"AuditLog",
|
||||
"StatutEmail",
|
||||
"StatutSignature",
|
||||
"User",
|
||||
"RefreshToken",
|
||||
"LoginAttempt",
|
||||
"SageGatewayConfig",
|
||||
"UniversignTransaction",
|
||||
"UniversignSigner",
|
||||
"UniversignSyncLog",
|
||||
"UniversignTransactionStatus",
|
||||
"LocalDocumentStatus",
|
||||
"UniversignSignerStatus",
|
||||
"SageDocumentType"
|
||||
]
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
import os
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from database.models import Base
|
||||
from sqlalchemy.pool import NullPool
|
||||
import logging
|
||||
|
||||
from database.models.generic_model import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./sage_dataven.db")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
engine = create_async_engine(
|
||||
DATABASE_URL,
|
||||
echo=False,
|
||||
future=True,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
|
|
@ -25,32 +25,27 @@ async_session_factory = async_sessionmaker(
|
|||
|
||||
|
||||
async def init_db():
|
||||
"""
|
||||
Crée toutes les tables dans la base de données
|
||||
⚠️ Utilise create_all qui ne crée QUE les tables manquantes
|
||||
"""
|
||||
logger.info("Debut init_db")
|
||||
try:
|
||||
logger.info("Tentative de connexion")
|
||||
async with engine.begin() as conn:
|
||||
logger.info("Connexion etablie")
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("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:
|
||||
logger.error(f"❌ Erreur initialisation DB: {e}")
|
||||
logger.error(f"Erreur initialisation DB: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""Dependency FastAPI pour obtenir une session DB"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def close_db():
|
||||
"""Ferme proprement toutes les connexions"""
|
||||
await engine.dispose()
|
||||
logger.info("🔌 Connexions DB fermées")
|
||||
logger.info("Connexions DB fermées")
|
||||
|
|
|
|||
18
database/enum/status.py
Normal file
18
database/enum/status.py
Normal 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"
|
||||
|
|
@ -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
43
database/models/email.py
Normal 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}>"
|
||||
91
database/models/generic_model.py
Normal file
91
database/models/generic_model.py
Normal 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}>"
|
||||
54
database/models/sage_config.py
Normal file
54
database/models/sage_config.py
Normal 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}>"
|
||||
44
database/models/signature.py
Normal file
44
database/models/signature.py
Normal 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}>"
|
||||
303
database/models/universign.py
Normal file
303
database/models/universign.py
Normal 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
39
database/models/user.py
Normal 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}>"
|
||||
37
database/models/workflow.py
Normal file
37
database/models/workflow.py
Normal 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
24
docker-compose.dev.yml
Normal 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
23
docker-compose.prod.yml
Normal 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
|
||||
22
docker-compose.staging.yml
Normal file
22
docker-compose.staging.yml
Normal 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
|
||||
|
|
@ -1,13 +1,4 @@
|
|||
version: "3.9"
|
||||
|
||||
services:
|
||||
vps-sage-api:
|
||||
build: .
|
||||
container_name: vps-sage-api
|
||||
env_file: .env
|
||||
volumes:
|
||||
# ✅ Monter un DOSSIER entier au lieu d'un fichier
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
478
email_queue.py
478
email_queue.py
|
|
@ -1,176 +1,133 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Queue d'envoi d'emails avec threading et génération PDF
|
||||
Version VPS Linux - utilise sage_client pour récupérer les données
|
||||
"""
|
||||
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
import smtplib
|
||||
import ssl
|
||||
import socket
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.application import MIMEApplication
|
||||
from config import settings
|
||||
from config.config import settings
|
||||
import logging
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
from io import BytesIO
|
||||
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.colors import HexColor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailQueue:
|
||||
"""
|
||||
Queue d'emails avec workers threadés et retry automatique
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.queue = queue.Queue()
|
||||
self.workers = []
|
||||
self.running = False
|
||||
self.session_factory = None
|
||||
self.sage_client = None # Sera injecté depuis api.py
|
||||
self.sage_client = None
|
||||
|
||||
def start(self, num_workers: int = 3):
|
||||
"""Démarre les workers"""
|
||||
if self.running:
|
||||
logger.warning("Queue déjà démarrée")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
for i in range(num_workers):
|
||||
worker = threading.Thread(
|
||||
target=self._worker,
|
||||
name=f"EmailWorker-{i}",
|
||||
daemon=True
|
||||
target=self._worker, name=f"EmailWorker-{i}", daemon=True
|
||||
)
|
||||
worker.start()
|
||||
self.workers.append(worker)
|
||||
|
||||
logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)")
|
||||
logger.info(f"Queue email démarrée avec {num_workers} worker(s)")
|
||||
|
||||
def stop(self):
|
||||
"""Arrête les workers proprement"""
|
||||
logger.info("🛑 Arrêt de la queue email...")
|
||||
self.running = False
|
||||
|
||||
# Attendre que la queue soit vide (max 30s)
|
||||
try:
|
||||
self.queue.join()
|
||||
logger.info("✅ Queue email arrêtée proprement")
|
||||
except:
|
||||
logger.warning("⚠️ Timeout lors de l'arrêt de la queue")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def enqueue(self, email_log_id: str):
|
||||
"""Ajoute un email dans la queue"""
|
||||
self.queue.put(email_log_id)
|
||||
logger.debug(f"📨 Email {email_log_id} ajouté à la queue")
|
||||
|
||||
def _worker(self):
|
||||
"""Worker qui traite les emails dans un thread"""
|
||||
# Créer une event loop pour ce thread
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
# Récupérer un email de la queue (timeout 1s)
|
||||
email_log_id = self.queue.get(timeout=1)
|
||||
|
||||
# Traiter l'email
|
||||
loop.run_until_complete(self._process_email(email_log_id))
|
||||
|
||||
# Marquer comme traité
|
||||
self.queue.task_done()
|
||||
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Erreur worker: {e}", exc_info=True)
|
||||
logger.error(f"Erreur worker: {e}")
|
||||
try:
|
||||
self.queue.task_done()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
async def _process_email(self, email_log_id: str):
|
||||
"""Traite un email avec retry automatique"""
|
||||
from database import EmailLog, StatutEmail
|
||||
from sqlalchemy import select
|
||||
|
||||
if not self.session_factory:
|
||||
logger.error("❌ session_factory non configuré")
|
||||
logger.error("session_factory non configuré")
|
||||
return
|
||||
|
||||
async with self.session_factory() as session:
|
||||
# Charger l'email log
|
||||
result = await session.execute(
|
||||
select(EmailLog).where(EmailLog.id == email_log_id)
|
||||
)
|
||||
email_log = result.scalar_one_or_none()
|
||||
|
||||
if not email_log:
|
||||
logger.error(f"❌ Email log {email_log_id} introuvable")
|
||||
logger.error(f"Email log {email_log_id} introuvable")
|
||||
return
|
||||
|
||||
# Marquer comme en cours
|
||||
email_log.statut = StatutEmail.EN_COURS
|
||||
email_log.nb_tentatives += 1
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
# Envoi avec retry automatique
|
||||
await self._send_with_retry(email_log)
|
||||
|
||||
# Succès
|
||||
email_log.statut = StatutEmail.ENVOYE
|
||||
email_log.date_envoi = datetime.now()
|
||||
email_log.derniere_erreur = None
|
||||
logger.info(f"✅ Email envoyé: {email_log.destinataire}")
|
||||
|
||||
except Exception as e:
|
||||
# Échec
|
||||
error_msg = str(e)
|
||||
email_log.statut = StatutEmail.ERREUR
|
||||
email_log.derniere_erreur = str(e)[:1000] # Limiter la taille
|
||||
email_log.derniere_erreur = error_msg[:1000]
|
||||
|
||||
# Programmer un retry si < max attempts
|
||||
if email_log.nb_tentatives < settings.max_retry_attempts:
|
||||
delay = settings.retry_delay_seconds * (2 ** (email_log.nb_tentatives - 1))
|
||||
delay = settings.retry_delay_seconds * (
|
||||
2 ** (email_log.nb_tentatives - 1)
|
||||
)
|
||||
email_log.prochain_retry = datetime.now() + timedelta(seconds=delay)
|
||||
|
||||
# Programmer le retry
|
||||
timer = threading.Timer(delay, self.enqueue, args=[email_log_id])
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
logger.warning(f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}")
|
||||
else:
|
||||
logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=4, max=10)
|
||||
)
|
||||
async def _send_with_retry(self, email_log):
|
||||
"""Envoi SMTP avec retry Tenacity + génération PDF"""
|
||||
# Préparer le message
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = settings.smtp_from
|
||||
msg['To'] = email_log.destinataire
|
||||
msg['Subject'] = email_log.sujet
|
||||
msg["From"] = settings.smtp_from
|
||||
msg["To"] = email_log.destinataire
|
||||
msg["Subject"] = email_log.sujet
|
||||
msg.attach(MIMEText(email_log.corps_html, "html"))
|
||||
|
||||
# Corps HTML
|
||||
msg.attach(MIMEText(email_log.corps_html, 'html'))
|
||||
|
||||
# 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs
|
||||
# Attachement des PDFs
|
||||
if email_log.document_ids:
|
||||
document_ids = email_log.document_ids.split(',')
|
||||
document_ids = email_log.document_ids.split(",")
|
||||
type_doc = email_log.type_document
|
||||
|
||||
for doc_id in document_ids:
|
||||
|
|
@ -179,168 +136,321 @@ class EmailQueue:
|
|||
continue
|
||||
|
||||
try:
|
||||
# Générer PDF (appel bloquant dans thread séparé)
|
||||
pdf_bytes = await asyncio.to_thread(
|
||||
self._generate_pdf,
|
||||
doc_id,
|
||||
type_doc
|
||||
self._generate_pdf, doc_id, type_doc
|
||||
)
|
||||
|
||||
if pdf_bytes:
|
||||
# Attacher PDF
|
||||
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf")
|
||||
part['Content-Disposition'] = f'attachment; filename="{doc_id}.pdf"'
|
||||
part["Content-Disposition"] = (
|
||||
f'attachment; filename="{doc_id}.pdf"'
|
||||
)
|
||||
msg.attach(part)
|
||||
logger.info(f"📎 PDF attaché: {doc_id}.pdf")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Erreur génération PDF {doc_id}: {e}")
|
||||
# Continuer avec les autres PDFs
|
||||
logger.error(f"Erreur génération PDF {doc_id}: {e}")
|
||||
|
||||
# Envoi SMTP (bloquant mais dans thread séparé)
|
||||
# Envoi SMTP
|
||||
await asyncio.to_thread(self._send_smtp, msg)
|
||||
|
||||
def _send_smtp(self, msg):
|
||||
server = None
|
||||
|
||||
try:
|
||||
# Résolution DNS
|
||||
socket.getaddrinfo(settings.smtp_host, settings.smtp_port)
|
||||
|
||||
# Connexion
|
||||
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30)
|
||||
|
||||
# EHLO
|
||||
server.ehlo()
|
||||
|
||||
# STARTTLS
|
||||
if settings.smtp_use_tls:
|
||||
if server.has_extn("STARTTLS"):
|
||||
context = ssl.create_default_context()
|
||||
server.starttls(context=context)
|
||||
server.ehlo()
|
||||
|
||||
# Authentification
|
||||
if settings.smtp_user and settings.smtp_password:
|
||||
server.login(settings.smtp_user, settings.smtp_password)
|
||||
|
||||
# Envoi
|
||||
refused = server.send_message(msg)
|
||||
if refused:
|
||||
raise Exception(f"Destinataires refusés: {refused}")
|
||||
|
||||
# Fermeture
|
||||
server.quit()
|
||||
|
||||
except Exception as e:
|
||||
if server:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
pass
|
||||
raise Exception(f"Erreur SMTP: {str(e)}")
|
||||
|
||||
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
|
||||
"""
|
||||
Génération PDF via ReportLab + sage_client
|
||||
|
||||
⚠️ Cette méthode est appelée depuis un thread worker
|
||||
"""
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.units import cm
|
||||
from io import BytesIO
|
||||
|
||||
if not self.sage_client:
|
||||
logger.error("❌ sage_client non configuré")
|
||||
raise Exception("sage_client non disponible")
|
||||
|
||||
# 📡 Récupérer document depuis gateway Windows via HTTP
|
||||
try:
|
||||
doc = self.sage_client.lire_document(doc_id, type_doc)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Erreur récupération document {doc_id}: {e}")
|
||||
raise Exception(f"Document {doc_id} inaccessible")
|
||||
raise Exception(f"Document {doc_id} inaccessible : {e}")
|
||||
|
||||
if not doc:
|
||||
raise Exception(f"Document {doc_id} introuvable")
|
||||
|
||||
# 📄 Créer PDF avec ReportLab
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||
width, height = A4
|
||||
|
||||
# === EN-TÊTE ===
|
||||
# Couleurs
|
||||
green_color = HexColor("#2A6F4F")
|
||||
gray_400 = HexColor("#9CA3AF")
|
||||
gray_600 = HexColor("#4B5563")
|
||||
gray_800 = HexColor("#1F2937")
|
||||
|
||||
# Marges
|
||||
margin = 8 * mm
|
||||
content_width = width - 2 * margin
|
||||
|
||||
y = height - margin
|
||||
|
||||
# ===== HEADER =====
|
||||
y -= 20 * mm
|
||||
|
||||
# Logo/Nom entreprise à gauche
|
||||
pdf.setFont("Helvetica-Bold", 18)
|
||||
pdf.setFillColor(green_color)
|
||||
pdf.drawString(margin, y, "Bijou S.A.S")
|
||||
|
||||
# Informations document à droite
|
||||
pdf.setFillColor(gray_800)
|
||||
pdf.setFont("Helvetica-Bold", 20)
|
||||
pdf.drawString(2*cm, height - 3*cm, f"Document N° {doc_id}")
|
||||
numero = doc.get("numero") or "BROUILLON"
|
||||
pdf.drawRightString(width - margin, y, numero.upper())
|
||||
|
||||
# Type de document
|
||||
type_labels = {
|
||||
0: "DEVIS",
|
||||
1: "BON DE LIVRAISON",
|
||||
2: "BON DE RETOUR",
|
||||
3: "COMMANDE",
|
||||
4: "PRÉPARATION",
|
||||
5: "FACTURE"
|
||||
}
|
||||
type_label = type_labels.get(type_doc, "DOCUMENT")
|
||||
y -= 7 * mm
|
||||
pdf.setFont("Helvetica", 9)
|
||||
pdf.setFillColor(gray_600)
|
||||
|
||||
pdf.setFont("Helvetica", 12)
|
||||
pdf.drawString(2*cm, height - 4*cm, f"Type: {type_label}")
|
||||
date_str = doc.get("date") or datetime.now().strftime("%d/%m/%Y")
|
||||
pdf.drawRightString(width - margin, y, f"Date : {date_str}")
|
||||
|
||||
# === INFORMATIONS CLIENT ===
|
||||
y = height - 5*cm
|
||||
pdf.setFont("Helvetica-Bold", 14)
|
||||
pdf.drawString(2*cm, y, "CLIENT")
|
||||
y -= 5 * mm
|
||||
date_livraison = (
|
||||
doc.get("date_livraison") or doc.get("date_echeance") or date_str
|
||||
)
|
||||
pdf.drawRightString(width - margin, y, f"Validité : {date_livraison}")
|
||||
|
||||
y -= 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', '')}")
|
||||
y -= 5 * mm
|
||||
reference = doc.get("reference") or "—"
|
||||
pdf.drawRightString(width - margin, y, f"Réf : {reference}")
|
||||
|
||||
# === LIGNES ===
|
||||
y -= 1.5*cm
|
||||
pdf.setFont("Helvetica-Bold", 14)
|
||||
pdf.drawString(2*cm, y, "ARTICLES")
|
||||
# ===== ADDRESSES =====
|
||||
y -= 20 * mm
|
||||
|
||||
y -= 1*cm
|
||||
# Émetteur (gauche)
|
||||
col1_x = margin
|
||||
col2_x = margin + content_width / 2 + 6 * mm
|
||||
col_width = content_width / 2 - 6 * mm
|
||||
|
||||
pdf.setFont("Helvetica-Bold", 8)
|
||||
pdf.setFillColor(gray_400)
|
||||
pdf.drawString(col1_x, y, "ÉMETTEUR")
|
||||
|
||||
y_emetteur = y - 5 * mm
|
||||
pdf.setFont("Helvetica-Bold", 10)
|
||||
pdf.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")
|
||||
pdf.setFillColor(gray_800)
|
||||
pdf.drawString(col1_x, y_emetteur, "Bijou S.A.S")
|
||||
|
||||
y -= 0.5*cm
|
||||
pdf.line(2*cm, y, width - 2*cm, y)
|
||||
|
||||
y -= 0.7*cm
|
||||
y_emetteur -= 5 * mm
|
||||
pdf.setFont("Helvetica", 9)
|
||||
pdf.setFillColor(gray_600)
|
||||
pdf.drawString(col1_x, y_emetteur, "123 Avenue de la République")
|
||||
|
||||
for ligne in doc.get('lignes', []):
|
||||
# Nouvelle page si nécessaire
|
||||
if y < 3*cm:
|
||||
pdf.showPage()
|
||||
y = height - 3*cm
|
||||
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")
|
||||
|
||||
designation = ligne.get('designation', '')[:50]
|
||||
pdf.drawString(2*cm, y, designation)
|
||||
pdf.drawString(10*cm, y, str(ligne.get('quantite', 0)))
|
||||
pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€")
|
||||
pdf.drawString(15*cm, y, f"{ligne.get('montant_ht', 0):.2f}€")
|
||||
y -= 0.6*cm
|
||||
y_dest -= 4 * mm
|
||||
pdf.drawString(col2_x + 4 * mm, y_dest, "75001 Paris")
|
||||
|
||||
# === TOTAUX ===
|
||||
y -= 1*cm
|
||||
pdf.line(12*cm, y, width - 2*cm, y)
|
||||
# ===== LIGNES D'ARTICLES =====
|
||||
y = min(y_emetteur, y_dest) - 20 * mm
|
||||
|
||||
y -= 0.8*cm
|
||||
pdf.setFont("Helvetica-Bold", 11)
|
||||
pdf.drawString(12*cm, y, "Total HT:")
|
||||
pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}€")
|
||||
# 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
|
||||
|
||||
y -= 0.6*cm
|
||||
pdf.drawString(12*cm, y, "TVA (20%):")
|
||||
tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0)
|
||||
pdf.drawString(15*cm, y, f"{tva:.2f}€")
|
||||
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 -= 0.6*cm
|
||||
pdf.setFont("Helvetica-Bold", 14)
|
||||
pdf.drawString(12*cm, y, "Total TTC:")
|
||||
pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}€")
|
||||
y -= 7 * mm
|
||||
|
||||
# === PIED DE PAGE ===
|
||||
# Lignes d'articles
|
||||
pdf.setFont("Helvetica", 8)
|
||||
pdf.drawString(2*cm, 2*cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}")
|
||||
pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven")
|
||||
lignes = doc.get("lignes", [])
|
||||
|
||||
for ligne in lignes:
|
||||
if y < 60 * mm: # Nouvelle page si nécessaire
|
||||
pdf.showPage()
|
||||
y = height - margin - 20 * mm
|
||||
pdf.setFont("Helvetica", 8)
|
||||
|
||||
designation = (
|
||||
ligne.get("designation") or ligne.get("designation_article") or ""
|
||||
)
|
||||
if len(designation) > 60:
|
||||
designation = designation[:57] + "..."
|
||||
|
||||
pdf.setFillColor(gray_800)
|
||||
pdf.setFont("Helvetica-Bold", 8)
|
||||
pdf.drawString(col_designation, y, designation)
|
||||
|
||||
y -= 4 * mm
|
||||
|
||||
# Description (si différente)
|
||||
description = ligne.get("description", "")
|
||||
if description and description != designation:
|
||||
pdf.setFont("Helvetica", 7)
|
||||
pdf.setFillColor(gray_600)
|
||||
if len(description) > 70:
|
||||
description = description[:67] + "..."
|
||||
pdf.drawString(col_designation, y, description)
|
||||
y -= 4 * mm
|
||||
|
||||
# Valeurs
|
||||
y += 4 * mm # Remonter pour aligner avec la désignation
|
||||
pdf.setFont("Helvetica", 8)
|
||||
pdf.setFillColor(gray_800)
|
||||
|
||||
quantite = ligne.get("quantite") or 0
|
||||
pdf.drawRightString(col_quantite, y, str(quantite))
|
||||
|
||||
prix_unit = ligne.get("prix_unitaire_ht") or ligne.get("prix_unitaire", 0)
|
||||
pdf.drawRightString(col_prix_unit, y, f"{prix_unit:.2f} €")
|
||||
|
||||
taux_taxe = ligne.get("taux_taxe1") or 20
|
||||
pdf.drawRightString(col_taux_taxe, y, f"{taux_taxe}%")
|
||||
|
||||
montant = ligne.get("montant_ligne_ht") or ligne.get("montant_ht", 0)
|
||||
pdf.setFont("Helvetica-Bold", 8)
|
||||
pdf.drawRightString(col_montant, y, f"{montant:.2f} €")
|
||||
|
||||
y -= 8 * mm
|
||||
|
||||
# Si aucune ligne
|
||||
if not lignes:
|
||||
pdf.setFont("Helvetica-Oblique", 9)
|
||||
pdf.setFillColor(gray_400)
|
||||
pdf.drawCentredString(width / 2, y, "Aucune ligne")
|
||||
y -= 15 * mm
|
||||
|
||||
# ===== TOTAUX =====
|
||||
y -= 10 * mm
|
||||
|
||||
totals_x = width - margin - 64 * mm
|
||||
totals_label_width = 40 * mm
|
||||
|
||||
pdf.setFont("Helvetica", 9)
|
||||
pdf.setFillColor(gray_600)
|
||||
|
||||
# Total HT
|
||||
pdf.drawString(totals_x, y, "Total HT")
|
||||
total_ht = doc.get("total_ht_net") or doc.get("total_ht") or 0
|
||||
pdf.drawRightString(width - margin, y, f"{total_ht:.2f} €")
|
||||
|
||||
y -= 6 * mm
|
||||
|
||||
# TVA
|
||||
pdf.drawString(totals_x, y, "TVA")
|
||||
total_ttc = doc.get("total_ttc") or 0
|
||||
tva = total_ttc - total_ht
|
||||
pdf.drawRightString(width - margin, y, f"{tva:.2f} €")
|
||||
|
||||
y -= 8 * mm
|
||||
|
||||
# Ligne de séparation
|
||||
pdf.setStrokeColor(gray_400)
|
||||
pdf.line(totals_x, y + 2 * mm, width - margin, y + 2 * mm)
|
||||
|
||||
# Net à payer
|
||||
pdf.setFont("Helvetica-Bold", 12)
|
||||
pdf.setFillColor(green_color)
|
||||
pdf.drawString(totals_x, y, "Net à payer")
|
||||
pdf.drawRightString(width - margin, y, f"{total_ttc:.2f} €")
|
||||
|
||||
# ===== NOTES =====
|
||||
notes = doc.get("notes_publique") or doc.get("notes")
|
||||
if notes:
|
||||
y -= 15 * mm
|
||||
pdf.setStrokeColor(gray_400)
|
||||
pdf.line(margin, y + 5 * mm, width - margin, y + 5 * mm)
|
||||
|
||||
y -= 5 * mm
|
||||
pdf.setFont("Helvetica-Bold", 8)
|
||||
pdf.setFillColor(gray_400)
|
||||
pdf.drawString(margin, y, "NOTES & CONDITIONS")
|
||||
|
||||
y -= 5 * mm
|
||||
pdf.setFont("Helvetica", 8)
|
||||
pdf.setFillColor(gray_600)
|
||||
|
||||
# Gérer les sauts de ligne dans les notes
|
||||
for line in notes.split("\n"):
|
||||
if y < 25 * mm:
|
||||
break
|
||||
pdf.drawString(margin, y, line[:100])
|
||||
y -= 4 * mm
|
||||
|
||||
# ===== FOOTER =====
|
||||
pdf.setFont("Helvetica", 7)
|
||||
pdf.setFillColor(gray_400)
|
||||
pdf.drawCentredString(width / 2, 15 * mm, "Page 1 / 1")
|
||||
|
||||
# Finaliser
|
||||
pdf.save()
|
||||
buffer.seek(0)
|
||||
|
||||
logger.info(f"✅ PDF généré: {doc_id}.pdf")
|
||||
return buffer.read()
|
||||
|
||||
def _send_smtp(self, msg):
|
||||
"""Envoi SMTP bloquant (appelé depuis asyncio.to_thread)"""
|
||||
try:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server:
|
||||
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()
|
||||
46
init_db.py
46
init_db.py
|
|
@ -1,59 +1,31 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Script d'initialisation de la base de données SQLite
|
||||
Lance ce script avant le premier démarrage de l'API
|
||||
|
||||
Usage:
|
||||
python init_db.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ajouter le répertoire parent au path pour les imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from database import init_db # ✅ Import depuis database/__init__.py
|
||||
from database import init_db
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Crée toutes les tables dans sage_dataven.db"""
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🚀 Initialisation de la base de données Sage Dataven")
|
||||
print("="*60 + "\n")
|
||||
|
||||
try:
|
||||
# Créer les tables
|
||||
logger.info("Debut de l'initialisation")
|
||||
await init_db()
|
||||
logger.info("Initialisation terminee")
|
||||
print("\nInitialisation terminee")
|
||||
|
||||
print("\n✅ Base de données créée avec succès!")
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Erreur lors de l'initialisation: {e}")
|
||||
print(f"\nErreur lors de l'initialisation: {e}")
|
||||
logger.exception("Détails de l'erreur:")
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,17 @@ pydantic-settings
|
|||
reportlab
|
||||
requests
|
||||
msal
|
||||
|
||||
python-multipart
|
||||
email-validator
|
||||
python-dotenv
|
||||
|
||||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
bcrypt==4.2.0
|
||||
|
||||
sqlalchemy
|
||||
aiosqlite
|
||||
tenacity
|
||||
|
||||
httpx
|
||||
529
routes/auth.py
Normal file
529
routes/auth.py
Normal 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
323
routes/sage_gateway.py
Normal 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
1615
routes/universign.py
Normal file
File diff suppressed because it is too large
Load diff
409
sage_client.py
409
sage_client.py
|
|
@ -1,25 +1,36 @@
|
|||
# sage_client.py
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
from config import settings
|
||||
from config.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SageGatewayClient:
|
||||
"""
|
||||
Client HTTP pour communiquer avec la gateway Sage Windows
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.url = settings.sage_gateway_url.rstrip("/")
|
||||
class SageGatewayClient:
|
||||
def __init__(
|
||||
self,
|
||||
gateway_url: Optional[str] = None,
|
||||
gateway_token: Optional[str] = None,
|
||||
gateway_id: Optional[str] = None,
|
||||
):
|
||||
self.url = (gateway_url or settings.sage_gateway_url).rstrip("/")
|
||||
self.token = gateway_token or settings.sage_gateway_token
|
||||
self.gateway_id = gateway_id
|
||||
|
||||
self.headers = {
|
||||
"X-Sage-Token": settings.sage_gateway_token,
|
||||
"Content-Type": "application/json"
|
||||
"X-Sage-Token": self.token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
self.timeout = 30
|
||||
|
||||
@classmethod
|
||||
def from_context(
|
||||
cls, url: str, token: str, gateway_id: Optional[str] = None
|
||||
) -> "SageGatewayClient":
|
||||
return cls(gateway_url=url, gateway_token=token, gateway_id=gateway_id)
|
||||
|
||||
def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict:
|
||||
"""POST avec retry automatique"""
|
||||
import time
|
||||
|
||||
for attempt in range(retries):
|
||||
|
|
@ -28,72 +39,406 @@ class SageGatewayClient:
|
|||
f"{self.url}{endpoint}",
|
||||
json=data or {},
|
||||
headers=self.headers,
|
||||
timeout=self.timeout
|
||||
timeout=self.timeout,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
if attempt == retries - 1:
|
||||
logger.error(f"❌ Échec après {retries} tentatives sur {endpoint}: {e}")
|
||||
logger.error(
|
||||
f"Échec après {retries} tentatives sur {endpoint}: {e}"
|
||||
)
|
||||
raise
|
||||
time.sleep(2 ** attempt) # Backoff exponentiel
|
||||
time.sleep(2**attempt)
|
||||
|
||||
def _get(self, endpoint: str, params: dict = None, retries: int = 3) -> dict:
|
||||
import time
|
||||
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{self.url}{endpoint}",
|
||||
params=params or {},
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
if attempt == retries - 1:
|
||||
logger.error(
|
||||
f"Échec GET après {retries} tentatives sur {endpoint}: {e}"
|
||||
)
|
||||
raise
|
||||
time.sleep(2**attempt)
|
||||
|
||||
# === CLIENTS ===
|
||||
def lister_clients(self, filtre: str = "") -> List[Dict]:
|
||||
return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
|
||||
|
||||
def lire_client(self, code: str) -> Optional[Dict]:
|
||||
return self._post("/sage/clients/get", {"code": code}).get("data")
|
||||
|
||||
# === ARTICLES ===
|
||||
def creer_client(self, client_data: Dict) -> Dict:
|
||||
return self._post("/sage/clients/create", client_data).get("data", {})
|
||||
|
||||
def modifier_client(self, code: str, client_data: Dict) -> Dict:
|
||||
return self._post(
|
||||
"/sage/clients/update", {"code": code, "client_data": client_data}
|
||||
).get("data", {})
|
||||
|
||||
def lister_articles(self, filtre: str = "") -> List[Dict]:
|
||||
return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
|
||||
|
||||
def lire_article(self, ref: str) -> Optional[Dict]:
|
||||
return self._post("/sage/articles/get", {"code": ref}).get("data")
|
||||
|
||||
# === DEVIS ===
|
||||
def creer_article(self, article_data: Dict) -> Dict:
|
||||
return self._post("/sage/articles/create", article_data).get("data", {})
|
||||
|
||||
def modifier_article(self, reference: str, article_data: Dict) -> Dict:
|
||||
return self._post(
|
||||
"/sage/articles/update",
|
||||
{"reference": reference, "article_data": article_data},
|
||||
).get("data", {})
|
||||
|
||||
def creer_devis(self, devis_data: Dict) -> Dict:
|
||||
return self._post("/sage/devis/create", devis_data).get("data", {})
|
||||
|
||||
def lire_devis(self, numero: str) -> Optional[Dict]:
|
||||
return self._post("/sage/devis/get", {"code": numero}).get("data")
|
||||
|
||||
# === DOCUMENTS ===
|
||||
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
|
||||
return self._post("/sage/documents/get", {"numero": numero, "type_doc": type_doc}).get("data")
|
||||
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 transformer_document(self, numero_source: str, type_source: int, type_cible: int) -> Dict:
|
||||
return self._post("/sage/documents/transform", {
|
||||
def modifier_devis(self, numero: str, devis_data: Dict) -> Dict:
|
||||
return self._post(
|
||||
"/sage/devis/update", {"numero": numero, "devis_data": devis_data}
|
||||
).get("data", {})
|
||||
|
||||
def lister_commandes(
|
||||
self, limit: int = 100, statut: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
payload = {"limit": limit}
|
||||
if statut is not None:
|
||||
payload["statut"] = statut
|
||||
return self._post("/sage/commandes/list", payload).get("data", [])
|
||||
|
||||
def creer_commande(self, commande_data: Dict) -> Dict:
|
||||
return self._post("/sage/commandes/create", commande_data).get("data", {})
|
||||
|
||||
def modifier_commande(self, numero: str, commande_data: Dict) -> Dict:
|
||||
return self._post(
|
||||
"/sage/commandes/update", {"numero": numero, "commande_data": commande_data}
|
||||
).get("data", {})
|
||||
|
||||
def lister_factures(
|
||||
self, limit: int = 100, statut: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
payload = {"limit": limit}
|
||||
if statut is not None:
|
||||
payload["statut"] = statut
|
||||
return self._post("/sage/factures/list", payload).get("data", [])
|
||||
|
||||
def creer_facture(self, facture_data: Dict) -> Dict:
|
||||
return self._post("/sage/factures/create", facture_data).get("data", {})
|
||||
|
||||
def modifier_facture(self, numero: str, facture_data: Dict) -> Dict:
|
||||
return self._post(
|
||||
"/sage/factures/update", {"numero": numero, "facture_data": facture_data}
|
||||
).get("data", {})
|
||||
|
||||
def lister_livraisons(
|
||||
self, limit: int = 100, statut: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
payload = {"limit": limit}
|
||||
if statut is not None:
|
||||
payload["statut"] = statut
|
||||
return self._post("/sage/livraisons/list", payload).get("data", [])
|
||||
|
||||
def creer_livraison(self, livraison_data: Dict) -> Dict:
|
||||
return self._post("/sage/livraisons/create", livraison_data).get("data", {})
|
||||
|
||||
def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict:
|
||||
return self._post(
|
||||
"/sage/livraisons/update",
|
||||
{"numero": numero, "livraison_data": livraison_data},
|
||||
).get("data", {})
|
||||
|
||||
def lister_avoirs(
|
||||
self, limit: int = 100, statut: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
payload = {"limit": limit}
|
||||
if statut is not None:
|
||||
payload["statut"] = statut
|
||||
return self._post("/sage/avoirs/list", payload).get("data", [])
|
||||
|
||||
def creer_avoir(self, avoir_data: Dict) -> Dict:
|
||||
return self._post("/sage/avoirs/create", avoir_data).get("data", {})
|
||||
|
||||
def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict:
|
||||
return self._post(
|
||||
"/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data}
|
||||
).get("data", {})
|
||||
|
||||
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
|
||||
return self._post(
|
||||
"/sage/documents/get", {"numero": numero, "type_doc": type_doc}
|
||||
).get("data")
|
||||
|
||||
def changer_statut_document(
|
||||
self, document_type_code: int, numero: str, nouveau_statut: int
|
||||
) -> Dict:
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{self.url}/sage/document/statut",
|
||||
params={
|
||||
"numero": numero,
|
||||
"type_doc": document_type_code,
|
||||
"nouveau_statut": nouveau_statut,
|
||||
},
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json().get("data", {})
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Erreur changement statut: {e}")
|
||||
raise
|
||||
|
||||
def transformer_document(
|
||||
self, numero_source: str, type_source: int, type_cible: int
|
||||
) -> Dict:
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{self.url}/sage/documents/transform",
|
||||
params={
|
||||
"numero_source": numero_source,
|
||||
"type_source": type_source,
|
||||
"type_cible": type_cible
|
||||
}).get("data", {})
|
||||
"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", {
|
||||
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
|
||||
})
|
||||
"valeur": valeur,
|
||||
},
|
||||
)
|
||||
return resp.get("success", False)
|
||||
|
||||
# === CONTACTS ===
|
||||
def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool:
|
||||
resp = self._post(
|
||||
"/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc}
|
||||
)
|
||||
return resp.get("success", False)
|
||||
|
||||
def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes:
|
||||
try:
|
||||
logger.info(f"Demande génération PDF: doc_id={doc_id}, type={type_doc}")
|
||||
|
||||
r = requests.post(
|
||||
f"{self.url}/sage/documents/generate-pdf",
|
||||
json={"doc_id": doc_id, "type_doc": type_doc},
|
||||
headers=self.headers,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
r.raise_for_status()
|
||||
|
||||
import base64
|
||||
|
||||
response_data = r.json()
|
||||
|
||||
if not response_data.get("success"):
|
||||
error_msg = response_data.get("error", "Erreur inconnue")
|
||||
raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}")
|
||||
|
||||
pdf_base64 = response_data.get("data", {}).get("pdf_base64", "")
|
||||
|
||||
if not pdf_base64:
|
||||
raise ValueError(
|
||||
f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})"
|
||||
)
|
||||
|
||||
pdf_bytes = base64.b64decode(pdf_base64)
|
||||
|
||||
logger.info(f"PDF décodé: {len(pdf_bytes)} octets")
|
||||
|
||||
return pdf_bytes
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error(f"Timeout génération PDF pour {doc_id}")
|
||||
raise RuntimeError(
|
||||
f"Timeout lors de la génération du PDF (>60s). "
|
||||
f"Le document {doc_id} est peut-être trop volumineux."
|
||||
)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Erreur HTTP génération PDF: {e}")
|
||||
raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur génération PDF: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def lister_prospects(self, filtre: str = "") -> List[Dict]:
|
||||
return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", [])
|
||||
|
||||
def lire_prospect(self, code: str) -> Optional[Dict]:
|
||||
return self._post("/sage/prospects/get", {"code": code}).get("data")
|
||||
|
||||
def lister_fournisseurs(self, filtre: str = "") -> List[Dict]:
|
||||
return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", [])
|
||||
|
||||
def lire_fournisseur(self, code: str) -> Optional[Dict]:
|
||||
return self._post("/sage/fournisseurs/get", {"code": code}).get("data")
|
||||
|
||||
def creer_fournisseur(self, fournisseur_data: Dict) -> Dict:
|
||||
return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {})
|
||||
|
||||
def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict:
|
||||
return self._post(
|
||||
"/sage/fournisseurs/update",
|
||||
{"code": code, "fournisseur_data": fournisseur_data},
|
||||
).get("data", {})
|
||||
|
||||
def lister_tiers(
|
||||
self, type_tiers: Optional[str] = None, filtre: str = ""
|
||||
) -> List[Dict]:
|
||||
return self._post(
|
||||
"/sage/tiers/list", {"type_tiers": type_tiers, "filtre": filtre}
|
||||
).get("data", [])
|
||||
|
||||
def lire_tiers(self, code: str) -> Optional[Dict]:
|
||||
return self._post("/sage/tiers/get", {"code": code}).get("data")
|
||||
|
||||
def lire_contact_client(self, code_client: str) -> Optional[Dict]:
|
||||
return self._post("/sage/contact/read", {"code": code_client}).get("data")
|
||||
|
||||
# === CACHE ===
|
||||
def creer_contact(self, contact_data: Dict) -> Dict:
|
||||
return self._post("/sage/contacts/create", contact_data)
|
||||
|
||||
def lister_contacts(self, numero: str) -> List[Dict]:
|
||||
return self._post("/sage/contacts/list", {"numero": numero}).get("data", [])
|
||||
|
||||
def obtenir_contact(self, numero: str, contact_numero: int) -> Dict:
|
||||
result = self._post(
|
||||
"/sage/contacts/get", {"numero": numero, "contact_numero": contact_numero}
|
||||
)
|
||||
return result.get("data") if result.get("success") else None
|
||||
|
||||
def modifier_contact(self, numero: str, contact_numero: int, updates: Dict) -> Dict:
|
||||
return self._post(
|
||||
"/sage/contacts/update",
|
||||
{"numero": numero, "contact_numero": contact_numero, "updates": updates},
|
||||
)
|
||||
|
||||
def supprimer_contact(self, numero: str, contact_numero: int) -> Dict:
|
||||
return self._post(
|
||||
"/sage/contacts/delete",
|
||||
{"numero": numero, "contact_numero": contact_numero},
|
||||
)
|
||||
|
||||
def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict:
|
||||
return self._post(
|
||||
"/sage/contacts/set-default",
|
||||
{"numero": numero, "contact_numero": contact_numero},
|
||||
)
|
||||
|
||||
def lister_familles(self, filtre: str = "") -> List[Dict]:
|
||||
return self._get("/sage/familles", params={"filtre": filtre}).get("data", [])
|
||||
|
||||
def lire_famille(self, code: str) -> Optional[Dict]:
|
||||
try:
|
||||
response = self._get(f"/sage/familles/{code}")
|
||||
return response.get("data")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture famille {code}: {e}")
|
||||
return None
|
||||
|
||||
def creer_famille(self, famille_data: Dict) -> Dict:
|
||||
return self._post("/sage/familles/create", famille_data).get("data", {})
|
||||
|
||||
def get_stats_familles(self) -> Dict:
|
||||
return self._get("/sage/familles/stats").get("data", {})
|
||||
|
||||
def creer_entree_stock(self, entree_data: Dict) -> Dict:
|
||||
return self._post("/sage/stock/entree", entree_data).get("data", {})
|
||||
|
||||
def creer_sortie_stock(self, sortie_data: Dict) -> Dict:
|
||||
return self._post("/sage/stock/sortie", sortie_data).get("data", {})
|
||||
|
||||
def lire_mouvement_stock(self, numero: str) -> Optional[Dict]:
|
||||
try:
|
||||
response = self._get(f"/sage/stock/mouvement/{numero}")
|
||||
return response.get("data")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture mouvement {numero}: {e}")
|
||||
return None
|
||||
|
||||
def lire_remise_max_client(self, code_client: str) -> float:
|
||||
result = self._post("/sage/client/remise-max", {"code": code_client})
|
||||
return result.get("data", {}).get("remise_max", 10.0)
|
||||
|
||||
def lister_collaborateurs(
|
||||
self, filtre: Optional[str] = None, actifs_seulement: bool = True
|
||||
) -> List[Dict]:
|
||||
"""Liste tous les collaborateurs"""
|
||||
return self._post(
|
||||
"/sage/collaborateurs/list",
|
||||
{
|
||||
"filtre": filtre or "", # Convertir None en ""
|
||||
"actifs_seulement": actifs_seulement,
|
||||
},
|
||||
).get("data", [])
|
||||
|
||||
def lire_collaborateur(self, numero: int) -> Optional[Dict]:
|
||||
"""Lit un collaborateur par numéro"""
|
||||
return self._post("/sage/collaborateurs/get", {"numero": numero}).get("data")
|
||||
|
||||
def creer_collaborateur(self, data: Dict) -> Optional[Dict]:
|
||||
"""Crée un nouveau collaborateur"""
|
||||
return self._post("/sage/collaborateurs/create", data).get("data")
|
||||
|
||||
def modifier_collaborateur(self, numero: int, data: Dict) -> Optional[Dict]:
|
||||
"""Modifie un collaborateur existant"""
|
||||
return self._post(
|
||||
"/sage/collaborateurs/update", {"numero": numero, **data}
|
||||
).get("data")
|
||||
|
||||
def refresh_cache(self) -> Dict:
|
||||
return self._post("/sage/cache/refresh")
|
||||
|
||||
# === HEALTH ===
|
||||
def get_cache_info(self) -> Dict:
|
||||
return self._get("/sage/cache/info").get("data", {})
|
||||
|
||||
def health(self) -> dict:
|
||||
try:
|
||||
r = requests.get(f"{self.url}/health", timeout=5)
|
||||
return r.json()
|
||||
except:
|
||||
except Exception:
|
||||
return {"status": "down"}
|
||||
|
||||
# Instance globale
|
||||
|
||||
sage_client = SageGatewayClient()
|
||||
108
schemas/__init__.py
Normal file
108
schemas/__init__.py
Normal 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",
|
||||
]
|
||||
650
schemas/articles/articles.py
Normal file
650
schemas/articles/articles.py
Normal 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")
|
||||
255
schemas/articles/famille_article.py
Normal file
255
schemas/articles/famille_article.py
Normal 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,
|
||||
}
|
||||
}
|
||||
55
schemas/documents/avoirs.py
Normal file
55
schemas/documents/avoirs.py
Normal 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,
|
||||
}
|
||||
}
|
||||
54
schemas/documents/commandes.py
Normal file
54
schemas/documents/commandes.py
Normal 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,
|
||||
}
|
||||
}
|
||||
55
schemas/documents/devis.py
Normal file
55
schemas/documents/devis.py
Normal 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
|
||||
22
schemas/documents/documents.py
Normal file
22
schemas/documents/documents.py
Normal 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
|
||||
23
schemas/documents/email.py
Normal file
23
schemas/documents/email.py
Normal 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
|
||||
53
schemas/documents/factures.py
Normal file
53
schemas/documents/factures.py
Normal 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,
|
||||
}
|
||||
}
|
||||
25
schemas/documents/ligne_document.py
Normal file
25
schemas/documents/ligne_document.py
Normal 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
|
||||
55
schemas/documents/livraisons.py
Normal file
55
schemas/documents/livraisons.py
Normal 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,
|
||||
}
|
||||
}
|
||||
18
schemas/documents/universign.py
Normal file
18
schemas/documents/universign.py
Normal 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
|
||||
164
schemas/sage/sage_gateway.py
Normal file
164
schemas/sage/sage_gateway.py
Normal 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
9
schemas/schema_mixte.py
Normal 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
|
||||
0
schemas/tiers/__init__.py
Normal file
0
schemas/tiers/__init__.py
Normal file
576
schemas/tiers/clients.py
Normal file
576
schemas/tiers/clients.py
Normal 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
116
schemas/tiers/commercial.py
Normal 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
111
schemas/tiers/contact.py
Normal 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
|
||||
79
schemas/tiers/fournisseurs.py
Normal file
79
schemas/tiers/fournisseurs.py
Normal 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
217
schemas/tiers/tiers.py
Normal 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"
|
||||
)
|
||||
54
schemas/tiers/tiers_collab.py
Normal file
54
schemas/tiers/tiers_collab.py
Normal 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,
|
||||
}
|
||||
}
|
||||
8
schemas/tiers/type_tiers.py
Normal file
8
schemas/tiers/type_tiers.py
Normal 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
18
schemas/user.py
Normal 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
92
security/auth.py
Normal 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
202
services/email_service.py
Normal 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
400
services/sage_gateway.py
Normal 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,
|
||||
}
|
||||
156
services/universign_document.py
Normal file
156
services/universign_document.py
Normal 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
695
services/universign_sync.py
Normal 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
15
tools/cleaner.py
Normal 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)
|
||||
54
tools/extract_pydantic_models.py
Normal file
54
tools/extract_pydantic_models.py
Normal 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
27
utils/__init__.py
Normal 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
129
utils/enums.py
Normal 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
468
utils/generic_functions.py
Normal 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
16
utils/normalization.py
Normal 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
|
||||
165
utils/universign_status_mapping.py
Normal file
165
utils/universign_status_mapping.py
Normal 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}"
|
||||
Loading…
Reference in a new issue