3293 lines
104 KiB
Python
3293 lines
104 KiB
Python
from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import StreamingResponse
|
|
from fastapi.encoders import jsonable_encoder
|
|
from pydantic import BaseModel, Field, EmailStr
|
|
from typing import List, Optional
|
|
from datetime import datetime
|
|
import uvicorn
|
|
from contextlib import asynccontextmanager
|
|
import uuid
|
|
import csv
|
|
import io
|
|
import logging
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
import os
|
|
from pathlib import Path as FilePath
|
|
from data.data import TAGS_METADATA, templates_signature_email
|
|
from routes.auth import router as auth_router
|
|
from config.config import settings
|
|
from database import (
|
|
init_db,
|
|
async_session_factory,
|
|
get_session,
|
|
EmailLog,
|
|
StatutEmail as StatutEmailDB,
|
|
WorkflowLog,
|
|
SignatureLog,
|
|
StatutSignature as StatutSignatureDB,
|
|
)
|
|
from email_queue import email_queue
|
|
from sage_client import sage_client, SageGatewayClient
|
|
|
|
from schemas import (
|
|
TiersDetails,
|
|
BaremeRemiseResponse,
|
|
Users,
|
|
ClientCreate,
|
|
ClientDetails,
|
|
ClientUpdate,
|
|
FournisseurCreate,
|
|
FournisseurDetails,
|
|
FournisseurUpdate,
|
|
Contact,
|
|
AvoirCreate,
|
|
AvoirUpdate,
|
|
CommandeCreate,
|
|
CommandeUpdate,
|
|
DevisRequest,
|
|
Devis,
|
|
DevisUpdate,
|
|
TypeDocument,
|
|
TypeDocumentSQL,
|
|
StatutEmail,
|
|
EmailEnvoi,
|
|
FactureCreate,
|
|
FactureUpdate,
|
|
LivraisonCreate,
|
|
LivraisonUpdate,
|
|
Signature,
|
|
StatutSignature,
|
|
ArticleCreate,
|
|
Article,
|
|
ArticleUpdate,
|
|
EntreeStock,
|
|
SortieStock,
|
|
MouvementStock,
|
|
RelanceDevis,
|
|
Familles,
|
|
FamilleCreate,
|
|
ContactCreate,
|
|
ContactUpdate,
|
|
)
|
|
from schemas.tiers.commercial import (
|
|
CollaborateurCreate,
|
|
CollaborateurDetails,
|
|
CollaborateurListe,
|
|
CollaborateurUpdate,
|
|
)
|
|
from utils.normalization import normaliser_type_tiers
|
|
from routes.sage_gateway import router as sage_gateway_router
|
|
from core.sage_context import (
|
|
get_sage_client_for_user,
|
|
get_gateway_context_for_user,
|
|
GatewayContext,
|
|
)
|
|
from utils.generic_functions import _preparer_lignes_document
|
|
|
|
if os.path.exists("/app"):
|
|
LOGS_DIR = FilePath("/app/logs")
|
|
else:
|
|
LOGS_DIR = FilePath(__file__).resolve().parent / "logs"
|
|
|
|
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
handlers=[
|
|
logging.FileHandler(LOGS_DIR / "sage_api.log", encoding="utf-8"),
|
|
logging.StreamHandler(),
|
|
],
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
await init_db()
|
|
logger.info("Base de données initialisée")
|
|
|
|
email_queue.session_factory = async_session_factory
|
|
email_queue.sage_client = sage_client
|
|
|
|
logger.info("sage_client injecté dans email_queue")
|
|
|
|
email_queue.start(num_workers=settings.max_email_workers)
|
|
logger.info("Email queue démarrée")
|
|
|
|
yield
|
|
|
|
email_queue.stop()
|
|
logger.info("Services arrêtés")
|
|
|
|
|
|
app = FastAPI(
|
|
title="Sage Gateways",
|
|
version="3.0.0",
|
|
description="Configuration multi-tenant des connexions Sage Gateway",
|
|
lifespan=lifespan,
|
|
openapi_tags=TAGS_METADATA,
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origins,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
allow_headers=["*"],
|
|
allow_credentials=True,
|
|
)
|
|
|
|
|
|
app.include_router(auth_router)
|
|
app.include_router(sage_gateway_router)
|
|
|
|
|
|
async def universign_envoyer(
|
|
doc_id: str,
|
|
pdf_bytes: bytes,
|
|
email: str,
|
|
nom: str,
|
|
doc_data: dict,
|
|
session: AsyncSession,
|
|
) -> dict:
|
|
import requests
|
|
|
|
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}")
|
|
|
|
if not pdf_bytes or len(pdf_bytes) == 0:
|
|
raise Exception("Le PDF généré est vide")
|
|
|
|
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:
|
|
raise Exception(f"Erreur création transaction: {response.status_code}")
|
|
transaction_id = response.json().get("id")
|
|
|
|
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]:
|
|
raise Exception(f"Erreur upload fichier: {response.status_code}")
|
|
file_id = response.json().get("id")
|
|
|
|
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]:
|
|
raise Exception(f"Erreur ajout document: {response.status_code}")
|
|
document_id = response.json().get("id")
|
|
|
|
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]:
|
|
raise Exception(f"Erreur création champ: {response.status_code}")
|
|
field_id = response.json().get("id")
|
|
|
|
response = requests.post(
|
|
f"{api_url}/transactions/{transaction_id}/signatures",
|
|
auth=auth,
|
|
data={"signer": email, "field": field_id},
|
|
timeout=30,
|
|
)
|
|
if response.status_code not in [200, 201]:
|
|
raise Exception(f"Erreur liaison signataire: {response.status_code}")
|
|
|
|
response = requests.post(
|
|
f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30
|
|
)
|
|
if response.status_code not in [200, 201]:
|
|
raise Exception(f"Erreur démarrage: {response.status_code}")
|
|
final_data = response.json()
|
|
|
|
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:
|
|
raise ValueError("URL de signature non retournée par Universign")
|
|
|
|
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=StatutEmailDB.EN_ATTENTE,
|
|
date_creation=datetime.now(),
|
|
nb_tentatives=0,
|
|
)
|
|
session.add(email_log)
|
|
await session.flush()
|
|
email_queue.enqueue(email_log.id)
|
|
|
|
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:
|
|
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"),
|
|
}
|
|
return {"statut": "ERREUR"}
|
|
except Exception as e:
|
|
logger.error(f"Erreur statut Universign: {e}")
|
|
return {"statut": "ERREUR", "error": str(e)}
|
|
|
|
|
|
@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"])
|
|
async def obtenir_clients(
|
|
query: Optional[str] = Query(None),
|
|
sage: SageGatewayClient = Depends(get_sage_client_for_user),
|
|
):
|
|
try:
|
|
clients = sage.lister_clients(filtre=query or "")
|
|
return [ClientDetails(**c) for c in clients]
|
|
except Exception as e:
|
|
logger.error(f"Erreur recherche clients: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/clients/{code}", response_model=ClientDetails, tags=["Clients"])
|
|
async def lire_client_detail(code: str):
|
|
try:
|
|
client = sage_client.lire_client(code)
|
|
|
|
if not client:
|
|
raise HTTPException(404, f"Client {code} introuvable")
|
|
|
|
return ClientDetails(**client)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture client {code}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put("/clients/{code}", tags=["Clients"])
|
|
async def modifier_client(
|
|
code: str,
|
|
client_update: ClientUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
resultat = sage_client.modifier_client(
|
|
code, client_update.dict(exclude_none=True)
|
|
)
|
|
|
|
logger.info(f"Client {code} modifié avec succès")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Client {code} modifié avec succès",
|
|
"client": resultat,
|
|
}
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Erreur métier modification client {code}: {e}")
|
|
raise HTTPException(404, str(e))
|
|
except Exception as e:
|
|
logger.error(f"Erreur technique modification client {code}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/clients", status_code=201, tags=["Clients"])
|
|
async def ajouter_client(
|
|
client: ClientCreate, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
nouveau_client = sage_client.creer_client(client.model_dump(mode="json"))
|
|
|
|
logger.info(f"Client créé via API: {nouveau_client.get('numero')}")
|
|
|
|
return jsonable_encoder(
|
|
{
|
|
"success": True,
|
|
"message": "Client créé avec succès",
|
|
"data": nouveau_client,
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de la création du client: {e}")
|
|
status = 400 if "existe déjà" in str(e) else 500
|
|
raise HTTPException(status, str(e))
|
|
|
|
|
|
@app.get("/articles", response_model=List[Article], tags=["Articles"])
|
|
async def rechercher_articles(query: Optional[str] = Query(None)):
|
|
try:
|
|
articles = sage_client.lister_articles(filtre=query or "")
|
|
return [Article(**a) for a in articles]
|
|
except Exception as e:
|
|
logger.error(f"Erreur recherche articles: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post(
|
|
"/articles",
|
|
response_model=Article,
|
|
status_code=status.HTTP_201_CREATED,
|
|
tags=["Articles"],
|
|
)
|
|
async def creer_article(article: ArticleCreate):
|
|
try:
|
|
if not article.reference or not article.designation:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Les champs 'reference' et 'designation' sont obligatoires",
|
|
)
|
|
|
|
article_data = article.dict(exclude_unset=True)
|
|
|
|
logger.info(f"Création article: {article.reference} - {article.designation}")
|
|
|
|
resultat = sage_client.creer_article(article_data)
|
|
|
|
logger.info(
|
|
f"Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})"
|
|
)
|
|
|
|
return Article(**resultat)
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Erreur métier création article: {e}")
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
|
|
except HTTPException:
|
|
raise
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur technique création article: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la création de l'article: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.put("/articles/{reference}", response_model=Article, tags=["Articles"])
|
|
async def modifier_article(
|
|
reference: str = Path(..., description="Référence de l'article à modifier"),
|
|
article: ArticleUpdate = Body(...),
|
|
):
|
|
try:
|
|
article_data = article.dict(exclude_unset=True)
|
|
|
|
if not article_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour.",
|
|
)
|
|
|
|
logger.info(f"Modification article {reference}: {list(article_data.keys())}")
|
|
|
|
resultat = sage_client.modifier_article(reference, article_data)
|
|
|
|
if "stock_reel" in article_data:
|
|
logger.info(
|
|
f"Stock {reference} modifié: {article_data['stock_reel']} "
|
|
f"(peut résoudre erreur 2881)"
|
|
)
|
|
|
|
logger.info(f"Article {reference} modifié ({len(article_data)} champs)")
|
|
|
|
return Article(**resultat)
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Erreur métier modification article: {e}")
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
|
|
|
except HTTPException:
|
|
raise
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur technique modification article: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la modification de l'article: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.get("/articles/{reference}", response_model=Article, tags=["Articles"])
|
|
async def lire_article(
|
|
reference: str = Path(..., description="Référence de l'article"),
|
|
):
|
|
try:
|
|
article = sage_client.lire_article(reference)
|
|
|
|
if not article:
|
|
logger.warning(f"Article {reference} introuvable")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Article {reference} introuvable",
|
|
)
|
|
|
|
logger.info(f"Article {reference} lu: {article.get('designation', '')}")
|
|
|
|
return Article(**article)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture article {reference}: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la lecture de l'article: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.post("/devis", response_model=Devis, status_code=201, tags=["Devis"])
|
|
async def creer_devis(devis: DevisRequest):
|
|
try:
|
|
devis_data = {
|
|
"client_id": devis.client_id,
|
|
"date_devis": devis.date_devis.isoformat() if devis.date_devis else None,
|
|
"date_livraison": (
|
|
devis.date_livraison.isoformat() if devis.date_livraison else None
|
|
),
|
|
"reference": devis.reference,
|
|
"lignes": _preparer_lignes_document(devis.lignes),
|
|
}
|
|
|
|
resultat = sage_client.creer_devis(devis_data)
|
|
|
|
logger.info(
|
|
f"✅ Devis créé: {resultat.get('numero_devis')} "
|
|
f"({resultat.get('total_ttc')}€ TTC)"
|
|
)
|
|
|
|
return Devis(
|
|
id=resultat["numero_devis"],
|
|
client_id=devis.client_id,
|
|
date_devis=resultat["date_devis"],
|
|
montant_total_ht=resultat["total_ht"],
|
|
montant_total_ttc=resultat["total_ttc"],
|
|
nb_lignes=resultat["nb_lignes"],
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur création devis: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put("/devis/{id}", tags=["Devis"])
|
|
async def modifier_devis(
|
|
id: str,
|
|
devis_update: DevisUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
update_data = {}
|
|
|
|
if devis_update.date_devis:
|
|
update_data["date_devis"] = devis_update.date_devis.isoformat()
|
|
|
|
if devis_update.lignes is not None:
|
|
update_data["lignes"] = [
|
|
{
|
|
"article_code": ligne.article_code,
|
|
"quantite": ligne.quantite,
|
|
"remise_pourcentage": ligne.remise_pourcentage,
|
|
}
|
|
for ligne in devis_update.lignes
|
|
]
|
|
|
|
if devis_update.statut is not None:
|
|
update_data["statut"] = devis_update.statut
|
|
|
|
if devis_update.reference is not None:
|
|
update_data["reference"] = devis_update.reference
|
|
|
|
resultat = sage_client.modifier_devis(id, update_data)
|
|
|
|
logger.info(f"Devis {id} modifié avec succès")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Devis {id} modifié avec succès",
|
|
"devis": resultat,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur modification devis {id}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/commandes", status_code=201, tags=["Commandes"])
|
|
async def creer_commande(
|
|
commande: CommandeCreate, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
commande_data = {
|
|
"client_id": commande.client_id,
|
|
"date_commande": (
|
|
commande.date_commande.isoformat() if commande.date_commande else None
|
|
),
|
|
"date_livraison": (
|
|
commande.date_livraison.isoformat() if commande.date_livraison else None
|
|
),
|
|
"reference": commande.reference,
|
|
"lignes": _preparer_lignes_document(commande.lignes),
|
|
}
|
|
|
|
resultat = sage_client.creer_commande(commande_data)
|
|
|
|
logger.info(
|
|
f"✅ Commande créée: {resultat.get('numero_commande')} "
|
|
f"({resultat.get('total_ttc')}€ TTC)"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Commande créée avec succès",
|
|
"data": {
|
|
"numero_commande": resultat["numero_commande"],
|
|
"client_id": commande.client_id,
|
|
"date_commande": resultat["date_commande"],
|
|
"total_ht": resultat["total_ht"],
|
|
"total_ttc": resultat["total_ttc"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
"reference": resultat.get("reference"),
|
|
"date_livraison": resultat.get("date_livraison"),
|
|
},
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur création commande: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put("/commandes/{id}", tags=["Commandes"])
|
|
async def modifier_commande(
|
|
id: str,
|
|
commande_update: CommandeUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
update_data = {}
|
|
|
|
if commande_update.date_commande:
|
|
update_data["date_commande"] = commande_update.date_commande.isoformat()
|
|
|
|
if commande_update.lignes is not None:
|
|
update_data["lignes"] = [
|
|
{
|
|
"article_code": ligne.article_code,
|
|
"quantite": ligne.quantite,
|
|
"remise_pourcentage": ligne.remise_pourcentage,
|
|
}
|
|
for ligne in commande_update.lignes
|
|
]
|
|
|
|
if commande_update.statut is not None:
|
|
update_data["statut"] = commande_update.statut
|
|
|
|
if commande_update.reference is not None:
|
|
update_data["reference"] = commande_update.reference
|
|
|
|
resultat = sage_client.modifier_commande(id, update_data)
|
|
|
|
logger.info(f"Commande {id} modifiée avec succès")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Commande {id} modifiée avec succès",
|
|
"commande": resultat,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur modification commande {id}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/devis", tags=["Devis"])
|
|
async def lister_devis(
|
|
limit: int = Query(100, le=1000),
|
|
statut: Optional[int] = Query(None),
|
|
inclure_lignes: bool = Query(
|
|
True, description="Inclure les lignes de chaque devis"
|
|
),
|
|
):
|
|
try:
|
|
devis_list = sage_client.lister_devis(
|
|
limit=limit, statut=statut, inclure_lignes=inclure_lignes
|
|
)
|
|
return devis_list
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste devis: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/devis/{id}", tags=["Devis"])
|
|
async def lire_devis(id: str):
|
|
try:
|
|
devis = sage_client.lire_devis(id)
|
|
|
|
if not devis:
|
|
raise HTTPException(404, f"Devis {id} introuvable")
|
|
|
|
return {"success": True, "data": devis}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture devis: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/devis/{id}/pdf", tags=["Devis"])
|
|
async def telecharger_devis_pdf(id: str):
|
|
try:
|
|
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.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"])
|
|
async def telecharger_document_pdf(
|
|
type_doc: int = Path(
|
|
...,
|
|
description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)",
|
|
),
|
|
numero: str = Path(..., description="Numéro du document"),
|
|
):
|
|
try:
|
|
types_labels = {
|
|
0: "Devis",
|
|
10: "Commande",
|
|
20: "Preparation",
|
|
30: "BonLivraison",
|
|
40: "BonRetour",
|
|
50: "Avoir",
|
|
60: "Facture",
|
|
}
|
|
|
|
if type_doc not in types_labels:
|
|
raise HTTPException(
|
|
400,
|
|
f"Type de document invalide: {type_doc}. "
|
|
f"Types valides: {list(types_labels.keys())}",
|
|
)
|
|
|
|
label = types_labels[type_doc]
|
|
|
|
logger.info(f"Génération PDF: {label} {numero} (type={type_doc})")
|
|
|
|
pdf_bytes = sage_client.generer_pdf_document(numero, type_doc)
|
|
|
|
if not pdf_bytes:
|
|
raise HTTPException(500, f"Le PDF du document {numero} est vide")
|
|
|
|
logger.info(f"PDF généré: {len(pdf_bytes)} octets")
|
|
|
|
filename = f"{label}_{numero}.pdf"
|
|
|
|
return StreamingResponse(
|
|
iter([pdf_bytes]),
|
|
media_type="application/pdf",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename={filename}",
|
|
"Content-Length": str(len(pdf_bytes)),
|
|
},
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True
|
|
)
|
|
raise HTTPException(500, f"Erreur génération PDF: {str(e)}")
|
|
|
|
|
|
@app.post("/devis/{id}/envoyer", tags=["Devis"])
|
|
async def envoyer_devis_email(
|
|
id: str, request: EmailEnvoi, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
tous_destinataires = [request.destinataire] + request.cc + request.cci
|
|
email_logs = []
|
|
|
|
for dest in tous_destinataires:
|
|
email_log = EmailLog(
|
|
id=str(uuid.uuid4()),
|
|
destinataire=dest,
|
|
sujet=request.sujet,
|
|
corps_html=request.corps_html,
|
|
document_ids=id,
|
|
type_document=TypeDocument.DEVIS,
|
|
statut=StatutEmailDB.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))
|
|
|
|
|
|
@app.put("/document/{type_doc}/{numero}/statut", tags=["Documents"])
|
|
async def changer_statut_document(
|
|
type_doc: int = Path(
|
|
...,
|
|
description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)",
|
|
),
|
|
numero: str = Path(..., description="Numéro du document"),
|
|
nouveau_statut: int = Query(
|
|
..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté"
|
|
),
|
|
):
|
|
document_type_sql = None
|
|
document_type_code = None
|
|
type_doc_normalized = None
|
|
|
|
try:
|
|
match type_doc:
|
|
case 0:
|
|
document_type_sql = TypeDocumentSQL.DEVIS
|
|
document_type_code = TypeDocument.DEVIS
|
|
type_doc_normalized = 0
|
|
case 10 | 1:
|
|
document_type_sql = TypeDocumentSQL.BON_COMMANDE
|
|
document_type_code = TypeDocument.BON_COMMANDE
|
|
type_doc_normalized = 10
|
|
case 20 | 2:
|
|
document_type_sql = TypeDocumentSQL.PREPARATION
|
|
document_type_code = TypeDocument.PREPARATION
|
|
type_doc_normalized = 20
|
|
case 30 | 3:
|
|
document_type_sql = TypeDocumentSQL.BON_LIVRAISON
|
|
document_type_code = TypeDocument.BON_LIVRAISON
|
|
type_doc_normalized = 30
|
|
case 40 | 4:
|
|
document_type_sql = TypeDocumentSQL.BON_RETOUR
|
|
document_type_code = TypeDocument.BON_RETOUR
|
|
type_doc_normalized = 40
|
|
case 50 | 5:
|
|
document_type_sql = TypeDocumentSQL.BON_AVOIR
|
|
document_type_code = TypeDocument.BON_AVOIR
|
|
type_doc_normalized = 50
|
|
case 60 | 6:
|
|
document_type_sql = TypeDocumentSQL.FACTURE
|
|
document_type_code = TypeDocument.FACTURE
|
|
type_doc_normalized = 60
|
|
case _:
|
|
raise HTTPException(
|
|
400,
|
|
f"Type de document invalide: {type_doc}",
|
|
)
|
|
|
|
document_existant = sage_client.lire_document(numero, document_type_sql)
|
|
if not document_existant:
|
|
raise HTTPException(404, f"Document {numero} introuvable")
|
|
|
|
statut_actuel = document_existant.get("statut", 0)
|
|
|
|
match type_doc:
|
|
case 0:
|
|
if statut_actuel >= 2:
|
|
statuts_devis = {2: "accepté", 3: "perdu", 4: "archivé"}
|
|
raise HTTPException(
|
|
400,
|
|
f"Le devis {numero} est {statuts_devis.get(statut_actuel, 'verrouillé')} "
|
|
f"et ne peut plus changer de statut",
|
|
)
|
|
|
|
case 10 | 1 | 20 | 2 | 30 | 3 | 40 | 4 | 50 | 5 | 60 | 6:
|
|
if statut_actuel >= 2:
|
|
type_names = {
|
|
10: "la commande",
|
|
1: "la commande",
|
|
20: "la préparation",
|
|
2: "la préparation",
|
|
30: "la livraison",
|
|
3: "la livraison",
|
|
40: "le retour",
|
|
4: "le retour",
|
|
50: "l'avoir",
|
|
5: "l'avoir",
|
|
60: "la facture",
|
|
6: "la facture",
|
|
}
|
|
raise HTTPException(
|
|
400,
|
|
f"Le document {numero} ({type_names.get(type_doc, 'document')}) "
|
|
f"ne peut plus changer de statut (statut actuel ≥ 2)",
|
|
)
|
|
|
|
document_type_int = (
|
|
document_type_code.value
|
|
if hasattr(document_type_code, "value")
|
|
else type_doc_normalized
|
|
)
|
|
|
|
resultat = sage_client.changer_statut_document(
|
|
document_type_code=document_type_int,
|
|
numero=numero,
|
|
nouveau_statut=nouveau_statut,
|
|
)
|
|
|
|
logger.info(
|
|
f"Statut document {numero} changé: {statut_actuel} → {nouveau_statut}"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"document_id": numero,
|
|
"type_document_code": document_type_int,
|
|
"type_document_sql": str(document_type_sql.value),
|
|
"statut_ancien": resultat.get("statut_ancien", statut_actuel),
|
|
"statut_nouveau": resultat.get("statut_nouveau", nouveau_statut),
|
|
"message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}",
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur changement statut document {numero}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/commandes/{id}", tags=["Commandes"])
|
|
async def lire_commande(id: str):
|
|
try:
|
|
commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE)
|
|
if not commande:
|
|
raise HTTPException(404, f"Commande {id} introuvable")
|
|
return commande
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture commande: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/commandes", tags=["Commandes"])
|
|
async def lister_commandes(
|
|
limit: int = Query(100, le=1000), statut: Optional[int] = Query(None)
|
|
):
|
|
try:
|
|
commandes = sage_client.lister_commandes(limit=limit, statut=statut)
|
|
return commandes
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste commandes: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"])
|
|
async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)):
|
|
try:
|
|
resultat = sage_client.transformer_document(
|
|
numero_source=id,
|
|
type_source=settings.SAGE_TYPE_DEVIS, # = 0
|
|
type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10
|
|
)
|
|
|
|
workflow_log = WorkflowLog(
|
|
id=str(uuid.uuid4()),
|
|
document_source=id,
|
|
type_source=TypeDocument.DEVIS,
|
|
document_cible=resultat.get("document_cible", ""),
|
|
type_cible=TypeDocument.BON_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"],
|
|
"statut_devis_mis_a_jour": True,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur transformation: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"])
|
|
async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)):
|
|
try:
|
|
resultat = sage_client.transformer_document(
|
|
numero_source=id,
|
|
type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10
|
|
type_cible=settings.SAGE_TYPE_FACTURE, # = 60
|
|
)
|
|
|
|
workflow_log = WorkflowLog(
|
|
id=str(uuid.uuid4()),
|
|
document_source=id,
|
|
type_source=TypeDocument.BON_COMMANDE,
|
|
document_cible=resultat.get("document_cible", ""),
|
|
type_cible=TypeDocument.FACTURE,
|
|
nb_lignes=resultat.get("nb_lignes", 0),
|
|
date_transformation=datetime.now(),
|
|
succes=True,
|
|
)
|
|
|
|
session.add(workflow_log)
|
|
await session.commit()
|
|
|
|
logger.info(
|
|
f"Transformation: Commande {id} → Facture {resultat['document_cible']}"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"document_source": id,
|
|
"document_cible": resultat["document_cible"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur transformation: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
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
|
|
|
|
|
|
@app.post("/signature/universign/send", tags=["Signatures"])
|
|
async def envoyer_signature_optimise(
|
|
demande: Signature, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
doc = sage_client.lire_document(
|
|
demande.doc_id, normaliser_type_doc(demande.type_doc)
|
|
)
|
|
if not doc:
|
|
raise HTTPException(404, f"Document {demande.doc_id} introuvable")
|
|
|
|
pdf_bytes = email_queue._generate_pdf(
|
|
demande.doc_id, normaliser_type_doc(demande.type_doc)
|
|
)
|
|
|
|
doc_data = {
|
|
"type_doc": demande.type_doc,
|
|
"type_label": {
|
|
0: "Devis",
|
|
10: "Commande",
|
|
30: "Bon de Livraison",
|
|
60: "Facture",
|
|
50: "Avoir",
|
|
}.get(demande.type_doc, "Document"),
|
|
"montant_ttc": doc.get("total_ttc", 0),
|
|
"date": doc.get("date", datetime.now().strftime("%d/%m/%Y")),
|
|
}
|
|
|
|
resultat = await universign_envoyer(
|
|
doc_id=demande.doc_id,
|
|
pdf_bytes=pdf_bytes,
|
|
email=demande.email_signataire,
|
|
nom=demande.nom_signataire,
|
|
doc_data=doc_data,
|
|
session=session,
|
|
)
|
|
|
|
if "error" in resultat:
|
|
raise HTTPException(500, resultat["error"])
|
|
|
|
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=StatutSignatureDB.ENVOYE,
|
|
date_envoi=datetime.now(),
|
|
)
|
|
|
|
session.add(signature_log)
|
|
await session.commit()
|
|
|
|
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} (Email: {resultat['email_sent']})"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"transaction_id": resultat["transaction_id"],
|
|
"signer_url": resultat["signer_url"],
|
|
"email_sent": resultat["email_sent"],
|
|
"email_log_id": resultat.get("email_log_id"),
|
|
"message": f"Demande de signature envoyée à {demande.email_signataire}",
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur signature: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/webhooks/universign", tags=["Signatures"])
|
|
async def webhook_universign(
|
|
request: Request, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
payload = await request.json()
|
|
|
|
event_type = payload.get("event")
|
|
transaction_id = payload.get("transaction_id")
|
|
|
|
if not transaction_id:
|
|
logger.warning("Webhook sans transaction_id")
|
|
return {"status": "ignored"}
|
|
|
|
query = select(SignatureLog).where(
|
|
SignatureLog.transaction_id == transaction_id
|
|
)
|
|
result = await session.execute(query)
|
|
signature_log = result.scalar_one_or_none()
|
|
|
|
if not signature_log:
|
|
logger.warning(f"Transaction {transaction_id} introuvable en DB")
|
|
return {"status": "not_found"}
|
|
|
|
if event_type == "transaction.completed":
|
|
signature_log.statut = StatutSignatureDB.SIGNE
|
|
signature_log.date_signature = datetime.now()
|
|
|
|
logger.info(f"Signature confirmée: {signature_log.document_id}")
|
|
|
|
template = templates_signature_email["signature_confirmee"]
|
|
|
|
type_labels = {
|
|
0: "Devis",
|
|
10: "Commande",
|
|
30: "Bon de Livraison",
|
|
60: "Facture",
|
|
50: "Avoir",
|
|
}
|
|
|
|
variables = {
|
|
"NOM_SIGNATAIRE": signature_log.nom_signataire,
|
|
"TYPE_DOC": type_labels.get(signature_log.type_document, "Document"),
|
|
"NUMERO": signature_log.document_id,
|
|
"DATE_SIGNATURE": datetime.now().strftime("%d/%m/%Y à %H:%M"),
|
|
"TRANSACTION_ID": transaction_id,
|
|
"CONTACT_EMAIL": settings.smtp_from,
|
|
}
|
|
|
|
sujet = template["sujet"]
|
|
corps = template["corps_html"]
|
|
|
|
for var, valeur in variables.items():
|
|
sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur))
|
|
corps = corps.replace(f"{{{{{var}}}}}", str(valeur))
|
|
|
|
email_log = EmailLog(
|
|
id=str(uuid.uuid4()),
|
|
destinataire=signature_log.email_signataire,
|
|
sujet=sujet,
|
|
corps_html=corps,
|
|
document_ids=signature_log.document_id,
|
|
type_document=signature_log.type_document,
|
|
statut=StatutEmailDB.EN_ATTENTE,
|
|
date_creation=datetime.now(),
|
|
nb_tentatives=0,
|
|
)
|
|
|
|
session.add(email_log)
|
|
email_queue.enqueue(email_log.id)
|
|
|
|
logger.info(
|
|
f" Email de confirmation envoyé: {signature_log.email_signataire}"
|
|
)
|
|
|
|
elif event_type == "transaction.refused":
|
|
signature_log.statut = StatutSignatureDB.REFUSE
|
|
logger.warning(f"Signature refusée: {signature_log.document_id}")
|
|
|
|
elif event_type == "transaction.expired":
|
|
signature_log.statut = StatutSignatureDB.EXPIRE
|
|
logger.warning(f"⏰ Transaction expirée: {signature_log.document_id}")
|
|
|
|
await session.commit()
|
|
|
|
return {
|
|
"status": "processed",
|
|
"event": event_type,
|
|
"transaction_id": transaction_id,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur webhook Universign: {e}")
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
|
|
@app.get("/admin/signatures/relances-auto", tags=["Admin"])
|
|
async def relancer_signatures_automatique(session: AsyncSession = Depends(get_session)):
|
|
try:
|
|
from datetime import timedelta
|
|
|
|
date_limite = datetime.now() - timedelta(days=7)
|
|
|
|
query = select(SignatureLog).where(
|
|
SignatureLog.statut.in_(
|
|
[StatutSignatureDB.EN_ATTENTE, StatutSignatureDB.ENVOYE]
|
|
),
|
|
SignatureLog.date_envoi < date_limite,
|
|
SignatureLog.nb_relances < 3, # Max 3 relances
|
|
)
|
|
|
|
result = await session.execute(query)
|
|
signatures_a_relancer = result.scalars().all()
|
|
|
|
nb_relances = 0
|
|
|
|
for signature in signatures_a_relancer:
|
|
try:
|
|
nb_jours = (datetime.now() - signature.date_envoi).days
|
|
jours_restants = 30 - nb_jours # Lien expire après 30 jours
|
|
|
|
if jours_restants <= 0:
|
|
signature.statut = StatutSignatureDB.EXPIRE
|
|
continue
|
|
|
|
template = templates_signature_email["relance_signature"]
|
|
|
|
type_labels = {
|
|
0: "Devis",
|
|
10: "Commande",
|
|
30: "Bon de Livraison",
|
|
60: "Facture",
|
|
50: "Avoir",
|
|
}
|
|
|
|
variables = {
|
|
"NOM_SIGNATAIRE": signature.nom_signataire,
|
|
"TYPE_DOC": type_labels.get(signature.type_document, "Document"),
|
|
"NUMERO": signature.document_id,
|
|
"NB_JOURS": str(nb_jours),
|
|
"JOURS_RESTANTS": str(jours_restants),
|
|
"SIGNER_URL": signature.signer_url,
|
|
"CONTACT_EMAIL": settings.smtp_from,
|
|
}
|
|
|
|
sujet = template["sujet"]
|
|
corps = template["corps_html"]
|
|
|
|
for var, valeur in variables.items():
|
|
sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur))
|
|
corps = corps.replace(f"{{{{{var}}}}}", str(valeur))
|
|
|
|
email_log = EmailLog(
|
|
id=str(uuid.uuid4()),
|
|
destinataire=signature.email_signataire,
|
|
sujet=sujet,
|
|
corps_html=corps,
|
|
document_ids=signature.document_id,
|
|
type_document=signature.type_document,
|
|
statut=StatutEmailDB.EN_ATTENTE,
|
|
date_creation=datetime.now(),
|
|
nb_tentatives=0,
|
|
)
|
|
|
|
session.add(email_log)
|
|
email_queue.enqueue(email_log.id)
|
|
|
|
signature.est_relance = True
|
|
signature.nb_relances = (signature.nb_relances or 0) + 1
|
|
|
|
nb_relances += 1
|
|
|
|
logger.info(
|
|
f" Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur relance signature {signature.id}: {e}")
|
|
continue
|
|
|
|
await session.commit()
|
|
|
|
return {
|
|
"success": True,
|
|
"signatures_verifiees": len(signatures_a_relancer),
|
|
"relances_envoyees": nb_relances,
|
|
"message": f"{nb_relances} email(s) de relance envoyé(s)",
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur relances automatiques: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/signature/universign/status", tags=["Signatures"])
|
|
async def statut_signature(docId: str = Query(...)):
|
|
try:
|
|
async with async_session_factory() as session:
|
|
query = select(SignatureLog).where(SignatureLog.document_id == docId)
|
|
result = await session.execute(query)
|
|
signature_log = result.scalar_one_or_none()
|
|
|
|
if not signature_log:
|
|
raise HTTPException(404, "Signature introuvable")
|
|
|
|
statut = await universign_statut(signature_log.transaction_id)
|
|
|
|
return {
|
|
"doc_id": docId,
|
|
"statut": statut["statut"],
|
|
"date_signature": statut.get("date_signature"),
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur statut signature: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/signatures", tags=["Signatures"])
|
|
async def lister_signatures(
|
|
statut: Optional[StatutSignature] = Query(None),
|
|
limit: int = Query(100, le=1000),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc())
|
|
|
|
if statut:
|
|
statut_db = StatutSignatureDB[statut.value]
|
|
query = query.where(SignatureLog.statut == statut_db)
|
|
|
|
query = query.limit(limit)
|
|
result = await session.execute(query)
|
|
signatures = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": sig.id,
|
|
"document_id": sig.document_id,
|
|
"type_document": sig.type_document.value,
|
|
"transaction_id": sig.transaction_id,
|
|
"signer_url": sig.signer_url,
|
|
"email_signataire": sig.email_signataire,
|
|
"nom_signataire": sig.nom_signataire,
|
|
"statut": sig.statut.value,
|
|
"date_envoi": sig.date_envoi.isoformat() if sig.date_envoi else None,
|
|
"date_signature": (
|
|
sig.date_signature.isoformat() if sig.date_signature else None
|
|
),
|
|
"est_relance": sig.est_relance,
|
|
"nb_relances": sig.nb_relances or 0,
|
|
}
|
|
for sig in signatures
|
|
]
|
|
|
|
|
|
@app.get("/signatures/{transaction_id}/status", tags=["Signatures"])
|
|
async def statut_signature_detail(
|
|
transaction_id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id)
|
|
result = await session.execute(query)
|
|
signature_log = result.scalar_one_or_none()
|
|
|
|
if not signature_log:
|
|
raise HTTPException(404, f"Transaction {transaction_id} introuvable")
|
|
|
|
statut_universign = await universign_statut(transaction_id)
|
|
|
|
if statut_universign.get("statut") != "ERREUR":
|
|
statut_map = {
|
|
"EN_ATTENTE": StatutSignatureDB.EN_ATTENTE,
|
|
"ENVOYE": StatutSignatureDB.ENVOYE,
|
|
"SIGNE": StatutSignatureDB.SIGNE,
|
|
"REFUSE": StatutSignatureDB.REFUSE,
|
|
"EXPIRE": StatutSignatureDB.EXPIRE,
|
|
}
|
|
|
|
nouveau_statut = statut_map.get(
|
|
statut_universign["statut"], StatutSignatureDB.EN_ATTENTE
|
|
)
|
|
|
|
signature_log.statut = nouveau_statut
|
|
|
|
if statut_universign.get("date_signature"):
|
|
signature_log.date_signature = datetime.fromisoformat(
|
|
statut_universign["date_signature"].replace("Z", "+00:00")
|
|
)
|
|
|
|
await session.commit()
|
|
|
|
return {
|
|
"transaction_id": transaction_id,
|
|
"document_id": signature_log.document_id,
|
|
"statut": signature_log.statut.value,
|
|
"email_signataire": signature_log.email_signataire,
|
|
"date_envoi": (
|
|
signature_log.date_envoi.isoformat() if signature_log.date_envoi else None
|
|
),
|
|
"date_signature": (
|
|
signature_log.date_signature.isoformat()
|
|
if signature_log.date_signature
|
|
else None
|
|
),
|
|
"signer_url": signature_log.signer_url,
|
|
}
|
|
|
|
|
|
@app.post("/signatures/refresh-all", tags=["Signatures"])
|
|
async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)):
|
|
query = select(SignatureLog).where(
|
|
SignatureLog.statut.in_(
|
|
[StatutSignatureDB.EN_ATTENTE, StatutSignatureDB.ENVOYE]
|
|
)
|
|
)
|
|
|
|
result = await session.execute(query)
|
|
signatures = result.scalars().all()
|
|
nb_mises_a_jour = 0
|
|
|
|
for sig in signatures:
|
|
try:
|
|
statut_universign = await universign_statut(sig.transaction_id)
|
|
|
|
if statut_universign.get("statut") != "ERREUR":
|
|
statut_map = {
|
|
"SIGNE": StatutSignatureDB.SIGNE,
|
|
"REFUSE": StatutSignatureDB.REFUSE,
|
|
"EXPIRE": StatutSignatureDB.EXPIRE,
|
|
}
|
|
|
|
nouveau = statut_map.get(statut_universign["statut"])
|
|
|
|
if nouveau and nouveau != sig.statut:
|
|
sig.statut = nouveau
|
|
|
|
if statut_universign.get("date_signature"):
|
|
sig.date_signature = datetime.fromisoformat(
|
|
statut_universign["date_signature"].replace("Z", "+00:00")
|
|
)
|
|
|
|
nb_mises_a_jour += 1
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur refresh signature {sig.transaction_id}: {e}")
|
|
continue
|
|
|
|
await session.commit()
|
|
|
|
return {
|
|
"success": True,
|
|
"nb_signatures_verifiees": len(signatures),
|
|
"nb_mises_a_jour": nb_mises_a_jour,
|
|
}
|
|
|
|
|
|
@app.post("/devis/{id}/signer", tags=["Devis"])
|
|
async def envoyer_devis_signature(
|
|
id: str, request: Signature, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
devis = sage_client.lire_devis(id)
|
|
if not devis:
|
|
raise HTTPException(404, f"Devis {id} introuvable")
|
|
|
|
pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS)
|
|
|
|
resultat = await universign_envoyer(
|
|
id, pdf_bytes, request.email_signataire, request.nom_signataire
|
|
)
|
|
|
|
if "error" in resultat:
|
|
raise HTTPException(500, f"Erreur Universign: {resultat['error']}")
|
|
|
|
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=request.email_signataire,
|
|
nom_signataire=request.nom_signataire,
|
|
statut=StatutSignatureDB.ENVOYE,
|
|
date_envoi=datetime.now(),
|
|
)
|
|
|
|
session.add(signature_log)
|
|
await session.commit()
|
|
|
|
sage_client.mettre_a_jour_champ_libre(
|
|
id, TypeDocument.DEVIS, "UniversignID", resultat["transaction_id"]
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"devis_id": id,
|
|
"transaction_id": resultat["transaction_id"],
|
|
"signer_url": resultat["signer_url"],
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur envoi signature: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
class EmailBatch(BaseModel):
|
|
destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100)
|
|
sujet: str = Field(..., min_length=1, max_length=500)
|
|
corps_html: str = Field(..., min_length=1)
|
|
document_ids: Optional[List[str]] = None
|
|
type_document: Optional[TypeDocument] = None
|
|
|
|
|
|
@app.post("/emails/send-batch", tags=["Emails"])
|
|
async def envoyer_emails_lot(
|
|
batch: EmailBatch, session: AsyncSession = Depends(get_session)
|
|
):
|
|
resultats = []
|
|
|
|
for destinataire in batch.destinataires:
|
|
email_log = EmailLog(
|
|
id=str(uuid.uuid4()),
|
|
destinataire=destinataire,
|
|
sujet=batch.sujet,
|
|
corps_html=batch.corps_html,
|
|
document_ids=",".join(batch.document_ids) if batch.document_ids else None,
|
|
type_document=batch.type_document,
|
|
statut=StatutEmailDB.EN_ATTENTE,
|
|
date_creation=datetime.now(),
|
|
nb_tentatives=0,
|
|
)
|
|
|
|
session.add(email_log)
|
|
await session.flush()
|
|
|
|
email_queue.enqueue(email_log.id)
|
|
|
|
resultats.append(
|
|
{
|
|
"destinataire": destinataire,
|
|
"log_id": email_log.id,
|
|
"statut": "EN_ATTENTE",
|
|
}
|
|
)
|
|
|
|
await session.commit()
|
|
|
|
nb_documents = len(batch.document_ids) if batch.document_ids else 0
|
|
|
|
logger.info(
|
|
f"{len(batch.destinataires)} emails mis en file avec {nb_documents} docs"
|
|
)
|
|
|
|
return {
|
|
"total": len(batch.destinataires),
|
|
"succes": len(batch.destinataires),
|
|
"documents_attaches": nb_documents,
|
|
"details": resultats,
|
|
}
|
|
|
|
|
|
@app.post(
|
|
"/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"]
|
|
)
|
|
async def valider_remise(
|
|
client_id: str = Query(..., min_length=1),
|
|
remise_pourcentage: float = Query(0.0, ge=0, le=100),
|
|
):
|
|
try:
|
|
remise_max = sage_client.lire_remise_max_client(client_id)
|
|
|
|
autorisee = remise_pourcentage <= remise_max
|
|
|
|
if not autorisee:
|
|
message = f"Remise trop élevée (max autorisé: {remise_max}%)"
|
|
logger.warning(
|
|
f"Remise refusée pour {client_id}: {remise_pourcentage}% > {remise_max}%"
|
|
)
|
|
else:
|
|
message = "Remise autorisée"
|
|
|
|
return BaremeRemiseResponse(
|
|
client_id=client_id,
|
|
remise_max_autorisee=remise_max,
|
|
remise_demandee=remise_pourcentage,
|
|
autorisee=autorisee,
|
|
message=message,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur validation remise: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/devis/{id}/relancer-signature", tags=["Devis"])
|
|
async def relancer_devis_signature(
|
|
id: str, relance: RelanceDevis, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
devis = sage_client.lire_devis(id)
|
|
if not devis:
|
|
raise HTTPException(404, f"Devis {id} introuvable")
|
|
|
|
contact = sage_client.lire_contact_client(devis["client_code"])
|
|
if not contact or not contact.get("email"):
|
|
raise HTTPException(400, "Aucun email trouvé pour ce client")
|
|
|
|
pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS)
|
|
|
|
resultat = await universign_envoyer(
|
|
id,
|
|
pdf_bytes,
|
|
contact["email"],
|
|
contact["nom"] or contact["client_intitule"],
|
|
)
|
|
|
|
if "error" in resultat:
|
|
raise HTTPException(500, resultat["error"])
|
|
|
|
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=StatutSignatureDB.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))
|
|
|
|
|
|
class ContactClientResponse(BaseModel):
|
|
client_code: str
|
|
client_intitule: str
|
|
email: Optional[str]
|
|
nom: Optional[str]
|
|
telephone: Optional[str]
|
|
peut_etre_relance: bool
|
|
|
|
|
|
@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"])
|
|
async def recuperer_contact_devis(id: str):
|
|
try:
|
|
devis = sage_client.lire_devis(id)
|
|
if not devis:
|
|
raise HTTPException(404, f"Devis {id} introuvable")
|
|
|
|
contact = sage_client.lire_contact_client(devis["client_code"])
|
|
if not contact:
|
|
raise HTTPException(
|
|
404, f"Contact introuvable pour client {devis['client_code']}"
|
|
)
|
|
|
|
peut_relancer = bool(contact.get("email"))
|
|
|
|
return ContactClientResponse(**contact, peut_etre_relance=peut_relancer)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur récupération contact: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/factures", tags=["Factures"])
|
|
async def lister_factures(
|
|
limit: int = Query(100, le=1000), statut: Optional[int] = Query(None)
|
|
):
|
|
try:
|
|
factures = sage_client.lister_factures(limit=limit, statut=statut)
|
|
return factures
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste factures: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/factures/{numero}", tags=["Factures"])
|
|
async def lire_facture_detail(numero: str):
|
|
try:
|
|
facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE)
|
|
|
|
if not facture:
|
|
raise HTTPException(404, f"Facture {numero} introuvable")
|
|
|
|
return {"success": True, "data": facture}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture facture {numero}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
class RelanceFacture(BaseModel):
|
|
doc_id: str
|
|
message_personnalise: Optional[str] = None
|
|
|
|
|
|
@app.post("/factures", status_code=201, tags=["Factures"])
|
|
async def creer_facture(
|
|
facture: FactureCreate, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
facture_data = {
|
|
"client_id": facture.client_id,
|
|
"date_facture": (
|
|
facture.date_facture.isoformat() if facture.date_facture else None
|
|
),
|
|
"date_livraison": (
|
|
facture.date_livraison.isoformat() if facture.date_livraison else None
|
|
),
|
|
"reference": facture.reference,
|
|
"lignes": _preparer_lignes_document(facture.lignes),
|
|
}
|
|
|
|
resultat = sage_client.creer_facture(facture_data)
|
|
|
|
logger.info(
|
|
f"✅ Facture créée: {resultat.get('numero_facture')} "
|
|
f"({resultat.get('total_ttc')}€ TTC)"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Facture créée avec succès",
|
|
"data": {
|
|
"numero_facture": resultat["numero_facture"],
|
|
"client_id": facture.client_id,
|
|
"date_facture": resultat["date_facture"],
|
|
"total_ht": resultat["total_ht"],
|
|
"total_ttc": resultat["total_ttc"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
"reference": resultat.get("reference"),
|
|
"date_livraison": resultat.get("date_livraison"),
|
|
},
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur création facture: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put("/factures/{id}", tags=["Factures"])
|
|
async def modifier_facture(
|
|
id: str,
|
|
facture_update: FactureUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
update_data = {}
|
|
|
|
if facture_update.date_facture:
|
|
update_data["date_facture"] = facture_update.date_facture.isoformat()
|
|
|
|
if facture_update.lignes is not None:
|
|
update_data["lignes"] = [
|
|
{
|
|
"article_code": ligne.article_code,
|
|
"quantite": ligne.quantite,
|
|
"remise_pourcentage": ligne.remise_pourcentage,
|
|
}
|
|
for ligne in facture_update.lignes
|
|
]
|
|
|
|
if facture_update.statut is not None:
|
|
update_data["statut"] = facture_update.statut
|
|
|
|
if facture_update.reference is not None:
|
|
update_data["reference"] = facture_update.reference
|
|
|
|
resultat = sage_client.modifier_facture(id, update_data)
|
|
|
|
logger.info(f"Facture {id} modifiée avec succès")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Facture {id} modifiée avec succès",
|
|
"facture": resultat,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur modification facture {id}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
templates_email_db = {
|
|
"relance_facture": {
|
|
"id": "relance_facture",
|
|
"nom": "Relance Facture",
|
|
"sujet": "Rappel - Facture {{DO_Piece}}",
|
|
"corps_html": """
|
|
<p>Bonjour {{CT_Intitule}},</p>
|
|
<p>La facture <strong>{{DO_Piece}}</strong> du {{DO_Date}}
|
|
d'un montant de <strong>{{DO_TotalTTC}}€ TTC</strong> reste impayée.</p>
|
|
<p>Merci de régulariser dans les meilleurs délais.</p>
|
|
<p>Cordialement,</p>
|
|
""",
|
|
"variables_disponibles": [
|
|
"DO_Piece",
|
|
"DO_Date",
|
|
"CT_Intitule",
|
|
"DO_TotalHT",
|
|
"DO_TotalTTC",
|
|
],
|
|
}
|
|
}
|
|
|
|
|
|
@app.post("/factures/{id}/relancer", tags=["Factures"])
|
|
async def relancer_facture(
|
|
id: str,
|
|
relance: RelanceFacture,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE)
|
|
if not facture:
|
|
raise HTTPException(404, f"Facture {id} introuvable")
|
|
|
|
contact = sage_client.lire_contact_client(facture["client_code"])
|
|
if not contact or not contact.get("email"):
|
|
raise HTTPException(400, "Aucun email trouvé pour ce client")
|
|
|
|
template = templates_email_db["relance_facture"]
|
|
|
|
variables = {
|
|
"DO_Piece": facture.get("numero", id),
|
|
"DO_Date": str(facture.get("date", "")),
|
|
"CT_Intitule": facture.get("client_intitule", ""),
|
|
"DO_TotalHT": f"{facture.get('total_ht', 0):.2f}",
|
|
"DO_TotalTTC": f"{facture.get('total_ttc', 0):.2f}",
|
|
}
|
|
|
|
sujet = template["sujet"]
|
|
corps = relance.message_personnalise or template["corps_html"]
|
|
|
|
for var, valeur in variables.items():
|
|
sujet = sujet.replace(f"{{{{{var}}}}}", valeur)
|
|
corps = corps.replace(f"{{{{{var}}}}}", valeur)
|
|
|
|
email_log = EmailLog(
|
|
id=str(uuid.uuid4()),
|
|
destinataire=contact["email"],
|
|
sujet=sujet,
|
|
corps_html=corps,
|
|
document_ids=id,
|
|
type_document=TypeDocument.FACTURE,
|
|
statut=StatutEmailDB.EN_ATTENTE,
|
|
date_creation=datetime.now(),
|
|
nb_tentatives=0,
|
|
)
|
|
|
|
session.add(email_log)
|
|
await session.flush()
|
|
|
|
email_queue.enqueue(email_log.id)
|
|
|
|
sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE)
|
|
|
|
await session.commit()
|
|
|
|
logger.info(f"Relance facture: {id} → {contact['email']}")
|
|
|
|
return {
|
|
"success": True,
|
|
"facture_id": id,
|
|
"email_log_id": email_log.id,
|
|
"destinataire": contact["email"],
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur relance facture: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/emails/logs", tags=["Emails"])
|
|
async def journal_emails(
|
|
statut: Optional[StatutEmail] = Query(None),
|
|
destinataire: Optional[str] = Query(None),
|
|
limit: int = Query(100, le=1000),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
query = select(EmailLog)
|
|
|
|
if statut:
|
|
query = query.where(EmailLog.statut == StatutEmailDB[statut.value])
|
|
|
|
if destinataire:
|
|
query = query.where(EmailLog.destinataire.contains(destinataire))
|
|
|
|
query = query.order_by(EmailLog.date_creation.desc()).limit(limit)
|
|
|
|
result = await session.execute(query)
|
|
logs = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": log.id,
|
|
"destinataire": log.destinataire,
|
|
"sujet": log.sujet,
|
|
"statut": log.statut.value,
|
|
"date_creation": log.date_creation.isoformat(),
|
|
"date_envoi": log.date_envoi.isoformat() if log.date_envoi else None,
|
|
"nb_tentatives": log.nb_tentatives,
|
|
"derniere_erreur": log.derniere_erreur,
|
|
"document_ids": log.document_ids,
|
|
}
|
|
for log in logs
|
|
]
|
|
|
|
|
|
@app.get("/emails/logs/export", tags=["Emails"])
|
|
async def exporter_logs_csv(
|
|
statut: Optional[StatutEmail] = Query(None),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
query = select(EmailLog)
|
|
if statut:
|
|
query = query.where(EmailLog.statut == StatutEmailDB[statut.value])
|
|
|
|
query = query.order_by(EmailLog.date_creation.desc())
|
|
|
|
result = await session.execute(query)
|
|
logs = result.scalars().all()
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
writer.writerow(
|
|
[
|
|
"ID",
|
|
"Destinataire",
|
|
"Sujet",
|
|
"Statut",
|
|
"Date Création",
|
|
"Date Envoi",
|
|
"Nb Tentatives",
|
|
"Erreur",
|
|
"Documents",
|
|
]
|
|
)
|
|
|
|
for log in logs:
|
|
writer.writerow(
|
|
[
|
|
log.id,
|
|
log.destinataire,
|
|
log.sujet,
|
|
log.statut.value,
|
|
log.date_creation.isoformat(),
|
|
log.date_envoi.isoformat() if log.date_envoi else "",
|
|
log.nb_tentatives,
|
|
log.derniere_erreur or "",
|
|
log.document_ids or "",
|
|
]
|
|
)
|
|
|
|
output.seek(0)
|
|
|
|
return StreamingResponse(
|
|
iter([output.getvalue()]),
|
|
media_type="text/csv",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename=emails_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
|
},
|
|
)
|
|
|
|
|
|
class TemplateEmail(BaseModel):
|
|
id: Optional[str] = None
|
|
nom: str
|
|
sujet: str
|
|
corps_html: str
|
|
variables_disponibles: List[str] = []
|
|
|
|
|
|
class TemplatePreview(BaseModel):
|
|
template_id: str
|
|
document_id: str
|
|
type_document: TypeDocument
|
|
|
|
|
|
@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"])
|
|
async def lister_templates():
|
|
return [TemplateEmail(**template) for template in templates_email_db.values()]
|
|
|
|
|
|
@app.get(
|
|
"/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"]
|
|
)
|
|
async def lire_template(template_id: str):
|
|
if template_id not in templates_email_db:
|
|
raise HTTPException(404, f"Template {template_id} introuvable")
|
|
|
|
return TemplateEmail(**templates_email_db[template_id])
|
|
|
|
|
|
@app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"])
|
|
async def creer_template(template: TemplateEmail):
|
|
template_id = str(uuid.uuid4())
|
|
|
|
templates_email_db[template_id] = {
|
|
"id": template_id,
|
|
"nom": template.nom,
|
|
"sujet": template.sujet,
|
|
"corps_html": template.corps_html,
|
|
"variables_disponibles": template.variables_disponibles,
|
|
}
|
|
|
|
logger.info(f"Template créé: {template_id}")
|
|
|
|
return TemplateEmail(id=template_id, **template.dict())
|
|
|
|
|
|
@app.put(
|
|
"/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"]
|
|
)
|
|
async def modifier_template(template_id: str, template: TemplateEmail):
|
|
if template_id not in templates_email_db:
|
|
raise HTTPException(404, f"Template {template_id} introuvable")
|
|
|
|
if template_id in ["relance_devis", "relance_facture"]:
|
|
raise HTTPException(400, "Les templates système ne peuvent pas être modifiés")
|
|
|
|
templates_email_db[template_id] = {
|
|
"id": template_id,
|
|
"nom": template.nom,
|
|
"sujet": template.sujet,
|
|
"corps_html": template.corps_html,
|
|
"variables_disponibles": template.variables_disponibles,
|
|
}
|
|
|
|
logger.info(f"Template modifié: {template_id}")
|
|
|
|
return TemplateEmail(id=template_id, **template.dict())
|
|
|
|
|
|
@app.delete("/templates/emails/{template_id}", tags=["Emails"])
|
|
async def supprimer_template(template_id: str):
|
|
if template_id not in templates_email_db:
|
|
raise HTTPException(404, f"Template {template_id} introuvable")
|
|
|
|
if template_id in ["relance_devis", "relance_facture"]:
|
|
raise HTTPException(400, "Les templates système ne peuvent pas être supprimés")
|
|
|
|
del templates_email_db[template_id]
|
|
|
|
logger.info(f"Template supprimé: {template_id}")
|
|
|
|
return {"success": True, "message": f"Template {template_id} supprimé"}
|
|
|
|
|
|
@app.post("/templates/emails/preview", tags=["Emails"])
|
|
async def previsualiser_email(preview: TemplatePreview):
|
|
if preview.template_id not in templates_email_db:
|
|
raise HTTPException(404, f"Template {preview.template_id} introuvable")
|
|
|
|
template = templates_email_db[preview.template_id]
|
|
|
|
doc = sage_client.lire_document(preview.document_id, preview.type_document)
|
|
if not doc:
|
|
raise HTTPException(404, f"Document {preview.document_id} introuvable")
|
|
|
|
variables = {
|
|
"DO_Piece": doc.get("numero", preview.document_id),
|
|
"DO_Date": str(doc.get("date", "")),
|
|
"CT_Intitule": doc.get("client_intitule", ""),
|
|
"DO_TotalHT": f"{doc.get('total_ht', 0):.2f}",
|
|
"DO_TotalTTC": f"{doc.get('total_ttc', 0):.2f}",
|
|
}
|
|
|
|
sujet_preview = template["sujet"]
|
|
corps_preview = template["corps_html"]
|
|
|
|
for var, valeur in variables.items():
|
|
sujet_preview = sujet_preview.replace(f"{{{{{var}}}}}", valeur)
|
|
corps_preview = corps_preview.replace(f"{{{{{var}}}}}", valeur)
|
|
|
|
return {
|
|
"template_id": preview.template_id,
|
|
"document_id": preview.document_id,
|
|
"sujet": sujet_preview,
|
|
"corps_html": corps_preview,
|
|
"variables_utilisees": variables,
|
|
}
|
|
|
|
|
|
@app.get("/prospects", tags=["Prospects"])
|
|
async def rechercher_prospects(query: Optional[str] = Query(None)):
|
|
try:
|
|
prospects = sage_client.lister_prospects(filtre=query or "")
|
|
return prospects
|
|
except Exception as e:
|
|
logger.error(f"Erreur recherche prospects: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/prospects/{code}", tags=["Prospects"])
|
|
async def lire_prospect(code: str):
|
|
try:
|
|
prospect = sage_client.lire_prospect(code)
|
|
if not prospect:
|
|
raise HTTPException(404, f"Prospect {code} introuvable")
|
|
return prospect
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture prospect: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get(
|
|
"/fournisseurs", response_model=List[FournisseurDetails], tags=["Fournisseurs"]
|
|
)
|
|
async def rechercher_fournisseurs(query: Optional[str] = Query(None)):
|
|
try:
|
|
fournisseurs = sage_client.lister_fournisseurs(filtre=query or "")
|
|
|
|
logger.info(f"{len(fournisseurs)} fournisseurs")
|
|
|
|
if len(fournisseurs) == 0:
|
|
logger.warning("Aucun fournisseur retourné - vérifier la gateway Windows")
|
|
|
|
return [FournisseurDetails(**f) for f in fournisseurs]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur recherche fournisseurs: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/fournisseurs", status_code=201, tags=["Fournisseurs"])
|
|
async def ajouter_fournisseur(
|
|
fournisseur: FournisseurCreate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict())
|
|
|
|
logger.info(f"Fournisseur créé via API: {nouveau_fournisseur.get('numero')}")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Fournisseur créé avec succès",
|
|
"data": nouveau_fournisseur,
|
|
}
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Erreur métier création fournisseur: {e}")
|
|
raise HTTPException(400, str(e))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur technique création fournisseur: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put(
|
|
"/fournisseurs/{code}", response_model=FournisseurDetails, tags=["Fournisseurs"]
|
|
)
|
|
async def modifier_fournisseur(
|
|
code: str,
|
|
fournisseur_update: FournisseurUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
resultat = sage_client.modifier_fournisseur(
|
|
code, fournisseur_update.dict(exclude_none=True)
|
|
)
|
|
|
|
logger.info(f"Fournisseur {code} modifié avec succès")
|
|
|
|
return FournisseurDetails(**resultat)
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Erreur métier modification fournisseur {code}: {e}")
|
|
raise HTTPException(404, str(e))
|
|
except Exception as e:
|
|
logger.error(f"Erreur technique modification fournisseur {code}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/fournisseurs/{code}", tags=["Fournisseurs"])
|
|
async def lire_fournisseur(code: str):
|
|
try:
|
|
fournisseur = sage_client.lire_fournisseur(code)
|
|
if not fournisseur:
|
|
raise HTTPException(404, f"Fournisseur {code} introuvable")
|
|
return fournisseur
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture fournisseur: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/avoirs", tags=["Avoirs"])
|
|
async def lister_avoirs(
|
|
limit: int = Query(100, le=1000), statut: Optional[int] = Query(None)
|
|
):
|
|
try:
|
|
avoirs = sage_client.lister_avoirs(limit=limit, statut=statut)
|
|
return avoirs
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste avoirs: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/avoirs/{numero}", tags=["Avoirs"])
|
|
async def lire_avoir(numero: str):
|
|
try:
|
|
avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR)
|
|
if not avoir:
|
|
raise HTTPException(404, f"Avoir {numero} introuvable")
|
|
return avoir
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture avoir: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/avoirs", status_code=201, tags=["Avoirs"])
|
|
async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_session)):
|
|
try:
|
|
avoir_data = {
|
|
"client_id": avoir.client_id,
|
|
"date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None),
|
|
"date_livraison": (
|
|
avoir.date_livraison.isoformat() if avoir.date_livraison else None
|
|
),
|
|
"reference": avoir.reference,
|
|
"lignes": _preparer_lignes_document(avoir.lignes),
|
|
}
|
|
|
|
resultat = sage_client.creer_avoir(avoir_data)
|
|
|
|
logger.info(
|
|
f"✅ Avoir créé: {resultat.get('numero_avoir')} "
|
|
f"({resultat.get('total_ttc')}€ TTC)"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Avoir créé avec succès",
|
|
"data": {
|
|
"numero_avoir": resultat["numero_avoir"],
|
|
"client_id": avoir.client_id,
|
|
"date_avoir": resultat["date_avoir"],
|
|
"total_ht": resultat["total_ht"],
|
|
"total_ttc": resultat["total_ttc"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
"reference": resultat.get("reference"),
|
|
"date_livraison": resultat.get("date_livraison"),
|
|
},
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur création avoir: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put("/avoirs/{id}", tags=["Avoirs"])
|
|
async def modifier_avoir(
|
|
id: str,
|
|
avoir_update: AvoirUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
update_data = {}
|
|
|
|
if avoir_update.date_avoir:
|
|
update_data["date_avoir"] = avoir_update.date_avoir.isoformat()
|
|
|
|
if avoir_update.lignes is not None:
|
|
update_data["lignes"] = [
|
|
{
|
|
"article_code": ligne.article_code,
|
|
"quantite": ligne.quantite,
|
|
"remise_pourcentage": ligne.remise_pourcentage,
|
|
}
|
|
for ligne in avoir_update.lignes
|
|
]
|
|
|
|
if avoir_update.statut is not None:
|
|
update_data["statut"] = avoir_update.statut
|
|
|
|
if avoir_update.reference is not None:
|
|
update_data["reference"] = avoir_update.reference
|
|
|
|
resultat = sage_client.modifier_avoir(id, update_data)
|
|
|
|
logger.info(f"Avoir {id} modifié avec succès")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Avoir {id} modifié avec succès",
|
|
"avoir": resultat,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur modification avoir {id}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/livraisons", tags=["Livraisons"])
|
|
async def lister_livraisons(
|
|
limit: int = Query(100, le=1000), statut: Optional[int] = Query(None)
|
|
):
|
|
try:
|
|
livraisons = sage_client.lister_livraisons(limit=limit, statut=statut)
|
|
return livraisons
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste livraisons: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/livraisons/{numero}", tags=["Livraisons"])
|
|
async def lire_livraison(numero: str):
|
|
try:
|
|
livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON)
|
|
if not livraison:
|
|
raise HTTPException(404, f"Livraison {numero} introuvable")
|
|
return livraison
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture livraison: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/livraisons", status_code=201, tags=["Livraisons"])
|
|
async def creer_livraison(
|
|
livraison: LivraisonCreate, session: AsyncSession = Depends(get_session)
|
|
):
|
|
"""
|
|
Crée un bon de livraison dans Sage 100
|
|
|
|
- Le prix_unitaire_ht est optionnel (utilise le prix Sage si non fourni)
|
|
- La remise_pourcentage est appliquée après le prix
|
|
"""
|
|
try:
|
|
livraison_data = {
|
|
"client_id": livraison.client_id,
|
|
"date_livraison": (
|
|
livraison.date_livraison.isoformat()
|
|
if livraison.date_livraison
|
|
else None
|
|
),
|
|
"date_livraison_prevue": (
|
|
livraison.date_livraison_prevue.isoformat()
|
|
if livraison.date_livraison_prevue
|
|
else None
|
|
),
|
|
"reference": livraison.reference,
|
|
"lignes": _preparer_lignes_document(livraison.lignes),
|
|
}
|
|
|
|
resultat = sage_client.creer_livraison(livraison_data)
|
|
|
|
logger.info(
|
|
f"✅ Livraison créée: {resultat.get('numero_livraison')} "
|
|
f"({resultat.get('total_ttc')}€ TTC)"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Livraison créée avec succès",
|
|
"data": {
|
|
"numero_livraison": resultat["numero_livraison"],
|
|
"client_id": livraison.client_id,
|
|
"date_livraison": resultat["date_livraison"],
|
|
"date_livraison_prevue": resultat.get("date_livraison_prevue"),
|
|
"total_ht": resultat["total_ht"],
|
|
"total_ttc": resultat["total_ttc"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
"reference": resultat.get("reference"),
|
|
},
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur création livraison: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put("/livraisons/{id}", tags=["Livraisons"])
|
|
async def modifier_livraison(
|
|
id: str,
|
|
livraison_update: LivraisonUpdate,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
update_data = {}
|
|
|
|
if livraison_update.date_livraison:
|
|
update_data["date_livraison"] = livraison_update.date_livraison.isoformat()
|
|
|
|
if livraison_update.lignes is not None:
|
|
update_data["lignes"] = [
|
|
{
|
|
"article_code": ligne.article_code,
|
|
"quantite": ligne.quantite,
|
|
"remise_pourcentage": ligne.remise_pourcentage,
|
|
}
|
|
for ligne in livraison_update.lignes
|
|
]
|
|
|
|
if livraison_update.statut is not None:
|
|
update_data["statut"] = livraison_update.statut
|
|
|
|
if livraison_update.reference is not None:
|
|
update_data["reference"] = livraison_update.reference
|
|
|
|
resultat = sage_client.modifier_livraison(id, update_data)
|
|
|
|
logger.info(f"Livraison {id} modifiée avec succès")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Livraison {id} modifiée avec succès",
|
|
"livraison": resultat,
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur modification livraison {id}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"])
|
|
async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)):
|
|
try:
|
|
resultat = sage_client.transformer_document(
|
|
numero_source=id,
|
|
type_source=settings.SAGE_TYPE_BON_LIVRAISON, # = 30
|
|
type_cible=settings.SAGE_TYPE_FACTURE, # = 60
|
|
)
|
|
|
|
workflow_log = WorkflowLog(
|
|
id=str(uuid.uuid4()),
|
|
document_source=id,
|
|
type_source=TypeDocument.BON_LIVRAISON,
|
|
document_cible=resultat.get("document_cible", ""),
|
|
type_cible=TypeDocument.FACTURE,
|
|
nb_lignes=resultat.get("nb_lignes", 0),
|
|
date_transformation=datetime.now(),
|
|
succes=True,
|
|
)
|
|
|
|
session.add(workflow_log)
|
|
await session.commit()
|
|
|
|
logger.info(
|
|
f"Transformation: Livraison {id} → Facture {resultat['document_cible']}"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"document_source": id,
|
|
"document_cible": resultat["document_cible"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur transformation: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/workflow/devis/{id}/to-facture", tags=["Workflows"])
|
|
async def devis_vers_facture_direct(
|
|
id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
devis_existant = sage_client.lire_devis(id)
|
|
if not devis_existant:
|
|
raise HTTPException(404, f"Devis {id} introuvable")
|
|
|
|
statut_devis = devis_existant.get("statut", 0)
|
|
if statut_devis == 5:
|
|
raise HTTPException(
|
|
400,
|
|
f"Le devis {id} a déjà été transformé (statut=5). "
|
|
f"Vérifiez les documents déjà créés depuis ce devis.",
|
|
)
|
|
|
|
resultat = sage_client.transformer_document(
|
|
numero_source=id,
|
|
type_source=settings.SAGE_TYPE_DEVIS, # = 0
|
|
type_cible=settings.SAGE_TYPE_FACTURE, # = 60
|
|
)
|
|
|
|
workflow_log = WorkflowLog(
|
|
id=str(uuid.uuid4()),
|
|
document_source=id,
|
|
type_source=TypeDocument.DEVIS,
|
|
document_cible=resultat.get("document_cible", ""),
|
|
type_cible=TypeDocument.FACTURE,
|
|
nb_lignes=resultat.get("nb_lignes", 0),
|
|
date_transformation=datetime.now(),
|
|
succes=True,
|
|
)
|
|
|
|
session.add(workflow_log)
|
|
await session.commit()
|
|
|
|
logger.info(
|
|
f"Transformation DIRECTE: Devis {id} → Facture {resultat['document_cible']}"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"workflow": "devis_to_facture_direct",
|
|
"document_source": id,
|
|
"document_cible": resultat["document_cible"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
"statut_devis_mis_a_jour": True,
|
|
"message": f"Facture {resultat['document_cible']} créée directement depuis le devis {id}",
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur transformation devis→facture: {e}", exc_info=True)
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/workflow/commande/{id}/to-livraison", tags=["Workflows"])
|
|
async def commande_vers_livraison(
|
|
id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
commande_existante = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE)
|
|
|
|
if not commande_existante:
|
|
raise HTTPException(404, f"Commande {id} introuvable")
|
|
|
|
statut_commande = commande_existante.get("statut", 0)
|
|
if statut_commande == 5:
|
|
raise HTTPException(
|
|
400,
|
|
f"La commande {id} a déjà été transformée (statut=5). "
|
|
f"Un bon de livraison existe probablement déjà.",
|
|
)
|
|
|
|
if statut_commande == 6:
|
|
raise HTTPException(
|
|
400,
|
|
f"La commande {id} est annulée (statut=6) et ne peut pas être transformée.",
|
|
)
|
|
|
|
resultat = sage_client.transformer_document(
|
|
numero_source=id,
|
|
type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10
|
|
type_cible=settings.SAGE_TYPE_BON_LIVRAISON, # = 30
|
|
)
|
|
|
|
workflow_log = WorkflowLog(
|
|
id=str(uuid.uuid4()),
|
|
document_source=id,
|
|
type_source=TypeDocument.BON_COMMANDE,
|
|
document_cible=resultat.get("document_cible", ""),
|
|
type_cible=TypeDocument.BON_LIVRAISON,
|
|
nb_lignes=resultat.get("nb_lignes", 0),
|
|
date_transformation=datetime.now(),
|
|
succes=True,
|
|
)
|
|
|
|
session.add(workflow_log)
|
|
await session.commit()
|
|
|
|
logger.info(
|
|
f"Transformation: Commande {id} → Livraison {resultat['document_cible']}"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"workflow": "commande_to_livraison",
|
|
"document_source": id,
|
|
"document_cible": resultat["document_cible"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
"message": f"Bon de livraison {resultat['document_cible']} créé depuis la commande {id}",
|
|
"next_step": f"Utilisez /workflow/livraison/{resultat['document_cible']}/to-facture pour créer la facture",
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur transformation commande→livraison: {e}", exc_info=True)
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get(
|
|
"/familles",
|
|
response_model=List[Familles],
|
|
tags=["Familles"],
|
|
summary="Liste toutes les familles d'articles",
|
|
)
|
|
async def lister_familles(
|
|
filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé"),
|
|
):
|
|
try:
|
|
familles = sage_client.lister_familles(filtre or "")
|
|
|
|
logger.info(f"{len(familles)} famille(s) retournée(s)")
|
|
|
|
return [Familles(**f) for f in familles]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste familles: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la récupération des familles: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.get(
|
|
"/familles/{code}",
|
|
response_model=Familles,
|
|
tags=["Familles"],
|
|
summary="Lecture d'une famille par son code",
|
|
)
|
|
async def lire_famille(
|
|
code: str = Path(..., description="Code de la famille (ex: ZDIVERS)"),
|
|
):
|
|
try:
|
|
famille = sage_client.lire_famille(code)
|
|
|
|
if not famille:
|
|
logger.warning(f"Famille {code} introuvable")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Famille {code} introuvable",
|
|
)
|
|
|
|
logger.info(f"Famille {code} lue: {famille.get('intitule', '')}")
|
|
|
|
return Familles(**famille)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture famille {code}: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la lecture de la famille: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.post(
|
|
"/familles",
|
|
response_model=Familles,
|
|
status_code=status.HTTP_201_CREATED,
|
|
tags=["Familles"],
|
|
summary="Création d'une famille d'articles",
|
|
)
|
|
async def creer_famille(famille: FamilleCreate):
|
|
try:
|
|
if not famille.code or not famille.intitule:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Les champs 'code' et 'intitule' sont obligatoires",
|
|
)
|
|
|
|
famille_data = famille.dict()
|
|
|
|
logger.info(f"Création famille: {famille.code} - {famille.intitule}")
|
|
|
|
resultat = sage_client.creer_famille(famille_data)
|
|
|
|
logger.info(f"Famille créée: {resultat.get('code')}")
|
|
|
|
return Familles(**resultat)
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Erreur métier création famille: {e}")
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
|
|
except HTTPException:
|
|
raise
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur technique création famille: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la création de la famille: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.post(
|
|
"/stock/entree",
|
|
response_model=MouvementStock,
|
|
status_code=status.HTTP_201_CREATED,
|
|
tags=["Stock"],
|
|
summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock",
|
|
)
|
|
async def creer_entree_stock(entree: EntreeStock):
|
|
try:
|
|
entree_data = entree.dict()
|
|
if entree_data.get("date_entree"):
|
|
entree_data["date_entree"] = entree_data["date_entree"].isoformat()
|
|
|
|
logger.info(f"Création entrée stock: {len(entree.lignes)} ligne(s)")
|
|
|
|
resultat = sage_client.creer_entree_stock(entree_data)
|
|
|
|
logger.info(f"Entrée stock créée: {resultat.get('numero')}")
|
|
|
|
return MouvementStock(**resultat)
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Erreur métier entrée stock: {e}")
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur technique entrée stock: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la création de l'entrée: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.post(
|
|
"/stock/sortie",
|
|
response_model=MouvementStock,
|
|
status_code=status.HTTP_201_CREATED,
|
|
tags=["Stock"],
|
|
summary="SORTIE DE STOCK : Retire des articles du stock",
|
|
)
|
|
async def creer_sortie_stock(sortie: SortieStock):
|
|
try:
|
|
sortie_data = sortie.dict()
|
|
if sortie_data.get("date_sortie"):
|
|
sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat()
|
|
|
|
logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)")
|
|
|
|
resultat = sage_client.creer_sortie_stock(sortie_data)
|
|
|
|
logger.info(f"Sortie stock créée: {resultat.get('numero')}")
|
|
|
|
return MouvementStock(**resultat)
|
|
|
|
except ValueError as e:
|
|
logger.warning(f"Erreur métier sortie stock: {e}")
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur technique sortie stock: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la création de la sortie: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.get(
|
|
"/stock/mouvement/{numero}",
|
|
response_model=MouvementStock,
|
|
tags=["Stock"],
|
|
summary="Lecture d'un mouvement de stock",
|
|
)
|
|
async def lire_mouvement_stock(
|
|
numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)"),
|
|
):
|
|
try:
|
|
mouvement = sage_client.lire_mouvement_stock(numero)
|
|
|
|
if not mouvement:
|
|
logger.warning(f"Mouvement {numero} introuvable")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Mouvement de stock {numero} introuvable",
|
|
)
|
|
|
|
logger.info(f"Mouvement {numero} lu")
|
|
|
|
return MouvementStock(**mouvement)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture mouvement {numero}: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la lecture du mouvement: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.get(
|
|
"/familles/stats/global",
|
|
tags=["Familles"],
|
|
summary="Statistiques sur les familles",
|
|
)
|
|
async def statistiques_familles():
|
|
try:
|
|
stats = sage_client.get_stats_familles()
|
|
|
|
return {"success": True, "data": stats}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur stats familles: {e}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Erreur lors de la récupération des statistiques: {str(e)}",
|
|
)
|
|
|
|
|
|
@app.get("/debug/users", response_model=List[Users], tags=["Debug"])
|
|
async def lister_utilisateurs_debug(
|
|
session: AsyncSession = Depends(get_session),
|
|
limit: int = Query(100, le=1000),
|
|
role: Optional[str] = Query(None),
|
|
verified_only: bool = Query(False),
|
|
):
|
|
from database import User
|
|
from sqlalchemy import select
|
|
|
|
try:
|
|
query = select(User)
|
|
|
|
if role:
|
|
query = query.where(User.role == role)
|
|
|
|
if verified_only:
|
|
query = query.where(User.is_verified)
|
|
|
|
query = query.order_by(User.created_at.desc()).limit(limit)
|
|
|
|
result = await session.execute(query)
|
|
users = result.scalars().all()
|
|
|
|
users_response = []
|
|
for user in users:
|
|
users_response.append(
|
|
Users(
|
|
id=user.id,
|
|
email=user.email,
|
|
nom=user.nom,
|
|
prenom=user.prenom,
|
|
role=user.role,
|
|
is_verified=user.is_verified,
|
|
is_active=user.is_active,
|
|
created_at=user.created_at.isoformat() if user.created_at else "",
|
|
last_login=user.last_login.isoformat() if user.last_login else None,
|
|
failed_login_attempts=user.failed_login_attempts or 0,
|
|
)
|
|
)
|
|
|
|
logger.info(f"Liste utilisateurs retournée: {len(users_response)} résultat(s)")
|
|
|
|
return users_response
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste utilisateurs: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/debug/users/stats", tags=["Debug"])
|
|
async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)):
|
|
from database import User
|
|
from sqlalchemy import select, func
|
|
|
|
try:
|
|
total_query = select(func.count(User.id))
|
|
total_result = await session.execute(total_query)
|
|
total = total_result.scalar()
|
|
|
|
verified_query = select(func.count(User.id)).where(User.is_verified)
|
|
verified_result = await session.execute(verified_query)
|
|
verified = verified_result.scalar()
|
|
|
|
active_query = select(func.count(User.id)).where(User.is_active)
|
|
active_result = await session.execute(active_query)
|
|
active = active_result.scalar()
|
|
|
|
roles_query = select(User.role, func.count(User.id)).group_by(User.role)
|
|
roles_result = await session.execute(roles_query)
|
|
roles_stats = {role: count for role, count in roles_result.all()}
|
|
|
|
return {
|
|
"total_utilisateurs": total,
|
|
"utilisateurs_verifies": verified,
|
|
"utilisateurs_actifs": active,
|
|
"utilisateurs_non_verifies": total - verified,
|
|
"repartition_roles": roles_stats,
|
|
"taux_verification": f"{(verified / total * 100):.1f}%"
|
|
if total > 0
|
|
else "0%",
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur stats utilisateurs: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/tiers/{numero}/contacts", response_model=Contact, tags=["Contacts"])
|
|
async def creer_contact(numero: str, contact: ContactCreate):
|
|
try:
|
|
try:
|
|
sage_client.lire_tiers(numero)
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
raise HTTPException(404, f"Tiers {numero} non trouvé")
|
|
|
|
if contact.numero != numero:
|
|
contact.numero = numero
|
|
|
|
resultat = sage_client.creer_contact(contact.dict())
|
|
|
|
if isinstance(resultat, dict) and "data" in resultat:
|
|
contact_data = resultat["data"]
|
|
else:
|
|
contact_data = resultat
|
|
|
|
return Contact(**contact_data)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur création contact: {e}", exc_info=True)
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/tiers/{numero}/contacts", response_model=List[Contact], tags=["Contacts"])
|
|
async def lister_contacts(numero: str):
|
|
try:
|
|
contacts = sage_client.lister_contacts(numero)
|
|
return [Contact(**c) for c in contacts]
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste contacts: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get(
|
|
"/tiers/{numero}/contacts/{contact_numero}",
|
|
response_model=Contact,
|
|
tags=["Contacts"],
|
|
)
|
|
async def obtenir_contact(numero: str, contact_numero: int):
|
|
try:
|
|
contact = sage_client.obtenir_contact(numero, contact_numero)
|
|
if not contact:
|
|
raise HTTPException(
|
|
404, f"Contact {contact_numero} non trouvé pour client {numero}"
|
|
)
|
|
return Contact(**contact)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur récupération contact: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put(
|
|
"/tiers/{numero}/contacts/{contact_numero}",
|
|
response_model=Contact,
|
|
tags=["Contacts"],
|
|
)
|
|
async def modifier_contact(numero: str, contact_numero: int, contact: ContactUpdate):
|
|
try:
|
|
contact_existant = sage_client.obtenir_contact(numero, contact_numero)
|
|
if not contact_existant:
|
|
raise HTTPException(404, f"Contact {contact_numero} non trouvé")
|
|
|
|
updates = {k: v for k, v in contact.dict().items() if v is not None}
|
|
|
|
if not updates:
|
|
raise HTTPException(400, "Aucune modification fournie")
|
|
|
|
resultat = sage_client.modifier_contact(numero, contact_numero, updates)
|
|
if isinstance(resultat, dict) and "data" in resultat:
|
|
contact_data = resultat["data"]
|
|
else:
|
|
contact_data = resultat
|
|
|
|
return Contact(**contact_data)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur modification contact: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.delete("/tiers/{numero}/contacts/{contact_numero}", tags=["Contacts"])
|
|
async def supprimer_contact(numero: str, contact_numero: int):
|
|
try:
|
|
sage_client.supprimer_contact(numero, contact_numero)
|
|
return {"success": True, "message": f"Contact {contact_numero} supprimé"}
|
|
except Exception as e:
|
|
logger.error(f"Erreur suppression contact: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/tiers/{numero}/contacts/{contact_numero}/definir-defaut", tags=["Contacts"])
|
|
async def definir_contact_defaut(numero: str, contact_numero: int):
|
|
try:
|
|
resultat = sage_client.definir_contact_defaut(numero, contact_numero)
|
|
return {
|
|
"success": True,
|
|
"message": f"Contact {contact_numero} défini comme contact par défaut",
|
|
"data": resultat,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Erreur définition contact par défaut: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/tiers", response_model=List[TiersDetails], tags=["Tiers"])
|
|
async def obtenir_tiers(
|
|
type_tiers: Optional[str] = Query(
|
|
None,
|
|
description="Filtre par type: 0/client, 1/fournisseur, 2/prospect, 3/all ou strings",
|
|
),
|
|
query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"),
|
|
):
|
|
try:
|
|
type_normalise = normaliser_type_tiers(type_tiers)
|
|
tiers = sage_client.lister_tiers(type_tiers=type_normalise, filtre=query or "")
|
|
return [TiersDetails(**t) for t in tiers]
|
|
except Exception as e:
|
|
logger.error(f"Erreur recherche tiers: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/tiers/{code}", response_model=TiersDetails, tags=["Tiers"])
|
|
async def lire_tiers_detail(code: str):
|
|
try:
|
|
tiers = sage_client.lire_tiers(code)
|
|
if not tiers:
|
|
raise HTTPException(404, f"Tiers {code} introuvable")
|
|
return TiersDetails(**tiers)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture tiers {code}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/sage/current-config", tags=["System"])
|
|
async def get_current_sage_config(
|
|
ctx: GatewayContext = Depends(get_gateway_context_for_user),
|
|
):
|
|
return {
|
|
"source": "user_gateway" if not ctx.is_fallback else "fallback_env",
|
|
"gateway_id": ctx.gateway_id,
|
|
"gateway_name": ctx.gateway_name,
|
|
"gateway_url": ctx.url,
|
|
"user_id": ctx.user_id,
|
|
}
|
|
|
|
|
|
# Routes Collaborateurs
|
|
@app.get(
|
|
"/collaborateurs", response_model=List[CollaborateurDetails], tags=["Collaborateurs"]
|
|
)
|
|
async def lister_collaborateurs(
|
|
filtre: Optional[str] = Query(None, description="Filtre sur nom/prénom"),
|
|
actifs_seulement: bool = Query(
|
|
True, description="Exclure les collaborateurs en sommeil"
|
|
),
|
|
):
|
|
"""Liste tous les collaborateurs"""
|
|
try:
|
|
collaborateurs = sage_client.lister_collaborateurs(filtre, actifs_seulement)
|
|
return [CollaborateurDetails(**c) for c in collaborateurs]
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste collaborateurs: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get(
|
|
"/collaborateurs/{numero}",
|
|
response_model=CollaborateurDetails,
|
|
tags=["Collaborateurs"],
|
|
)
|
|
async def lire_collaborateur_detail(numero: int):
|
|
"""Lit un collaborateur par son numéro"""
|
|
try:
|
|
collaborateur = sage_client.lire_collaborateur(numero)
|
|
|
|
if not collaborateur:
|
|
raise HTTPException(404, f"Collaborateur {numero} introuvable")
|
|
|
|
return CollaborateurDetails(**collaborateur)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture collaborateur {numero}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post(
|
|
"/collaborateurs",
|
|
response_model=CollaborateurDetails,
|
|
tags=["Collaborateurs"],
|
|
status_code=201,
|
|
)
|
|
async def creer_collaborateur(collaborateur: CollaborateurCreate):
|
|
"""Crée un nouveau collaborateur"""
|
|
try:
|
|
nouveau = sage_client.creer_collaborateur(collaborateur.model_dump())
|
|
|
|
if not nouveau:
|
|
raise HTTPException(500, "Échec création collaborateur")
|
|
|
|
return CollaborateurDetails(**nouveau)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur création collaborateur: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put(
|
|
"/collaborateurs/{numero}",
|
|
response_model=CollaborateurDetails,
|
|
tags=["Collaborateurs"],
|
|
)
|
|
async def modifier_collaborateur(numero: int, collaborateur: CollaborateurUpdate):
|
|
"""Modifie un collaborateur existant"""
|
|
try:
|
|
modifie = sage_client.modifier_collaborateur(
|
|
numero, collaborateur.model_dump(exclude_unset=True)
|
|
)
|
|
|
|
if not modifie:
|
|
raise HTTPException(404, f"Collaborateur {numero} introuvable")
|
|
|
|
return CollaborateurDetails(**modifie)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur modification collaborateur {numero}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/health", tags=["System"])
|
|
async def health_check(
|
|
sage: SageGatewayClient = Depends(get_sage_client_for_user),
|
|
):
|
|
gateway_health = sage.health()
|
|
|
|
return {
|
|
"status": "healthy",
|
|
"sage_gateway": gateway_health,
|
|
"using_gateway_id": sage.gateway_id,
|
|
"email_queue": {
|
|
"running": email_queue.running,
|
|
"workers": len(email_queue.workers),
|
|
"queue_size": email_queue.queue.qsize(),
|
|
},
|
|
"timestamp": datetime.now().isoformat(),
|
|
}
|
|
|
|
|
|
@app.get("/", tags=["System"])
|
|
async def root():
|
|
return {
|
|
"api": "Sage 100c Dataven - VPS Linux",
|
|
"version": "2.0.0",
|
|
"documentation": "/docs",
|
|
"health": "/health",
|
|
}
|
|
|
|
|
|
@app.get("/admin/cache/info", tags=["Admin"])
|
|
async def info_cache():
|
|
try:
|
|
cache_info = sage_client.get_cache_info()
|
|
return cache_info
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur info cache: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/admin/queue/status", tags=["Admin"])
|
|
async def statut_queue():
|
|
return {
|
|
"queue_size": email_queue.queue.qsize(),
|
|
"workers": len(email_queue.workers),
|
|
"running": email_queue.running,
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(
|
|
"api:app",
|
|
host=settings.api_host,
|
|
port=settings.api_port,
|
|
reload=settings.api_reload,
|
|
)
|