Initial backend FastAPI VPS
This commit is contained in:
commit
5e06aafff7
5 changed files with 878 additions and 0 deletions
32
.env.example
Normal file
32
.env.example
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# ============================================
|
||||||
|
# Configuration Linux VPS - API Principale
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# === Sage Gateway Windows ===
|
||||||
|
SAGE_GATEWAY_URL=http://192.168.1.50:8100
|
||||||
|
SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
|
||||||
|
|
||||||
|
# === Base de données ===
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db
|
||||||
|
|
||||||
|
# === SMTP ===
|
||||||
|
SMTP_HOST=smtp.office365.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=commercial@monentreprise.fr
|
||||||
|
SMTP_PASSWORD=MonMotDePasseEmail123!
|
||||||
|
SMTP_FROM=commercial@monentreprise.fr
|
||||||
|
|
||||||
|
# === Universign ===
|
||||||
|
UNIVERSIGN_API_KEY=your_real_universign_key_here
|
||||||
|
UNIVERSIGN_API_URL=https://api.universign.com/v1
|
||||||
|
|
||||||
|
# === API ===
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8002
|
||||||
|
API_RELOAD=False
|
||||||
|
|
||||||
|
# === Email Queue ===
|
||||||
|
MAX_EMAIL_WORKERS=3
|
||||||
|
|
||||||
|
# === Logs ===
|
||||||
|
LOG_LEVEL=INFO
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# ================================
|
||||||
|
# Python / FastAPI
|
||||||
|
# ================================
|
||||||
|
|
||||||
|
# Environnements virtuels
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
*.local.env
|
||||||
|
|
||||||
|
# caches
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Compilations
|
||||||
|
*.so
|
||||||
|
*.dll
|
||||||
|
|
||||||
|
# Outils Python
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# VSCode
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*~
|
||||||
|
.build/
|
||||||
|
dist/
|
||||||
666
api.py
Normal file
666
api.py
Normal file
|
|
@ -0,0 +1,666 @@
|
||||||
|
from fastapi import FastAPI, HTTPException, Query, Depends, status
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field, EmailStr
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
from datetime import date, datetime
|
||||||
|
from enum import Enum
|
||||||
|
import uvicorn
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import uuid
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
# Configuration logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler("sage_api.log"),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Imports locaux
|
||||||
|
from config import settings
|
||||||
|
from database.config import init_db, async_session_factory, get_session
|
||||||
|
from database.models import (
|
||||||
|
EmailLog as EmailLogModel,
|
||||||
|
StatutEmail as StatutEmailEnum,
|
||||||
|
WorkflowLog,
|
||||||
|
SignatureLog,
|
||||||
|
StatutSignature as StatutSignatureEnum
|
||||||
|
)
|
||||||
|
from email_queue import email_queue
|
||||||
|
from sage_client import sage_client
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# ENUMS
|
||||||
|
# =====================================================
|
||||||
|
class TypeDocument(int, Enum):
|
||||||
|
DEVIS = 0
|
||||||
|
BON_LIVRAISON = 1
|
||||||
|
BON_RETOUR = 2
|
||||||
|
COMMANDE = 3
|
||||||
|
PREPARATION = 4
|
||||||
|
FACTURE = 5
|
||||||
|
|
||||||
|
class StatutSignature(str, Enum):
|
||||||
|
EN_ATTENTE = "EN_ATTENTE"
|
||||||
|
ENVOYE = "ENVOYE"
|
||||||
|
SIGNE = "SIGNE"
|
||||||
|
REFUSE = "REFUSE"
|
||||||
|
EXPIRE = "EXPIRE"
|
||||||
|
|
||||||
|
class StatutEmail(str, Enum):
|
||||||
|
EN_ATTENTE = "EN_ATTENTE"
|
||||||
|
EN_COURS = "EN_COURS"
|
||||||
|
ENVOYE = "ENVOYE"
|
||||||
|
OUVERT = "OUVERT"
|
||||||
|
ERREUR = "ERREUR"
|
||||||
|
BOUNCE = "BOUNCE"
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# MODÈLES PYDANTIC
|
||||||
|
# =====================================================
|
||||||
|
class ClientResponse(BaseModel):
|
||||||
|
numero: str
|
||||||
|
intitule: str
|
||||||
|
adresse: Optional[str] = None
|
||||||
|
code_postal: Optional[str] = None
|
||||||
|
ville: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
telephone: Optional[str] = None
|
||||||
|
|
||||||
|
class ArticleResponse(BaseModel):
|
||||||
|
reference: str
|
||||||
|
designation: str
|
||||||
|
prix_vente: float
|
||||||
|
stock_reel: float
|
||||||
|
|
||||||
|
class LigneDevis(BaseModel):
|
||||||
|
article_code: str
|
||||||
|
quantite: float
|
||||||
|
prix_unitaire_ht: Optional[float] = None
|
||||||
|
remise_pourcentage: Optional[float] = 0.0
|
||||||
|
|
||||||
|
class DevisRequest(BaseModel):
|
||||||
|
client_id: str
|
||||||
|
date_devis: Optional[date] = None
|
||||||
|
lignes: List[LigneDevis]
|
||||||
|
|
||||||
|
class DevisResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
client_id: str
|
||||||
|
date_devis: str
|
||||||
|
montant_total_ht: float
|
||||||
|
montant_total_ttc: float
|
||||||
|
nb_lignes: int
|
||||||
|
|
||||||
|
class SignatureRequest(BaseModel):
|
||||||
|
doc_id: str
|
||||||
|
type_doc: TypeDocument
|
||||||
|
email_signataire: EmailStr
|
||||||
|
nom_signataire: str
|
||||||
|
|
||||||
|
class EmailEnvoiRequest(BaseModel):
|
||||||
|
destinataire: EmailStr
|
||||||
|
cc: Optional[List[EmailStr]] = []
|
||||||
|
cci: Optional[List[EmailStr]] = []
|
||||||
|
sujet: str
|
||||||
|
corps_html: str
|
||||||
|
document_ids: Optional[List[str]] = None
|
||||||
|
type_document: Optional[TypeDocument] = None
|
||||||
|
|
||||||
|
class RelanceDevisRequest(BaseModel):
|
||||||
|
doc_id: str
|
||||||
|
message_personnalise: Optional[str] = None
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# SERVICES EXTERNES (Universign)
|
||||||
|
# =====================================================
|
||||||
|
async def universign_envoyer(doc_id: str, pdf_bytes: bytes, email: str, nom: str) -> Dict:
|
||||||
|
"""Envoi signature via API Universign"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
api_key = settings.universign_api_key
|
||||||
|
api_url = settings.universign_api_url
|
||||||
|
auth = (api_key, "")
|
||||||
|
|
||||||
|
# Étape 1: Créer transaction
|
||||||
|
response = requests.post(
|
||||||
|
f"{api_url}/transactions",
|
||||||
|
auth=auth,
|
||||||
|
json={"name": f"Devis {doc_id}", "language": "fr"},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
transaction_id = response.json().get("id")
|
||||||
|
|
||||||
|
# Étape 2: Upload PDF
|
||||||
|
files = {'file': (f'Devis_{doc_id}.pdf', pdf_bytes, 'application/pdf')}
|
||||||
|
response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
file_id = response.json().get("id")
|
||||||
|
|
||||||
|
# Étape 3: Ajouter document
|
||||||
|
response = requests.post(
|
||||||
|
f"{api_url}/transactions/{transaction_id}/documents",
|
||||||
|
auth=auth,
|
||||||
|
data={"document": file_id},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
document_id = response.json().get("id")
|
||||||
|
|
||||||
|
# Étape 4: Créer champ signature
|
||||||
|
response = requests.post(
|
||||||
|
f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields",
|
||||||
|
auth=auth,
|
||||||
|
data={"type": "signature"},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
field_id = response.json().get("id")
|
||||||
|
|
||||||
|
# Étape 5: Assigner signataire
|
||||||
|
response = requests.post(
|
||||||
|
f"{api_url}/transactions/{transaction_id}/signatures",
|
||||||
|
auth=auth,
|
||||||
|
data={"signer": email, "field": field_id},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Étape 6: Démarrer transaction
|
||||||
|
response = requests.post(
|
||||||
|
f"{api_url}/transactions/{transaction_id}/start",
|
||||||
|
auth=auth,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
final_data = response.json()
|
||||||
|
signer_url = final_data.get("actions", [{}])[0].get("url", "") if final_data.get("actions") else ""
|
||||||
|
|
||||||
|
logger.info(f"✅ Signature Universign envoyée: {transaction_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"transaction_id": transaction_id,
|
||||||
|
"signer_url": signer_url,
|
||||||
|
"statut": "ENVOYE"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Erreur Universign: {e}")
|
||||||
|
return {"error": str(e), "statut": "ERREUR"}
|
||||||
|
|
||||||
|
async def universign_statut(transaction_id: str) -> Dict:
|
||||||
|
"""Récupération statut signature"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{settings.universign_api_url}/transactions/{transaction_id}",
|
||||||
|
auth=(settings.universign_api_key, ""),
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
statut_map = {
|
||||||
|
"draft": "EN_ATTENTE",
|
||||||
|
"started": "EN_ATTENTE",
|
||||||
|
"completed": "SIGNE",
|
||||||
|
"refused": "REFUSE",
|
||||||
|
"expired": "EXPIRE",
|
||||||
|
"canceled": "REFUSE"
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"statut": statut_map.get(data.get("state"), "EN_ATTENTE"),
|
||||||
|
"date_signature": data.get("completed_at")
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {"statut": "ERREUR"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur statut Universign: {e}")
|
||||||
|
return {"statut": "ERREUR", "error": str(e)}
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# CYCLE DE VIE
|
||||||
|
# =====================================================
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Init base de données
|
||||||
|
await init_db()
|
||||||
|
logger.info("✅ Base de données initialisée")
|
||||||
|
|
||||||
|
# Injecter session_factory dans email_queue
|
||||||
|
email_queue.session_factory = async_session_factory
|
||||||
|
|
||||||
|
# ⚠️ PAS de sage_connector ici (c'est sur Windows !)
|
||||||
|
# email_queue utilisera sage_client pour générer les PDFs via HTTP
|
||||||
|
|
||||||
|
# Démarrer queue
|
||||||
|
email_queue.start(num_workers=settings.max_email_workers)
|
||||||
|
logger.info(f"✅ Email queue démarrée")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
email_queue.stop()
|
||||||
|
logger.info("👋 Services arrêtés")
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# APPLICATION
|
||||||
|
# =====================================================
|
||||||
|
app = FastAPI(
|
||||||
|
title="API Sage 100c Dataven",
|
||||||
|
version="2.0.0",
|
||||||
|
description="API de gestion commerciale - VPS Linux",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
allow_credentials=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS)
|
||||||
|
# =====================================================
|
||||||
|
@app.get("/clients", response_model=List[ClientResponse], tags=["US-A1"])
|
||||||
|
async def rechercher_clients(query: Optional[str] = Query(None)):
|
||||||
|
"""🔍 Recherche clients via gateway Windows"""
|
||||||
|
try:
|
||||||
|
clients = sage_client.lister_clients(filtre=query or "")
|
||||||
|
return [ClientResponse(**c) for c in clients]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur recherche clients: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@app.get("/articles", response_model=List[ArticleResponse], tags=["US-A1"])
|
||||||
|
async def rechercher_articles(query: Optional[str] = Query(None)):
|
||||||
|
"""🔍 Recherche articles via gateway Windows"""
|
||||||
|
try:
|
||||||
|
articles = sage_client.lister_articles(filtre=query or "")
|
||||||
|
return [ArticleResponse(**a) for a in articles]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur recherche articles: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["US-A1"])
|
||||||
|
async def creer_devis(devis: DevisRequest):
|
||||||
|
"""📝 Création de devis via gateway Windows"""
|
||||||
|
try:
|
||||||
|
# Préparer les données pour la gateway
|
||||||
|
devis_data = {
|
||||||
|
"client_id": devis.client_id,
|
||||||
|
"date_devis": devis.date_devis.isoformat() if devis.date_devis else None,
|
||||||
|
"lignes": [
|
||||||
|
{
|
||||||
|
"article_code": l.article_code,
|
||||||
|
"quantite": l.quantite,
|
||||||
|
"prix_unitaire_ht": l.prix_unitaire_ht,
|
||||||
|
"remise_pourcentage": l.remise_pourcentage
|
||||||
|
}
|
||||||
|
for l in devis.lignes
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Appel HTTP vers Windows
|
||||||
|
resultat = sage_client.creer_devis(devis_data)
|
||||||
|
|
||||||
|
logger.info(f"✅ Devis créé: {resultat.get('numero_devis')}")
|
||||||
|
|
||||||
|
return DevisResponse(
|
||||||
|
id=resultat["numero_devis"],
|
||||||
|
client_id=devis.client_id,
|
||||||
|
date_devis=resultat["date_devis"],
|
||||||
|
montant_total_ht=resultat["total_ht"],
|
||||||
|
montant_total_ttc=resultat["total_ttc"],
|
||||||
|
nb_lignes=resultat["nb_lignes"]
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur création devis: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@app.get("/devis/{id}", tags=["US-A1"])
|
||||||
|
async def lire_devis(id: str):
|
||||||
|
"""📄 Lecture d'un devis via gateway Windows"""
|
||||||
|
try:
|
||||||
|
devis = sage_client.lire_devis(id)
|
||||||
|
if not devis:
|
||||||
|
raise HTTPException(404, f"Devis {id} introuvable")
|
||||||
|
return devis
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture devis: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@app.get("/devis/{id}/pdf", tags=["US-A1"])
|
||||||
|
async def telecharger_devis_pdf(id: str):
|
||||||
|
"""📄 Téléchargement PDF (généré via email_queue)"""
|
||||||
|
try:
|
||||||
|
# Générer PDF en appelant la méthode de email_queue
|
||||||
|
# qui elle-même appellera sage_client pour récupérer les données
|
||||||
|
pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([pdf_bytes]),
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename=Devis_{id}.pdf"}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur génération PDF: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@app.post("/devis/{id}/envoyer", tags=["US-A1"])
|
||||||
|
async def envoyer_devis_email(
|
||||||
|
id: str,
|
||||||
|
request: EmailEnvoiRequest,
|
||||||
|
session: AsyncSession = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""📧 Envoi devis par email"""
|
||||||
|
try:
|
||||||
|
# Vérifier que le devis existe
|
||||||
|
devis = sage_client.lire_devis(id)
|
||||||
|
if not devis:
|
||||||
|
raise HTTPException(404, f"Devis {id} introuvable")
|
||||||
|
|
||||||
|
# Créer logs email pour chaque destinataire
|
||||||
|
tous_destinataires = [request.destinataire] + request.cc + request.cci
|
||||||
|
email_logs = []
|
||||||
|
|
||||||
|
for dest in tous_destinataires:
|
||||||
|
email_log = EmailLogModel(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
destinataire=dest,
|
||||||
|
sujet=request.sujet,
|
||||||
|
corps_html=request.corps_html,
|
||||||
|
document_ids=id,
|
||||||
|
type_document=TypeDocument.DEVIS,
|
||||||
|
statut=StatutEmailEnum.EN_ATTENTE,
|
||||||
|
date_creation=datetime.now(),
|
||||||
|
nb_tentatives=0
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(email_log)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
email_queue.enqueue(email_log.id)
|
||||||
|
email_logs.append(email_log.id)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"✅ Email devis {id} mis en file: {len(tous_destinataires)} destinataire(s)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"email_log_ids": email_logs,
|
||||||
|
"devis_id": id,
|
||||||
|
"message": f"{len(tous_destinataires)} email(s) en file d'attente"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur envoi email: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# ENDPOINTS - US-A2 (WORKFLOW SANS RESSAISIE)
|
||||||
|
# =====================================================
|
||||||
|
@app.post("/workflow/devis/{id}/to-commande", tags=["US-A2"])
|
||||||
|
async def devis_vers_commande(
|
||||||
|
id: str,
|
||||||
|
session: AsyncSession = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""🔧 Transformation Devis → Commande via gateway Windows"""
|
||||||
|
try:
|
||||||
|
# Appel HTTP vers Windows
|
||||||
|
resultat = sage_client.transformer_document(
|
||||||
|
numero_source=id,
|
||||||
|
type_source=TypeDocument.DEVIS,
|
||||||
|
type_cible=TypeDocument.COMMANDE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Logger en DB
|
||||||
|
workflow_log = WorkflowLog(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
document_source=id,
|
||||||
|
type_source=TypeDocument.DEVIS,
|
||||||
|
document_cible=resultat.get("document_cible", ""),
|
||||||
|
type_cible=TypeDocument.COMMANDE,
|
||||||
|
nb_lignes=resultat.get("nb_lignes", 0),
|
||||||
|
date_transformation=datetime.now(),
|
||||||
|
succes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(workflow_log)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"✅ Transformation: Devis {id} → Commande {resultat['document_cible']}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"document_source": id,
|
||||||
|
"document_cible": resultat["document_cible"],
|
||||||
|
"nb_lignes": resultat["nb_lignes"]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur transformation: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
@app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"])
|
||||||
|
async def commande_vers_facture(
|
||||||
|
id: str,
|
||||||
|
session: AsyncSession = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""🔧 Transformation Commande → Facture via gateway Windows"""
|
||||||
|
try:
|
||||||
|
resultat = sage_client.transformer_document(
|
||||||
|
numero_source=id,
|
||||||
|
type_source=TypeDocument.COMMANDE,
|
||||||
|
type_cible=TypeDocument.FACTURE
|
||||||
|
)
|
||||||
|
|
||||||
|
workflow_log = WorkflowLog(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
document_source=id,
|
||||||
|
type_source=TypeDocument.COMMANDE,
|
||||||
|
document_cible=resultat.get("document_cible", ""),
|
||||||
|
type_cible=TypeDocument.FACTURE,
|
||||||
|
nb_lignes=resultat.get("nb_lignes", 0),
|
||||||
|
date_transformation=datetime.now(),
|
||||||
|
succes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(workflow_log)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return resultat
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur transformation: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE)
|
||||||
|
# =====================================================
|
||||||
|
@app.post("/signature/universign/send", tags=["US-A3"])
|
||||||
|
async def envoyer_signature(
|
||||||
|
demande: SignatureRequest,
|
||||||
|
session: AsyncSession = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""✍️ Envoi document pour signature Universign"""
|
||||||
|
try:
|
||||||
|
# Générer PDF
|
||||||
|
pdf_bytes = email_queue._generate_pdf(demande.doc_id, demande.type_doc)
|
||||||
|
|
||||||
|
# Envoi Universign
|
||||||
|
resultat = await universign_envoyer(
|
||||||
|
demande.doc_id,
|
||||||
|
pdf_bytes,
|
||||||
|
demande.email_signataire,
|
||||||
|
demande.nom_signataire
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in resultat:
|
||||||
|
raise HTTPException(500, resultat["error"])
|
||||||
|
|
||||||
|
# Logger en DB
|
||||||
|
signature_log = SignatureLog(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
document_id=demande.doc_id,
|
||||||
|
type_document=demande.type_doc,
|
||||||
|
transaction_id=resultat["transaction_id"],
|
||||||
|
signer_url=resultat["signer_url"],
|
||||||
|
email_signataire=demande.email_signataire,
|
||||||
|
nom_signataire=demande.nom_signataire,
|
||||||
|
statut=StatutSignatureEnum.ENVOYE,
|
||||||
|
date_envoi=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(signature_log)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# MAJ champ libre Sage via gateway Windows
|
||||||
|
sage_client.mettre_a_jour_champ_libre(
|
||||||
|
demande.doc_id,
|
||||||
|
demande.type_doc,
|
||||||
|
"UniversignID",
|
||||||
|
resultat["transaction_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Signature envoyée: {demande.doc_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"transaction_id": resultat["transaction_id"],
|
||||||
|
"signer_url": resultat["signer_url"]
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur signature: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# ENDPOINTS - US-A6 (RELANCE DEVIS)
|
||||||
|
# =====================================================
|
||||||
|
@app.post("/devis/{id}/relancer-signature", tags=["US-A6"])
|
||||||
|
async def relancer_devis_signature(
|
||||||
|
id: str,
|
||||||
|
relance: RelanceDevisRequest,
|
||||||
|
session: AsyncSession = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""📧 Relance devis via Universign"""
|
||||||
|
try:
|
||||||
|
# Lire devis via gateway
|
||||||
|
devis = sage_client.lire_devis(id)
|
||||||
|
if not devis:
|
||||||
|
raise HTTPException(404, f"Devis {id} introuvable")
|
||||||
|
|
||||||
|
# Récupérer contact via gateway
|
||||||
|
contact = sage_client.lire_contact_client(devis["client_code"])
|
||||||
|
if not contact or not contact.get("email"):
|
||||||
|
raise HTTPException(400, "Aucun email trouvé pour ce client")
|
||||||
|
|
||||||
|
# Générer PDF
|
||||||
|
pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS)
|
||||||
|
|
||||||
|
# Envoi Universign
|
||||||
|
resultat = await universign_envoyer(
|
||||||
|
id,
|
||||||
|
pdf_bytes,
|
||||||
|
contact["email"],
|
||||||
|
contact["nom"] or contact["client_intitule"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in resultat:
|
||||||
|
raise HTTPException(500, resultat["error"])
|
||||||
|
|
||||||
|
# Logger en DB
|
||||||
|
signature_log = SignatureLog(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
document_id=id,
|
||||||
|
type_document=TypeDocument.DEVIS,
|
||||||
|
transaction_id=resultat["transaction_id"],
|
||||||
|
signer_url=resultat["signer_url"],
|
||||||
|
email_signataire=contact["email"],
|
||||||
|
nom_signataire=contact["nom"] or contact["client_intitule"],
|
||||||
|
statut=StatutSignatureEnum.ENVOYE,
|
||||||
|
date_envoi=datetime.now(),
|
||||||
|
est_relance=True,
|
||||||
|
nb_relances=1
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(signature_log)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"devis_id": id,
|
||||||
|
"transaction_id": resultat["transaction_id"],
|
||||||
|
"message": "Relance signature envoyée"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur relance: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# ENDPOINTS - HEALTH
|
||||||
|
# =====================================================
|
||||||
|
@app.get("/health", tags=["System"])
|
||||||
|
async def health_check():
|
||||||
|
"""🏥 Health check"""
|
||||||
|
gateway_health = sage_client.health()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"sage_gateway": gateway_health,
|
||||||
|
"email_queue": {
|
||||||
|
"running": email_queue.running,
|
||||||
|
"workers": len(email_queue.workers),
|
||||||
|
"queue_size": email_queue.queue.qsize()
|
||||||
|
},
|
||||||
|
"timestamp": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/", tags=["System"])
|
||||||
|
async def root():
|
||||||
|
"""🏠 Page d'accueil"""
|
||||||
|
return {
|
||||||
|
"api": "Sage 100c Dataven - VPS Linux",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"documentation": "/docs",
|
||||||
|
"health": "/health"
|
||||||
|
}
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# LANCEMENT
|
||||||
|
# =====================================================
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"api:app",
|
||||||
|
host=settings.api_host,
|
||||||
|
port=settings.api_port,
|
||||||
|
reload=settings.api_reload
|
||||||
|
)
|
||||||
43
config.py
Normal file
43
config.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
# === Sage Gateway (Windows) ===
|
||||||
|
sage_gateway_url: str
|
||||||
|
sage_gateway_token: str
|
||||||
|
|
||||||
|
# === Base de données ===
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./sage_dataven.db"
|
||||||
|
|
||||||
|
# === SMTP ===
|
||||||
|
smtp_host: str
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_user: str
|
||||||
|
smtp_password: str
|
||||||
|
smtp_from: str
|
||||||
|
|
||||||
|
# === Universign ===
|
||||||
|
universign_api_key: str
|
||||||
|
universign_api_url: str = "https://api.universign.com/v1"
|
||||||
|
|
||||||
|
# === API ===
|
||||||
|
api_host: str = "0.0.0.0"
|
||||||
|
api_port: int = 8002
|
||||||
|
api_reload: bool = False
|
||||||
|
|
||||||
|
# === Email Queue ===
|
||||||
|
max_email_workers: int = 3
|
||||||
|
max_retry_attempts: int = 3
|
||||||
|
retry_delay_seconds: int = 60
|
||||||
|
|
||||||
|
# === CORS ===
|
||||||
|
cors_origins: List[str] = ["*"]
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
99
sage_client.py
Normal file
99
sage_client.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import requests
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from config import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SageGatewayClient:
|
||||||
|
"""
|
||||||
|
Client HTTP pour communiquer avec la gateway Sage Windows
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.url = settings.sage_gateway_url.rstrip("/")
|
||||||
|
self.headers = {
|
||||||
|
"X-Sage-Token": settings.sage_gateway_token,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
self.timeout = 30
|
||||||
|
|
||||||
|
def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict:
|
||||||
|
"""POST avec retry automatique"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
for attempt in range(retries):
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
f"{self.url}{endpoint}",
|
||||||
|
json=data or {},
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
if attempt == retries - 1:
|
||||||
|
logger.error(f"❌ Échec après {retries} tentatives sur {endpoint}: {e}")
|
||||||
|
raise
|
||||||
|
time.sleep(2 ** attempt) # Backoff exponentiel
|
||||||
|
|
||||||
|
# === CLIENTS ===
|
||||||
|
def lister_clients(self, filtre: str = "") -> List[Dict]:
|
||||||
|
return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
|
def lire_client(self, code: str) -> Optional[Dict]:
|
||||||
|
return self._post("/sage/clients/get", {"code": code}).get("data")
|
||||||
|
|
||||||
|
# === ARTICLES ===
|
||||||
|
def lister_articles(self, filtre: str = "") -> List[Dict]:
|
||||||
|
return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
|
def lire_article(self, ref: str) -> Optional[Dict]:
|
||||||
|
return self._post("/sage/articles/get", {"code": ref}).get("data")
|
||||||
|
|
||||||
|
# === DEVIS ===
|
||||||
|
def creer_devis(self, devis_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/devis/create", devis_data).get("data", {})
|
||||||
|
|
||||||
|
def lire_devis(self, numero: str) -> Optional[Dict]:
|
||||||
|
return self._post("/sage/devis/get", {"code": numero}).get("data")
|
||||||
|
|
||||||
|
# === DOCUMENTS ===
|
||||||
|
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
|
||||||
|
return self._post("/sage/documents/get", {"numero": numero, "type_doc": type_doc}).get("data")
|
||||||
|
|
||||||
|
def transformer_document(self, numero_source: str, type_source: int, type_cible: int) -> Dict:
|
||||||
|
return self._post("/sage/documents/transform", {
|
||||||
|
"numero_source": numero_source,
|
||||||
|
"type_source": type_source,
|
||||||
|
"type_cible": type_cible
|
||||||
|
}).get("data", {})
|
||||||
|
|
||||||
|
def mettre_a_jour_champ_libre(self, doc_id: str, type_doc: int, nom_champ: str, valeur: str) -> bool:
|
||||||
|
resp = self._post("/sage/documents/champ-libre", {
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"type_doc": type_doc,
|
||||||
|
"nom_champ": nom_champ,
|
||||||
|
"valeur": valeur
|
||||||
|
})
|
||||||
|
return resp.get("success", False)
|
||||||
|
|
||||||
|
# === CONTACTS ===
|
||||||
|
def lire_contact_client(self, code_client: str) -> Optional[Dict]:
|
||||||
|
return self._post("/sage/contact/read", {"code": code_client}).get("data")
|
||||||
|
|
||||||
|
# === CACHE ===
|
||||||
|
def refresh_cache(self) -> Dict:
|
||||||
|
return self._post("/sage/cache/refresh")
|
||||||
|
|
||||||
|
# === HEALTH ===
|
||||||
|
def health(self) -> dict:
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{self.url}/health", timeout=5)
|
||||||
|
return r.json()
|
||||||
|
except:
|
||||||
|
return {"status": "down"}
|
||||||
|
|
||||||
|
# Instance globale
|
||||||
|
sage_client = SageGatewayClient()
|
||||||
Loading…
Reference in a new issue