feat(universign): add signed document storage and download functionality
This commit is contained in:
parent
0be28f6744
commit
d8ec61802d
6 changed files with 489 additions and 59 deletions
20
api.py
20
api.py
|
|
@ -391,7 +391,7 @@ async def creer_devis(devis: DevisRequest):
|
||||||
resultat = sage_client.creer_devis(devis_data)
|
resultat = sage_client.creer_devis(devis_data)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Devis créé: {resultat.get('numero_devis')} "
|
f"Devis créé: {resultat.get('numero_devis')} "
|
||||||
f"({resultat.get('total_ttc')}€ TTC)"
|
f"({resultat.get('total_ttc')}€ TTC)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -405,7 +405,7 @@ async def creer_devis(devis: DevisRequest):
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur création devis: {e}")
|
logger.error(f"Erreur création devis: {e}")
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -474,7 +474,7 @@ async def creer_commande(
|
||||||
resultat = sage_client.creer_commande(commande_data)
|
resultat = sage_client.creer_commande(commande_data)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Commande créée: {resultat.get('numero_commande')} "
|
f"Commande créée: {resultat.get('numero_commande')} "
|
||||||
f"({resultat.get('total_ttc')}€ TTC)"
|
f"({resultat.get('total_ttc')}€ TTC)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -496,7 +496,7 @@ async def creer_commande(
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur création commande: {e}")
|
logger.error(f"Erreur création commande: {e}")
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1420,7 +1420,7 @@ async def creer_facture(
|
||||||
resultat = sage_client.creer_facture(facture_data)
|
resultat = sage_client.creer_facture(facture_data)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Facture créée: {resultat.get('numero_facture')} "
|
f"Facture créée: {resultat.get('numero_facture')} "
|
||||||
f"({resultat.get('total_ttc')}€ TTC)"
|
f"({resultat.get('total_ttc')}€ TTC)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1442,7 +1442,7 @@ async def creer_facture(
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur création facture: {e}")
|
logger.error(f"Erreur création facture: {e}")
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1943,7 +1943,7 @@ async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_se
|
||||||
resultat = sage_client.creer_avoir(avoir_data)
|
resultat = sage_client.creer_avoir(avoir_data)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Avoir créé: {resultat.get('numero_avoir')} "
|
f"Avoir créé: {resultat.get('numero_avoir')} "
|
||||||
f"({resultat.get('total_ttc')}€ TTC)"
|
f"({resultat.get('total_ttc')}€ TTC)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1965,7 +1965,7 @@ async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_se
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur création avoir: {e}")
|
logger.error(f"Erreur création avoir: {e}")
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2070,7 +2070,7 @@ async def creer_livraison(
|
||||||
resultat = sage_client.creer_livraison(livraison_data)
|
resultat = sage_client.creer_livraison(livraison_data)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Livraison créée: {resultat.get('numero_livraison')} "
|
f"Livraison créée: {resultat.get('numero_livraison')} "
|
||||||
f"({resultat.get('total_ttc')}€ TTC)"
|
f"({resultat.get('total_ttc')}€ TTC)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2092,7 +2092,7 @@ async def creer_livraison(
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur création livraison: {e}")
|
logger.error(f"Erreur création livraison: {e}")
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,23 @@ class UniversignTransaction(Base):
|
||||||
# === URLS ET MÉTADONNÉES UNIVERSIGN ===
|
# === URLS ET MÉTADONNÉES UNIVERSIGN ===
|
||||||
signer_url = Column(Text, nullable=True, comment="URL de signature")
|
signer_url = Column(Text, nullable=True, comment="URL de signature")
|
||||||
document_url = Column(Text, nullable=True, comment="URL du document signé")
|
document_url = Column(Text, nullable=True, comment="URL du document signé")
|
||||||
|
|
||||||
|
signed_document_path = Column(
|
||||||
|
Text, nullable=True, comment="Chemin local du PDF signé"
|
||||||
|
)
|
||||||
|
signed_document_downloaded_at = Column(
|
||||||
|
DateTime, nullable=True, comment="Date de téléchargement du document"
|
||||||
|
)
|
||||||
|
signed_document_size_bytes = Column(
|
||||||
|
Integer, nullable=True, comment="Taille du fichier en octets"
|
||||||
|
)
|
||||||
|
download_attempts = Column(
|
||||||
|
Integer, default=0, comment="Nombre de tentatives de téléchargement"
|
||||||
|
)
|
||||||
|
download_error = Column(
|
||||||
|
Text, nullable=True, comment="Dernière erreur de téléchargement"
|
||||||
|
)
|
||||||
|
|
||||||
certificate_url = Column(Text, nullable=True, comment="URL du certificat")
|
certificate_url = Column(Text, nullable=True, comment="URL du certificat")
|
||||||
|
|
||||||
# === SIGNATAIRES ===
|
# === SIGNATAIRES ===
|
||||||
|
|
@ -268,7 +285,7 @@ class UniversignConfig(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
api_url = Column(String(500), nullable=False)
|
api_url = Column(String(500), nullable=False)
|
||||||
api_key = Column(String(500), nullable=False, comment="⚠️ À chiffrer")
|
api_key = Column(String(500), nullable=False, comment="À chiffrer")
|
||||||
|
|
||||||
# === OPTIONS ===
|
# === OPTIONS ===
|
||||||
webhook_url = Column(String(500), nullable=True)
|
webhook_url = Column(String(500), nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, or_, and_
|
from sqlalchemy import false, select, func, or_, and_, true
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
@ -16,7 +17,10 @@ from database import (
|
||||||
LocalDocumentStatus,
|
LocalDocumentStatus,
|
||||||
SageDocumentType,
|
SageDocumentType,
|
||||||
)
|
)
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
|
from services.universign_document import UniversignDocumentService
|
||||||
from services.universign_sync import UniversignSyncService
|
from services.universign_sync import UniversignSyncService
|
||||||
from config.config import settings
|
from config.config import settings
|
||||||
from utils.generic_functions import normaliser_type_doc
|
from utils.generic_functions import normaliser_type_doc
|
||||||
|
|
@ -63,6 +67,10 @@ class TransactionResponse(BaseModel):
|
||||||
needs_sync: bool
|
needs_sync: bool
|
||||||
signers: List[dict]
|
signers: List[dict]
|
||||||
|
|
||||||
|
signed_document_available: bool = False
|
||||||
|
signed_document_downloaded_at: Optional[datetime] = None
|
||||||
|
signed_document_size_kb: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class SyncStatsResponse(BaseModel):
|
class SyncStatsResponse(BaseModel):
|
||||||
"""Statistiques de synchronisation"""
|
"""Statistiques de synchronisation"""
|
||||||
|
|
@ -96,7 +104,7 @@ async def create_signature(
|
||||||
|
|
||||||
if all_existing:
|
if all_existing:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"⚠️ {len(all_existing)} transaction(s) existante(s) trouvée(s)"
|
f"{len(all_existing)} transaction(s) existante(s) trouvée(s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filtrer les transactions non-finales
|
# Filtrer les transactions non-finales
|
||||||
|
|
@ -115,7 +123,7 @@ async def create_signature(
|
||||||
if active_txs:
|
if active_txs:
|
||||||
active_tx = active_txs[0]
|
active_tx = active_txs[0]
|
||||||
logger.error(
|
logger.error(
|
||||||
f"❌ Transaction active existante: {active_tx.transaction_id} "
|
f"Transaction active existante: {active_tx.transaction_id} "
|
||||||
f"(statut: {active_tx.local_status.value})"
|
f"(statut: {active_tx.local_status.value})"
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -126,7 +134,7 @@ async def create_signature(
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"✅ Toutes les transactions existantes sont finales, création autorisée"
|
"Toutes les transactions existantes sont finales, création autorisée"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Génération PDF
|
# Génération PDF
|
||||||
|
|
@ -138,7 +146,7 @@ async def create_signature(
|
||||||
if not pdf_bytes:
|
if not pdf_bytes:
|
||||||
raise HTTPException(400, "Échec génération PDF")
|
raise HTTPException(400, "Échec génération PDF")
|
||||||
|
|
||||||
logger.info(f"✅ PDF généré: {len(pdf_bytes)} octets")
|
logger.info(f"PDF généré: {len(pdf_bytes)} octets")
|
||||||
|
|
||||||
# === CRÉATION TRANSACTION UNIVERSIGN ===
|
# === CRÉATION TRANSACTION UNIVERSIGN ===
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -160,11 +168,11 @@ async def create_signature(
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.error(f"❌ Erreur Universign (création): {resp.text}")
|
logger.error(f"Erreur Universign (création): {resp.text}")
|
||||||
raise HTTPException(500, f"Erreur Universign: {resp.status_code}")
|
raise HTTPException(500, f"Erreur Universign: {resp.status_code}")
|
||||||
|
|
||||||
universign_tx_id = resp.json().get("id")
|
universign_tx_id = resp.json().get("id")
|
||||||
logger.info(f"✅ Transaction Universign créée: {universign_tx_id}")
|
logger.info(f"Transaction Universign créée: {universign_tx_id}")
|
||||||
|
|
||||||
# Upload PDF
|
# Upload PDF
|
||||||
logger.info("📤 Upload PDF...")
|
logger.info("📤 Upload PDF...")
|
||||||
|
|
@ -176,11 +184,11 @@ async def create_signature(
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status_code not in [200, 201]:
|
if resp.status_code not in [200, 201]:
|
||||||
logger.error(f"❌ Erreur upload: {resp.text}")
|
logger.error(f"Erreur upload: {resp.text}")
|
||||||
raise HTTPException(500, "Erreur upload PDF")
|
raise HTTPException(500, "Erreur upload PDF")
|
||||||
|
|
||||||
file_id = resp.json().get("id")
|
file_id = resp.json().get("id")
|
||||||
logger.info(f"✅ PDF uploadé: {file_id}")
|
logger.info(f"PDF uploadé: {file_id}")
|
||||||
|
|
||||||
# Attachement document
|
# Attachement document
|
||||||
logger.info("🔗 Attachement document...")
|
logger.info("🔗 Attachement document...")
|
||||||
|
|
@ -246,14 +254,14 @@ async def create_signature(
|
||||||
if not signer_url:
|
if not signer_url:
|
||||||
raise HTTPException(500, "URL de signature non retournée")
|
raise HTTPException(500, "URL de signature non retournée")
|
||||||
|
|
||||||
logger.info("✅ URL de signature obtenue")
|
logger.info("URL de signature obtenue")
|
||||||
|
|
||||||
# === ENREGISTREMENT LOCAL ===
|
# === ENREGISTREMENT LOCAL ===
|
||||||
local_id = str(uuid.uuid4())
|
local_id = str(uuid.uuid4())
|
||||||
|
|
||||||
transaction = UniversignTransaction(
|
transaction = UniversignTransaction(
|
||||||
id=local_id,
|
id=local_id,
|
||||||
transaction_id=universign_tx_id, # ⚠️ Utiliser l'ID Universign, ne jamais le changer
|
transaction_id=universign_tx_id, # Utiliser l'ID Universign, ne jamais le changer
|
||||||
sage_document_id=request.sage_document_id,
|
sage_document_id=request.sage_document_id,
|
||||||
sage_document_type=request.sage_document_type,
|
sage_document_type=request.sage_document_type,
|
||||||
universign_status=UniversignTransactionStatus.STARTED,
|
universign_status=UniversignTransactionStatus.STARTED,
|
||||||
|
|
@ -436,6 +444,16 @@ async def list_transactions(
|
||||||
}
|
}
|
||||||
for s in tx.signers
|
for s in tx.signers
|
||||||
],
|
],
|
||||||
|
# ✅ NOUVEAUX CHAMPS
|
||||||
|
signed_document_available=bool(
|
||||||
|
tx.signed_document_path and Path(tx.signed_document_path).exists()
|
||||||
|
),
|
||||||
|
signed_document_downloaded_at=tx.signed_document_downloaded_at,
|
||||||
|
signed_document_size_kb=(
|
||||||
|
tx.signed_document_size_bytes / 1024
|
||||||
|
if tx.signed_document_size_bytes
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for tx in transactions
|
for tx in transactions
|
||||||
]
|
]
|
||||||
|
|
@ -482,6 +500,16 @@ async def get_transaction(
|
||||||
}
|
}
|
||||||
for s in tx.signers
|
for s in tx.signers
|
||||||
],
|
],
|
||||||
|
# ✅ NOUVEAUX CHAMPS
|
||||||
|
signed_document_available=bool(
|
||||||
|
tx.signed_document_path and Path(tx.signed_document_path).exists()
|
||||||
|
),
|
||||||
|
signed_document_downloaded_at=tx.signed_document_downloaded_at,
|
||||||
|
signed_document_size_kb=(
|
||||||
|
tx.signed_document_size_bytes / 1024
|
||||||
|
if tx.signed_document_size_bytes
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -544,7 +572,7 @@ async def webhook_universign(
|
||||||
)
|
)
|
||||||
logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}")
|
logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}")
|
||||||
|
|
||||||
# ✅ EXTRACTION CORRECTE DU TRANSACTION_ID
|
# EXTRACTION CORRECTE DU TRANSACTION_ID
|
||||||
transaction_id = None
|
transaction_id = None
|
||||||
|
|
||||||
# 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante)
|
# 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante)
|
||||||
|
|
@ -555,7 +583,7 @@ async def webhook_universign(
|
||||||
if nested_object.get("object") == "transaction":
|
if nested_object.get("object") == "transaction":
|
||||||
transaction_id = nested_object.get("id")
|
transaction_id = nested_object.get("id")
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Transaction ID extrait de payload.object.id: {transaction_id}"
|
f"Transaction ID extrait de payload.object.id: {transaction_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 🔍 Structure 2 : Action événements (action.opened, action.completed)
|
# 🔍 Structure 2 : Action événements (action.opened, action.completed)
|
||||||
|
|
@ -565,25 +593,23 @@ async def webhook_universign(
|
||||||
payload.get("payload", {}).get("object", {}).get("transaction_id")
|
payload.get("payload", {}).get("object", {}).get("transaction_id")
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Transaction ID extrait de payload.object.transaction_id: {transaction_id}"
|
f"Transaction ID extrait de payload.object.transaction_id: {transaction_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 🔍 Structure 3 : Transaction directe (fallback)
|
# 🔍 Structure 3 : Transaction directe (fallback)
|
||||||
elif payload.get("object") == "transaction":
|
elif payload.get("object") == "transaction":
|
||||||
transaction_id = payload.get("id")
|
transaction_id = payload.get("id")
|
||||||
logger.info(f"✅ Transaction ID extrait direct: {transaction_id}")
|
logger.info(f"Transaction ID extrait direct: {transaction_id}")
|
||||||
|
|
||||||
# 🔍 Structure 4 : Ancien format (pour rétro-compatibilité)
|
# 🔍 Structure 4 : Ancien format (pour rétro-compatibilité)
|
||||||
elif "transaction" in payload:
|
elif "transaction" in payload:
|
||||||
transaction_id = payload.get("transaction", {}).get("id")
|
transaction_id = payload.get("transaction", {}).get("id")
|
||||||
logger.info(
|
logger.info(f"Transaction ID extrait de transaction.id: {transaction_id}")
|
||||||
f"✅ Transaction ID extrait de transaction.id: {transaction_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ❌ Échec d'extraction
|
# Échec d'extraction
|
||||||
if not transaction_id:
|
if not transaction_id:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"❌ Transaction ID introuvable dans webhook\n"
|
f"Transaction ID introuvable dans webhook\n"
|
||||||
f"Type d'événement: {payload.get('type', 'unknown')}\n"
|
f"Type d'événement: {payload.get('type', 'unknown')}\n"
|
||||||
f"Clés racine: {list(payload.keys())}\n"
|
f"Clés racine: {list(payload.keys())}\n"
|
||||||
f"Payload simplifié: {json.dumps({k: v if k != 'payload' else '...' for k, v in payload.items()})}"
|
f"Payload simplifié: {json.dumps({k: v if k != 'payload' else '...' for k, v in payload.items()})}"
|
||||||
|
|
@ -606,7 +632,7 @@ async def webhook_universign(
|
||||||
|
|
||||||
if not tx:
|
if not tx:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"⚠️ Transaction {transaction_id} inconnue en local\n"
|
f"Transaction {transaction_id} inconnue en local\n"
|
||||||
f"Type d'événement: {payload.get('type')}\n"
|
f"Type d'événement: {payload.get('type')}\n"
|
||||||
f"Elle sera synchronisée au prochain polling"
|
f"Elle sera synchronisée au prochain polling"
|
||||||
)
|
)
|
||||||
|
|
@ -623,16 +649,16 @@ async def webhook_universign(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
logger.error(f"❌ Erreur traitement webhook: {error}")
|
logger.error(f"Erreur traitement webhook: {error}")
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": error,
|
"message": error,
|
||||||
"transaction_id": transaction_id,
|
"transaction_id": transaction_id,
|
||||||
}, 500
|
}, 500
|
||||||
|
|
||||||
# ✅ Succès
|
# Succès
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Webhook traité avec succès\n"
|
f"Webhook traité avec succès\n"
|
||||||
f"Transaction: {transaction_id}\n"
|
f"Transaction: {transaction_id}\n"
|
||||||
f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n"
|
f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n"
|
||||||
f"Type d'événement: {payload.get('type')}"
|
f"Type d'événement: {payload.get('type')}"
|
||||||
|
|
@ -967,7 +993,7 @@ async def diagnostic_complet(session: AsyncSession = Depends(get_session)):
|
||||||
date_limite = datetime.now() - timedelta(hours=1)
|
date_limite = datetime.now() - timedelta(hours=1)
|
||||||
sans_sync_query = select(func.count(UniversignTransaction.id)).where(
|
sans_sync_query = select(func.count(UniversignTransaction.id)).where(
|
||||||
and_(
|
and_(
|
||||||
UniversignTransaction.needs_sync == True,
|
UniversignTransaction.needs_sync.is_(true()),
|
||||||
or_(
|
or_(
|
||||||
UniversignTransaction.last_synced_at < date_limite,
|
UniversignTransaction.last_synced_at < date_limite,
|
||||||
UniversignTransaction.last_synced_at.is_(None),
|
UniversignTransaction.last_synced_at.is_(None),
|
||||||
|
|
@ -997,7 +1023,7 @@ async def diagnostic_complet(session: AsyncSession = Depends(get_session)):
|
||||||
# Transactions sans webhook reçu
|
# Transactions sans webhook reçu
|
||||||
sans_webhook_query = select(func.count(UniversignTransaction.id)).where(
|
sans_webhook_query = select(func.count(UniversignTransaction.id)).where(
|
||||||
and_(
|
and_(
|
||||||
UniversignTransaction.webhook_received == False,
|
UniversignTransaction.webhook_received.is_(false()),
|
||||||
UniversignTransaction.local_status != LocalDocumentStatus.PENDING,
|
UniversignTransaction.local_status != LocalDocumentStatus.PENDING,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -1029,13 +1055,13 @@ async def diagnostic_complet(session: AsyncSession = Depends(get_session)):
|
||||||
|
|
||||||
if len(doublons) > 0:
|
if len(doublons) > 0:
|
||||||
diagnostic["recommandations"].append(
|
diagnostic["recommandations"].append(
|
||||||
f"⚠️ {len(doublons)} document(s) avec doublons. "
|
f"{len(doublons)} document(s) avec doublons. "
|
||||||
f"Utilisez POST /universign/cleanup/all-duplicates"
|
f"Utilisez POST /universign/cleanup/all-duplicates"
|
||||||
)
|
)
|
||||||
|
|
||||||
if erreurs > 0:
|
if erreurs > 0:
|
||||||
diagnostic["recommandations"].append(
|
diagnostic["recommandations"].append(
|
||||||
f"❌ {erreurs} transaction(s) en erreur. "
|
f"{erreurs} transaction(s) en erreur. "
|
||||||
f"Vérifiez les logs avec GET /universign/transactions?status=ERREUR"
|
f"Vérifiez les logs avec GET /universign/transactions?status=ERREUR"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1373,3 +1399,217 @@ async def voir_dernier_webhook(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur debug webhook: {e}")
|
logger.error(f"Erreur debug webhook: {e}")
|
||||||
raise HTTPException(500, str(e))
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/transactions/{transaction_id}/document/download", tags=["Documents Signés"]
|
||||||
|
)
|
||||||
|
async def telecharger_document_signe(
|
||||||
|
transaction_id: str, session: AsyncSession = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Télécharge le document signé localement stocké
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = select(UniversignTransaction).where(
|
||||||
|
UniversignTransaction.transaction_id == transaction_id
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
transaction = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not transaction:
|
||||||
|
raise HTTPException(404, f"Transaction {transaction_id} introuvable")
|
||||||
|
|
||||||
|
if not transaction.signed_document_path:
|
||||||
|
raise HTTPException(
|
||||||
|
404,
|
||||||
|
"Document signé non disponible localement. "
|
||||||
|
"Utilisez POST /admin/download-missing-documents pour le récupérer.",
|
||||||
|
)
|
||||||
|
|
||||||
|
file_path = Path(transaction.signed_document_path)
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
# Document perdu, on peut tenter de le retélécharger
|
||||||
|
logger.warning(f"Fichier perdu : {file_path}")
|
||||||
|
raise HTTPException(
|
||||||
|
404,
|
||||||
|
"Fichier introuvable sur le serveur. "
|
||||||
|
"Utilisez POST /admin/download-missing-documents pour le récupérer.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Génération du nom de fichier pour le téléchargement
|
||||||
|
download_name = (
|
||||||
|
f"{transaction.sage_document_id}_"
|
||||||
|
f"{transaction.sage_document_type.name}_"
|
||||||
|
f"signe.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(file_path), media_type="application/pdf", filename=download_name
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur téléchargement document : {e}", exc_info=True)
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions/{transaction_id}/document/info", tags=["Documents Signés"])
|
||||||
|
async def info_document_signe(
|
||||||
|
transaction_id: str, session: AsyncSession = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Informations sur le document signé
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = select(UniversignTransaction).where(
|
||||||
|
UniversignTransaction.transaction_id == transaction_id
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
transaction = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not transaction:
|
||||||
|
raise HTTPException(404, f"Transaction {transaction_id} introuvable")
|
||||||
|
|
||||||
|
file_exists = False
|
||||||
|
file_size_mb = None
|
||||||
|
|
||||||
|
if transaction.signed_document_path:
|
||||||
|
file_path = Path(transaction.signed_document_path)
|
||||||
|
file_exists = file_path.exists()
|
||||||
|
|
||||||
|
if file_exists:
|
||||||
|
file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"transaction_id": transaction_id,
|
||||||
|
"document_available_locally": file_exists,
|
||||||
|
"document_url_universign": transaction.document_url,
|
||||||
|
"downloaded_at": (
|
||||||
|
transaction.signed_document_downloaded_at.isoformat()
|
||||||
|
if transaction.signed_document_downloaded_at
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"file_size_mb": round(file_size_mb, 2) if file_size_mb else None,
|
||||||
|
"download_attempts": transaction.download_attempts,
|
||||||
|
"last_download_error": transaction.download_error,
|
||||||
|
"local_path": transaction.signed_document_path if file_exists else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur info document : {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/download-missing-documents", tags=["Admin"])
|
||||||
|
async def telecharger_documents_manquants(
|
||||||
|
force_redownload: bool = Query(
|
||||||
|
False, description="Forcer le retéléchargement même si déjà présent"
|
||||||
|
),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Télécharge tous les documents signés manquants pour les transactions SIGNE
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Transactions signées sans document local
|
||||||
|
query = select(UniversignTransaction).where(
|
||||||
|
UniversignTransaction.local_status == LocalDocumentStatus.SIGNED,
|
||||||
|
or_(
|
||||||
|
UniversignTransaction.signed_document_path.is_(None),
|
||||||
|
force_redownload.is_(true()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
transactions = result.scalars().all()
|
||||||
|
|
||||||
|
logger.info(f"📥 {len(transactions)} document(s) à télécharger")
|
||||||
|
|
||||||
|
document_service = UniversignDocumentService(
|
||||||
|
api_key=settings.universign_api_key, timeout=60
|
||||||
|
)
|
||||||
|
|
||||||
|
results = {"total": len(transactions), "success": 0, "failed": 0, "details": []}
|
||||||
|
|
||||||
|
for transaction in transactions:
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
) = await document_service.download_and_store_signed_document(
|
||||||
|
session=session, transaction=transaction, force=force_redownload
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
results["success"] += 1
|
||||||
|
results["details"].append(
|
||||||
|
{
|
||||||
|
"transaction_id": transaction.transaction_id,
|
||||||
|
"sage_document_id": transaction.sage_document_id,
|
||||||
|
"status": "success",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results["failed"] += 1
|
||||||
|
results["details"].append(
|
||||||
|
{
|
||||||
|
"transaction_id": transaction.transaction_id,
|
||||||
|
"sage_document_id": transaction.sage_document_id,
|
||||||
|
"status": "failed",
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur téléchargement {transaction.transaction_id}: {e}")
|
||||||
|
results["failed"] += 1
|
||||||
|
results["details"].append(
|
||||||
|
{"transaction_id": transaction.transaction_id, "error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Téléchargement terminé : {results['success']}/{results['total']} réussis"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur téléchargement batch : {e}", exc_info=True)
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/cleanup-old-documents", tags=["Admin"])
|
||||||
|
async def nettoyer_anciens_documents(
|
||||||
|
days_to_keep: int = Query(
|
||||||
|
90, ge=7, le=365, description="Nombre de jours à conserver"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Supprime les documents signés de plus de X jours (par défaut 90)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
document_service = UniversignDocumentService(
|
||||||
|
api_key=settings.universign_api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted, size_freed_mb = await document_service.cleanup_old_documents(
|
||||||
|
days_to_keep=days_to_keep
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"files_deleted": deleted,
|
||||||
|
"space_freed_mb": size_freed_mb,
|
||||||
|
"days_kept": days_to_keep,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur nettoyage : {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
|
||||||
|
|
@ -408,7 +408,7 @@ class SageGatewayClient:
|
||||||
return self._post(
|
return self._post(
|
||||||
"/sage/collaborateurs/list",
|
"/sage/collaborateurs/list",
|
||||||
{
|
{
|
||||||
"filtre": filtre or "", # ⚠️ Convertir None en ""
|
"filtre": filtre or "", # Convertir None en ""
|
||||||
"actifs_seulement": actifs_seulement,
|
"actifs_seulement": actifs_seulement,
|
||||||
},
|
},
|
||||||
).get("data", [])
|
).get("data", [])
|
||||||
|
|
|
||||||
156
services/universign_document.py
Normal file
156
services/universign_document.py
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SIGNED_DOCS_DIR = Path(os.getenv("SIGNED_DOCS_PATH", "/app/data/signed_documents"))
|
||||||
|
SIGNED_DOCS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UniversignDocumentService:
|
||||||
|
"""Service de gestion des documents signés Universign"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, timeout: int = 60):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.auth = (api_key, "")
|
||||||
|
|
||||||
|
async def download_and_store_signed_document(
|
||||||
|
self, session: AsyncSession, transaction, force: bool = False
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
if not force and transaction.signed_document_path:
|
||||||
|
if os.path.exists(transaction.signed_document_path):
|
||||||
|
logger.debug(f"Document déjà téléchargé : {transaction.transaction_id}")
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
if not transaction.document_url:
|
||||||
|
error = "Aucune URL de document disponible"
|
||||||
|
logger.warning(f"{error} pour {transaction.transaction_id}")
|
||||||
|
transaction.download_error = error
|
||||||
|
await session.commit()
|
||||||
|
return False, error
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Téléchargement document signé : {transaction.transaction_id}")
|
||||||
|
|
||||||
|
transaction.download_attempts += 1
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
transaction.document_url,
|
||||||
|
auth=self.auth,
|
||||||
|
timeout=self.timeout,
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if "pdf" not in content_type.lower():
|
||||||
|
error = f"Type de contenu invalide : {content_type}"
|
||||||
|
logger.error(error)
|
||||||
|
transaction.download_error = error
|
||||||
|
await session.commit()
|
||||||
|
return False, error
|
||||||
|
|
||||||
|
filename = self._generate_filename(transaction)
|
||||||
|
file_path = SIGNED_DOCS_DIR / filename
|
||||||
|
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
if file_size < 1024: # Moins de 1 KB = suspect
|
||||||
|
error = f"Fichier trop petit : {file_size} octets"
|
||||||
|
logger.error(error)
|
||||||
|
os.remove(file_path)
|
||||||
|
transaction.download_error = error
|
||||||
|
await session.commit()
|
||||||
|
return False, error
|
||||||
|
|
||||||
|
transaction.signed_document_path = str(file_path)
|
||||||
|
transaction.signed_document_downloaded_at = datetime.now()
|
||||||
|
transaction.signed_document_size_bytes = file_size
|
||||||
|
transaction.download_error = None
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"Document téléchargé : {filename} ({file_size / 1024:.1f} KB)")
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
error = f"Erreur HTTP : {str(e)}"
|
||||||
|
logger.error(f"{error} pour {transaction.transaction_id}")
|
||||||
|
transaction.download_error = error
|
||||||
|
await session.commit()
|
||||||
|
return False, error
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
error = f"Erreur filesystem : {str(e)}"
|
||||||
|
logger.error(f"{error}")
|
||||||
|
transaction.download_error = error
|
||||||
|
await session.commit()
|
||||||
|
return False, error
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Erreur inattendue : {str(e)}"
|
||||||
|
logger.error(f"{error}", exc_info=True)
|
||||||
|
transaction.download_error = error
|
||||||
|
await session.commit()
|
||||||
|
return False, error
|
||||||
|
|
||||||
|
def _generate_filename(self, transaction) -> str:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
tx_id = transaction.transaction_id.replace("tr_", "")
|
||||||
|
|
||||||
|
filename = f"{transaction.sage_document_id}_{tx_id}_{timestamp}.pdf"
|
||||||
|
|
||||||
|
return filename
|
||||||
|
|
||||||
|
def get_document_path(self, transaction) -> Optional[Path]:
|
||||||
|
if not transaction.signed_document_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = Path(transaction.signed_document_path)
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def cleanup_old_documents(self, days_to_keep: int = 90) -> Tuple[int, int]:
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=days_to_keep)
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
size_freed = 0
|
||||||
|
|
||||||
|
for file_path in SIGNED_DOCS_DIR.glob("*.pdf"):
|
||||||
|
try:
|
||||||
|
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||||
|
|
||||||
|
if file_time < cutoff_date:
|
||||||
|
size_freed += os.path.getsize(file_path)
|
||||||
|
os.remove(file_path)
|
||||||
|
deleted += 1
|
||||||
|
logger.info(f"🗑️ Supprimé : {file_path.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur suppression {file_path}: {e}")
|
||||||
|
|
||||||
|
size_freed_mb = size_freed / (1024 * 1024)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Nettoyage terminé : {deleted} fichiers supprimés "
|
||||||
|
f"({size_freed_mb:.2f} MB libérés)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return deleted, int(size_freed_mb)
|
||||||
|
|
@ -19,6 +19,7 @@ from database import (
|
||||||
StatutEmail,
|
StatutEmail,
|
||||||
)
|
)
|
||||||
from data.data import templates_signature_email
|
from data.data import templates_signature_email
|
||||||
|
from services.universign_document import UniversignDocumentService
|
||||||
from utils.universign_status_mapping import (
|
from utils.universign_status_mapping import (
|
||||||
map_universign_to_local,
|
map_universign_to_local,
|
||||||
is_transition_allowed,
|
is_transition_allowed,
|
||||||
|
|
@ -39,6 +40,7 @@ class UniversignSyncService:
|
||||||
self.sage_client = None
|
self.sage_client = None
|
||||||
self.email_queue = None
|
self.email_queue = None
|
||||||
self.settings = None
|
self.settings = None
|
||||||
|
self.document_service = UniversignDocumentService(api_key=api_key, timeout=60)
|
||||||
|
|
||||||
def configure(self, sage_client, email_queue, settings):
|
def configure(self, sage_client, email_queue, settings):
|
||||||
self.sage_client = sage_client
|
self.sage_client = sage_client
|
||||||
|
|
@ -201,7 +203,7 @@ class UniversignSyncService:
|
||||||
transaction = result.scalar_one_or_none()
|
transaction = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not transaction:
|
if not transaction:
|
||||||
logger.warning(f"⚠️ Transaction {transaction_id} inconnue localement")
|
logger.warning(f"Transaction {transaction_id} inconnue localement")
|
||||||
return False, "Transaction inconnue"
|
return False, "Transaction inconnue"
|
||||||
|
|
||||||
# Marquer comme webhook reçu
|
# Marquer comme webhook reçu
|
||||||
|
|
@ -218,7 +220,7 @@ class UniversignSyncService:
|
||||||
# Log du changement de statut
|
# Log du changement de statut
|
||||||
if success and transaction.local_status.value != old_status:
|
if success and transaction.local_status.value != old_status:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Webhook traité: {transaction_id} | "
|
f"Webhook traité: {transaction_id} | "
|
||||||
f"{old_status} → {transaction.local_status.value}"
|
f"{old_status} → {transaction.local_status.value}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -233,7 +235,7 @@ class UniversignSyncService:
|
||||||
new_status=transaction.local_status.value,
|
new_status=transaction.local_status.value,
|
||||||
changes=json.dumps(
|
changes=json.dumps(
|
||||||
payload, default=str
|
payload, default=str
|
||||||
), # ✅ Ajout default=str pour éviter les erreurs JSON
|
), # Ajout default=str pour éviter les erreurs JSON
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -267,7 +269,7 @@ class UniversignSyncService:
|
||||||
logger.warning(f"Signataire sans email à l'index {idx}, ignoré")
|
logger.warning(f"Signataire sans email à l'index {idx}, ignoré")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ✅ PROTECTION : gérer les statuts inconnus
|
# PROTECTION : gérer les statuts inconnus
|
||||||
raw_status = signer_data.get("status") or signer_data.get(
|
raw_status = signer_data.get("status") or signer_data.get(
|
||||||
"state", "waiting"
|
"state", "waiting"
|
||||||
)
|
)
|
||||||
|
|
@ -298,7 +300,7 @@ class UniversignSyncService:
|
||||||
if signer_data.get("name") and not signer.name:
|
if signer_data.get("name") and not signer.name:
|
||||||
signer.name = signer_data.get("name")
|
signer.name = signer_data.get("name")
|
||||||
else:
|
else:
|
||||||
# ✅ Nouveau signer avec gestion d'erreur intégrée
|
# Nouveau signer avec gestion d'erreur intégrée
|
||||||
try:
|
try:
|
||||||
signer = UniversignSigner(
|
signer = UniversignSigner(
|
||||||
id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}",
|
id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}",
|
||||||
|
|
@ -345,9 +347,9 @@ class UniversignSyncService:
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
error = "Échec récupération données Universign"
|
error = "Échec récupération données Universign"
|
||||||
logger.error(f"❌ {error}: {transaction.transaction_id}")
|
logger.error(f"{error}: {transaction.transaction_id}")
|
||||||
|
|
||||||
# ✅ CORRECTION : Incrémenter les tentatives MÊME en cas d'échec
|
# CORRECTION : Incrémenter les tentatives MÊME en cas d'échec
|
||||||
transaction.sync_attempts += 1
|
transaction.sync_attempts += 1
|
||||||
transaction.sync_error = error
|
transaction.sync_error = error
|
||||||
|
|
||||||
|
|
@ -373,14 +375,12 @@ class UniversignSyncService:
|
||||||
# Vérifier la transition
|
# Vérifier la transition
|
||||||
if not is_transition_allowed(previous_local_status, new_local_status):
|
if not is_transition_allowed(previous_local_status, new_local_status):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"⚠️ Transition refusée: {previous_local_status} → {new_local_status}"
|
f"Transition refusée: {previous_local_status} → {new_local_status}"
|
||||||
)
|
)
|
||||||
new_local_status = resolve_status_conflict(
|
new_local_status = resolve_status_conflict(
|
||||||
previous_local_status, new_local_status
|
previous_local_status, new_local_status
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(f"Résolution conflit: statut résolu = {new_local_status}")
|
||||||
f"✅ Résolution conflit: statut résolu = {new_local_status}"
|
|
||||||
)
|
|
||||||
|
|
||||||
status_changed = previous_local_status != new_local_status
|
status_changed = previous_local_status != new_local_status
|
||||||
|
|
||||||
|
|
@ -395,7 +395,7 @@ class UniversignSyncService:
|
||||||
universign_status_raw
|
universign_status_raw
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"⚠️ Statut Universign inconnu: {universign_status_raw}")
|
logger.warning(f"Statut Universign inconnu: {universign_status_raw}")
|
||||||
# Fallback intelligent
|
# Fallback intelligent
|
||||||
if new_local_status == "SIGNE":
|
if new_local_status == "SIGNE":
|
||||||
transaction.universign_status = (
|
transaction.universign_status = (
|
||||||
|
|
@ -408,7 +408,7 @@ class UniversignSyncService:
|
||||||
else:
|
else:
|
||||||
transaction.universign_status = UniversignTransactionStatus.STARTED
|
transaction.universign_status = UniversignTransactionStatus.STARTED
|
||||||
|
|
||||||
# ✅ Mise à jour du statut local
|
# Mise à jour du statut local
|
||||||
transaction.local_status = LocalDocumentStatus(new_local_status)
|
transaction.local_status = LocalDocumentStatus(new_local_status)
|
||||||
transaction.universign_status_updated_at = datetime.now()
|
transaction.universign_status_updated_at = datetime.now()
|
||||||
|
|
||||||
|
|
@ -419,11 +419,11 @@ class UniversignSyncService:
|
||||||
|
|
||||||
if new_local_status == "SIGNE" and not transaction.signed_at:
|
if new_local_status == "SIGNE" and not transaction.signed_at:
|
||||||
transaction.signed_at = datetime.now()
|
transaction.signed_at = datetime.now()
|
||||||
logger.info("✅ Date de signature mise à jour")
|
logger.info("Date de signature mise à jour")
|
||||||
|
|
||||||
if new_local_status == "REFUSE" and not transaction.refused_at:
|
if new_local_status == "REFUSE" and not transaction.refused_at:
|
||||||
transaction.refused_at = datetime.now()
|
transaction.refused_at = datetime.now()
|
||||||
logger.info("❌ Date de refus mise à jour")
|
logger.info("Date de refus mise à jour")
|
||||||
|
|
||||||
if new_local_status == "EXPIRE" and not transaction.expired_at:
|
if new_local_status == "EXPIRE" and not transaction.expired_at:
|
||||||
transaction.expired_at = datetime.now()
|
transaction.expired_at = datetime.now()
|
||||||
|
|
@ -438,6 +438,23 @@ class UniversignSyncService:
|
||||||
if first_doc.get("url"):
|
if first_doc.get("url"):
|
||||||
transaction.document_url = first_doc["url"]
|
transaction.document_url = first_doc["url"]
|
||||||
|
|
||||||
|
# NOUVEAU : Téléchargement automatique du document signé
|
||||||
|
if new_local_status == "SIGNE" and transaction.document_url:
|
||||||
|
if not transaction.signed_document_path:
|
||||||
|
logger.info("Déclenchement téléchargement document signé")
|
||||||
|
|
||||||
|
(
|
||||||
|
download_success,
|
||||||
|
download_error,
|
||||||
|
) = await self.document_service.download_and_store_signed_document(
|
||||||
|
session=session, transaction=transaction, force=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if download_success:
|
||||||
|
logger.info("Document signé téléchargé avec succès")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Échec téléchargement : {download_error}")
|
||||||
|
|
||||||
# Synchroniser les signataires
|
# Synchroniser les signataires
|
||||||
await self._sync_signers(session, transaction, universign_data)
|
await self._sync_signers(session, transaction, universign_data)
|
||||||
|
|
||||||
|
|
@ -445,7 +462,7 @@ class UniversignSyncService:
|
||||||
transaction.last_synced_at = datetime.now()
|
transaction.last_synced_at = datetime.now()
|
||||||
transaction.sync_attempts += 1
|
transaction.sync_attempts += 1
|
||||||
transaction.needs_sync = not is_final_status(new_local_status)
|
transaction.needs_sync = not is_final_status(new_local_status)
|
||||||
transaction.sync_error = None # ✅ Effacer l'erreur précédente
|
transaction.sync_error = None # Effacer l'erreur précédente
|
||||||
|
|
||||||
# Log de la tentative
|
# Log de la tentative
|
||||||
await self._log_sync_attempt(
|
await self._log_sync_attempt(
|
||||||
|
|
@ -462,7 +479,7 @@ class UniversignSyncService:
|
||||||
"universign_raw": universign_status_raw,
|
"universign_raw": universign_status_raw,
|
||||||
"response_time_ms": result.get("response_time_ms"),
|
"response_time_ms": result.get("response_time_ms"),
|
||||||
},
|
},
|
||||||
default=str, # ✅ Éviter les erreurs de sérialisation
|
default=str, # Éviter les erreurs de sérialisation
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -476,7 +493,7 @@ class UniversignSyncService:
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Sync terminée: {transaction.transaction_id} | "
|
f"Sync terminée: {transaction.transaction_id} | "
|
||||||
f"{previous_local_status} → {new_local_status}"
|
f"{previous_local_status} → {new_local_status}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -484,7 +501,7 @@ class UniversignSyncService:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Erreur lors de la synchronisation: {str(e)}"
|
error_msg = f"Erreur lors de la synchronisation: {str(e)}"
|
||||||
logger.error(f"❌ {error_msg}", exc_info=True)
|
logger.error(f"{error_msg}", exc_info=True)
|
||||||
|
|
||||||
transaction.sync_error = error_msg[:1000] # Tronquer si trop long
|
transaction.sync_error = error_msg[:1000] # Tronquer si trop long
|
||||||
transaction.sync_attempts += 1
|
transaction.sync_attempts += 1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue