feat(enterprise): add enterprise search functionality with API integration
This commit is contained in:
parent
30ffc7a493
commit
e7003d4059
8 changed files with 338 additions and 86 deletions
4
api.py
4
api.py
|
|
@ -17,7 +17,6 @@ from sqlalchemy import select
|
||||||
import os
|
import os
|
||||||
from pathlib import Path as FilePath
|
from pathlib import Path as FilePath
|
||||||
from data.data import TAGS_METADATA, templates_signature_email
|
from data.data import TAGS_METADATA, templates_signature_email
|
||||||
from routes.auth import router as auth_router
|
|
||||||
from config.config import settings
|
from config.config import settings
|
||||||
from database import (
|
from database import (
|
||||||
init_db,
|
init_db,
|
||||||
|
|
@ -78,8 +77,10 @@ from schemas.tiers.commercial import (
|
||||||
CollaborateurUpdate,
|
CollaborateurUpdate,
|
||||||
)
|
)
|
||||||
from utils.normalization import normaliser_type_tiers
|
from utils.normalization import normaliser_type_tiers
|
||||||
|
from routes.auth import router as auth_router
|
||||||
from routes.sage_gateway import router as sage_gateway_router
|
from routes.sage_gateway import router as sage_gateway_router
|
||||||
from routes.universign import router as universign_router
|
from routes.universign import router as universign_router
|
||||||
|
from routes.enterprise import router as entreprises_router
|
||||||
|
|
||||||
from services.universign_sync import UniversignSyncService, UniversignSyncScheduler
|
from services.universign_sync import UniversignSyncService, UniversignSyncScheduler
|
||||||
|
|
||||||
|
|
@ -172,6 +173,7 @@ app.add_middleware(
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(sage_gateway_router)
|
app.include_router(sage_gateway_router)
|
||||||
app.include_router(universign_router)
|
app.include_router(universign_router)
|
||||||
|
app.include_router(entreprises_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"])
|
@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"])
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ from reportlab.lib.units import mm
|
||||||
from reportlab.lib.colors import HexColor, Color
|
from reportlab.lib.colors import HexColor, Color
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -74,11 +76,6 @@ def _register_sage_font():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# HELPERS POUR GESTION DES LOCKS SQLite
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_with_retry(
|
async def execute_with_retry(
|
||||||
session,
|
session,
|
||||||
operation,
|
operation,
|
||||||
|
|
@ -86,19 +83,6 @@ async def execute_with_retry(
|
||||||
base_delay: float = 0.1,
|
base_delay: float = 0.1,
|
||||||
max_delay: float = 2.0,
|
max_delay: float = 2.0,
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
Exécute une opération async avec retry en cas de lock SQLite.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Session SQLAlchemy async
|
|
||||||
operation: Coroutine à exécuter
|
|
||||||
max_retries: Nombre max de tentatives
|
|
||||||
base_delay: Délai initial entre les tentatives (secondes)
|
|
||||||
max_delay: Délai maximum entre les tentatives
|
|
||||||
"""
|
|
||||||
import sqlite3
|
|
||||||
from sqlalchemy.exc import OperationalError
|
|
||||||
|
|
||||||
last_exception = None
|
last_exception = None
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
|
|
@ -107,10 +91,8 @@ async def execute_with_retry(
|
||||||
return result
|
return result
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
last_exception = e
|
last_exception = e
|
||||||
# Vérifier si c'est un lock SQLite
|
|
||||||
if "database is locked" in str(e).lower():
|
if "database is locked" in str(e).lower():
|
||||||
delay = min(base_delay * (2**attempt), max_delay)
|
delay = min(base_delay * (2**attempt), max_delay)
|
||||||
# Ajouter un jitter aléatoire pour éviter les collisions
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
delay += random.uniform(0, delay * 0.1)
|
delay += random.uniform(0, delay * 0.1)
|
||||||
|
|
@ -121,13 +103,10 @@ async def execute_with_retry(
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
else:
|
else:
|
||||||
# Autre erreur OperationalError, ne pas retry
|
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception:
|
||||||
# Autres exceptions, ne pas retry
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Toutes les tentatives ont échoué
|
|
||||||
logger.error(f"Échec après {max_retries} tentatives: {last_exception}")
|
logger.error(f"Échec après {max_retries} tentatives: {last_exception}")
|
||||||
raise last_exception
|
raise last_exception
|
||||||
|
|
||||||
|
|
@ -139,10 +118,9 @@ class EmailQueue:
|
||||||
self.running = False
|
self.running = False
|
||||||
self.session_factory = None
|
self.session_factory = None
|
||||||
self.sage_client = None
|
self.sage_client = None
|
||||||
# Lock pour synchroniser les accès DB dans le worker
|
|
||||||
self._db_lock = asyncio.Lock()
|
self._db_lock = asyncio.Lock()
|
||||||
|
|
||||||
def start(self, num_workers: int = 2): # Réduire le nombre de workers pour SQLite
|
def start(self, num_workers: int = 2):
|
||||||
if self.running:
|
if self.running:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -164,13 +142,6 @@ class EmailQueue:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def enqueue(self, email_log_id: str, delay_seconds: float = 0):
|
def enqueue(self, email_log_id: str, delay_seconds: float = 0):
|
||||||
"""
|
|
||||||
Ajoute un email à la queue avec un délai optionnel.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
email_log_id: ID de l'email log
|
|
||||||
delay_seconds: Délai avant traitement (pour éviter les conflits)
|
|
||||||
"""
|
|
||||||
if delay_seconds > 0:
|
if delay_seconds > 0:
|
||||||
timer = threading.Timer(delay_seconds, lambda: self.queue.put(email_log_id))
|
timer = threading.Timer(delay_seconds, lambda: self.queue.put(email_log_id))
|
||||||
timer.daemon = True
|
timer.daemon = True
|
||||||
|
|
@ -214,7 +185,7 @@ class EmailQueue:
|
||||||
for db_attempt in range(max_db_retries):
|
for db_attempt in range(max_db_retries):
|
||||||
try:
|
try:
|
||||||
async with self.session_factory() as session:
|
async with self.session_factory() as session:
|
||||||
# Lecture de l'email log avec retry
|
|
||||||
async def fetch_email():
|
async def fetch_email():
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(EmailLog).where(EmailLog.id == email_log_id)
|
select(EmailLog).where(EmailLog.id == email_log_id)
|
||||||
|
|
@ -227,7 +198,6 @@ class EmailQueue:
|
||||||
logger.error(f"Email log {email_log_id} introuvable")
|
logger.error(f"Email log {email_log_id} introuvable")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Mise à jour du statut avec retry
|
|
||||||
async def update_status_en_cours():
|
async def update_status_en_cours():
|
||||||
email_log.statut = StatutEmail.EN_COURS
|
email_log.statut = StatutEmail.EN_COURS
|
||||||
email_log.nb_tentatives += 1
|
email_log.nb_tentatives += 1
|
||||||
|
|
@ -236,10 +206,8 @@ class EmailQueue:
|
||||||
await execute_with_retry(session, update_status_en_cours)
|
await execute_with_retry(session, update_status_en_cours)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Envoi de l'email (pas de DB ici)
|
|
||||||
await self._send_with_retry(email_log)
|
await self._send_with_retry(email_log)
|
||||||
|
|
||||||
# Mise à jour succès avec retry
|
|
||||||
async def update_status_success():
|
async def update_status_success():
|
||||||
email_log.statut = StatutEmail.ENVOYE
|
email_log.statut = StatutEmail.ENVOYE
|
||||||
email_log.date_envoi = datetime.now()
|
email_log.date_envoi = datetime.now()
|
||||||
|
|
@ -248,13 +216,12 @@ class EmailQueue:
|
||||||
|
|
||||||
await execute_with_retry(session, update_status_success)
|
await execute_with_retry(session, update_status_success)
|
||||||
|
|
||||||
logger.info(f"✅ Email envoyé: {email_log.destinataire}")
|
logger.info(f" Email envoyé: {email_log.destinataire}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
logger.error(f"Erreur envoi email: {error_msg}")
|
logger.error(f"Erreur envoi email: {error_msg}")
|
||||||
|
|
||||||
# Mise à jour erreur avec retry
|
|
||||||
async def update_status_error():
|
async def update_status_error():
|
||||||
email_log.statut = StatutEmail.ERREUR
|
email_log.statut = StatutEmail.ERREUR
|
||||||
email_log.derniere_erreur = error_msg[:1000]
|
email_log.derniere_erreur = error_msg[:1000]
|
||||||
|
|
@ -271,7 +238,6 @@ class EmailQueue:
|
||||||
|
|
||||||
await execute_with_retry(session, update_status_error)
|
await execute_with_retry(session, update_status_error)
|
||||||
|
|
||||||
# Replanifier si tentatives restantes
|
|
||||||
if email_log.nb_tentatives < settings.max_retry_attempts:
|
if email_log.nb_tentatives < settings.max_retry_attempts:
|
||||||
delay = settings.retry_delay_seconds * (
|
delay = settings.retry_delay_seconds * (
|
||||||
2 ** (email_log.nb_tentatives - 1)
|
2 ** (email_log.nb_tentatives - 1)
|
||||||
|
|
@ -281,7 +247,6 @@ class EmailQueue:
|
||||||
f"Email {email_log_id} replanifié dans {delay}s"
|
f"Email {email_log_id} replanifié dans {delay}s"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sortir de la boucle de retry si tout s'est bien passé
|
|
||||||
return
|
return
|
||||||
|
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
|
|
@ -300,11 +265,9 @@ class EmailQueue:
|
||||||
logger.error(f"Erreur inattendue traitement email: {e}", exc_info=True)
|
logger.error(f"Erreur inattendue traitement email: {e}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Si on arrive ici, toutes les tentatives ont échoué
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Échec définitif traitement email {email_log_id} après {max_db_retries} tentatives DB"
|
f"Échec définitif traitement email {email_log_id} après {max_db_retries} tentatives DB"
|
||||||
)
|
)
|
||||||
# Replanifier l'email pour plus tard
|
|
||||||
self.enqueue(email_log_id, delay_seconds=30)
|
self.enqueue(email_log_id, delay_seconds=30)
|
||||||
|
|
||||||
async def _send_with_retry(self, email_log):
|
async def _send_with_retry(self, email_log):
|
||||||
|
|
@ -314,7 +277,6 @@ class EmailQueue:
|
||||||
msg["Subject"] = email_log.sujet
|
msg["Subject"] = email_log.sujet
|
||||||
msg.attach(MIMEText(email_log.corps_html, "html"))
|
msg.attach(MIMEText(email_log.corps_html, "html"))
|
||||||
|
|
||||||
# Attachement des PDFs
|
|
||||||
if email_log.document_ids:
|
if email_log.document_ids:
|
||||||
document_ids = email_log.document_ids.split(",")
|
document_ids = email_log.document_ids.split(",")
|
||||||
type_doc = email_log.type_document
|
type_doc = email_log.type_document
|
||||||
|
|
@ -339,39 +301,31 @@ class EmailQueue:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur génération PDF {doc_id}: {e}")
|
logger.error(f"Erreur génération PDF {doc_id}: {e}")
|
||||||
|
|
||||||
# Envoi SMTP
|
|
||||||
await asyncio.to_thread(self._send_smtp, msg)
|
await asyncio.to_thread(self._send_smtp, msg)
|
||||||
|
|
||||||
def _send_smtp(self, msg):
|
def _send_smtp(self, msg):
|
||||||
server = None
|
server = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Résolution DNS
|
|
||||||
socket.getaddrinfo(settings.smtp_host, settings.smtp_port)
|
socket.getaddrinfo(settings.smtp_host, settings.smtp_port)
|
||||||
|
|
||||||
# Connexion
|
|
||||||
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30)
|
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30)
|
||||||
|
|
||||||
# EHLO
|
|
||||||
server.ehlo()
|
server.ehlo()
|
||||||
|
|
||||||
# STARTTLS
|
|
||||||
if settings.smtp_use_tls:
|
if settings.smtp_use_tls:
|
||||||
if server.has_extn("STARTTLS"):
|
if server.has_extn("STARTTLS"):
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
server.starttls(context=context)
|
server.starttls(context=context)
|
||||||
server.ehlo()
|
server.ehlo()
|
||||||
|
|
||||||
# Authentification
|
|
||||||
if settings.smtp_user and settings.smtp_password:
|
if settings.smtp_user and settings.smtp_password:
|
||||||
server.login(settings.smtp_user, settings.smtp_password)
|
server.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
|
||||||
# Envoi
|
|
||||||
refused = server.send_message(msg)
|
refused = server.send_message(msg)
|
||||||
if refused:
|
if refused:
|
||||||
raise Exception(f"Destinataires refusés: {refused}")
|
raise Exception(f"Destinataires refusés: {refused}")
|
||||||
|
|
||||||
# Fermeture
|
|
||||||
server.quit()
|
server.quit()
|
||||||
|
|
||||||
logger.info(f"SMTP: Email envoyé à {msg['To']}")
|
logger.info(f"SMTP: Email envoyé à {msg['To']}")
|
||||||
|
|
@ -396,11 +350,9 @@ class EmailQueue:
|
||||||
if not doc:
|
if not doc:
|
||||||
raise Exception(f"Document {doc_id} introuvable")
|
raise Exception(f"Document {doc_id} introuvable")
|
||||||
|
|
||||||
# Récupérer les informations de la société émettrice
|
|
||||||
societe_info = None
|
societe_info = None
|
||||||
try:
|
try:
|
||||||
societe_info = self.sage_client.lire_informations_societe()
|
societe_info = self.sage_client.lire_informations_societe()
|
||||||
# Log selon le type (dict ou objet)
|
|
||||||
if societe_info:
|
if societe_info:
|
||||||
if isinstance(societe_info, dict):
|
if isinstance(societe_info, dict):
|
||||||
raison = societe_info.get("raison_sociale", "N/A")
|
raison = societe_info.get("raison_sociale", "N/A")
|
||||||
|
|
@ -410,13 +362,11 @@ class EmailQueue:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Impossible de récupérer les infos société: {e}")
|
logger.warning(f"Impossible de récupérer les infos société: {e}")
|
||||||
|
|
||||||
# Générer le PDF avec le nouveau générateur
|
|
||||||
generator = SagePDFGenerator(doc, type_doc, societe_info=societe_info)
|
generator = SagePDFGenerator(doc, type_doc, societe_info=societe_info)
|
||||||
return generator.generate()
|
return generator.generate()
|
||||||
|
|
||||||
|
|
||||||
class SagePDFGenerator:
|
class SagePDFGenerator:
|
||||||
# Couleurs Sage
|
|
||||||
SAGE_GREEN = HexColor("#00D639")
|
SAGE_GREEN = HexColor("#00D639")
|
||||||
SAGE_GREEN_DARK = HexColor("#00B830")
|
SAGE_GREEN_DARK = HexColor("#00B830")
|
||||||
GRAY_50 = HexColor("#F9FAFB")
|
GRAY_50 = HexColor("#F9FAFB")
|
||||||
|
|
@ -430,13 +380,11 @@ class SagePDFGenerator:
|
||||||
GRAY_800 = HexColor("#1F2937")
|
GRAY_800 = HexColor("#1F2937")
|
||||||
GRAY_900 = HexColor("#111827")
|
GRAY_900 = HexColor("#111827")
|
||||||
|
|
||||||
# Valeurs par défaut (fallback si société non disponible)
|
|
||||||
DEFAULT_COMPANY_NAME = "Entreprise"
|
DEFAULT_COMPANY_NAME = "Entreprise"
|
||||||
DEFAULT_COMPANY_ADDRESS = ""
|
DEFAULT_COMPANY_ADDRESS = ""
|
||||||
DEFAULT_COMPANY_CITY = ""
|
DEFAULT_COMPANY_CITY = ""
|
||||||
DEFAULT_COMPANY_EMAIL = ""
|
DEFAULT_COMPANY_EMAIL = ""
|
||||||
|
|
||||||
# Labels des types de documents
|
|
||||||
TYPE_LABELS = {
|
TYPE_LABELS = {
|
||||||
0: "Devis",
|
0: "Devis",
|
||||||
10: "Commande",
|
10: "Commande",
|
||||||
|
|
@ -451,19 +399,16 @@ class SagePDFGenerator:
|
||||||
self.type_label = self.TYPE_LABELS.get(type_doc, "Document")
|
self.type_label = self.TYPE_LABELS.get(type_doc, "Document")
|
||||||
self.societe_info = societe_info
|
self.societe_info = societe_info
|
||||||
|
|
||||||
# Configuration de la page
|
|
||||||
self.page_width, self.page_height = A4
|
self.page_width, self.page_height = A4
|
||||||
self.margin = 15 * mm
|
self.margin = 15 * mm
|
||||||
self.content_width = self.page_width - 2 * self.margin
|
self.content_width = self.page_width - 2 * self.margin
|
||||||
|
|
||||||
# État du générateur
|
|
||||||
self.buffer = BytesIO()
|
self.buffer = BytesIO()
|
||||||
self.pdf = None
|
self.pdf = None
|
||||||
self.current_y = 0
|
self.current_y = 0
|
||||||
self.page_number = 1
|
self.page_number = 1
|
||||||
self.total_pages = 1 # Sera calculé
|
self.total_pages = 1
|
||||||
|
|
||||||
# Initialiser les polices
|
|
||||||
_register_sage_font()
|
_register_sage_font()
|
||||||
self.use_sage_font = _sage_font_registered
|
self.use_sage_font = _sage_font_registered
|
||||||
|
|
||||||
|
|
@ -472,7 +417,6 @@ class SagePDFGenerator:
|
||||||
if self.societe_info is None:
|
if self.societe_info is None:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
# Support dict ou objet Pydantic
|
|
||||||
if isinstance(self.societe_info, dict):
|
if isinstance(self.societe_info, dict):
|
||||||
value = self.societe_info.get(field)
|
value = self.societe_info.get(field)
|
||||||
else:
|
else:
|
||||||
|
|
@ -514,7 +458,6 @@ class SagePDFGenerator:
|
||||||
|
|
||||||
def _get_societe_email(self) -> str:
|
def _get_societe_email(self) -> str:
|
||||||
"""Retourne l'email de la société."""
|
"""Retourne l'email de la société."""
|
||||||
# Priorité à email_societe, puis email
|
|
||||||
email = self._get_societe_field("email_societe", "")
|
email = self._get_societe_field("email_societe", "")
|
||||||
if not email:
|
if not email:
|
||||||
email = self._get_societe_field("email", "")
|
email = self._get_societe_field("email", "")
|
||||||
|
|
@ -668,7 +611,6 @@ class SagePDFGenerator:
|
||||||
col1_x, y, "ÉMETTEUR", font_size=8, bold=True, color=self.GRAY_400
|
col1_x, y, "ÉMETTEUR", font_size=8, bold=True, color=self.GRAY_400
|
||||||
)
|
)
|
||||||
|
|
||||||
# === INFORMATIONS SOCIÉTÉ (dynamiques) ===
|
|
||||||
y_emetteur = y - 6 * mm
|
y_emetteur = y - 6 * mm
|
||||||
self._draw_text(
|
self._draw_text(
|
||||||
col1_x,
|
col1_x,
|
||||||
|
|
@ -679,18 +621,15 @@ class SagePDFGenerator:
|
||||||
color=self.GRAY_800,
|
color=self.GRAY_800,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Adresse ligne 1
|
|
||||||
address_line1 = self._get_societe_address_line1()
|
address_line1 = self._get_societe_address_line1()
|
||||||
if address_line1:
|
if address_line1:
|
||||||
y_emetteur -= 5 * mm
|
y_emetteur -= 5 * mm
|
||||||
# Tronquer si trop long
|
|
||||||
if len(address_line1) > 45:
|
if len(address_line1) > 45:
|
||||||
address_line1 = address_line1[:42] + "..."
|
address_line1 = address_line1[:42] + "..."
|
||||||
self._draw_text(
|
self._draw_text(
|
||||||
col1_x, y_emetteur, address_line1, font_size=9, color=self.GRAY_600
|
col1_x, y_emetteur, address_line1, font_size=9, color=self.GRAY_600
|
||||||
)
|
)
|
||||||
|
|
||||||
# Adresse ligne 2 (CP + Ville)
|
|
||||||
address_line2 = self._get_societe_address_line2()
|
address_line2 = self._get_societe_address_line2()
|
||||||
if address_line2:
|
if address_line2:
|
||||||
y_emetteur -= 4 * mm
|
y_emetteur -= 4 * mm
|
||||||
|
|
@ -698,7 +637,6 @@ class SagePDFGenerator:
|
||||||
col1_x, y_emetteur, address_line2, font_size=9, color=self.GRAY_600
|
col1_x, y_emetteur, address_line2, font_size=9, color=self.GRAY_600
|
||||||
)
|
)
|
||||||
|
|
||||||
# Email
|
|
||||||
societe_email = self._get_societe_email()
|
societe_email = self._get_societe_email()
|
||||||
if societe_email:
|
if societe_email:
|
||||||
y_emetteur -= 5 * mm
|
y_emetteur -= 5 * mm
|
||||||
|
|
@ -706,7 +644,6 @@ class SagePDFGenerator:
|
||||||
col1_x, y_emetteur, societe_email, font_size=9, color=self.GRAY_600
|
col1_x, y_emetteur, societe_email, font_size=9, color=self.GRAY_600
|
||||||
)
|
)
|
||||||
|
|
||||||
# Téléphone (optionnel)
|
|
||||||
societe_phone = self._get_societe_phone()
|
societe_phone = self._get_societe_phone()
|
||||||
if societe_phone:
|
if societe_phone:
|
||||||
y_emetteur -= 4 * mm
|
y_emetteur -= 4 * mm
|
||||||
|
|
@ -718,12 +655,10 @@ class SagePDFGenerator:
|
||||||
color=self.GRAY_500,
|
color=self.GRAY_500,
|
||||||
)
|
)
|
||||||
|
|
||||||
# === DESTINATAIRE ===
|
|
||||||
box_padding = 4 * mm
|
box_padding = 4 * mm
|
||||||
box_height = 26 * mm
|
box_height = 26 * mm
|
||||||
box_y = y - 3 * mm
|
box_y = y - 3 * mm
|
||||||
|
|
||||||
# Fond gris arrondi
|
|
||||||
self.pdf.setFillColor(self.GRAY_50)
|
self.pdf.setFillColor(self.GRAY_50)
|
||||||
self.pdf.roundRect(
|
self.pdf.roundRect(
|
||||||
col2_x - box_padding,
|
col2_x - box_padding,
|
||||||
|
|
@ -907,7 +842,6 @@ class SagePDFGenerator:
|
||||||
align="right",
|
align="right",
|
||||||
)
|
)
|
||||||
|
|
||||||
# TVA
|
|
||||||
taux_tva = ligne.get("taux_taxe1") or ligne.get("taux_tva") or 20
|
taux_tva = ligne.get("taux_taxe1") or ligne.get("taux_tva") or 20
|
||||||
self._draw_text(
|
self._draw_text(
|
||||||
col_tva,
|
col_tva,
|
||||||
|
|
@ -967,7 +901,6 @@ class SagePDFGenerator:
|
||||||
)
|
)
|
||||||
y -= 6 * mm
|
y -= 6 * mm
|
||||||
|
|
||||||
# TVA
|
|
||||||
total_ttc = self.doc.get("total_ttc") or 0
|
total_ttc = self.doc.get("total_ttc") or 0
|
||||||
tva = total_ttc - total_ht + remise_globale
|
tva = total_ttc - total_ht + remise_globale
|
||||||
taux_tva_global = self.doc.get("taux_tva_principal") or 20
|
taux_tva_global = self.doc.get("taux_tva_principal") or 20
|
||||||
|
|
@ -1045,7 +978,6 @@ class SagePDFGenerator:
|
||||||
def _draw_footer(self):
|
def _draw_footer(self):
|
||||||
footer_y = 15 * mm
|
footer_y = 15 * mm
|
||||||
|
|
||||||
# Informations légales de la société
|
|
||||||
legal_parts = []
|
legal_parts = []
|
||||||
|
|
||||||
societe_name = self._get_societe_name()
|
societe_name = self._get_societe_name()
|
||||||
|
|
@ -1060,11 +992,9 @@ class SagePDFGenerator:
|
||||||
if tva:
|
if tva:
|
||||||
legal_parts.append(f"TVA: {tva}")
|
legal_parts.append(f"TVA: {tva}")
|
||||||
|
|
||||||
# Forme juridique et capital si disponibles
|
|
||||||
if self.societe_info:
|
if self.societe_info:
|
||||||
forme = self._get_societe_field("forme_juridique", "")
|
forme = self._get_societe_field("forme_juridique", "")
|
||||||
capital = self._get_societe_field("capital", 0)
|
capital = self._get_societe_field("capital", 0)
|
||||||
# Convertir en float si c'est une string
|
|
||||||
if isinstance(capital, str):
|
if isinstance(capital, str):
|
||||||
try:
|
try:
|
||||||
capital = float(capital)
|
capital = float(capital)
|
||||||
|
|
@ -1075,10 +1005,8 @@ class SagePDFGenerator:
|
||||||
f"{forme} au capital de {float(capital):,.0f} €".replace(",", " ")
|
f"{forme} au capital de {float(capital):,.0f} €".replace(",", " ")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Dessiner les informations légales
|
|
||||||
if legal_parts:
|
if legal_parts:
|
||||||
legal_text = " • ".join(legal_parts)
|
legal_text = " • ".join(legal_parts)
|
||||||
# Tronquer si trop long
|
|
||||||
if len(legal_text) > 100:
|
if len(legal_text) > 100:
|
||||||
legal_text = legal_text[:97] + "..."
|
legal_text = legal_text[:97] + "..."
|
||||||
self._draw_text(
|
self._draw_text(
|
||||||
|
|
@ -1090,7 +1018,6 @@ class SagePDFGenerator:
|
||||||
align="center",
|
align="center",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pagination
|
|
||||||
page_text = f"Page {self.page_number} / {self.total_pages}"
|
page_text = f"Page {self.page_number} / {self.total_pages}"
|
||||||
self._draw_text(
|
self._draw_text(
|
||||||
self.page_width / 2,
|
self.page_width / 2,
|
||||||
|
|
|
||||||
159
routes/enterprise.py
Normal file
159
routes/enterprise.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from schemas import EntrepriseSearch, EntrepriseSearchResponse
|
||||||
|
from utils.enterprise import (
|
||||||
|
calculer_tva_intracommunautaire,
|
||||||
|
mapper_resultat_api,
|
||||||
|
rechercher_entreprise_api,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/entreprises", tags=["Entreprises"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=EntrepriseSearchResponse)
|
||||||
|
async def rechercher_entreprise(
|
||||||
|
q: str = Query(..., min_length=2, description="Nom d'entreprise, SIREN ou SIRET"),
|
||||||
|
per_page: int = Query(5, ge=1, le=25, description="Nombre de résultats (max 25)"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
logger.info(f" Recherche entreprise: '{q}'")
|
||||||
|
|
||||||
|
# Appel API
|
||||||
|
api_response = await rechercher_entreprise_api(q, per_page)
|
||||||
|
|
||||||
|
resultats_api = api_response.get("results", [])
|
||||||
|
|
||||||
|
if not resultats_api:
|
||||||
|
logger.info(f"Aucun résultat pour: {q}")
|
||||||
|
return EntrepriseSearchResponse(total_results=0, results=[], query=q)
|
||||||
|
|
||||||
|
entreprises = []
|
||||||
|
for data in resultats_api:
|
||||||
|
entreprise = mapper_resultat_api(data)
|
||||||
|
if entreprise:
|
||||||
|
entreprises.append(entreprise)
|
||||||
|
|
||||||
|
logger.info(f" {len(entreprises)} résultat(s) trouvé(s)")
|
||||||
|
|
||||||
|
return EntrepriseSearchResponse(
|
||||||
|
total_results=len(entreprises), results=entreprises, query=q
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur recherche entreprise: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Erreur lors de la recherche: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/siren/{siren}", response_model=EntrepriseSearch)
|
||||||
|
async def lire_entreprise_par_siren(
|
||||||
|
siren: str = Query(
|
||||||
|
...,
|
||||||
|
min_length=9,
|
||||||
|
max_length=9,
|
||||||
|
regex=r"^\d{9}$",
|
||||||
|
description="Numéro SIREN (9 chiffres)",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
logger.info(f"Lecture entreprise SIREN: {siren}")
|
||||||
|
|
||||||
|
api_response = await rechercher_entreprise_api(siren, per_page=1)
|
||||||
|
|
||||||
|
resultats = api_response.get("results", [])
|
||||||
|
|
||||||
|
if not resultats:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Aucune entreprise trouvée pour le SIREN {siren}",
|
||||||
|
)
|
||||||
|
|
||||||
|
entreprise_data = resultats[0]
|
||||||
|
|
||||||
|
if entreprise_data.get("siren") != siren:
|
||||||
|
raise HTTPException(status_code=404, detail=f"SIREN {siren} introuvable")
|
||||||
|
|
||||||
|
entreprise = mapper_resultat_api(entreprise_data)
|
||||||
|
|
||||||
|
if not entreprise:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Erreur lors du traitement des données entreprise",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not entreprise.is_active:
|
||||||
|
logger.warning(f" Entreprise CESSÉE: {siren}")
|
||||||
|
|
||||||
|
return entreprise
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture SIREN {siren}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Erreur lors de la récupération: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tva/{siren}")
|
||||||
|
async def calculer_tva(
|
||||||
|
siren: str = Query(
|
||||||
|
...,
|
||||||
|
min_length=9,
|
||||||
|
max_length=9,
|
||||||
|
regex=r"^\d{9}$",
|
||||||
|
description="Numéro SIREN (9 chiffres)",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tva_number = calculer_tva_intracommunautaire(siren)
|
||||||
|
|
||||||
|
if not tva_number:
|
||||||
|
raise HTTPException(status_code=400, detail=f"SIREN invalide: {siren}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"siren": siren,
|
||||||
|
"vat_number": tva_number,
|
||||||
|
"format": "FR + Clé (2 chiffres) + SIREN (9 chiffres)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check_api_sirene():
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://recherche-entreprises.api.gouv.fr/search",
|
||||||
|
params={"q": "test", "per_page": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"api_sirene": "disponible",
|
||||||
|
"response_time_ms": response.elapsed.total_seconds() * 1000,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "degraded",
|
||||||
|
"api_sirene": f"statut {response.status_code}",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Health check failed: {e}")
|
||||||
|
return {
|
||||||
|
"status": "unhealthy",
|
||||||
|
"api_sirene": "indisponible",
|
||||||
|
"error": str(e),
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,8 @@ from schemas.sage.sage_gateway import (
|
||||||
|
|
||||||
from schemas.society.societe import SocieteInfo
|
from schemas.society.societe import SocieteInfo
|
||||||
|
|
||||||
|
from schemas.society.enterprise import EntrepriseSearch, EntrepriseSearchResponse
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"TiersDetails",
|
"TiersDetails",
|
||||||
"TypeTiers",
|
"TypeTiers",
|
||||||
|
|
@ -116,5 +118,7 @@ __all__ = [
|
||||||
"SyncStatsResponse",
|
"SyncStatsResponse",
|
||||||
"CreateSignatureRequest",
|
"CreateSignatureRequest",
|
||||||
"TransactionResponse",
|
"TransactionResponse",
|
||||||
"SocieteInfo"
|
"SocieteInfo",
|
||||||
|
"EntrepriseSearch",
|
||||||
|
"EntrepriseSearchResponse",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
24
schemas/society/enterprise.py
Normal file
24
schemas/society/enterprise.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
class EntrepriseSearch(BaseModel):
|
||||||
|
"""Modèle de réponse pour une entreprise trouvée"""
|
||||||
|
|
||||||
|
company_name: str = Field(..., description="Raison sociale complète")
|
||||||
|
siren: str = Field(..., description="Numéro SIREN (9 chiffres)")
|
||||||
|
vat_number: str = Field(..., description="Numéro de TVA intracommunautaire")
|
||||||
|
address: str = Field(..., description="Adresse complète du siège")
|
||||||
|
naf_code: str = Field(..., description="Code NAF/APE")
|
||||||
|
is_active: bool = Field(..., description="True si entreprise active")
|
||||||
|
siret_siege: Optional[str] = Field(None, description="SIRET du siège")
|
||||||
|
code_postal: Optional[str] = None
|
||||||
|
ville: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EntrepriseSearchResponse(BaseModel):
|
||||||
|
"""Réponse globale de la recherche"""
|
||||||
|
|
||||||
|
total_results: int
|
||||||
|
results: List[EntrepriseSearch]
|
||||||
|
query: str
|
||||||
136
utils/enterprise.py
Normal file
136
utils/enterprise.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from schemas import EntrepriseSearch
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def calculer_tva_intracommunautaire(siren: str) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
siren_clean = siren.replace(" ", "").strip()
|
||||||
|
|
||||||
|
if not siren_clean.isdigit() or len(siren_clean) != 9:
|
||||||
|
logger.warning(f"SIREN invalide: {siren}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
siren_int = int(siren_clean)
|
||||||
|
|
||||||
|
cle = (12 + 3 * (siren_int % 97)) % 97
|
||||||
|
|
||||||
|
cle_str = f"{cle:02d}"
|
||||||
|
|
||||||
|
return f"FR{cle_str}{siren_clean}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur calcul TVA pour SIREN {siren}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def formater_adresse(siege_data: dict) -> str:
|
||||||
|
try:
|
||||||
|
adresse_parts = []
|
||||||
|
|
||||||
|
if siege_data.get("numero_voie"):
|
||||||
|
adresse_parts.append(siege_data["numero_voie"])
|
||||||
|
|
||||||
|
if siege_data.get("type_voie"):
|
||||||
|
adresse_parts.append(siege_data["type_voie"])
|
||||||
|
|
||||||
|
if siege_data.get("libelle_voie"):
|
||||||
|
adresse_parts.append(siege_data["libelle_voie"])
|
||||||
|
|
||||||
|
if siege_data.get("code_postal"):
|
||||||
|
adresse_parts.append(siege_data["code_postal"])
|
||||||
|
|
||||||
|
if siege_data.get("libelle_commune"):
|
||||||
|
adresse_parts.append(siege_data["libelle_commune"].upper())
|
||||||
|
|
||||||
|
return " ".join(adresse_parts)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur formatage adresse: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def rechercher_entreprise_api(query: str, per_page: int = 5) -> dict:
|
||||||
|
api_url = "https://recherche-entreprises.api.gouv.fr/search"
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"q": query,
|
||||||
|
"per_page": per_page,
|
||||||
|
"limite_etablissements": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(api_url, params=params)
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
logger.warning("Rate limit atteint (7 req/s)")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Trop de requêtes. Veuillez réessayer dans 1 seconde.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 503:
|
||||||
|
logger.error("API Sirene indisponible (503)")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Service de recherche momentanément indisponible.",
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error(f"Timeout lors de la recherche: {query}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504, detail="Délai d'attente dépassé pour l'API de recherche."
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"Erreur HTTP API Sirene: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Erreur lors de la communication avec l'API: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mapper_resultat_api(entreprise_data: dict) -> Optional[EntrepriseSearch]:
|
||||||
|
try:
|
||||||
|
siren = entreprise_data.get("siren")
|
||||||
|
|
||||||
|
if not siren:
|
||||||
|
logger.warning("Entreprise sans SIREN, ignorée")
|
||||||
|
return None
|
||||||
|
|
||||||
|
tva_number = calculer_tva_intracommunautaire(siren)
|
||||||
|
|
||||||
|
if not tva_number:
|
||||||
|
logger.warning(f"Impossible de calculer TVA pour SIREN: {siren}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
siege = entreprise_data.get("siege", {})
|
||||||
|
|
||||||
|
etat_admin = entreprise_data.get("etat_administratif", "A")
|
||||||
|
is_active = etat_admin == "A"
|
||||||
|
|
||||||
|
return EntrepriseSearch(
|
||||||
|
company_name=entreprise_data.get("nom_complet", ""),
|
||||||
|
siren=siren,
|
||||||
|
vat_number=tva_number,
|
||||||
|
address=formater_adresse(siege),
|
||||||
|
naf_code=entreprise_data.get("activite_principale", ""),
|
||||||
|
is_active=is_active,
|
||||||
|
siret_siege=siege.get("siret"),
|
||||||
|
code_postal=siege.get("code_postal"),
|
||||||
|
ville=siege.get("libelle_commune"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur mapping entreprise: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
@ -423,7 +423,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = {
|
||||||
"SIGNE": {
|
"SIGNE": {
|
||||||
"fr": "Signé avec succès",
|
"fr": "Signé avec succès",
|
||||||
"en": "Successfully signed",
|
"en": "Successfully signed",
|
||||||
"icon": "✅",
|
"icon": "",
|
||||||
"color": "green",
|
"color": "green",
|
||||||
},
|
},
|
||||||
"REFUSE": {
|
"REFUSE": {
|
||||||
|
|
@ -441,7 +441,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = {
|
||||||
"ERREUR": {
|
"ERREUR": {
|
||||||
"fr": "Erreur technique",
|
"fr": "Erreur technique",
|
||||||
"en": "Technical error",
|
"en": "Technical error",
|
||||||
"icon": "⚠️",
|
"icon": "",
|
||||||
"color": "red",
|
"color": "red",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = {
|
||||||
"SIGNE": {
|
"SIGNE": {
|
||||||
"fr": "Signé avec succès",
|
"fr": "Signé avec succès",
|
||||||
"en": "Successfully signed",
|
"en": "Successfully signed",
|
||||||
"icon": "✅",
|
"icon": "",
|
||||||
"color": "green",
|
"color": "green",
|
||||||
},
|
},
|
||||||
"REFUSE": {
|
"REFUSE": {
|
||||||
|
|
@ -108,7 +108,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = {
|
||||||
"ERREUR": {
|
"ERREUR": {
|
||||||
"fr": "Erreur technique",
|
"fr": "Erreur technique",
|
||||||
"en": "Technical error",
|
"en": "Technical error",
|
||||||
"icon": "⚠️",
|
"icon": "",
|
||||||
"color": "red",
|
"color": "red",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue