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
|
SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
|
||||||
|
|
||||||
# === Base de données ===
|
# === Base de données ===
|
||||||
DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db
|
DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db
|
||||||
|
|
||||||
# === SMTP ===
|
# === SMTP ===
|
||||||
SMTP_HOST=smtp.office365.com
|
SMTP_HOST=smtp.office365.com
|
||||||
|
|
|
||||||
9
.trunk/.gitignore
vendored
Normal file
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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copier et installer les dépendances
|
# Installer dépendances système si nécessaire
|
||||||
COPY requirements.txt .
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
RUN pip install --no-cache-dir --upgrade pip \
|
curl \
|
||||||
&& pip install --no-cache-dir -r requirements.txt
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# DEV
|
||||||
|
# ================================
|
||||||
|
FROM base AS dev
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
ENV=development
|
||||||
|
|
||||||
|
# Installer dépendances dev (si vous avez un requirements.dev.txt)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Créer dossiers
|
||||||
|
RUN mkdir -p /app/data /app/logs && chmod -R 777 /app/data /app/logs
|
||||||
|
|
||||||
# Copier le reste du projet
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# ✅ Créer dossier persistant pour SQLite avec bonnes permissions
|
|
||||||
RUN mkdir -p /app/data && chmod 777 /app/data
|
|
||||||
|
|
||||||
# Exposer le port
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|
||||||
# Lancer l'API et initialiser la DB au démarrage
|
# ================================
|
||||||
CMD ["sh", "-c", "python init_db.py && uvicorn api:app --host 0.0.0.0 --port 8000 --reload"]
|
# STAGING
|
||||||
|
# ================================
|
||||||
|
FROM base AS staging
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
ENV=staging
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data /app/logs && chmod -R 755 /app/data /app/logs
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Initialiser la DB au build
|
||||||
|
RUN python init_db.py || true
|
||||||
|
|
||||||
|
EXPOSE 8002
|
||||||
|
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8002", "--log-level", "info"]
|
||||||
|
|
||||||
|
# ================================
|
||||||
|
# PROD
|
||||||
|
# ================================
|
||||||
|
FROM base AS prod
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
ENV=production
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Créer utilisateur non-root pour la sécurité
|
||||||
|
RUN useradd -m -u 1000 appuser && \
|
||||||
|
mkdir -p /app/data /app/logs && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
|
# Initialiser la DB au build
|
||||||
|
RUN python init_db.py || true
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8004
|
||||||
|
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8004", "--workers", "4"]
|
||||||
|
|
@ -1,20 +1,33 @@
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
|
||||||
env_file_encoding="utf-8",
|
|
||||||
case_sensitive=False,
|
|
||||||
extra="ignore"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# === JWT & Auth ===
|
||||||
|
jwt_secret: str
|
||||||
|
jwt_algorithm: str
|
||||||
|
access_token_expire_minutes: int
|
||||||
|
refresh_token_expire_days: int
|
||||||
|
|
||||||
|
SAGE_TYPE_DEVIS: int = 0
|
||||||
|
SAGE_TYPE_BON_COMMANDE: int = 10
|
||||||
|
SAGE_TYPE_PREPARATION: int = 20
|
||||||
|
SAGE_TYPE_BON_LIVRAISON: int = 30
|
||||||
|
SAGE_TYPE_BON_RETOUR: int = 40
|
||||||
|
SAGE_TYPE_BON_AVOIR: int = 50
|
||||||
|
SAGE_TYPE_FACTURE: int = 60
|
||||||
|
|
||||||
# === Sage Gateway (Windows) ===
|
# === Sage Gateway (Windows) ===
|
||||||
sage_gateway_url: str
|
sage_gateway_url: str
|
||||||
sage_gateway_token: str
|
sage_gateway_token: str
|
||||||
|
frontend_url: str
|
||||||
|
|
||||||
# === Base de données ===
|
# === Base de données ===
|
||||||
database_url: str = "sqlite+aiosqlite:///./sage_dataven.db"
|
database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db"
|
||||||
|
|
||||||
# === SMTP ===
|
# === SMTP ===
|
||||||
smtp_host: str
|
smtp_host: str
|
||||||
|
|
@ -22,6 +35,7 @@ class Settings(BaseSettings):
|
||||||
smtp_user: str
|
smtp_user: str
|
||||||
smtp_password: str
|
smtp_password: str
|
||||||
smtp_from: str
|
smtp_from: str
|
||||||
|
smtp_use_tls: bool = True
|
||||||
|
|
||||||
# === Universign ===
|
# === Universign ===
|
||||||
universign_api_key: str
|
universign_api_key: str
|
||||||
|
|
@ -35,9 +49,10 @@ class Settings(BaseSettings):
|
||||||
# === Email Queue ===
|
# === Email Queue ===
|
||||||
max_email_workers: int = 3
|
max_email_workers: int = 3
|
||||||
max_retry_attempts: int = 3
|
max_retry_attempts: int = 3
|
||||||
retry_delay_seconds: int = 60
|
retry_delay_seconds: int = 3
|
||||||
|
|
||||||
# === CORS ===
|
# === CORS ===
|
||||||
cors_origins: List[str] = ["*"]
|
cors_origins: List[str] = ["*"]
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
94
core/dependencies.py
Normal file
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,
|
async_session_factory,
|
||||||
init_db,
|
init_db,
|
||||||
get_session,
|
get_session,
|
||||||
close_db
|
close_db,
|
||||||
)
|
)
|
||||||
|
from database.models.generic_model import (
|
||||||
from database.models import (
|
|
||||||
Base,
|
|
||||||
EmailLog,
|
|
||||||
SignatureLog,
|
|
||||||
WorkflowLog,
|
|
||||||
CacheMetadata,
|
CacheMetadata,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
|
RefreshToken,
|
||||||
|
LoginAttempt,
|
||||||
|
)
|
||||||
|
from database.models.user import User
|
||||||
|
from database.models.email import EmailLog
|
||||||
|
from database.models.signature import SignatureLog
|
||||||
|
from database.models.sage_config import SageGatewayConfig
|
||||||
|
from database.enum.status import (
|
||||||
StatutEmail,
|
StatutEmail,
|
||||||
StatutSignature
|
StatutSignature,
|
||||||
|
)
|
||||||
|
from database.models.workflow import WorkflowLog
|
||||||
|
from database.models.universign import (
|
||||||
|
UniversignTransaction,
|
||||||
|
UniversignSigner,
|
||||||
|
UniversignSyncLog,
|
||||||
|
UniversignTransactionStatus,
|
||||||
|
LocalDocumentStatus,
|
||||||
|
UniversignSignerStatus,
|
||||||
|
SageDocumentType
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Config
|
"engine",
|
||||||
'engine',
|
"async_session_factory",
|
||||||
'async_session_factory',
|
"init_db",
|
||||||
'init_db',
|
"get_session",
|
||||||
'get_session',
|
"close_db",
|
||||||
'close_db',
|
"Base",
|
||||||
|
"EmailLog",
|
||||||
# Models
|
"SignatureLog",
|
||||||
'Base',
|
"WorkflowLog",
|
||||||
'EmailLog',
|
"CacheMetadata",
|
||||||
'SignatureLog',
|
"AuditLog",
|
||||||
'WorkflowLog',
|
"StatutEmail",
|
||||||
'CacheMetadata',
|
"StatutSignature",
|
||||||
'AuditLog',
|
"User",
|
||||||
|
"RefreshToken",
|
||||||
# Enums
|
"LoginAttempt",
|
||||||
'StatutEmail',
|
"SageGatewayConfig",
|
||||||
'StatutSignature',
|
"UniversignTransaction",
|
||||||
|
"UniversignSigner",
|
||||||
|
"UniversignSyncLog",
|
||||||
|
"UniversignTransactionStatus",
|
||||||
|
"LocalDocumentStatus",
|
||||||
|
"UniversignSignerStatus",
|
||||||
|
"SageDocumentType"
|
||||||
]
|
]
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import os
|
import os
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import NullPool
|
||||||
from database.models import Base
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from database.models.generic_model import Base
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./sage_dataven.db")
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
future=True,
|
future=True,
|
||||||
connect_args={"check_same_thread": False},
|
poolclass=NullPool,
|
||||||
poolclass=StaticPool,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async_session_factory = async_sessionmaker(
|
async_session_factory = async_sessionmaker(
|
||||||
|
|
@ -25,32 +25,27 @@ async_session_factory = async_sessionmaker(
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
"""
|
logger.info("Debut init_db")
|
||||||
Crée toutes les tables dans la base de données
|
|
||||||
⚠️ Utilise create_all qui ne crée QUE les tables manquantes
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
|
logger.info("Tentative de connexion")
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
|
logger.info("Connexion etablie")
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info("create_all execute")
|
||||||
|
|
||||||
logger.info("✅ Base de données initialisée avec succès")
|
logger.info("Base de données initialisée avec succès")
|
||||||
logger.info(f"📍 Fichier DB: {DATABASE_URL}")
|
logger.info(f"Fichier DB: {DATABASE_URL}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur initialisation DB: {e}")
|
logger.error(f"Erreur initialisation DB: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def get_session() -> AsyncSession:
|
async def get_session() -> AsyncSession:
|
||||||
"""Dependency FastAPI pour obtenir une session DB"""
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
try:
|
yield session
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def close_db():
|
async def close_db():
|
||||||
"""Ferme proprement toutes les connexions"""
|
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
logger.info("🔌 Connexions DB fermées")
|
logger.info("Connexions DB fermées")
|
||||||
|
|
|
||||||
18
database/enum/status.py
Normal file
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:
|
services:
|
||||||
vps-sage-api:
|
backend:
|
||||||
build: .
|
build:
|
||||||
container_name: vps-sage-api
|
context: .
|
||||||
env_file: .env
|
|
||||||
volumes:
|
|
||||||
# ✅ Monter un DOSSIER entier au lieu d'un fichier
|
|
||||||
- ./data:/app/data
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
restart: unless-stopped
|
|
||||||
494
email_queue.py
494
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 threading
|
||||||
import queue
|
import queue
|
||||||
import time
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
|
||||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import socket
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.application import MIMEApplication
|
from email.mime.application import MIMEApplication
|
||||||
from config import settings
|
from config.config import settings
|
||||||
import logging
|
import logging
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from reportlab.lib.units import mm
|
||||||
|
from reportlab.lib.colors import HexColor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class EmailQueue:
|
class EmailQueue:
|
||||||
"""
|
|
||||||
Queue d'emails avec workers threadés et retry automatique
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.queue = queue.Queue()
|
self.queue = queue.Queue()
|
||||||
self.workers = []
|
self.workers = []
|
||||||
self.running = False
|
self.running = False
|
||||||
self.session_factory = None
|
self.session_factory = None
|
||||||
self.sage_client = None # Sera injecté depuis api.py
|
self.sage_client = None
|
||||||
|
|
||||||
def start(self, num_workers: int = 3):
|
def start(self, num_workers: int = 3):
|
||||||
"""Démarre les workers"""
|
|
||||||
if self.running:
|
if self.running:
|
||||||
logger.warning("Queue déjà démarrée")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self.running = True
|
self.running = True
|
||||||
for i in range(num_workers):
|
for i in range(num_workers):
|
||||||
worker = threading.Thread(
|
worker = threading.Thread(
|
||||||
target=self._worker,
|
target=self._worker, name=f"EmailWorker-{i}", daemon=True
|
||||||
name=f"EmailWorker-{i}",
|
|
||||||
daemon=True
|
|
||||||
)
|
)
|
||||||
worker.start()
|
worker.start()
|
||||||
self.workers.append(worker)
|
self.workers.append(worker)
|
||||||
|
|
||||||
logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)")
|
logger.info(f"Queue email démarrée avec {num_workers} worker(s)")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Arrête les workers proprement"""
|
|
||||||
logger.info("🛑 Arrêt de la queue email...")
|
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
# Attendre que la queue soit vide (max 30s)
|
|
||||||
try:
|
try:
|
||||||
self.queue.join()
|
self.queue.join()
|
||||||
logger.info("✅ Queue email arrêtée proprement")
|
except Exception:
|
||||||
except:
|
pass
|
||||||
logger.warning("⚠️ Timeout lors de l'arrêt de la queue")
|
|
||||||
|
|
||||||
def enqueue(self, email_log_id: str):
|
def enqueue(self, email_log_id: str):
|
||||||
"""Ajoute un email dans la queue"""
|
|
||||||
self.queue.put(email_log_id)
|
self.queue.put(email_log_id)
|
||||||
logger.debug(f"📨 Email {email_log_id} ajouté à la queue")
|
|
||||||
|
|
||||||
def _worker(self):
|
def _worker(self):
|
||||||
"""Worker qui traite les emails dans un thread"""
|
|
||||||
# Créer une event loop pour ce thread
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
# Récupérer un email de la queue (timeout 1s)
|
|
||||||
email_log_id = self.queue.get(timeout=1)
|
email_log_id = self.queue.get(timeout=1)
|
||||||
|
|
||||||
# Traiter l'email
|
|
||||||
loop.run_until_complete(self._process_email(email_log_id))
|
loop.run_until_complete(self._process_email(email_log_id))
|
||||||
|
|
||||||
# Marquer comme traité
|
|
||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
|
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur worker: {e}", exc_info=True)
|
logger.error(f"Erreur worker: {e}")
|
||||||
try:
|
try:
|
||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
async def _process_email(self, email_log_id: str):
|
async def _process_email(self, email_log_id: str):
|
||||||
"""Traite un email avec retry automatique"""
|
|
||||||
from database import EmailLog, StatutEmail
|
from database import EmailLog, StatutEmail
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
if not self.session_factory:
|
if not self.session_factory:
|
||||||
logger.error("❌ session_factory non configuré")
|
logger.error("session_factory non configuré")
|
||||||
return
|
return
|
||||||
|
|
||||||
async with self.session_factory() as session:
|
async with self.session_factory() as session:
|
||||||
# Charger l'email log
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(EmailLog).where(EmailLog.id == email_log_id)
|
select(EmailLog).where(EmailLog.id == email_log_id)
|
||||||
)
|
)
|
||||||
email_log = result.scalar_one_or_none()
|
email_log = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not email_log:
|
if not email_log:
|
||||||
logger.error(f"❌ Email log {email_log_id} introuvable")
|
logger.error(f"Email log {email_log_id} introuvable")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Marquer comme en cours
|
|
||||||
email_log.statut = StatutEmail.EN_COURS
|
email_log.statut = StatutEmail.EN_COURS
|
||||||
email_log.nb_tentatives += 1
|
email_log.nb_tentatives += 1
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Envoi avec retry automatique
|
|
||||||
await self._send_with_retry(email_log)
|
await self._send_with_retry(email_log)
|
||||||
|
|
||||||
# Succès
|
|
||||||
email_log.statut = StatutEmail.ENVOYE
|
email_log.statut = StatutEmail.ENVOYE
|
||||||
email_log.date_envoi = datetime.now()
|
email_log.date_envoi = datetime.now()
|
||||||
email_log.derniere_erreur = None
|
email_log.derniere_erreur = None
|
||||||
logger.info(f"✅ Email envoyé: {email_log.destinataire}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Échec
|
error_msg = str(e)
|
||||||
email_log.statut = StatutEmail.ERREUR
|
email_log.statut = StatutEmail.ERREUR
|
||||||
email_log.derniere_erreur = str(e)[:1000] # Limiter la taille
|
email_log.derniere_erreur = error_msg[:1000]
|
||||||
|
|
||||||
# Programmer un retry si < max attempts
|
|
||||||
if email_log.nb_tentatives < settings.max_retry_attempts:
|
if email_log.nb_tentatives < settings.max_retry_attempts:
|
||||||
delay = settings.retry_delay_seconds * (2 ** (email_log.nb_tentatives - 1))
|
delay = settings.retry_delay_seconds * (
|
||||||
|
2 ** (email_log.nb_tentatives - 1)
|
||||||
|
)
|
||||||
email_log.prochain_retry = datetime.now() + timedelta(seconds=delay)
|
email_log.prochain_retry = datetime.now() + timedelta(seconds=delay)
|
||||||
|
|
||||||
# Programmer le retry
|
|
||||||
timer = threading.Timer(delay, self.enqueue, args=[email_log_id])
|
timer = threading.Timer(delay, self.enqueue, args=[email_log_id])
|
||||||
timer.daemon = True
|
timer.daemon = True
|
||||||
timer.start()
|
timer.start()
|
||||||
|
|
||||||
logger.warning(f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}")
|
|
||||||
else:
|
|
||||||
logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}")
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@retry(
|
|
||||||
stop=stop_after_attempt(3),
|
|
||||||
wait=wait_exponential(multiplier=1, min=4, max=10)
|
|
||||||
)
|
|
||||||
async def _send_with_retry(self, email_log):
|
async def _send_with_retry(self, email_log):
|
||||||
"""Envoi SMTP avec retry Tenacity + génération PDF"""
|
|
||||||
# Préparer le message
|
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg['From'] = settings.smtp_from
|
msg["From"] = settings.smtp_from
|
||||||
msg['To'] = email_log.destinataire
|
msg["To"] = email_log.destinataire
|
||||||
msg['Subject'] = email_log.sujet
|
msg["Subject"] = email_log.sujet
|
||||||
|
msg.attach(MIMEText(email_log.corps_html, "html"))
|
||||||
|
|
||||||
# Corps HTML
|
# Attachement des PDFs
|
||||||
msg.attach(MIMEText(email_log.corps_html, 'html'))
|
|
||||||
|
|
||||||
# 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs
|
|
||||||
if email_log.document_ids:
|
if email_log.document_ids:
|
||||||
document_ids = email_log.document_ids.split(',')
|
document_ids = email_log.document_ids.split(",")
|
||||||
type_doc = email_log.type_document
|
type_doc = email_log.type_document
|
||||||
|
|
||||||
for doc_id in document_ids:
|
for doc_id in document_ids:
|
||||||
|
|
@ -179,168 +136,321 @@ class EmailQueue:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Générer PDF (appel bloquant dans thread séparé)
|
|
||||||
pdf_bytes = await asyncio.to_thread(
|
pdf_bytes = await asyncio.to_thread(
|
||||||
self._generate_pdf,
|
self._generate_pdf, doc_id, type_doc
|
||||||
doc_id,
|
|
||||||
type_doc
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if pdf_bytes:
|
if pdf_bytes:
|
||||||
# Attacher PDF
|
|
||||||
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf")
|
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf")
|
||||||
part['Content-Disposition'] = f'attachment; filename="{doc_id}.pdf"'
|
part["Content-Disposition"] = (
|
||||||
|
f'attachment; filename="{doc_id}.pdf"'
|
||||||
|
)
|
||||||
msg.attach(part)
|
msg.attach(part)
|
||||||
logger.info(f"📎 PDF attaché: {doc_id}.pdf")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur génération PDF {doc_id}: {e}")
|
logger.error(f"Erreur génération PDF {doc_id}: {e}")
|
||||||
# Continuer avec les autres PDFs
|
|
||||||
|
|
||||||
# Envoi SMTP (bloquant mais dans thread séparé)
|
# Envoi SMTP
|
||||||
await asyncio.to_thread(self._send_smtp, msg)
|
await asyncio.to_thread(self._send_smtp, msg)
|
||||||
|
|
||||||
|
def _send_smtp(self, msg):
|
||||||
|
server = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Résolution DNS
|
||||||
|
socket.getaddrinfo(settings.smtp_host, settings.smtp_port)
|
||||||
|
|
||||||
|
# Connexion
|
||||||
|
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30)
|
||||||
|
|
||||||
|
# EHLO
|
||||||
|
server.ehlo()
|
||||||
|
|
||||||
|
# STARTTLS
|
||||||
|
if settings.smtp_use_tls:
|
||||||
|
if server.has_extn("STARTTLS"):
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
server.starttls(context=context)
|
||||||
|
server.ehlo()
|
||||||
|
|
||||||
|
# Authentification
|
||||||
|
if settings.smtp_user and settings.smtp_password:
|
||||||
|
server.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
|
||||||
|
# Envoi
|
||||||
|
refused = server.send_message(msg)
|
||||||
|
if refused:
|
||||||
|
raise Exception(f"Destinataires refusés: {refused}")
|
||||||
|
|
||||||
|
# Fermeture
|
||||||
|
server.quit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if server:
|
||||||
|
try:
|
||||||
|
server.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise Exception(f"Erreur SMTP: {str(e)}")
|
||||||
|
|
||||||
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
|
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
|
||||||
"""
|
|
||||||
Génération PDF via ReportLab + sage_client
|
|
||||||
|
|
||||||
⚠️ Cette méthode est appelée depuis un thread worker
|
|
||||||
"""
|
|
||||||
from reportlab.lib.pagesizes import A4
|
|
||||||
from reportlab.pdfgen import canvas
|
|
||||||
from reportlab.lib.units import cm
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
if not self.sage_client:
|
if not self.sage_client:
|
||||||
logger.error("❌ sage_client non configuré")
|
|
||||||
raise Exception("sage_client non disponible")
|
raise Exception("sage_client non disponible")
|
||||||
|
|
||||||
# 📡 Récupérer document depuis gateway Windows via HTTP
|
|
||||||
try:
|
try:
|
||||||
doc = self.sage_client.lire_document(doc_id, type_doc)
|
doc = self.sage_client.lire_document(doc_id, type_doc)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur récupération document {doc_id}: {e}")
|
raise Exception(f"Document {doc_id} inaccessible : {e}")
|
||||||
raise Exception(f"Document {doc_id} inaccessible")
|
|
||||||
|
|
||||||
if not doc:
|
if not doc:
|
||||||
raise Exception(f"Document {doc_id} introuvable")
|
raise Exception(f"Document {doc_id} introuvable")
|
||||||
|
|
||||||
# 📄 Créer PDF avec ReportLab
|
|
||||||
buffer = BytesIO()
|
buffer = BytesIO()
|
||||||
pdf = canvas.Canvas(buffer, pagesize=A4)
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||||
width, height = A4
|
width, height = A4
|
||||||
|
|
||||||
# === EN-TÊTE ===
|
# Couleurs
|
||||||
|
green_color = HexColor("#2A6F4F")
|
||||||
|
gray_400 = HexColor("#9CA3AF")
|
||||||
|
gray_600 = HexColor("#4B5563")
|
||||||
|
gray_800 = HexColor("#1F2937")
|
||||||
|
|
||||||
|
# Marges
|
||||||
|
margin = 8 * mm
|
||||||
|
content_width = width - 2 * margin
|
||||||
|
|
||||||
|
y = height - margin
|
||||||
|
|
||||||
|
# ===== HEADER =====
|
||||||
|
y -= 20 * mm
|
||||||
|
|
||||||
|
# Logo/Nom entreprise à gauche
|
||||||
|
pdf.setFont("Helvetica-Bold", 18)
|
||||||
|
pdf.setFillColor(green_color)
|
||||||
|
pdf.drawString(margin, y, "Bijou S.A.S")
|
||||||
|
|
||||||
|
# Informations document à droite
|
||||||
|
pdf.setFillColor(gray_800)
|
||||||
pdf.setFont("Helvetica-Bold", 20)
|
pdf.setFont("Helvetica-Bold", 20)
|
||||||
pdf.drawString(2*cm, height - 3*cm, f"Document N° {doc_id}")
|
numero = doc.get("numero") or "BROUILLON"
|
||||||
|
pdf.drawRightString(width - margin, y, numero.upper())
|
||||||
|
|
||||||
# Type de document
|
y -= 7 * mm
|
||||||
type_labels = {
|
|
||||||
0: "DEVIS",
|
|
||||||
1: "BON DE LIVRAISON",
|
|
||||||
2: "BON DE RETOUR",
|
|
||||||
3: "COMMANDE",
|
|
||||||
4: "PRÉPARATION",
|
|
||||||
5: "FACTURE"
|
|
||||||
}
|
|
||||||
type_label = type_labels.get(type_doc, "DOCUMENT")
|
|
||||||
|
|
||||||
pdf.setFont("Helvetica", 12)
|
|
||||||
pdf.drawString(2*cm, height - 4*cm, f"Type: {type_label}")
|
|
||||||
|
|
||||||
# === INFORMATIONS CLIENT ===
|
|
||||||
y = height - 5*cm
|
|
||||||
pdf.setFont("Helvetica-Bold", 14)
|
|
||||||
pdf.drawString(2*cm, y, "CLIENT")
|
|
||||||
|
|
||||||
y -= 0.8*cm
|
|
||||||
pdf.setFont("Helvetica", 11)
|
|
||||||
pdf.drawString(2*cm, y, f"Code: {doc.get('client_code', '')}")
|
|
||||||
y -= 0.6*cm
|
|
||||||
pdf.drawString(2*cm, y, f"Nom: {doc.get('client_intitule', '')}")
|
|
||||||
y -= 0.6*cm
|
|
||||||
pdf.drawString(2*cm, y, f"Date: {doc.get('date', '')}")
|
|
||||||
|
|
||||||
# === LIGNES ===
|
|
||||||
y -= 1.5*cm
|
|
||||||
pdf.setFont("Helvetica-Bold", 14)
|
|
||||||
pdf.drawString(2*cm, y, "ARTICLES")
|
|
||||||
|
|
||||||
y -= 1*cm
|
|
||||||
pdf.setFont("Helvetica-Bold", 10)
|
|
||||||
pdf.drawString(2*cm, y, "Désignation")
|
|
||||||
pdf.drawString(10*cm, y, "Qté")
|
|
||||||
pdf.drawString(12*cm, y, "Prix Unit.")
|
|
||||||
pdf.drawString(15*cm, y, "Total HT")
|
|
||||||
|
|
||||||
y -= 0.5*cm
|
|
||||||
pdf.line(2*cm, y, width - 2*cm, y)
|
|
||||||
|
|
||||||
y -= 0.7*cm
|
|
||||||
pdf.setFont("Helvetica", 9)
|
pdf.setFont("Helvetica", 9)
|
||||||
|
pdf.setFillColor(gray_600)
|
||||||
|
|
||||||
for ligne in doc.get('lignes', []):
|
date_str = doc.get("date") or datetime.now().strftime("%d/%m/%Y")
|
||||||
# Nouvelle page si nécessaire
|
pdf.drawRightString(width - margin, y, f"Date : {date_str}")
|
||||||
if y < 3*cm:
|
|
||||||
pdf.showPage()
|
|
||||||
y = height - 3*cm
|
|
||||||
pdf.setFont("Helvetica", 9)
|
|
||||||
|
|
||||||
designation = ligne.get('designation', '')[:50]
|
y -= 5 * mm
|
||||||
pdf.drawString(2*cm, y, designation)
|
date_livraison = (
|
||||||
pdf.drawString(10*cm, y, str(ligne.get('quantite', 0)))
|
doc.get("date_livraison") or doc.get("date_echeance") or date_str
|
||||||
pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€")
|
)
|
||||||
pdf.drawString(15*cm, y, f"{ligne.get('montant_ht', 0):.2f}€")
|
pdf.drawRightString(width - margin, y, f"Validité : {date_livraison}")
|
||||||
y -= 0.6*cm
|
|
||||||
|
|
||||||
# === TOTAUX ===
|
y -= 5 * mm
|
||||||
y -= 1*cm
|
reference = doc.get("reference") or "—"
|
||||||
pdf.line(12*cm, y, width - 2*cm, y)
|
pdf.drawRightString(width - margin, y, f"Réf : {reference}")
|
||||||
|
|
||||||
y -= 0.8*cm
|
# ===== ADDRESSES =====
|
||||||
pdf.setFont("Helvetica-Bold", 11)
|
y -= 20 * mm
|
||||||
pdf.drawString(12*cm, y, "Total HT:")
|
|
||||||
pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}€")
|
|
||||||
|
|
||||||
y -= 0.6*cm
|
# Émetteur (gauche)
|
||||||
pdf.drawString(12*cm, y, "TVA (20%):")
|
col1_x = margin
|
||||||
tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0)
|
col2_x = margin + content_width / 2 + 6 * mm
|
||||||
pdf.drawString(15*cm, y, f"{tva:.2f}€")
|
col_width = content_width / 2 - 6 * mm
|
||||||
|
|
||||||
y -= 0.6*cm
|
pdf.setFont("Helvetica-Bold", 8)
|
||||||
pdf.setFont("Helvetica-Bold", 14)
|
pdf.setFillColor(gray_400)
|
||||||
pdf.drawString(12*cm, y, "Total TTC:")
|
pdf.drawString(col1_x, y, "ÉMETTEUR")
|
||||||
pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}€")
|
|
||||||
|
|
||||||
# === PIED DE PAGE ===
|
y_emetteur = y - 5 * mm
|
||||||
|
pdf.setFont("Helvetica-Bold", 10)
|
||||||
|
pdf.setFillColor(gray_800)
|
||||||
|
pdf.drawString(col1_x, y_emetteur, "Bijou S.A.S")
|
||||||
|
|
||||||
|
y_emetteur -= 5 * mm
|
||||||
|
pdf.setFont("Helvetica", 9)
|
||||||
|
pdf.setFillColor(gray_600)
|
||||||
|
pdf.drawString(col1_x, y_emetteur, "123 Avenue de la République")
|
||||||
|
|
||||||
|
y_emetteur -= 4 * mm
|
||||||
|
pdf.drawString(col1_x, y_emetteur, "75011 Paris, France")
|
||||||
|
|
||||||
|
y_emetteur -= 5 * mm
|
||||||
|
pdf.drawString(col1_x, y_emetteur, "contact@bijou.com")
|
||||||
|
|
||||||
|
# Destinataire (droite, avec fond gris)
|
||||||
|
box_y = y - 4 * mm
|
||||||
|
box_height = 28 * mm
|
||||||
|
pdf.setFillColorRGB(0.97, 0.97, 0.97) # bg-gray-50
|
||||||
|
pdf.roundRect(
|
||||||
|
col2_x, box_y - box_height, col_width, box_height, 3 * mm, fill=1, stroke=0
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf.setFillColor(gray_400)
|
||||||
|
pdf.setFont("Helvetica-Bold", 8)
|
||||||
|
pdf.drawString(col2_x + 4 * mm, y, "DESTINATAIRE")
|
||||||
|
|
||||||
|
y_dest = y - 5 * mm
|
||||||
|
pdf.setFont("Helvetica-Bold", 10)
|
||||||
|
pdf.setFillColor(gray_800)
|
||||||
|
client_name = doc.get("client_intitule") or "Client"
|
||||||
|
pdf.drawString(col2_x + 4 * mm, y_dest, client_name)
|
||||||
|
|
||||||
|
y_dest -= 5 * mm
|
||||||
|
pdf.setFont("Helvetica", 9)
|
||||||
|
pdf.setFillColor(gray_600)
|
||||||
|
pdf.drawString(col2_x + 4 * mm, y_dest, "10 rue des Clients")
|
||||||
|
|
||||||
|
y_dest -= 4 * mm
|
||||||
|
pdf.drawString(col2_x + 4 * mm, y_dest, "75001 Paris")
|
||||||
|
|
||||||
|
# ===== LIGNES D'ARTICLES =====
|
||||||
|
y = min(y_emetteur, y_dest) - 20 * mm
|
||||||
|
|
||||||
|
# En-têtes des colonnes
|
||||||
|
col_designation = margin
|
||||||
|
col_quantite = width - margin - 80 * mm
|
||||||
|
col_prix_unit = width - margin - 64 * mm
|
||||||
|
col_taux_taxe = width - margin - 40 * mm
|
||||||
|
col_montant = width - margin - 24 * mm
|
||||||
|
|
||||||
|
pdf.setFont("Helvetica-Bold", 9)
|
||||||
|
pdf.setFillColor(gray_800)
|
||||||
|
pdf.drawString(col_designation, y, "Désignation")
|
||||||
|
pdf.drawRightString(col_quantite, y, "Qté")
|
||||||
|
pdf.drawRightString(col_prix_unit, y, "Prix Unit. HT")
|
||||||
|
pdf.drawRightString(col_taux_taxe, y, "TVA")
|
||||||
|
pdf.drawRightString(col_montant, y, "Montant HT")
|
||||||
|
|
||||||
|
y -= 7 * mm
|
||||||
|
|
||||||
|
# Lignes d'articles
|
||||||
pdf.setFont("Helvetica", 8)
|
pdf.setFont("Helvetica", 8)
|
||||||
pdf.drawString(2*cm, 2*cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}")
|
lignes = doc.get("lignes", [])
|
||||||
pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven")
|
|
||||||
|
for ligne in lignes:
|
||||||
|
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()
|
pdf.save()
|
||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
|
|
||||||
logger.info(f"✅ PDF généré: {doc_id}.pdf")
|
|
||||||
return buffer.read()
|
return buffer.read()
|
||||||
|
|
||||||
def _send_smtp(self, msg):
|
|
||||||
"""Envoi SMTP bloquant (appelé depuis asyncio.to_thread)"""
|
|
||||||
try:
|
|
||||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server:
|
|
||||||
if settings.smtp_use_tls:
|
|
||||||
server.starttls()
|
|
||||||
|
|
||||||
if settings.smtp_user and settings.smtp_password:
|
|
||||||
server.login(settings.smtp_user, settings.smtp_password)
|
|
||||||
|
|
||||||
server.send_message(msg)
|
|
||||||
|
|
||||||
except smtplib.SMTPException as e:
|
|
||||||
raise Exception(f"Erreur SMTP: {str(e)}")
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Erreur envoi: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
# Instance globale
|
|
||||||
email_queue = EmailQueue()
|
email_queue = EmailQueue()
|
||||||
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 asyncio
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Ajouter le répertoire parent au path pour les imports
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from database import init_db # ✅ Import depuis database/__init__.py
|
from database import init_db
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
"""Crée toutes les tables dans sage_dataven.db"""
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("🚀 Initialisation de la base de données Sage Dataven")
|
|
||||||
print("="*60 + "\n")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Créer les tables
|
logger.info("Debut de l'initialisation")
|
||||||
await init_db()
|
await init_db()
|
||||||
|
logger.info("Initialisation terminee")
|
||||||
|
print("\nInitialisation terminee")
|
||||||
|
|
||||||
print("\n✅ Base de données créée avec succès!")
|
print("\nBase de données créée avec succès !")
|
||||||
print(f"📍 Fichier: sage_dataven.db")
|
|
||||||
|
|
||||||
print("\n📊 Tables créées:")
|
|
||||||
print(" ├─ email_logs (Journalisation emails)")
|
|
||||||
print(" ├─ signature_logs (Suivi signatures Universign)")
|
|
||||||
print(" ├─ workflow_logs (Transformations documents)")
|
|
||||||
print(" ├─ cache_metadata (Métadonnées cache)")
|
|
||||||
print(" └─ audit_logs (Journal d'audit)")
|
|
||||||
|
|
||||||
print("\n📝 Prochaines étapes:")
|
|
||||||
print(" 1. Configurer le fichier .env avec vos credentials")
|
|
||||||
print(" 2. Lancer la gateway Windows sur la machine Sage")
|
|
||||||
print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000")
|
|
||||||
print(" 4. Ou avec Docker: docker-compose up -d")
|
|
||||||
print(" 5. Tester: http://votre-vps:8000/docs")
|
|
||||||
|
|
||||||
print("\n" + "="*60 + "\n")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
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:")
|
logger.exception("Détails de l'erreur:")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,17 @@ pydantic-settings
|
||||||
reportlab
|
reportlab
|
||||||
requests
|
requests
|
||||||
msal
|
msal
|
||||||
|
|
||||||
python-multipart
|
python-multipart
|
||||||
email-validator
|
email-validator
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
|
||||||
|
python-jose[cryptography]
|
||||||
|
passlib[bcrypt]
|
||||||
|
bcrypt==4.2.0
|
||||||
|
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
aiosqlite
|
aiosqlite
|
||||||
tenacity
|
tenacity
|
||||||
|
|
||||||
|
httpx
|
||||||
529
routes/auth.py
Normal file
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
417
sage_client.py
417
sage_client.py
|
|
@ -1,25 +1,36 @@
|
||||||
|
# sage_client.py
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from config import settings
|
from config.config import settings
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SageGatewayClient:
|
|
||||||
"""
|
|
||||||
Client HTTP pour communiquer avec la gateway Sage Windows
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
class SageGatewayClient:
|
||||||
self.url = settings.sage_gateway_url.rstrip("/")
|
def __init__(
|
||||||
|
self,
|
||||||
|
gateway_url: Optional[str] = None,
|
||||||
|
gateway_token: Optional[str] = None,
|
||||||
|
gateway_id: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self.url = (gateway_url or settings.sage_gateway_url).rstrip("/")
|
||||||
|
self.token = gateway_token or settings.sage_gateway_token
|
||||||
|
self.gateway_id = gateway_id
|
||||||
|
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"X-Sage-Token": settings.sage_gateway_token,
|
"X-Sage-Token": self.token,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
self.timeout = 30
|
self.timeout = 30
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_context(
|
||||||
|
cls, url: str, token: str, gateway_id: Optional[str] = None
|
||||||
|
) -> "SageGatewayClient":
|
||||||
|
return cls(gateway_url=url, gateway_token=token, gateway_id=gateway_id)
|
||||||
|
|
||||||
def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict:
|
def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict:
|
||||||
"""POST avec retry automatique"""
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
for attempt in range(retries):
|
for attempt in range(retries):
|
||||||
|
|
@ -28,72 +39,406 @@ class SageGatewayClient:
|
||||||
f"{self.url}{endpoint}",
|
f"{self.url}{endpoint}",
|
||||||
json=data or {},
|
json=data or {},
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
timeout=self.timeout
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
if attempt == retries - 1:
|
if attempt == retries - 1:
|
||||||
logger.error(f"❌ Échec après {retries} tentatives sur {endpoint}: {e}")
|
logger.error(
|
||||||
|
f"Échec après {retries} tentatives sur {endpoint}: {e}"
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
time.sleep(2 ** attempt) # Backoff exponentiel
|
time.sleep(2**attempt)
|
||||||
|
|
||||||
|
def _get(self, endpoint: str, params: dict = None, retries: int = 3) -> dict:
|
||||||
|
import time
|
||||||
|
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
f"{self.url}{endpoint}",
|
||||||
|
params=params or {},
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
if attempt == retries - 1:
|
||||||
|
logger.error(
|
||||||
|
f"Échec GET après {retries} tentatives sur {endpoint}: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
time.sleep(2**attempt)
|
||||||
|
|
||||||
# === CLIENTS ===
|
|
||||||
def lister_clients(self, filtre: str = "") -> List[Dict]:
|
def lister_clients(self, filtre: str = "") -> List[Dict]:
|
||||||
return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
|
return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
def lire_client(self, code: str) -> Optional[Dict]:
|
def lire_client(self, code: str) -> Optional[Dict]:
|
||||||
return self._post("/sage/clients/get", {"code": code}).get("data")
|
return self._post("/sage/clients/get", {"code": code}).get("data")
|
||||||
|
|
||||||
# === ARTICLES ===
|
def creer_client(self, client_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/clients/create", client_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_client(self, code: str, client_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/clients/update", {"code": code, "client_data": client_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
def lister_articles(self, filtre: str = "") -> List[Dict]:
|
def lister_articles(self, filtre: str = "") -> List[Dict]:
|
||||||
return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
|
return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
def lire_article(self, ref: str) -> Optional[Dict]:
|
def lire_article(self, ref: str) -> Optional[Dict]:
|
||||||
return self._post("/sage/articles/get", {"code": ref}).get("data")
|
return self._post("/sage/articles/get", {"code": ref}).get("data")
|
||||||
|
|
||||||
# === DEVIS ===
|
def creer_article(self, article_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/articles/create", article_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_article(self, reference: str, article_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/articles/update",
|
||||||
|
{"reference": reference, "article_data": article_data},
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
def creer_devis(self, devis_data: Dict) -> Dict:
|
def creer_devis(self, devis_data: Dict) -> Dict:
|
||||||
return self._post("/sage/devis/create", devis_data).get("data", {})
|
return self._post("/sage/devis/create", devis_data).get("data", {})
|
||||||
|
|
||||||
def lire_devis(self, numero: str) -> Optional[Dict]:
|
def lire_devis(self, numero: str) -> Optional[Dict]:
|
||||||
return self._post("/sage/devis/get", {"code": numero}).get("data")
|
return self._post("/sage/devis/get", {"code": numero}).get("data")
|
||||||
|
|
||||||
# === DOCUMENTS ===
|
def lister_devis(
|
||||||
|
self,
|
||||||
|
limit: int = 100,
|
||||||
|
statut: Optional[int] = None,
|
||||||
|
inclure_lignes: bool = True,
|
||||||
|
) -> List[Dict]:
|
||||||
|
payload = {"limit": limit, "inclure_lignes": inclure_lignes}
|
||||||
|
if statut is not None:
|
||||||
|
payload["statut"] = statut
|
||||||
|
return self._post("/sage/devis/list", payload).get("data", [])
|
||||||
|
|
||||||
|
def modifier_devis(self, numero: str, devis_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/devis/update", {"numero": numero, "devis_data": devis_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def lister_commandes(
|
||||||
|
self, limit: int = 100, statut: Optional[int] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
payload = {"limit": limit}
|
||||||
|
if statut is not None:
|
||||||
|
payload["statut"] = statut
|
||||||
|
return self._post("/sage/commandes/list", payload).get("data", [])
|
||||||
|
|
||||||
|
def creer_commande(self, commande_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/commandes/create", commande_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_commande(self, numero: str, commande_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/commandes/update", {"numero": numero, "commande_data": commande_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def lister_factures(
|
||||||
|
self, limit: int = 100, statut: Optional[int] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
payload = {"limit": limit}
|
||||||
|
if statut is not None:
|
||||||
|
payload["statut"] = statut
|
||||||
|
return self._post("/sage/factures/list", payload).get("data", [])
|
||||||
|
|
||||||
|
def creer_facture(self, facture_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/factures/create", facture_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_facture(self, numero: str, facture_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/factures/update", {"numero": numero, "facture_data": facture_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def lister_livraisons(
|
||||||
|
self, limit: int = 100, statut: Optional[int] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
payload = {"limit": limit}
|
||||||
|
if statut is not None:
|
||||||
|
payload["statut"] = statut
|
||||||
|
return self._post("/sage/livraisons/list", payload).get("data", [])
|
||||||
|
|
||||||
|
def creer_livraison(self, livraison_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/livraisons/create", livraison_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/livraisons/update",
|
||||||
|
{"numero": numero, "livraison_data": livraison_data},
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def lister_avoirs(
|
||||||
|
self, limit: int = 100, statut: Optional[int] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
payload = {"limit": limit}
|
||||||
|
if statut is not None:
|
||||||
|
payload["statut"] = statut
|
||||||
|
return self._post("/sage/avoirs/list", payload).get("data", [])
|
||||||
|
|
||||||
|
def creer_avoir(self, avoir_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/avoirs/create", avoir_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
|
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
|
||||||
return self._post("/sage/documents/get", {"numero": numero, "type_doc": type_doc}).get("data")
|
return self._post(
|
||||||
|
"/sage/documents/get", {"numero": numero, "type_doc": type_doc}
|
||||||
|
).get("data")
|
||||||
|
|
||||||
def transformer_document(self, numero_source: str, type_source: int, type_cible: int) -> Dict:
|
def changer_statut_document(
|
||||||
return self._post("/sage/documents/transform", {
|
self, document_type_code: int, numero: str, nouveau_statut: int
|
||||||
"numero_source": numero_source,
|
) -> Dict:
|
||||||
"type_source": type_source,
|
try:
|
||||||
"type_cible": type_cible
|
r = requests.post(
|
||||||
}).get("data", {})
|
f"{self.url}/sage/document/statut",
|
||||||
|
params={
|
||||||
|
"numero": numero,
|
||||||
|
"type_doc": document_type_code,
|
||||||
|
"nouveau_statut": nouveau_statut,
|
||||||
|
},
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("data", {})
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Erreur changement statut: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
def mettre_a_jour_champ_libre(self, doc_id: str, type_doc: int, nom_champ: str, valeur: str) -> bool:
|
def transformer_document(
|
||||||
resp = self._post("/sage/documents/champ-libre", {
|
self, numero_source: str, type_source: int, type_cible: int
|
||||||
"doc_id": doc_id,
|
) -> Dict:
|
||||||
"type_doc": type_doc,
|
try:
|
||||||
"nom_champ": nom_champ,
|
r = requests.post(
|
||||||
"valeur": valeur
|
f"{self.url}/sage/documents/transform",
|
||||||
})
|
params={
|
||||||
|
"numero_source": numero_source,
|
||||||
|
"type_source": type_source,
|
||||||
|
"type_cible": type_cible,
|
||||||
|
},
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("data", {})
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Erreur transformation: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def mettre_a_jour_champ_libre(
|
||||||
|
self, doc_id: str, type_doc: int, nom_champ: str, valeur: str
|
||||||
|
) -> bool:
|
||||||
|
resp = self._post(
|
||||||
|
"/sage/documents/champ-libre",
|
||||||
|
{
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"type_doc": type_doc,
|
||||||
|
"nom_champ": nom_champ,
|
||||||
|
"valeur": valeur,
|
||||||
|
},
|
||||||
|
)
|
||||||
return resp.get("success", False)
|
return resp.get("success", False)
|
||||||
|
|
||||||
# === CONTACTS ===
|
def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool:
|
||||||
|
resp = self._post(
|
||||||
|
"/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc}
|
||||||
|
)
|
||||||
|
return resp.get("success", False)
|
||||||
|
|
||||||
|
def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes:
|
||||||
|
try:
|
||||||
|
logger.info(f"Demande génération PDF: doc_id={doc_id}, type={type_doc}")
|
||||||
|
|
||||||
|
r = requests.post(
|
||||||
|
f"{self.url}/sage/documents/generate-pdf",
|
||||||
|
json={"doc_id": doc_id, "type_doc": type_doc},
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
response_data = r.json()
|
||||||
|
|
||||||
|
if not response_data.get("success"):
|
||||||
|
error_msg = response_data.get("error", "Erreur inconnue")
|
||||||
|
raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}")
|
||||||
|
|
||||||
|
pdf_base64 = response_data.get("data", {}).get("pdf_base64", "")
|
||||||
|
|
||||||
|
if not pdf_base64:
|
||||||
|
raise ValueError(
|
||||||
|
f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})"
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_bytes = base64.b64decode(pdf_base64)
|
||||||
|
|
||||||
|
logger.info(f"PDF décodé: {len(pdf_bytes)} octets")
|
||||||
|
|
||||||
|
return pdf_bytes
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error(f"Timeout génération PDF pour {doc_id}")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Timeout lors de la génération du PDF (>60s). "
|
||||||
|
f"Le document {doc_id} est peut-être trop volumineux."
|
||||||
|
)
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Erreur HTTP génération PDF: {e}")
|
||||||
|
raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur génération PDF: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def lister_prospects(self, filtre: str = "") -> List[Dict]:
|
||||||
|
return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
|
def lire_prospect(self, code: str) -> Optional[Dict]:
|
||||||
|
return self._post("/sage/prospects/get", {"code": code}).get("data")
|
||||||
|
|
||||||
|
def lister_fournisseurs(self, filtre: str = "") -> List[Dict]:
|
||||||
|
return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
|
def lire_fournisseur(self, code: str) -> Optional[Dict]:
|
||||||
|
return self._post("/sage/fournisseurs/get", {"code": code}).get("data")
|
||||||
|
|
||||||
|
def creer_fournisseur(self, fournisseur_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/fournisseurs/update",
|
||||||
|
{"code": code, "fournisseur_data": fournisseur_data},
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def lister_tiers(
|
||||||
|
self, type_tiers: Optional[str] = None, filtre: str = ""
|
||||||
|
) -> List[Dict]:
|
||||||
|
return self._post(
|
||||||
|
"/sage/tiers/list", {"type_tiers": type_tiers, "filtre": filtre}
|
||||||
|
).get("data", [])
|
||||||
|
|
||||||
|
def lire_tiers(self, code: str) -> Optional[Dict]:
|
||||||
|
return self._post("/sage/tiers/get", {"code": code}).get("data")
|
||||||
|
|
||||||
def lire_contact_client(self, code_client: str) -> Optional[Dict]:
|
def lire_contact_client(self, code_client: str) -> Optional[Dict]:
|
||||||
return self._post("/sage/contact/read", {"code": code_client}).get("data")
|
return self._post("/sage/contact/read", {"code": code_client}).get("data")
|
||||||
|
|
||||||
# === CACHE ===
|
def creer_contact(self, contact_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/contacts/create", contact_data)
|
||||||
|
|
||||||
|
def lister_contacts(self, numero: str) -> List[Dict]:
|
||||||
|
return self._post("/sage/contacts/list", {"numero": numero}).get("data", [])
|
||||||
|
|
||||||
|
def obtenir_contact(self, numero: str, contact_numero: int) -> Dict:
|
||||||
|
result = self._post(
|
||||||
|
"/sage/contacts/get", {"numero": numero, "contact_numero": contact_numero}
|
||||||
|
)
|
||||||
|
return result.get("data") if result.get("success") else None
|
||||||
|
|
||||||
|
def modifier_contact(self, numero: str, contact_numero: int, updates: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/contacts/update",
|
||||||
|
{"numero": numero, "contact_numero": contact_numero, "updates": updates},
|
||||||
|
)
|
||||||
|
|
||||||
|
def supprimer_contact(self, numero: str, contact_numero: int) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/contacts/delete",
|
||||||
|
{"numero": numero, "contact_numero": contact_numero},
|
||||||
|
)
|
||||||
|
|
||||||
|
def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/contacts/set-default",
|
||||||
|
{"numero": numero, "contact_numero": contact_numero},
|
||||||
|
)
|
||||||
|
|
||||||
|
def lister_familles(self, filtre: str = "") -> List[Dict]:
|
||||||
|
return self._get("/sage/familles", params={"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
|
def lire_famille(self, code: str) -> Optional[Dict]:
|
||||||
|
try:
|
||||||
|
response = self._get(f"/sage/familles/{code}")
|
||||||
|
return response.get("data")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture famille {code}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def creer_famille(self, famille_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/familles/create", famille_data).get("data", {})
|
||||||
|
|
||||||
|
def get_stats_familles(self) -> Dict:
|
||||||
|
return self._get("/sage/familles/stats").get("data", {})
|
||||||
|
|
||||||
|
def creer_entree_stock(self, entree_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/stock/entree", entree_data).get("data", {})
|
||||||
|
|
||||||
|
def creer_sortie_stock(self, sortie_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/stock/sortie", sortie_data).get("data", {})
|
||||||
|
|
||||||
|
def lire_mouvement_stock(self, numero: str) -> Optional[Dict]:
|
||||||
|
try:
|
||||||
|
response = self._get(f"/sage/stock/mouvement/{numero}")
|
||||||
|
return response.get("data")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture mouvement {numero}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def lire_remise_max_client(self, code_client: str) -> float:
|
||||||
|
result = self._post("/sage/client/remise-max", {"code": code_client})
|
||||||
|
return result.get("data", {}).get("remise_max", 10.0)
|
||||||
|
|
||||||
|
def lister_collaborateurs(
|
||||||
|
self, filtre: Optional[str] = None, actifs_seulement: bool = True
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Liste tous les collaborateurs"""
|
||||||
|
return self._post(
|
||||||
|
"/sage/collaborateurs/list",
|
||||||
|
{
|
||||||
|
"filtre": filtre or "", # Convertir None en ""
|
||||||
|
"actifs_seulement": actifs_seulement,
|
||||||
|
},
|
||||||
|
).get("data", [])
|
||||||
|
|
||||||
|
def lire_collaborateur(self, numero: int) -> Optional[Dict]:
|
||||||
|
"""Lit un collaborateur par numéro"""
|
||||||
|
return self._post("/sage/collaborateurs/get", {"numero": numero}).get("data")
|
||||||
|
|
||||||
|
def creer_collaborateur(self, data: Dict) -> Optional[Dict]:
|
||||||
|
"""Crée un nouveau collaborateur"""
|
||||||
|
return self._post("/sage/collaborateurs/create", data).get("data")
|
||||||
|
|
||||||
|
def modifier_collaborateur(self, numero: int, data: Dict) -> Optional[Dict]:
|
||||||
|
"""Modifie un collaborateur existant"""
|
||||||
|
return self._post(
|
||||||
|
"/sage/collaborateurs/update", {"numero": numero, **data}
|
||||||
|
).get("data")
|
||||||
|
|
||||||
def refresh_cache(self) -> Dict:
|
def refresh_cache(self) -> Dict:
|
||||||
return self._post("/sage/cache/refresh")
|
return self._post("/sage/cache/refresh")
|
||||||
|
|
||||||
# === HEALTH ===
|
def get_cache_info(self) -> Dict:
|
||||||
|
return self._get("/sage/cache/info").get("data", {})
|
||||||
|
|
||||||
def health(self) -> dict:
|
def health(self) -> dict:
|
||||||
try:
|
try:
|
||||||
r = requests.get(f"{self.url}/health", timeout=5)
|
r = requests.get(f"{self.url}/health", timeout=5)
|
||||||
return r.json()
|
return r.json()
|
||||||
except:
|
except Exception:
|
||||||
return {"status": "down"}
|
return {"status": "down"}
|
||||||
|
|
||||||
# Instance globale
|
|
||||||
sage_client = SageGatewayClient()
|
sage_client = SageGatewayClient()
|
||||||
108
schemas/__init__.py
Normal file
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