factpulse 1.0.6__py3-none-any.whl → 2.0.37__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of factpulse might be problematic. Click here for more details.
- factpulse/__init__.py +54 -54
- factpulse/api/__init__.py +1 -2
- factpulse/api/afnorpdppa_api.py +552 -2
- factpulse/api/afnorpdppa_directory_service_api.py +4312 -65
- factpulse/api/afnorpdppa_flow_service_api.py +1 -1
- factpulse/api/chorus_pro_api.py +152 -194
- factpulse/api/sant_api.py +246 -1
- factpulse/api/traitement_facture_api.py +25 -27
- factpulse/api/utilisateur_api.py +1 -1
- factpulse/api/vrification_pdfxml_api.py +1719 -0
- factpulse/api_client.py +5 -5
- factpulse/configuration.py +5 -3
- factpulse/exceptions.py +7 -4
- factpulse/models/__init__.py +26 -25
- factpulse/models/adresse_electronique.py +1 -1
- factpulse/models/adresse_postale.py +1 -1
- factpulse/models/{body_ajouter_fichier_api_v1_chorus_pro_transverses_ajouter_fichier_post.py → api_error.py} +24 -24
- factpulse/models/{body_completer_facture_api_v1_chorus_pro_factures_completer_post.py → bounding_box_schema.py} +23 -27
- factpulse/models/cadre_de_facturation.py +11 -3
- factpulse/models/categorie_tva.py +11 -11
- factpulse/models/certificate_info_response.py +1 -1
- factpulse/models/champ_verifie_schema.py +129 -0
- factpulse/models/chorus_pro_credentials.py +1 -1
- factpulse/models/code_cadre_facturation.py +2 -2
- factpulse/models/code_raison_reduction.py +9 -9
- factpulse/models/consulter_facture_request.py +1 -1
- factpulse/models/consulter_facture_response.py +1 -1
- factpulse/models/consulter_structure_request.py +1 -1
- factpulse/models/consulter_structure_response.py +1 -1
- factpulse/models/credentials_afnor.py +1 -1
- factpulse/models/credentials_chorus_pro.py +1 -1
- factpulse/models/destinataire.py +16 -2
- factpulse/models/destination.py +1 -1
- factpulse/models/destination_afnor.py +1 -1
- factpulse/models/destination_chorus_pro.py +1 -1
- factpulse/models/{quota_info.py → dimension_page_schema.py} +12 -18
- factpulse/models/direction_flux.py +1 -1
- factpulse/models/donnees_facture_simplifiees.py +1 -1
- factpulse/models/error_level.py +37 -0
- factpulse/models/error_source.py +43 -0
- factpulse/models/{facture_enrichie_info_output.py → facture_enrichie_info.py} +4 -4
- factpulse/models/facture_entrante.py +196 -0
- factpulse/models/facture_factur_x.py +12 -2
- factpulse/models/flux_resume.py +1 -1
- factpulse/models/format_facture.py +38 -0
- factpulse/models/format_sortie.py +1 -1
- factpulse/models/fournisseur.py +9 -2
- factpulse/models/fournisseur_entrant.py +144 -0
- factpulse/models/generate_certificate_request.py +1 -1
- factpulse/models/generate_certificate_response.py +1 -1
- factpulse/models/http_validation_error.py +1 -1
- factpulse/models/information_signature_api.py +1 -1
- factpulse/models/ligne_de_poste.py +7 -12
- factpulse/models/ligne_de_poste_montant_remise_ht.py +2 -2
- factpulse/models/ligne_de_poste_taux_tva_manuel.py +2 -2
- factpulse/models/ligne_de_tva.py +24 -10
- factpulse/models/mode_depot.py +1 -1
- factpulse/models/mode_paiement.py +1 -1
- factpulse/models/{montantapayer.py → montant_a_payer.py} +6 -6
- factpulse/models/{montantbaseht.py → montant_base_ht.py} +6 -6
- factpulse/models/montant_ht_total.py +4 -4
- factpulse/models/{montant_total_montant_remise_globale_ttc.py → montant_remise_globale_ttc.py} +7 -13
- factpulse/models/montant_total.py +16 -21
- factpulse/models/montant_total_acompte.py +2 -2
- factpulse/models/{ligne_de_poste_montant_total_ligne_ht.py → montant_total_ligne_ht.py} +7 -13
- factpulse/models/montant_ttc_total.py +4 -4
- factpulse/models/montant_tva.py +2 -2
- factpulse/models/{montanttva1.py → montant_tva_ligne.py} +7 -7
- factpulse/models/{montantttctotal.py → montant_tva_total.py} +7 -7
- factpulse/models/{montantunitaireht.py → montant_unitaire_ht.py} +5 -5
- factpulse/models/nature_operation.py +49 -0
- factpulse/models/{body_valideur_rechercher_factures_api_v1_chorus_pro_factures_valideur_rechercher_post.py → note.py} +14 -24
- factpulse/models/{utilisateur.py → note_obligatoire_schema.py} +40 -44
- factpulse/models/obtenir_id_chorus_pro_request.py +1 -1
- factpulse/models/obtenir_id_chorus_pro_response.py +1 -1
- factpulse/models/options_processing.py +5 -14
- factpulse/models/parametres_signature.py +1 -1
- factpulse/models/parametres_structure.py +1 -1
- factpulse/models/pdf_factur_x_info.py +1 -1
- factpulse/models/pdp_credentials.py +10 -3
- factpulse/models/piece_jointe_complementaire.py +1 -1
- factpulse/models/profil_api.py +1 -1
- factpulse/models/profil_flux.py +1 -1
- factpulse/models/quantite.py +1 -1
- factpulse/models/rechercher_services_response.py +1 -1
- factpulse/models/rechercher_structure_request.py +1 -1
- factpulse/models/rechercher_structure_response.py +1 -1
- factpulse/models/references.py +1 -1
- factpulse/models/reponse_healthcheck_afnor.py +1 -1
- factpulse/models/reponse_recherche_flux.py +1 -1
- factpulse/models/reponse_soumission_flux.py +1 -1
- factpulse/models/reponse_tache.py +1 -1
- factpulse/models/reponse_validation_erreur.py +1 -1
- factpulse/models/reponse_validation_succes.py +1 -1
- factpulse/models/reponse_verification_succes.py +135 -0
- factpulse/models/requete_recherche_flux.py +1 -1
- factpulse/models/requete_soumission_flux.py +1 -1
- factpulse/models/resultat_afnor.py +1 -1
- factpulse/models/resultat_chorus_pro.py +1 -1
- factpulse/models/resultat_validation_pdfapi.py +1 -1
- factpulse/models/scheme_id.py +7 -7
- factpulse/models/service_structure.py +1 -1
- factpulse/models/signature_info.py +1 -1
- factpulse/models/soumettre_facture_complete_request.py +1 -1
- factpulse/models/soumettre_facture_complete_response.py +4 -4
- factpulse/models/soumettre_facture_request.py +19 -7
- factpulse/models/soumettre_facture_response.py +1 -1
- factpulse/models/statut_acquittement.py +1 -1
- factpulse/models/statut_celery.py +40 -0
- factpulse/models/statut_champ_api.py +40 -0
- factpulse/models/statut_facture.py +1 -1
- factpulse/models/statut_tache.py +7 -9
- factpulse/models/structure_info.py +1 -1
- factpulse/models/syntaxe_flux.py +1 -1
- factpulse/models/tauxmanuel.py +2 -2
- factpulse/models/type_document.py +40 -0
- factpulse/models/type_facture.py +1 -1
- factpulse/models/type_flux.py +1 -1
- factpulse/models/type_tva.py +1 -1
- factpulse/models/unite.py +1 -1
- factpulse/models/validation_error.py +1 -1
- factpulse/models/{body_rechercher_factures_fournisseur_api_v1_chorus_pro_factures_rechercher_fournisseur_post.py → validation_error_detail.py} +27 -24
- factpulse/models/validation_error_loc_inner.py +1 -1
- factpulse/rest.py +8 -3
- factpulse-2.0.37.dist-info/METADATA +292 -0
- factpulse-2.0.37.dist-info/RECORD +134 -0
- factpulse-2.0.37.dist-info/top_level.txt +2 -0
- factpulse_helpers/__init__.py +96 -0
- factpulse_helpers/client.py +1887 -0
- factpulse_helpers/exceptions.py +253 -0
- factpulse/api/processing_endpoints_unifis_api.py +0 -592
- factpulse/api/signature_lectronique_api.py +0 -1358
- factpulse/models/body_lister_services_structure_api_v1_chorus_pro_structures_id_structure_cpp_services_get.py +0 -102
- factpulse/models/body_rechercher_factures_destinataire_api_v1_chorus_pro_factures_rechercher_destinataire_post.py +0 -104
- factpulse/models/body_recycler_facture_api_v1_chorus_pro_factures_recycler_post.py +0 -104
- factpulse/models/body_telecharger_groupe_factures_api_v1_chorus_pro_factures_telecharger_groupe_post.py +0 -104
- factpulse/models/body_traiter_facture_recue_api_v1_chorus_pro_factures_traiter_facture_recue_post.py +0 -104
- factpulse/models/body_valideur_consulter_facture_api_v1_chorus_pro_factures_valideur_consulter_post.py +0 -104
- factpulse/models/body_valideur_traiter_facture_api_v1_chorus_pro_factures_valideur_traiter_post.py +0 -104
- factpulse/models/facture_enrichie_info_input.py +0 -123
- factpulse/models/montanthttotal.py +0 -139
- factpulse/models/montanttva.py +0 -139
- factpulse-1.0.6.dist-info/METADATA +0 -182
- factpulse-1.0.6.dist-info/RECORD +0 -131
- factpulse-1.0.6.dist-info/top_level.txt +0 -1
- {factpulse-1.0.6.dist-info → factpulse-2.0.37.dist-info}/WHEEL +0 -0
- {factpulse-1.0.6.dist-info → factpulse-2.0.37.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1887 @@
|
|
|
1
|
+
"""Client simplifié pour l'API FactPulse avec authentification JWT et polling intégrés."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
import factpulse
|
|
15
|
+
from factpulse import ApiClient, Configuration, TraitementFactureApi
|
|
16
|
+
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
FactPulseAuthError,
|
|
19
|
+
FactPulsePollingTimeout,
|
|
20
|
+
FactPulseValidationError,
|
|
21
|
+
ValidationErrorDetail,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# JSON Encoder pour Decimal et autres types non sérialisables
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
class DecimalEncoder(json.JSONEncoder):
|
|
32
|
+
"""Encoder JSON personnalisé qui gère les Decimal et autres types Python."""
|
|
33
|
+
|
|
34
|
+
def default(self, obj):
|
|
35
|
+
if isinstance(obj, Decimal):
|
|
36
|
+
# Convertir en string pour préserver la précision monétaire
|
|
37
|
+
return str(obj)
|
|
38
|
+
if hasattr(obj, "isoformat"):
|
|
39
|
+
# datetime, date, time
|
|
40
|
+
return obj.isoformat()
|
|
41
|
+
if hasattr(obj, "to_dict"):
|
|
42
|
+
# Modèles Pydantic ou dataclasses avec to_dict
|
|
43
|
+
return obj.to_dict()
|
|
44
|
+
return super().default(obj)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def json_dumps_safe(data: Any, **kwargs) -> str:
|
|
48
|
+
"""Sérialise en JSON en gérant les Decimal et autres types Python.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
data: Données à sérialiser (dict, list, etc.)
|
|
52
|
+
**kwargs: Arguments supplémentaires pour json.dumps
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
String JSON
|
|
56
|
+
|
|
57
|
+
Example:
|
|
58
|
+
>>> from decimal import Decimal
|
|
59
|
+
>>> json_dumps_safe({"montant": Decimal("1234.56")})
|
|
60
|
+
'{"montant": "1234.56"}'
|
|
61
|
+
"""
|
|
62
|
+
kwargs.setdefault("ensure_ascii", False)
|
|
63
|
+
kwargs.setdefault("cls", DecimalEncoder)
|
|
64
|
+
return json.dumps(data, **kwargs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# =============================================================================
|
|
68
|
+
# Credentials dataclasses - pour une configuration simplifiée
|
|
69
|
+
# =============================================================================
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ChorusProCredentials:
|
|
73
|
+
"""Credentials Chorus Pro pour le mode Zero-Trust.
|
|
74
|
+
|
|
75
|
+
Ces credentials sont passés dans chaque requête et ne sont jamais stockés côté serveur.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
piste_client_id: Client ID PISTE (portail API gouvernement)
|
|
79
|
+
piste_client_secret: Client Secret PISTE
|
|
80
|
+
chorus_pro_login: Login Chorus Pro
|
|
81
|
+
chorus_pro_password: Mot de passe Chorus Pro
|
|
82
|
+
sandbox: True pour l'environnement sandbox, False pour production
|
|
83
|
+
"""
|
|
84
|
+
piste_client_id: str
|
|
85
|
+
piste_client_secret: str
|
|
86
|
+
chorus_pro_login: str
|
|
87
|
+
chorus_pro_password: str
|
|
88
|
+
sandbox: bool = True
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
91
|
+
"""Convertit en dictionnaire pour l'API."""
|
|
92
|
+
return {
|
|
93
|
+
"piste_client_id": self.piste_client_id,
|
|
94
|
+
"piste_client_secret": self.piste_client_secret,
|
|
95
|
+
"chorus_pro_login": self.chorus_pro_login,
|
|
96
|
+
"chorus_pro_password": self.chorus_pro_password,
|
|
97
|
+
"sandbox": self.sandbox,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class AFNORCredentials:
|
|
103
|
+
"""Credentials AFNOR PDP pour le mode Zero-Trust.
|
|
104
|
+
|
|
105
|
+
Ces credentials sont passés dans chaque requête et ne sont jamais stockés côté serveur.
|
|
106
|
+
L'API FactPulse utilise ces credentials pour s'authentifier auprès de la PDP AFNOR
|
|
107
|
+
et obtenir un token OAuth2 spécifique.
|
|
108
|
+
|
|
109
|
+
Attributes:
|
|
110
|
+
flow_service_url: URL du Flow Service de la PDP (ex: https://api.pdp.fr/flow/v1)
|
|
111
|
+
token_url: URL du serveur OAuth2 de la PDP (ex: https://auth.pdp.fr/oauth/token)
|
|
112
|
+
client_id: Client ID OAuth2 de la PDP
|
|
113
|
+
client_secret: Client Secret OAuth2 de la PDP
|
|
114
|
+
directory_service_url: URL du Directory Service (optionnel, déduit de flow_service_url)
|
|
115
|
+
"""
|
|
116
|
+
flow_service_url: str
|
|
117
|
+
token_url: str
|
|
118
|
+
client_id: str
|
|
119
|
+
client_secret: str
|
|
120
|
+
directory_service_url: Optional[str] = None
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
123
|
+
"""Convertit en dictionnaire pour l'API."""
|
|
124
|
+
result = {
|
|
125
|
+
"flow_service_url": self.flow_service_url,
|
|
126
|
+
"token_url": self.token_url,
|
|
127
|
+
"client_id": self.client_id,
|
|
128
|
+
"client_secret": self.client_secret,
|
|
129
|
+
}
|
|
130
|
+
if self.directory_service_url:
|
|
131
|
+
result["directory_service_url"] = self.directory_service_url
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# =============================================================================
|
|
136
|
+
# Helpers pour les types anyOf - évite la verbosité des wrappers générés
|
|
137
|
+
# =============================================================================
|
|
138
|
+
|
|
139
|
+
def montant(value: Union[str, float, int, Decimal, None]) -> str:
|
|
140
|
+
"""Convertit une valeur en string de montant pour l'API.
|
|
141
|
+
|
|
142
|
+
L'API FactPulse accepte les montants comme strings ou floats.
|
|
143
|
+
Cette fonction normalise en string pour garantir la précision monétaire.
|
|
144
|
+
"""
|
|
145
|
+
if value is None:
|
|
146
|
+
return "0.00"
|
|
147
|
+
if isinstance(value, Decimal):
|
|
148
|
+
return f"{value:.2f}"
|
|
149
|
+
if isinstance(value, (int, float)):
|
|
150
|
+
return f"{value:.2f}"
|
|
151
|
+
if isinstance(value, str):
|
|
152
|
+
return value
|
|
153
|
+
return "0.00"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def montant_total(
|
|
157
|
+
ht: Union[str, float, int, Decimal],
|
|
158
|
+
tva: Union[str, float, int, Decimal],
|
|
159
|
+
ttc: Union[str, float, int, Decimal],
|
|
160
|
+
a_payer: Union[str, float, int, Decimal],
|
|
161
|
+
remise_ttc: Union[str, float, int, Decimal, None] = None,
|
|
162
|
+
motif_remise: Optional[str] = None,
|
|
163
|
+
acompte: Union[str, float, int, Decimal, None] = None,
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""Crée un objet MontantTotal simplifié.
|
|
166
|
+
|
|
167
|
+
Évite d'avoir à utiliser les wrappers MontantHtTotal, MontantTvaTotal, etc.
|
|
168
|
+
"""
|
|
169
|
+
result = {
|
|
170
|
+
"montantHtTotal": montant(ht),
|
|
171
|
+
"montantTva": montant(tva),
|
|
172
|
+
"montantTtcTotal": montant(ttc),
|
|
173
|
+
"montantAPayer": montant(a_payer),
|
|
174
|
+
}
|
|
175
|
+
if remise_ttc is not None:
|
|
176
|
+
result["montantRemiseGlobaleTtc"] = montant(remise_ttc)
|
|
177
|
+
if motif_remise is not None:
|
|
178
|
+
result["motifRemiseGlobaleTtc"] = motif_remise
|
|
179
|
+
if acompte is not None:
|
|
180
|
+
result["acompte"] = montant(acompte)
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def ligne_de_poste(
|
|
185
|
+
numero: int,
|
|
186
|
+
denomination: str,
|
|
187
|
+
quantite: Union[str, float, int, Decimal],
|
|
188
|
+
montant_unitaire_ht: Union[str, float, int, Decimal],
|
|
189
|
+
montant_total_ligne_ht: Union[str, float, int, Decimal],
|
|
190
|
+
taux_tva: Optional[str] = None,
|
|
191
|
+
taux_tva_manuel: Union[str, float, int, Decimal, None] = "20.00",
|
|
192
|
+
categorie_tva: str = "S",
|
|
193
|
+
unite: str = "FORFAIT",
|
|
194
|
+
reference: Optional[str] = None,
|
|
195
|
+
montant_remise_ht: Union[str, float, int, Decimal, None] = None,
|
|
196
|
+
code_raison_reduction: Optional[str] = None,
|
|
197
|
+
raison_reduction: Optional[str] = None,
|
|
198
|
+
date_debut_periode: Optional[str] = None,
|
|
199
|
+
date_fin_periode: Optional[str] = None,
|
|
200
|
+
) -> Dict[str, Any]:
|
|
201
|
+
"""Crée une ligne de poste pour l'API FactPulse.
|
|
202
|
+
|
|
203
|
+
Les clés JSON sont en camelCase (convention API FactPulse).
|
|
204
|
+
Les champs correspondent exactement à LigneDePoste dans models.py.
|
|
205
|
+
|
|
206
|
+
Pour le taux de TVA, vous pouvez utiliser soit:
|
|
207
|
+
- taux_tva: Code prédéfini (ex: "TVA20", "TVA10", "TVA5.5")
|
|
208
|
+
- taux_tva_manuel: Valeur numérique (ex: "20.00", 20, 20.0)
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
numero: Numéro de la ligne
|
|
212
|
+
denomination: Libellé du produit/service
|
|
213
|
+
quantite: Quantité
|
|
214
|
+
montant_unitaire_ht: Prix unitaire HT
|
|
215
|
+
montant_total_ligne_ht: Montant total HT de la ligne
|
|
216
|
+
taux_tva: Code TVA prédéfini (ex: "TVA20") - optionnel
|
|
217
|
+
taux_tva_manuel: Taux de TVA en valeur (défaut: "20.00") - utilisé si taux_tva non fourni
|
|
218
|
+
categorie_tva: Catégorie TVA - S (standard), Z (zéro), E (exonéré), AE (autoliquidation), K (intracommunautaire)
|
|
219
|
+
unite: Unité de facturation (défaut: "FORFAIT")
|
|
220
|
+
reference: Référence article
|
|
221
|
+
montant_remise_ht: Montant de remise HT (optionnel)
|
|
222
|
+
code_raison_reduction: Code raison de la réduction
|
|
223
|
+
raison_reduction: Description textuelle de la réduction
|
|
224
|
+
date_debut_periode: Date début période de facturation (YYYY-MM-DD)
|
|
225
|
+
date_fin_periode: Date fin période de facturation (YYYY-MM-DD)
|
|
226
|
+
"""
|
|
227
|
+
result = {
|
|
228
|
+
"numero": numero,
|
|
229
|
+
"denomination": denomination,
|
|
230
|
+
"quantite": montant(quantite),
|
|
231
|
+
"montantUnitaireHt": montant(montant_unitaire_ht),
|
|
232
|
+
"montantTotalLigneHt": montant(montant_total_ligne_ht),
|
|
233
|
+
"categorieTva": categorie_tva,
|
|
234
|
+
"unite": unite,
|
|
235
|
+
}
|
|
236
|
+
# Soit taux_tva (code) soit taux_tva_manuel (valeur)
|
|
237
|
+
if taux_tva is not None:
|
|
238
|
+
result["tauxTva"] = taux_tva
|
|
239
|
+
elif taux_tva_manuel is not None:
|
|
240
|
+
result["tauxTvaManuel"] = montant(taux_tva_manuel)
|
|
241
|
+
if reference is not None:
|
|
242
|
+
result["reference"] = reference
|
|
243
|
+
if montant_remise_ht is not None:
|
|
244
|
+
result["montantRemiseHt"] = montant(montant_remise_ht)
|
|
245
|
+
if code_raison_reduction is not None:
|
|
246
|
+
result["codeRaisonReduction"] = code_raison_reduction
|
|
247
|
+
if raison_reduction is not None:
|
|
248
|
+
result["raisonReduction"] = raison_reduction
|
|
249
|
+
if date_debut_periode is not None:
|
|
250
|
+
result["dateDebutPeriode"] = date_debut_periode
|
|
251
|
+
if date_fin_periode is not None:
|
|
252
|
+
result["dateFinPeriode"] = date_fin_periode
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def ligne_de_tva(
|
|
257
|
+
montant_base_ht: Union[str, float, int, Decimal],
|
|
258
|
+
montant_tva: Union[str, float, int, Decimal],
|
|
259
|
+
taux: Optional[str] = None,
|
|
260
|
+
taux_manuel: Union[str, float, int, Decimal, None] = "20.00",
|
|
261
|
+
categorie: str = "S",
|
|
262
|
+
) -> Dict[str, Any]:
|
|
263
|
+
"""Crée une ligne de TVA pour l'API FactPulse.
|
|
264
|
+
|
|
265
|
+
Les clés JSON sont en camelCase (convention API FactPulse).
|
|
266
|
+
Les champs correspondent exactement à LigneDeTVA dans models.py.
|
|
267
|
+
|
|
268
|
+
Pour le taux de TVA, vous pouvez utiliser soit:
|
|
269
|
+
- taux: Code prédéfini (ex: "TVA20", "TVA10", "TVA5.5")
|
|
270
|
+
- taux_manuel: Valeur numérique (ex: "20.00", 20, 20.0)
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
montant_base_ht: Montant de la base HT
|
|
274
|
+
montant_tva: Montant de la TVA
|
|
275
|
+
taux: Code TVA prédéfini (ex: "TVA20") - optionnel
|
|
276
|
+
taux_manuel: Taux de TVA en valeur (défaut: "20.00") - utilisé si taux non fourni
|
|
277
|
+
categorie: Catégorie de TVA (défaut: "S" pour standard)
|
|
278
|
+
"""
|
|
279
|
+
result = {
|
|
280
|
+
"montantBaseHt": montant(montant_base_ht),
|
|
281
|
+
"montantTva": montant(montant_tva),
|
|
282
|
+
"categorie": categorie,
|
|
283
|
+
}
|
|
284
|
+
# Soit taux (code) soit taux_manuel (valeur)
|
|
285
|
+
if taux is not None:
|
|
286
|
+
result["taux"] = taux
|
|
287
|
+
elif taux_manuel is not None:
|
|
288
|
+
result["tauxManuel"] = montant(taux_manuel)
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def adresse_postale(
|
|
293
|
+
ligne1: str,
|
|
294
|
+
code_postal: str,
|
|
295
|
+
ville: str,
|
|
296
|
+
pays: str = "FR",
|
|
297
|
+
ligne2: Optional[str] = None,
|
|
298
|
+
ligne3: Optional[str] = None,
|
|
299
|
+
) -> Dict[str, Any]:
|
|
300
|
+
"""Crée une adresse postale pour l'API FactPulse.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
ligne1: Première ligne d'adresse (numéro, rue)
|
|
304
|
+
code_postal: Code postal
|
|
305
|
+
ville: Nom de la ville
|
|
306
|
+
pays: Code pays ISO (défaut: "FR")
|
|
307
|
+
ligne2: Deuxième ligne d'adresse (optionnel)
|
|
308
|
+
ligne3: Troisième ligne d'adresse (optionnel)
|
|
309
|
+
|
|
310
|
+
Example:
|
|
311
|
+
>>> adresse = adresse_postale("123 rue Example", "75001", "Paris")
|
|
312
|
+
"""
|
|
313
|
+
result = {
|
|
314
|
+
"ligneUn": ligne1,
|
|
315
|
+
"codePostal": code_postal,
|
|
316
|
+
"nomVille": ville,
|
|
317
|
+
"paysCodeIso": pays,
|
|
318
|
+
}
|
|
319
|
+
if ligne2:
|
|
320
|
+
result["ligneDeux"] = ligne2
|
|
321
|
+
if ligne3:
|
|
322
|
+
result["ligneTrois"] = ligne3
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def adresse_electronique(
|
|
327
|
+
identifiant: str,
|
|
328
|
+
scheme_id: str = "0009",
|
|
329
|
+
) -> Dict[str, Any]:
|
|
330
|
+
"""Crée une adresse électronique pour l'API FactPulse.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
identifiant: Identifiant de l'adresse (SIRET, SIREN, etc.)
|
|
334
|
+
scheme_id: Schéma d'identification (défaut: "0009" pour SIREN)
|
|
335
|
+
- "0009": SIREN
|
|
336
|
+
- "0088": EAN
|
|
337
|
+
- "0096": DUNS
|
|
338
|
+
- "0130": Codification propre
|
|
339
|
+
- "0225": FR - SIRET (schéma français)
|
|
340
|
+
|
|
341
|
+
Example:
|
|
342
|
+
>>> adresse = adresse_electronique("12345678901234", "0225") # SIRET
|
|
343
|
+
"""
|
|
344
|
+
return {
|
|
345
|
+
"identifiant": identifiant,
|
|
346
|
+
"schemeId": scheme_id,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def fournisseur(
|
|
351
|
+
nom: str,
|
|
352
|
+
siret: str,
|
|
353
|
+
adresse_ligne1: str,
|
|
354
|
+
code_postal: str,
|
|
355
|
+
ville: str,
|
|
356
|
+
id_fournisseur: int = 0,
|
|
357
|
+
siren: Optional[str] = None,
|
|
358
|
+
numero_tva_intra: Optional[str] = None,
|
|
359
|
+
iban: Optional[str] = None,
|
|
360
|
+
pays: str = "FR",
|
|
361
|
+
adresse_ligne2: Optional[str] = None,
|
|
362
|
+
code_service: Optional[int] = None,
|
|
363
|
+
code_coordonnees_bancaires: Optional[int] = None,
|
|
364
|
+
) -> Dict[str, Any]:
|
|
365
|
+
"""Crée un fournisseur (émetteur de la facture) pour l'API FactPulse.
|
|
366
|
+
|
|
367
|
+
Cette fonction simplifie la création d'un fournisseur en générant automatiquement:
|
|
368
|
+
- L'adresse postale structurée
|
|
369
|
+
- L'adresse électronique (basée sur le SIRET)
|
|
370
|
+
- Le SIREN (extrait du SIRET si non fourni)
|
|
371
|
+
- Le numéro de TVA intracommunautaire (calculé depuis le SIREN si non fourni)
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
nom: Raison sociale / dénomination
|
|
375
|
+
siret: Numéro SIRET (14 chiffres)
|
|
376
|
+
adresse_ligne1: Première ligne d'adresse
|
|
377
|
+
code_postal: Code postal
|
|
378
|
+
ville: Ville
|
|
379
|
+
id_fournisseur: ID Chorus Pro du fournisseur (défaut: 0)
|
|
380
|
+
siren: Numéro SIREN (9 chiffres) - calculé depuis SIRET si absent
|
|
381
|
+
numero_tva_intra: Numéro TVA intracommunautaire - calculé si absent
|
|
382
|
+
iban: IBAN pour le paiement
|
|
383
|
+
pays: Code pays ISO (défaut: "FR")
|
|
384
|
+
adresse_ligne2: Deuxième ligne d'adresse (optionnel)
|
|
385
|
+
code_service: ID du service fournisseur Chorus Pro (optionnel)
|
|
386
|
+
code_coordonnees_bancaires: Code coordonnées bancaires Chorus Pro (optionnel)
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Dict prêt à être utilisé dans une facture
|
|
390
|
+
|
|
391
|
+
Example:
|
|
392
|
+
>>> f = fournisseur(
|
|
393
|
+
... nom="Ma Société SAS",
|
|
394
|
+
... siret="12345678900001",
|
|
395
|
+
... adresse_ligne1="123 Rue de la République",
|
|
396
|
+
... code_postal="75001",
|
|
397
|
+
... ville="Paris",
|
|
398
|
+
... iban="FR7630006000011234567890189",
|
|
399
|
+
... )
|
|
400
|
+
"""
|
|
401
|
+
# Auto-calcul SIREN depuis SIRET
|
|
402
|
+
if not siren and len(siret) == 14:
|
|
403
|
+
siren = siret[:9]
|
|
404
|
+
|
|
405
|
+
# Auto-calcul TVA intracommunautaire française
|
|
406
|
+
if not numero_tva_intra and siren and len(siren) == 9:
|
|
407
|
+
# Clé TVA = (12 + 3 * (SIREN % 97)) % 97
|
|
408
|
+
try:
|
|
409
|
+
cle = (12 + 3 * (int(siren) % 97)) % 97
|
|
410
|
+
numero_tva_intra = f"FR{cle:02d}{siren}"
|
|
411
|
+
except ValueError:
|
|
412
|
+
pass # SIREN non numérique, on skip
|
|
413
|
+
|
|
414
|
+
result: Dict[str, Any] = {
|
|
415
|
+
"nom": nom,
|
|
416
|
+
"idFournisseur": id_fournisseur,
|
|
417
|
+
"siret": siret,
|
|
418
|
+
"adresseElectronique": adresse_electronique(siret, "0225"),
|
|
419
|
+
"adressePostale": adresse_postale(adresse_ligne1, code_postal, ville, pays, adresse_ligne2),
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if siren:
|
|
423
|
+
result["siren"] = siren
|
|
424
|
+
if numero_tva_intra:
|
|
425
|
+
result["numeroTvaIntra"] = numero_tva_intra
|
|
426
|
+
if iban:
|
|
427
|
+
result["iban"] = iban
|
|
428
|
+
if code_service:
|
|
429
|
+
result["idServiceFournisseur"] = code_service
|
|
430
|
+
if code_coordonnees_bancaires:
|
|
431
|
+
result["codeCoordonnesBancairesFournisseur"] = code_coordonnees_bancaires
|
|
432
|
+
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def destinataire(
|
|
437
|
+
nom: str,
|
|
438
|
+
siret: str,
|
|
439
|
+
adresse_ligne1: str,
|
|
440
|
+
code_postal: str,
|
|
441
|
+
ville: str,
|
|
442
|
+
siren: Optional[str] = None,
|
|
443
|
+
pays: str = "FR",
|
|
444
|
+
adresse_ligne2: Optional[str] = None,
|
|
445
|
+
code_service_executant: Optional[str] = None,
|
|
446
|
+
) -> Dict[str, Any]:
|
|
447
|
+
"""Crée un destinataire (client de la facture) pour l'API FactPulse.
|
|
448
|
+
|
|
449
|
+
Cette fonction simplifie la création d'un destinataire en générant automatiquement:
|
|
450
|
+
- L'adresse postale structurée
|
|
451
|
+
- L'adresse électronique (basée sur le SIRET)
|
|
452
|
+
- Le SIREN (extrait du SIRET si non fourni)
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
nom: Raison sociale / dénomination
|
|
456
|
+
siret: Numéro SIRET (14 chiffres)
|
|
457
|
+
adresse_ligne1: Première ligne d'adresse
|
|
458
|
+
code_postal: Code postal
|
|
459
|
+
ville: Ville
|
|
460
|
+
siren: Numéro SIREN (9 chiffres) - calculé depuis SIRET si absent
|
|
461
|
+
pays: Code pays ISO (défaut: "FR")
|
|
462
|
+
adresse_ligne2: Deuxième ligne d'adresse (optionnel)
|
|
463
|
+
code_service_executant: Code du service destinataire (optionnel)
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Dict prêt à être utilisé dans une facture
|
|
467
|
+
|
|
468
|
+
Example:
|
|
469
|
+
>>> d = destinataire(
|
|
470
|
+
... nom="Client SARL",
|
|
471
|
+
... siret="98765432109876",
|
|
472
|
+
... adresse_ligne1="456 Avenue des Champs",
|
|
473
|
+
... code_postal="69001",
|
|
474
|
+
... ville="Lyon",
|
|
475
|
+
... )
|
|
476
|
+
"""
|
|
477
|
+
# Auto-calcul SIREN depuis SIRET
|
|
478
|
+
if not siren and len(siret) == 14:
|
|
479
|
+
siren = siret[:9]
|
|
480
|
+
|
|
481
|
+
result: Dict[str, Any] = {
|
|
482
|
+
"nom": nom,
|
|
483
|
+
"siret": siret,
|
|
484
|
+
"adresseElectronique": adresse_electronique(siret, "0225"),
|
|
485
|
+
"adressePostale": adresse_postale(adresse_ligne1, code_postal, ville, pays, adresse_ligne2),
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if siren:
|
|
489
|
+
result["siren"] = siren
|
|
490
|
+
if code_service_executant:
|
|
491
|
+
result["codeServiceExecutant"] = code_service_executant
|
|
492
|
+
|
|
493
|
+
return result
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class FactPulseClient:
|
|
497
|
+
"""Client simplifié pour l'API FactPulse.
|
|
498
|
+
|
|
499
|
+
Gère l'authentification JWT, le polling des tâches asynchrones,
|
|
500
|
+
et permet de configurer les credentials Chorus Pro / AFNOR à l'initialisation.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
DEFAULT_API_URL = "https://factpulse.fr"
|
|
504
|
+
DEFAULT_POLLING_INTERVAL = 2000 # ms
|
|
505
|
+
DEFAULT_POLLING_TIMEOUT = 120000 # ms
|
|
506
|
+
DEFAULT_MAX_RETRIES = 1
|
|
507
|
+
|
|
508
|
+
def __init__(
|
|
509
|
+
self,
|
|
510
|
+
email: str,
|
|
511
|
+
password: str,
|
|
512
|
+
api_url: Optional[str] = None,
|
|
513
|
+
client_uid: Optional[str] = None,
|
|
514
|
+
chorus_credentials: Optional[ChorusProCredentials] = None,
|
|
515
|
+
afnor_credentials: Optional[AFNORCredentials] = None,
|
|
516
|
+
polling_interval: Optional[int] = None,
|
|
517
|
+
polling_timeout: Optional[int] = None,
|
|
518
|
+
max_retries: Optional[int] = None,
|
|
519
|
+
):
|
|
520
|
+
self.email = email
|
|
521
|
+
self.password = password
|
|
522
|
+
self.api_url = (api_url or self.DEFAULT_API_URL).rstrip("/")
|
|
523
|
+
self.client_uid = client_uid
|
|
524
|
+
self.chorus_credentials = chorus_credentials
|
|
525
|
+
self.afnor_credentials = afnor_credentials
|
|
526
|
+
self.polling_interval = polling_interval or self.DEFAULT_POLLING_INTERVAL
|
|
527
|
+
self.polling_timeout = polling_timeout or self.DEFAULT_POLLING_TIMEOUT
|
|
528
|
+
self.max_retries = max_retries if max_retries is not None else self.DEFAULT_MAX_RETRIES
|
|
529
|
+
|
|
530
|
+
self._access_token: Optional[str] = None
|
|
531
|
+
self._refresh_token: Optional[str] = None
|
|
532
|
+
self._token_expires_at: Optional[datetime] = None
|
|
533
|
+
self._api_client: Optional[ApiClient] = None
|
|
534
|
+
|
|
535
|
+
def get_chorus_credentials_for_api(self) -> Optional[Dict[str, Any]]:
|
|
536
|
+
"""Retourne les credentials Chorus Pro au format API."""
|
|
537
|
+
return self.chorus_credentials.to_dict() if self.chorus_credentials else None
|
|
538
|
+
|
|
539
|
+
def get_afnor_credentials_for_api(self) -> Optional[Dict[str, Any]]:
|
|
540
|
+
"""Retourne les credentials AFNOR au format API."""
|
|
541
|
+
return self.afnor_credentials.to_dict() if self.afnor_credentials else None
|
|
542
|
+
|
|
543
|
+
# Alias plus courts pour faciliter l'usage
|
|
544
|
+
def get_chorus_pro_credentials(self) -> Optional[Dict[str, Any]]:
|
|
545
|
+
"""Alias pour get_chorus_credentials_for_api()."""
|
|
546
|
+
return self.get_chorus_credentials_for_api()
|
|
547
|
+
|
|
548
|
+
def get_afnor_credentials(self) -> Optional[Dict[str, Any]]:
|
|
549
|
+
"""Alias pour get_afnor_credentials_for_api()."""
|
|
550
|
+
return self.get_afnor_credentials_for_api()
|
|
551
|
+
|
|
552
|
+
def _obtain_token(self) -> Dict[str, str]:
|
|
553
|
+
"""Obtient un nouveau token JWT."""
|
|
554
|
+
token_url = f"{self.api_url}/api/token/"
|
|
555
|
+
payload = {"username": self.email, "password": self.password}
|
|
556
|
+
if self.client_uid:
|
|
557
|
+
payload["client_uid"] = self.client_uid
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
response = requests.post(token_url, json=payload, timeout=30)
|
|
561
|
+
response.raise_for_status()
|
|
562
|
+
logger.info("Token JWT obtenu pour %s", self.email)
|
|
563
|
+
return response.json()
|
|
564
|
+
except requests.RequestException as e:
|
|
565
|
+
error_detail = ""
|
|
566
|
+
if hasattr(e, "response") and e.response is not None:
|
|
567
|
+
try:
|
|
568
|
+
error_detail = e.response.json().get("detail", str(e))
|
|
569
|
+
except Exception:
|
|
570
|
+
error_detail = str(e)
|
|
571
|
+
raise FactPulseAuthError(f"Impossible d'obtenir le token JWT: {error_detail or e}")
|
|
572
|
+
|
|
573
|
+
def _refresh_access_token(self) -> str:
|
|
574
|
+
"""Rafraîchit le token d'accès."""
|
|
575
|
+
if not self._refresh_token:
|
|
576
|
+
raise FactPulseAuthError("Aucun refresh token disponible")
|
|
577
|
+
|
|
578
|
+
refresh_url = f"{self.api_url}/api/token/refresh/"
|
|
579
|
+
try:
|
|
580
|
+
response = requests.post(
|
|
581
|
+
refresh_url, json={"refresh": self._refresh_token}, timeout=30
|
|
582
|
+
)
|
|
583
|
+
response.raise_for_status()
|
|
584
|
+
logger.info("Token rafraîchi avec succès")
|
|
585
|
+
return response.json()["access"]
|
|
586
|
+
except requests.RequestException:
|
|
587
|
+
logger.warning("Refresh échoué, ré-obtention d'un nouveau token")
|
|
588
|
+
tokens = self._obtain_token()
|
|
589
|
+
self._refresh_token = tokens["refresh"]
|
|
590
|
+
return tokens["access"]
|
|
591
|
+
|
|
592
|
+
def ensure_authenticated(self, force_refresh: bool = False) -> None:
|
|
593
|
+
"""S'assure que le client est authentifié."""
|
|
594
|
+
now = datetime.now()
|
|
595
|
+
|
|
596
|
+
if force_refresh or not self._access_token or not self._token_expires_at or now >= self._token_expires_at:
|
|
597
|
+
if self._refresh_token and self._token_expires_at and not force_refresh:
|
|
598
|
+
try:
|
|
599
|
+
self._access_token = self._refresh_access_token()
|
|
600
|
+
self._token_expires_at = now + timedelta(minutes=28)
|
|
601
|
+
return
|
|
602
|
+
except FactPulseAuthError:
|
|
603
|
+
pass
|
|
604
|
+
|
|
605
|
+
tokens = self._obtain_token()
|
|
606
|
+
self._access_token = tokens["access"]
|
|
607
|
+
self._refresh_token = tokens["refresh"]
|
|
608
|
+
self._token_expires_at = now + timedelta(minutes=28)
|
|
609
|
+
|
|
610
|
+
def reset_auth(self) -> None:
|
|
611
|
+
"""Réinitialise l'authentification."""
|
|
612
|
+
self._access_token = None
|
|
613
|
+
self._refresh_token = None
|
|
614
|
+
self._token_expires_at = None
|
|
615
|
+
self._api_client = None
|
|
616
|
+
logger.info("Authentification réinitialisée")
|
|
617
|
+
|
|
618
|
+
def get_traitement_api(self) -> TraitementFactureApi:
|
|
619
|
+
"""Retourne l'API de traitement de factures."""
|
|
620
|
+
self.ensure_authenticated()
|
|
621
|
+
config = Configuration(host=f"{self.api_url}/api/facturation")
|
|
622
|
+
config.access_token = self._access_token
|
|
623
|
+
self._api_client = ApiClient(configuration=config)
|
|
624
|
+
return TraitementFactureApi(api_client=self._api_client)
|
|
625
|
+
|
|
626
|
+
def poll_task(self, task_id: str, timeout: Optional[int] = None, interval: Optional[int] = None) -> Dict[str, Any]:
|
|
627
|
+
"""Effectue un polling sur une tâche jusqu'à son achèvement."""
|
|
628
|
+
timeout_ms = timeout or self.polling_timeout
|
|
629
|
+
interval_ms = interval or self.polling_interval
|
|
630
|
+
|
|
631
|
+
start_time = time.time() * 1000
|
|
632
|
+
current_interval = float(interval_ms)
|
|
633
|
+
|
|
634
|
+
logger.info("Début du polling pour la tâche %s (timeout: %dms)", task_id, timeout_ms)
|
|
635
|
+
|
|
636
|
+
while True:
|
|
637
|
+
elapsed = (time.time() * 1000) - start_time
|
|
638
|
+
|
|
639
|
+
if elapsed > timeout_ms:
|
|
640
|
+
raise FactPulsePollingTimeout(task_id, timeout_ms)
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
logger.debug("Polling tâche %s (elapsed: %.0fms)...", task_id, elapsed)
|
|
644
|
+
api = self.get_traitement_api()
|
|
645
|
+
statut = api.obtenir_statut_tache_api_v1_traitement_taches_id_tache_statut_get(id_tache=task_id)
|
|
646
|
+
logger.debug("Réponse statut reçue: %s", statut)
|
|
647
|
+
|
|
648
|
+
status_value = statut.statut.value if hasattr(statut.statut, "value") else str(statut.statut)
|
|
649
|
+
logger.info("Tâche %s: statut=%s (%.0fms)", task_id, status_value, elapsed)
|
|
650
|
+
|
|
651
|
+
if status_value == "SUCCESS":
|
|
652
|
+
logger.info("Tâche %s terminée avec succès", task_id)
|
|
653
|
+
if statut.resultat:
|
|
654
|
+
if hasattr(statut.resultat, "to_dict"):
|
|
655
|
+
return statut.resultat.to_dict()
|
|
656
|
+
return dict(statut.resultat)
|
|
657
|
+
return {}
|
|
658
|
+
|
|
659
|
+
if status_value == "FAILURE":
|
|
660
|
+
error_msg = "Erreur inconnue"
|
|
661
|
+
errors = []
|
|
662
|
+
if statut.resultat:
|
|
663
|
+
result = statut.resultat.to_dict() if hasattr(statut.resultat, "to_dict") else dict(statut.resultat)
|
|
664
|
+
# Format AFNOR: errorMessage, details
|
|
665
|
+
error_msg = result.get("errorMessage", error_msg)
|
|
666
|
+
for err in result.get("details", []):
|
|
667
|
+
errors.append(ValidationErrorDetail(
|
|
668
|
+
level=err.get("level", ""),
|
|
669
|
+
item=err.get("item", ""),
|
|
670
|
+
reason=err.get("reason", ""),
|
|
671
|
+
source=err.get("source"),
|
|
672
|
+
code=err.get("code"),
|
|
673
|
+
))
|
|
674
|
+
raise FactPulseValidationError(f"La tâche {task_id} a échoué: {error_msg}", errors)
|
|
675
|
+
|
|
676
|
+
except (FactPulseValidationError, FactPulsePollingTimeout):
|
|
677
|
+
raise
|
|
678
|
+
except Exception as e:
|
|
679
|
+
error_str = str(e)
|
|
680
|
+
logger.warning("Erreur lors du polling: %s", error_str)
|
|
681
|
+
|
|
682
|
+
# Rate limit (429) - attendre et réessayer avec backoff
|
|
683
|
+
if "429" in error_str:
|
|
684
|
+
wait_time = min(current_interval * 2, 30000) # Max 30s
|
|
685
|
+
logger.warning("Rate limit (429), attente de %.1fs avant retry...", wait_time / 1000)
|
|
686
|
+
time.sleep(wait_time / 1000)
|
|
687
|
+
current_interval = wait_time
|
|
688
|
+
continue
|
|
689
|
+
|
|
690
|
+
# Token expiré (401) - re-authentification
|
|
691
|
+
if "401" in error_str:
|
|
692
|
+
logger.warning("Token expiré, re-authentification...")
|
|
693
|
+
self.reset_auth()
|
|
694
|
+
continue
|
|
695
|
+
|
|
696
|
+
# Erreur serveur temporaire (502, 503, 504) - retry avec backoff
|
|
697
|
+
if any(code in error_str for code in ("502", "503", "504")):
|
|
698
|
+
wait_time = min(current_interval * 1.5, 15000)
|
|
699
|
+
logger.warning("Erreur serveur temporaire, attente de %.1fs avant retry...", wait_time / 1000)
|
|
700
|
+
time.sleep(wait_time / 1000)
|
|
701
|
+
current_interval = wait_time
|
|
702
|
+
continue
|
|
703
|
+
|
|
704
|
+
raise FactPulseValidationError(f"Erreur API: {e}")
|
|
705
|
+
|
|
706
|
+
time.sleep(current_interval / 1000)
|
|
707
|
+
current_interval = min(current_interval * 1.5, 10000)
|
|
708
|
+
|
|
709
|
+
def generer_facturx(
|
|
710
|
+
self,
|
|
711
|
+
facture_data: Union[Dict, str, Any],
|
|
712
|
+
pdf_source: Union[bytes, str, Path],
|
|
713
|
+
profil: str = "EN16931",
|
|
714
|
+
format_sortie: str = "pdf",
|
|
715
|
+
sync: bool = True,
|
|
716
|
+
timeout: Optional[int] = None,
|
|
717
|
+
) -> bytes:
|
|
718
|
+
"""Génère une facture Factur-X.
|
|
719
|
+
|
|
720
|
+
Accepte les données de facture sous plusieurs formes :
|
|
721
|
+
- Dict : dictionnaire Python (recommandé avec les helpers montant_total(), ligne_de_poste(), etc.)
|
|
722
|
+
- str : JSON sérialisé
|
|
723
|
+
- Modèle Pydantic : modèle généré par le SDK (sera converti via .to_dict())
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
facture_data: Données de la facture (dict, JSON string, ou modèle Pydantic)
|
|
727
|
+
pdf_source: Chemin vers le PDF source, ou bytes du PDF
|
|
728
|
+
profil: Profil Factur-X (MINIMUM, BASIC, EN16931, EXTENDED)
|
|
729
|
+
format_sortie: Format de sortie (pdf, xml, both)
|
|
730
|
+
sync: Si True, attend la fin de la tâche et retourne le résultat
|
|
731
|
+
timeout: Timeout en ms pour le polling
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
bytes: Contenu du fichier généré (PDF ou XML)
|
|
735
|
+
"""
|
|
736
|
+
# Conversion des données en JSON string (gère Decimal, datetime, etc.)
|
|
737
|
+
if isinstance(facture_data, str):
|
|
738
|
+
json_data = facture_data
|
|
739
|
+
elif isinstance(facture_data, dict):
|
|
740
|
+
json_data = json_dumps_safe(facture_data)
|
|
741
|
+
elif hasattr(facture_data, "to_dict"):
|
|
742
|
+
# Modèle Pydantic généré par le SDK
|
|
743
|
+
json_data = json_dumps_safe(facture_data.to_dict())
|
|
744
|
+
else:
|
|
745
|
+
raise FactPulseValidationError(f"Type de données non supporté: {type(facture_data)}")
|
|
746
|
+
|
|
747
|
+
# Préparation du PDF
|
|
748
|
+
if isinstance(pdf_source, (str, Path)):
|
|
749
|
+
pdf_path = Path(pdf_source)
|
|
750
|
+
pdf_bytes = pdf_path.read_bytes()
|
|
751
|
+
pdf_filename = pdf_path.name
|
|
752
|
+
else:
|
|
753
|
+
pdf_bytes = pdf_source
|
|
754
|
+
pdf_filename = "source.pdf"
|
|
755
|
+
|
|
756
|
+
# Envoi direct via requests (bypass des modèles Pydantic du SDK)
|
|
757
|
+
for attempt in range(self.max_retries + 1):
|
|
758
|
+
self.ensure_authenticated()
|
|
759
|
+
try:
|
|
760
|
+
url = f"{self.api_url}/api/v1/traitement/generer-facture"
|
|
761
|
+
files = {
|
|
762
|
+
"donnees_facture": (None, json_data, "application/json"),
|
|
763
|
+
"profil": (None, profil),
|
|
764
|
+
"format_sortie": (None, format_sortie),
|
|
765
|
+
"source_pdf": (pdf_filename, pdf_bytes, "application/pdf"),
|
|
766
|
+
}
|
|
767
|
+
headers = {"Authorization": f"Bearer {self._access_token}"}
|
|
768
|
+
response = requests.post(url, files=files, headers=headers, timeout=60)
|
|
769
|
+
|
|
770
|
+
if response.status_code == 401 and attempt < self.max_retries:
|
|
771
|
+
logger.warning("Erreur 401, réinitialisation du token (tentative %d/%d)", attempt + 1, self.max_retries + 1)
|
|
772
|
+
self.reset_auth()
|
|
773
|
+
continue
|
|
774
|
+
|
|
775
|
+
response.raise_for_status()
|
|
776
|
+
result = response.json()
|
|
777
|
+
task_id = result.get("id_tache")
|
|
778
|
+
|
|
779
|
+
if not task_id:
|
|
780
|
+
raise FactPulseValidationError("Pas d'ID de tâche dans la réponse")
|
|
781
|
+
|
|
782
|
+
if not sync:
|
|
783
|
+
return task_id.encode()
|
|
784
|
+
|
|
785
|
+
poll_result = self.poll_task(task_id, timeout)
|
|
786
|
+
|
|
787
|
+
if poll_result.get("statut") == "ERREUR":
|
|
788
|
+
# Format AFNOR: errorMessage, details
|
|
789
|
+
error_msg = poll_result.get("errorMessage", "Erreur de validation")
|
|
790
|
+
errors = [
|
|
791
|
+
ValidationErrorDetail(
|
|
792
|
+
level=e.get("level", ""),
|
|
793
|
+
item=e.get("item", ""),
|
|
794
|
+
reason=e.get("reason", ""),
|
|
795
|
+
source=e.get("source"),
|
|
796
|
+
code=e.get("code"),
|
|
797
|
+
)
|
|
798
|
+
for e in poll_result.get("details", [])
|
|
799
|
+
]
|
|
800
|
+
raise FactPulseValidationError(error_msg, errors)
|
|
801
|
+
|
|
802
|
+
if "contenu_b64" in poll_result:
|
|
803
|
+
return base64.b64decode(poll_result["contenu_b64"])
|
|
804
|
+
|
|
805
|
+
raise FactPulseValidationError("Le résultat ne contient pas de contenu")
|
|
806
|
+
|
|
807
|
+
except requests.RequestException as e:
|
|
808
|
+
if attempt < self.max_retries:
|
|
809
|
+
logger.warning("Erreur réseau (tentative %d/%d): %s", attempt + 1, self.max_retries + 1, e)
|
|
810
|
+
continue
|
|
811
|
+
raise FactPulseValidationError(f"Erreur API: {e}")
|
|
812
|
+
|
|
813
|
+
raise FactPulseValidationError("Échec après toutes les tentatives")
|
|
814
|
+
|
|
815
|
+
@staticmethod
|
|
816
|
+
def format_montant(montant) -> str:
|
|
817
|
+
"""Formate un montant pour l'API FactPulse."""
|
|
818
|
+
if montant is None:
|
|
819
|
+
return "0.00"
|
|
820
|
+
if isinstance(montant, Decimal):
|
|
821
|
+
return f"{montant:.2f}"
|
|
822
|
+
if isinstance(montant, (int, float)):
|
|
823
|
+
return f"{montant:.2f}"
|
|
824
|
+
if isinstance(montant, str):
|
|
825
|
+
return montant
|
|
826
|
+
return "0.00"
|
|
827
|
+
|
|
828
|
+
# =========================================================================
|
|
829
|
+
# AFNOR PDP/PA - Flow Service
|
|
830
|
+
# =========================================================================
|
|
831
|
+
#
|
|
832
|
+
# ARCHITECTURE GRAVÉE DANS LE MARBRE - NE PAS MODIFIER SANS COMPRENDRE
|
|
833
|
+
#
|
|
834
|
+
# Le proxy AFNOR est 100% TRANSPARENT. Il a le même OpenAPI que l'AFNOR.
|
|
835
|
+
# Le SDK doit TOUJOURS :
|
|
836
|
+
# 1. Obtenir les credentials AFNOR (mode stored: via /credentials, mode zero-trust: fournis)
|
|
837
|
+
# 2. Faire l'OAuth AFNOR lui-même
|
|
838
|
+
# 3. Appeler les endpoints avec le token AFNOR + header X-PDP-Base-URL
|
|
839
|
+
#
|
|
840
|
+
# Le token JWT FactPulse n'est JAMAIS utilisé pour appeler la PDP !
|
|
841
|
+
# =========================================================================
|
|
842
|
+
|
|
843
|
+
def _get_afnor_credentials(self) -> "AFNORCredentials":
|
|
844
|
+
"""Obtient les credentials AFNOR (mode stored ou zero-trust).
|
|
845
|
+
|
|
846
|
+
**Mode zero-trust** : Retourne les afnor_credentials fournis au constructeur.
|
|
847
|
+
**Mode stored** : Récupère les credentials via GET /api/v1/afnor/credentials.
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
AFNORCredentials avec flow_service_url, token_url, client_id, client_secret
|
|
851
|
+
|
|
852
|
+
Raises:
|
|
853
|
+
FactPulseAuthError: Si pas de credentials disponibles
|
|
854
|
+
FactPulseServiceUnavailableError: Si le serveur est indisponible
|
|
855
|
+
"""
|
|
856
|
+
from .exceptions import FactPulseServiceUnavailableError
|
|
857
|
+
|
|
858
|
+
# Mode zero-trust : credentials fournis au constructeur
|
|
859
|
+
if self.afnor_credentials:
|
|
860
|
+
logger.info("Mode zero-trust: utilisation des AFNORCredentials fournis")
|
|
861
|
+
return self.afnor_credentials
|
|
862
|
+
|
|
863
|
+
# Mode stored : récupérer les credentials via l'API
|
|
864
|
+
logger.info("Mode stored: récupération des credentials via /api/v1/afnor/credentials")
|
|
865
|
+
|
|
866
|
+
self.ensure_authenticated() # S'assurer qu'on a un token JWT FactPulse
|
|
867
|
+
|
|
868
|
+
url = f"{self.api_url}/api/v1/afnor/credentials"
|
|
869
|
+
headers = {"Authorization": f"Bearer {self._access_token}"}
|
|
870
|
+
|
|
871
|
+
try:
|
|
872
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
873
|
+
except requests.RequestException as e:
|
|
874
|
+
raise FactPulseServiceUnavailableError("FactPulse AFNOR credentials", e)
|
|
875
|
+
|
|
876
|
+
if response.status_code == 400:
|
|
877
|
+
error_json = response.json()
|
|
878
|
+
error_detail = error_json.get("detail", {})
|
|
879
|
+
if isinstance(error_detail, dict) and error_detail.get("error") == "NO_CLIENT_UID":
|
|
880
|
+
raise FactPulseAuthError(
|
|
881
|
+
"Aucun client_uid dans le JWT. "
|
|
882
|
+
"Pour utiliser les endpoints AFNOR, soit :\n"
|
|
883
|
+
"1. Générez un token avec un client_uid (mode stored)\n"
|
|
884
|
+
"2. Fournissez AFNORCredentials au constructeur du client (mode zero-trust)"
|
|
885
|
+
)
|
|
886
|
+
raise FactPulseAuthError(f"Erreur credentials AFNOR: {error_detail}")
|
|
887
|
+
|
|
888
|
+
if response.status_code != 200:
|
|
889
|
+
try:
|
|
890
|
+
error_json = response.json()
|
|
891
|
+
error_msg = error_json.get("detail", str(error_json))
|
|
892
|
+
except Exception:
|
|
893
|
+
error_msg = response.text or f"HTTP {response.status_code}"
|
|
894
|
+
raise FactPulseAuthError(f"Échec récupération credentials AFNOR: {error_msg}")
|
|
895
|
+
|
|
896
|
+
creds = response.json()
|
|
897
|
+
logger.info(f"Credentials AFNOR récupérés pour PDP: {creds.get('flow_service_url')}")
|
|
898
|
+
|
|
899
|
+
# Créer un AFNORCredentials temporaire
|
|
900
|
+
return AFNORCredentials(
|
|
901
|
+
flow_service_url=creds["flow_service_url"],
|
|
902
|
+
token_url=creds["token_url"],
|
|
903
|
+
client_id=creds["client_id"],
|
|
904
|
+
client_secret=creds["client_secret"],
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
def _get_afnor_token_and_url(self) -> Tuple[str, str]:
|
|
908
|
+
"""Obtient le token OAuth2 AFNOR et l'URL de la PDP.
|
|
909
|
+
|
|
910
|
+
Cette méthode :
|
|
911
|
+
1. Récupère les credentials AFNOR (mode stored ou zero-trust)
|
|
912
|
+
2. Fait l'OAuth AFNOR pour obtenir un token
|
|
913
|
+
3. Retourne le token et l'URL de la PDP
|
|
914
|
+
|
|
915
|
+
Returns:
|
|
916
|
+
Tuple (afnor_token, pdp_base_url)
|
|
917
|
+
|
|
918
|
+
Raises:
|
|
919
|
+
FactPulseAuthError: Si l'authentification échoue
|
|
920
|
+
FactPulseServiceUnavailableError: Si le service est indisponible
|
|
921
|
+
"""
|
|
922
|
+
from .exceptions import FactPulseServiceUnavailableError
|
|
923
|
+
|
|
924
|
+
# Étape 1: Obtenir les credentials AFNOR
|
|
925
|
+
credentials = self._get_afnor_credentials()
|
|
926
|
+
|
|
927
|
+
# Étape 2: Faire l'OAuth AFNOR
|
|
928
|
+
logger.info(f"OAuth AFNOR vers: {credentials.token_url}")
|
|
929
|
+
|
|
930
|
+
url = f"{self.api_url}/api/v1/afnor/oauth/token"
|
|
931
|
+
oauth_data = {
|
|
932
|
+
"grant_type": "client_credentials",
|
|
933
|
+
"client_id": credentials.client_id,
|
|
934
|
+
"client_secret": credentials.client_secret,
|
|
935
|
+
}
|
|
936
|
+
headers = {
|
|
937
|
+
"X-PDP-Token-URL": credentials.token_url,
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
response = requests.post(url, data=oauth_data, headers=headers, timeout=10)
|
|
942
|
+
except requests.RequestException as e:
|
|
943
|
+
raise FactPulseServiceUnavailableError("AFNOR OAuth", e)
|
|
944
|
+
|
|
945
|
+
if response.status_code != 200:
|
|
946
|
+
try:
|
|
947
|
+
error_json = response.json()
|
|
948
|
+
error_msg = error_json.get("detail", error_json.get("error", str(error_json)))
|
|
949
|
+
except Exception:
|
|
950
|
+
error_msg = response.text or f"HTTP {response.status_code}"
|
|
951
|
+
raise FactPulseAuthError(f"Échec OAuth2 AFNOR: {error_msg}")
|
|
952
|
+
|
|
953
|
+
token_data = response.json()
|
|
954
|
+
afnor_token = token_data.get("access_token")
|
|
955
|
+
|
|
956
|
+
if not afnor_token:
|
|
957
|
+
raise FactPulseAuthError("Réponse OAuth2 AFNOR invalide: access_token manquant")
|
|
958
|
+
|
|
959
|
+
logger.info("Token OAuth2 AFNOR obtenu avec succès")
|
|
960
|
+
return afnor_token, credentials.flow_service_url
|
|
961
|
+
|
|
962
|
+
def _make_afnor_request(
|
|
963
|
+
self,
|
|
964
|
+
method: str,
|
|
965
|
+
endpoint: str,
|
|
966
|
+
json_data: Optional[Dict] = None,
|
|
967
|
+
files: Optional[Dict] = None,
|
|
968
|
+
params: Optional[Dict] = None,
|
|
969
|
+
) -> requests.Response:
|
|
970
|
+
"""Effectue une requête vers l'API AFNOR avec gestion d'auth et d'erreurs.
|
|
971
|
+
|
|
972
|
+
================================================================================
|
|
973
|
+
ARCHITECTURE GRAVÉE DANS LE MARBRE
|
|
974
|
+
================================================================================
|
|
975
|
+
|
|
976
|
+
Cette méthode :
|
|
977
|
+
1. Récupère les credentials AFNOR (mode stored: API, mode zero-trust: fournis)
|
|
978
|
+
2. Fait l'OAuth AFNOR pour obtenir un token AFNOR
|
|
979
|
+
3. Appelle l'endpoint avec :
|
|
980
|
+
- Authorization: Bearer {token_afnor} ← TOKEN AFNOR, PAS JWT FACTPULSE !
|
|
981
|
+
- X-PDP-Base-URL: {url_pdp} ← Pour que le proxy route vers la bonne PDP
|
|
982
|
+
|
|
983
|
+
Le token JWT FactPulse n'est JAMAIS utilisé pour appeler la PDP.
|
|
984
|
+
Il sert uniquement à récupérer les credentials en mode stored.
|
|
985
|
+
|
|
986
|
+
================================================================================
|
|
987
|
+
|
|
988
|
+
Args:
|
|
989
|
+
method: Méthode HTTP (GET, POST, etc.)
|
|
990
|
+
endpoint: Endpoint relatif (ex: /flow/v1/flows)
|
|
991
|
+
json_data: Données JSON (optionnel)
|
|
992
|
+
files: Fichiers multipart (optionnel)
|
|
993
|
+
params: Query params (optionnel)
|
|
994
|
+
|
|
995
|
+
Returns:
|
|
996
|
+
Response de l'API
|
|
997
|
+
|
|
998
|
+
Raises:
|
|
999
|
+
FactPulseAuthError: Si 401 ou credentials manquants
|
|
1000
|
+
FactPulseNotFoundError: Si 404
|
|
1001
|
+
FactPulseServiceUnavailableError: Si 503
|
|
1002
|
+
FactPulseValidationError: Si 400/422
|
|
1003
|
+
FactPulseAPIError: Autres erreurs
|
|
1004
|
+
"""
|
|
1005
|
+
from .exceptions import (
|
|
1006
|
+
parse_api_error,
|
|
1007
|
+
FactPulseServiceUnavailableError,
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
# Obtenir le token AFNOR et l'URL de la PDP
|
|
1011
|
+
# (mode stored: récupère credentials via API, mode zero-trust: utilise credentials fournis)
|
|
1012
|
+
afnor_token, pdp_base_url = self._get_afnor_token_and_url()
|
|
1013
|
+
|
|
1014
|
+
url = f"{self.api_url}/api/v1/afnor{endpoint}"
|
|
1015
|
+
|
|
1016
|
+
# TOUJOURS utiliser le token AFNOR + header X-PDP-Base-URL
|
|
1017
|
+
# Le token JWT FactPulse n'est JAMAIS utilisé pour appeler la PDP !
|
|
1018
|
+
headers = {
|
|
1019
|
+
"Authorization": f"Bearer {afnor_token}",
|
|
1020
|
+
"X-PDP-Base-URL": pdp_base_url,
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
try:
|
|
1024
|
+
if files:
|
|
1025
|
+
response = requests.request(
|
|
1026
|
+
method, url, files=files, headers=headers, params=params, timeout=60
|
|
1027
|
+
)
|
|
1028
|
+
else:
|
|
1029
|
+
response = requests.request(
|
|
1030
|
+
method, url, json=json_data, headers=headers, params=params, timeout=30
|
|
1031
|
+
)
|
|
1032
|
+
except requests.RequestException as e:
|
|
1033
|
+
raise FactPulseServiceUnavailableError("AFNOR PDP", e)
|
|
1034
|
+
|
|
1035
|
+
if response.status_code >= 400:
|
|
1036
|
+
try:
|
|
1037
|
+
error_json = response.json()
|
|
1038
|
+
except Exception:
|
|
1039
|
+
error_json = {"errorMessage": response.text or f"Erreur HTTP {response.status_code}"}
|
|
1040
|
+
raise parse_api_error(error_json, response.status_code)
|
|
1041
|
+
|
|
1042
|
+
return response
|
|
1043
|
+
|
|
1044
|
+
def soumettre_facture_afnor(
|
|
1045
|
+
self,
|
|
1046
|
+
flow_name: str,
|
|
1047
|
+
pdf_path: Optional[Union[str, Path]] = None,
|
|
1048
|
+
pdf_bytes: Optional[bytes] = None,
|
|
1049
|
+
pdf_filename: str = "facture.pdf",
|
|
1050
|
+
flow_syntax: str = "CII",
|
|
1051
|
+
flow_profile: str = "EN16931",
|
|
1052
|
+
tracking_id: Optional[str] = None,
|
|
1053
|
+
sha256: Optional[str] = None,
|
|
1054
|
+
) -> Dict[str, Any]:
|
|
1055
|
+
"""Soumet une facture Factur-X à une PDP via l'API AFNOR.
|
|
1056
|
+
|
|
1057
|
+
L'authentification utilise soit le client_uid du JWT (mode stocké),
|
|
1058
|
+
soit les afnor_credentials fournis au constructeur (mode zero-trust).
|
|
1059
|
+
|
|
1060
|
+
Args:
|
|
1061
|
+
flow_name: Nom du flux (ex: "Facture FAC-2025-001")
|
|
1062
|
+
pdf_path: Chemin vers le fichier PDF/A-3 (exclusif avec pdf_bytes)
|
|
1063
|
+
pdf_bytes: Contenu PDF en bytes (exclusif avec pdf_path)
|
|
1064
|
+
pdf_filename: Nom du fichier pour les bytes (défaut: "facture.pdf")
|
|
1065
|
+
flow_syntax: Syntaxe du flux (CII ou UBL)
|
|
1066
|
+
flow_profile: Profil Factur-X (MINIMUM, BASIC, EN16931, EXTENDED)
|
|
1067
|
+
tracking_id: Identifiant de suivi métier (optionnel)
|
|
1068
|
+
sha256: Empreinte SHA-256 du fichier (calculée auto si absent)
|
|
1069
|
+
|
|
1070
|
+
Returns:
|
|
1071
|
+
Dict avec flowId, trackingId, status, sha256, etc.
|
|
1072
|
+
|
|
1073
|
+
Raises:
|
|
1074
|
+
FactPulseValidationError: Si le PDF n'est pas valide
|
|
1075
|
+
FactPulseServiceUnavailableError: Si la PDP est indisponible
|
|
1076
|
+
ValueError: Si ni pdf_path ni pdf_bytes n'est fourni
|
|
1077
|
+
|
|
1078
|
+
Example:
|
|
1079
|
+
>>> # Avec un chemin de fichier
|
|
1080
|
+
>>> result = client.soumettre_facture_afnor(
|
|
1081
|
+
... flow_name="Facture FAC-2025-001",
|
|
1082
|
+
... pdf_path="facture.pdf",
|
|
1083
|
+
... tracking_id="FAC-2025-001",
|
|
1084
|
+
... )
|
|
1085
|
+
>>> print(result["flowId"])
|
|
1086
|
+
|
|
1087
|
+
>>> # Avec des bytes (ex: après génération Factur-X)
|
|
1088
|
+
>>> result = client.soumettre_facture_afnor(
|
|
1089
|
+
... flow_name="Facture FAC-2025-001",
|
|
1090
|
+
... pdf_bytes=pdf_content,
|
|
1091
|
+
... pdf_filename="FAC-2025-001.pdf",
|
|
1092
|
+
... tracking_id="FAC-2025-001",
|
|
1093
|
+
... )
|
|
1094
|
+
"""
|
|
1095
|
+
import hashlib
|
|
1096
|
+
|
|
1097
|
+
# Charger le PDF depuis le chemin si fourni
|
|
1098
|
+
filename = pdf_filename
|
|
1099
|
+
if pdf_path:
|
|
1100
|
+
pdf_path = Path(pdf_path)
|
|
1101
|
+
pdf_bytes = pdf_path.read_bytes()
|
|
1102
|
+
filename = pdf_path.name
|
|
1103
|
+
|
|
1104
|
+
if not pdf_bytes:
|
|
1105
|
+
raise ValueError("pdf_path ou pdf_bytes requis")
|
|
1106
|
+
|
|
1107
|
+
# Calculer SHA-256 si non fourni
|
|
1108
|
+
if not sha256:
|
|
1109
|
+
sha256 = hashlib.sha256(pdf_bytes).hexdigest()
|
|
1110
|
+
|
|
1111
|
+
# Préparer flowInfo
|
|
1112
|
+
flow_info = {
|
|
1113
|
+
"name": flow_name,
|
|
1114
|
+
"flowSyntax": flow_syntax,
|
|
1115
|
+
"flowProfile": flow_profile,
|
|
1116
|
+
"sha256": sha256,
|
|
1117
|
+
}
|
|
1118
|
+
if tracking_id:
|
|
1119
|
+
flow_info["trackingId"] = tracking_id
|
|
1120
|
+
|
|
1121
|
+
files = {
|
|
1122
|
+
"file": (filename, pdf_bytes, "application/pdf"),
|
|
1123
|
+
"flowInfo": (None, json_dumps_safe(flow_info), "application/json"),
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
response = self._make_afnor_request("POST", "/flow/v1/flows", files=files)
|
|
1127
|
+
return response.json()
|
|
1128
|
+
|
|
1129
|
+
def rechercher_flux_afnor(
|
|
1130
|
+
self,
|
|
1131
|
+
tracking_id: Optional[str] = None,
|
|
1132
|
+
status: Optional[str] = None,
|
|
1133
|
+
offset: int = 0,
|
|
1134
|
+
limit: int = 25,
|
|
1135
|
+
) -> Dict[str, Any]:
|
|
1136
|
+
"""Recherche des flux de facturation AFNOR.
|
|
1137
|
+
|
|
1138
|
+
Args:
|
|
1139
|
+
tracking_id: Filtrer par trackingId
|
|
1140
|
+
status: Filtrer par status (submitted, processing, delivered, etc.)
|
|
1141
|
+
offset: Index de début (pagination)
|
|
1142
|
+
limit: Nombre max de résultats
|
|
1143
|
+
|
|
1144
|
+
Returns:
|
|
1145
|
+
Dict avec flows (liste), total, offset, limit
|
|
1146
|
+
|
|
1147
|
+
Example:
|
|
1148
|
+
>>> results = client.rechercher_flux_afnor(tracking_id="FAC-2025-001")
|
|
1149
|
+
>>> for flux in results["flows"]:
|
|
1150
|
+
... print(flux["flowId"], flux["status"])
|
|
1151
|
+
"""
|
|
1152
|
+
search_body = {
|
|
1153
|
+
"offset": offset,
|
|
1154
|
+
"limit": limit,
|
|
1155
|
+
"where": {},
|
|
1156
|
+
}
|
|
1157
|
+
if tracking_id:
|
|
1158
|
+
search_body["where"]["trackingId"] = tracking_id
|
|
1159
|
+
if status:
|
|
1160
|
+
search_body["where"]["status"] = status
|
|
1161
|
+
|
|
1162
|
+
response = self._make_afnor_request("POST", "/flow/v1/flows/search", json_data=search_body)
|
|
1163
|
+
return response.json()
|
|
1164
|
+
|
|
1165
|
+
def telecharger_flux_afnor(self, flow_id: str) -> bytes:
|
|
1166
|
+
"""Télécharge le fichier PDF d'un flux AFNOR.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
flow_id: Identifiant du flux (UUID)
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
Contenu du fichier PDF
|
|
1173
|
+
|
|
1174
|
+
Raises:
|
|
1175
|
+
FactPulseNotFoundError: Si le flux n'existe pas
|
|
1176
|
+
|
|
1177
|
+
Example:
|
|
1178
|
+
>>> pdf_bytes = client.telecharger_flux_afnor("550e8400-e29b-41d4-a716-446655440000")
|
|
1179
|
+
>>> with open("facture.pdf", "wb") as f:
|
|
1180
|
+
... f.write(pdf_bytes)
|
|
1181
|
+
"""
|
|
1182
|
+
response = self._make_afnor_request("GET", f"/flow/v1/flows/{flow_id}")
|
|
1183
|
+
return response.content
|
|
1184
|
+
|
|
1185
|
+
def obtenir_facture_entrante_afnor(
|
|
1186
|
+
self,
|
|
1187
|
+
flow_id: str,
|
|
1188
|
+
include_document: bool = False,
|
|
1189
|
+
) -> Dict[str, Any]:
|
|
1190
|
+
"""Récupère les métadonnées JSON d'un flux entrant (facture fournisseur).
|
|
1191
|
+
|
|
1192
|
+
Télécharge un flux entrant depuis la PDP AFNOR et extrait les métadonnées
|
|
1193
|
+
de la facture vers un format JSON unifié. Supporte les formats Factur-X, CII et UBL.
|
|
1194
|
+
|
|
1195
|
+
Note: Cet endpoint utilise l'authentification JWT FactPulse (pas OAuth AFNOR).
|
|
1196
|
+
Le serveur FactPulse se charge d'appeler la PDP avec les credentials stockés.
|
|
1197
|
+
|
|
1198
|
+
Args:
|
|
1199
|
+
flow_id: Identifiant du flux (UUID)
|
|
1200
|
+
include_document: Si True, inclut le document original encodé en base64
|
|
1201
|
+
|
|
1202
|
+
Returns:
|
|
1203
|
+
Dict avec les métadonnées de la facture:
|
|
1204
|
+
- flow_id: Identifiant du flux
|
|
1205
|
+
- format_source: Format détecté (Factur-X, CII, UBL)
|
|
1206
|
+
- ref_fournisseur: Numéro de facture fournisseur
|
|
1207
|
+
- type_document: Code type (380=facture, 381=avoir, etc.)
|
|
1208
|
+
- fournisseur: Dict avec nom, siret, numero_tva_intra
|
|
1209
|
+
- site_facturation_nom: Nom du destinataire
|
|
1210
|
+
- site_facturation_siret: SIRET du destinataire
|
|
1211
|
+
- date_de_piece: Date de la facture (YYYY-MM-DD)
|
|
1212
|
+
- date_reglement: Date d'échéance (YYYY-MM-DD)
|
|
1213
|
+
- devise: Code devise (EUR, USD, etc.)
|
|
1214
|
+
- montant_ht: Montant HT
|
|
1215
|
+
- montant_tva: Montant TVA
|
|
1216
|
+
- montant_ttc: Montant TTC
|
|
1217
|
+
- document_base64: (si include_document=True) Document encodé
|
|
1218
|
+
- document_content_type: (si include_document=True) Type MIME
|
|
1219
|
+
- document_filename: (si include_document=True) Nom de fichier
|
|
1220
|
+
|
|
1221
|
+
Raises:
|
|
1222
|
+
FactPulseNotFoundError: Si le flux n'existe pas
|
|
1223
|
+
FactPulseValidationError: Si le format n'est pas supporté
|
|
1224
|
+
|
|
1225
|
+
Example:
|
|
1226
|
+
>>> # Récupérer les métadonnées d'une facture entrante
|
|
1227
|
+
>>> facture = client.obtenir_facture_entrante_afnor("550e8400-e29b-41d4-a716-446655440000")
|
|
1228
|
+
>>> print(f"Fournisseur: {facture['fournisseur']['nom']}")
|
|
1229
|
+
>>> print(f"Montant TTC: {facture['montant_ttc']} {facture['devise']}")
|
|
1230
|
+
|
|
1231
|
+
>>> # Avec le document original
|
|
1232
|
+
>>> facture = client.obtenir_facture_entrante_afnor(flow_id, include_document=True)
|
|
1233
|
+
>>> if facture.get('document_base64'):
|
|
1234
|
+
... import base64
|
|
1235
|
+
... pdf_bytes = base64.b64decode(facture['document_base64'])
|
|
1236
|
+
... with open(facture['document_filename'], 'wb') as f:
|
|
1237
|
+
... f.write(pdf_bytes)
|
|
1238
|
+
"""
|
|
1239
|
+
from .exceptions import FactPulseNotFoundError, FactPulseServiceUnavailableError, parse_api_error
|
|
1240
|
+
|
|
1241
|
+
self.ensure_authenticated()
|
|
1242
|
+
|
|
1243
|
+
url = f"{self.api_url}/api/v1/afnor/flux-entrants/{flow_id}"
|
|
1244
|
+
params = {}
|
|
1245
|
+
if include_document:
|
|
1246
|
+
params["include_document"] = "true"
|
|
1247
|
+
|
|
1248
|
+
headers = {"Authorization": f"Bearer {self._access_token}"}
|
|
1249
|
+
|
|
1250
|
+
try:
|
|
1251
|
+
response = requests.get(url, headers=headers, params=params if params else None, timeout=60)
|
|
1252
|
+
except requests.RequestException as e:
|
|
1253
|
+
raise FactPulseServiceUnavailableError("FactPulse AFNOR flux-entrants", e)
|
|
1254
|
+
|
|
1255
|
+
if response.status_code >= 400:
|
|
1256
|
+
try:
|
|
1257
|
+
error_json = response.json()
|
|
1258
|
+
except Exception:
|
|
1259
|
+
error_json = {"detail": response.text or f"Erreur HTTP {response.status_code}"}
|
|
1260
|
+
raise parse_api_error(error_json, response.status_code)
|
|
1261
|
+
|
|
1262
|
+
return response.json()
|
|
1263
|
+
|
|
1264
|
+
def healthcheck_afnor(self) -> Dict[str, Any]:
|
|
1265
|
+
"""Vérifie la disponibilité du Flow Service AFNOR.
|
|
1266
|
+
|
|
1267
|
+
Returns:
|
|
1268
|
+
Dict avec status et service
|
|
1269
|
+
|
|
1270
|
+
Example:
|
|
1271
|
+
>>> status = client.healthcheck_afnor()
|
|
1272
|
+
>>> print(status["status"]) # "ok"
|
|
1273
|
+
"""
|
|
1274
|
+
response = self._make_afnor_request("GET", "/flow/v1/healthcheck")
|
|
1275
|
+
return response.json()
|
|
1276
|
+
|
|
1277
|
+
# =========================================================================
|
|
1278
|
+
# Chorus Pro
|
|
1279
|
+
# =========================================================================
|
|
1280
|
+
|
|
1281
|
+
def _make_chorus_request(
|
|
1282
|
+
self,
|
|
1283
|
+
method: str,
|
|
1284
|
+
endpoint: str,
|
|
1285
|
+
json_data: Optional[Dict] = None,
|
|
1286
|
+
params: Optional[Dict] = None,
|
|
1287
|
+
) -> requests.Response:
|
|
1288
|
+
"""Effectue une requête vers l'API Chorus Pro avec gestion d'auth et d'erreurs.
|
|
1289
|
+
|
|
1290
|
+
Args:
|
|
1291
|
+
method: Méthode HTTP (GET, POST, etc.)
|
|
1292
|
+
endpoint: Endpoint relatif (ex: /structures/rechercher)
|
|
1293
|
+
json_data: Données JSON (optionnel)
|
|
1294
|
+
params: Query params (optionnel)
|
|
1295
|
+
|
|
1296
|
+
Returns:
|
|
1297
|
+
Response de l'API
|
|
1298
|
+
"""
|
|
1299
|
+
from .exceptions import (
|
|
1300
|
+
parse_api_error,
|
|
1301
|
+
FactPulseServiceUnavailableError,
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
self.ensure_authenticated()
|
|
1305
|
+
url = f"{self.api_url}/api/v1/chorus-pro{endpoint}"
|
|
1306
|
+
|
|
1307
|
+
headers = {"Authorization": f"Bearer {self._access_token}"}
|
|
1308
|
+
|
|
1309
|
+
# Ajouter credentials dans le body si mode zero-trust
|
|
1310
|
+
if json_data is None:
|
|
1311
|
+
json_data = {}
|
|
1312
|
+
if self.chorus_credentials:
|
|
1313
|
+
json_data["credentials"] = self.chorus_credentials.to_dict()
|
|
1314
|
+
|
|
1315
|
+
try:
|
|
1316
|
+
response = requests.request(
|
|
1317
|
+
method, url, json=json_data, headers=headers, params=params, timeout=30
|
|
1318
|
+
)
|
|
1319
|
+
except requests.RequestException as e:
|
|
1320
|
+
raise FactPulseServiceUnavailableError("Chorus Pro", e)
|
|
1321
|
+
|
|
1322
|
+
if response.status_code >= 400:
|
|
1323
|
+
try:
|
|
1324
|
+
error_json = response.json()
|
|
1325
|
+
except Exception:
|
|
1326
|
+
error_json = {"errorMessage": response.text or f"Erreur HTTP {response.status_code}"}
|
|
1327
|
+
raise parse_api_error(error_json, response.status_code)
|
|
1328
|
+
|
|
1329
|
+
return response
|
|
1330
|
+
|
|
1331
|
+
def rechercher_structure_chorus(
|
|
1332
|
+
self,
|
|
1333
|
+
identifiant_structure: Optional[str] = None,
|
|
1334
|
+
raison_sociale: Optional[str] = None,
|
|
1335
|
+
type_identifiant: str = "SIRET",
|
|
1336
|
+
restreindre_privees: bool = True,
|
|
1337
|
+
) -> Dict[str, Any]:
|
|
1338
|
+
"""Recherche des structures sur Chorus Pro.
|
|
1339
|
+
|
|
1340
|
+
Args:
|
|
1341
|
+
identifiant_structure: SIRET ou SIREN de la structure
|
|
1342
|
+
raison_sociale: Raison sociale (recherche partielle)
|
|
1343
|
+
type_identifiant: Type d'identifiant (SIRET, SIREN, etc.)
|
|
1344
|
+
restreindre_privees: Si True, limite aux structures privées
|
|
1345
|
+
|
|
1346
|
+
Returns:
|
|
1347
|
+
Dict avec liste_structures, total, code_retour, libelle
|
|
1348
|
+
|
|
1349
|
+
Example:
|
|
1350
|
+
>>> result = client.rechercher_structure_chorus(identifiant_structure="12345678901234")
|
|
1351
|
+
>>> for struct in result["liste_structures"]:
|
|
1352
|
+
... print(struct["id_structure_cpp"], struct["designation_structure"])
|
|
1353
|
+
"""
|
|
1354
|
+
body = {
|
|
1355
|
+
"restreindre_structures_privees": restreindre_privees,
|
|
1356
|
+
}
|
|
1357
|
+
if identifiant_structure:
|
|
1358
|
+
body["identifiant_structure"] = identifiant_structure
|
|
1359
|
+
if raison_sociale:
|
|
1360
|
+
body["raison_sociale_structure"] = raison_sociale
|
|
1361
|
+
if type_identifiant:
|
|
1362
|
+
body["type_identifiant_structure"] = type_identifiant
|
|
1363
|
+
|
|
1364
|
+
response = self._make_chorus_request("POST", "/structures/rechercher", json_data=body)
|
|
1365
|
+
return response.json()
|
|
1366
|
+
|
|
1367
|
+
def consulter_structure_chorus(self, id_structure_cpp: int) -> Dict[str, Any]:
|
|
1368
|
+
"""Consulte les détails d'une structure Chorus Pro.
|
|
1369
|
+
|
|
1370
|
+
Retourne notamment les paramètres obligatoires pour soumettre une facture :
|
|
1371
|
+
- code_service_doit_etre_renseigne
|
|
1372
|
+
- numero_ej_doit_etre_renseigne
|
|
1373
|
+
|
|
1374
|
+
Args:
|
|
1375
|
+
id_structure_cpp: ID Chorus Pro de la structure
|
|
1376
|
+
|
|
1377
|
+
Returns:
|
|
1378
|
+
Dict avec les détails de la structure et ses paramètres
|
|
1379
|
+
|
|
1380
|
+
Example:
|
|
1381
|
+
>>> details = client.consulter_structure_chorus(12345)
|
|
1382
|
+
>>> if details["parametres"]["code_service_doit_etre_renseigne"]:
|
|
1383
|
+
... print("Code service obligatoire")
|
|
1384
|
+
"""
|
|
1385
|
+
body = {"id_structure_cpp": id_structure_cpp}
|
|
1386
|
+
response = self._make_chorus_request("POST", "/structures/consulter", json_data=body)
|
|
1387
|
+
return response.json()
|
|
1388
|
+
|
|
1389
|
+
def obtenir_id_chorus_depuis_siret(
|
|
1390
|
+
self,
|
|
1391
|
+
siret: str,
|
|
1392
|
+
type_identifiant: str = "SIRET",
|
|
1393
|
+
) -> Dict[str, Any]:
|
|
1394
|
+
"""Obtient l'ID Chorus Pro d'une structure depuis son SIRET.
|
|
1395
|
+
|
|
1396
|
+
Raccourci pratique pour obtenir l'id_structure_cpp avant de soumettre une facture.
|
|
1397
|
+
|
|
1398
|
+
Args:
|
|
1399
|
+
siret: Numéro SIRET ou SIREN
|
|
1400
|
+
type_identifiant: Type d'identifiant (SIRET ou SIREN)
|
|
1401
|
+
|
|
1402
|
+
Returns:
|
|
1403
|
+
Dict avec id_structure_cpp, designation_structure, message
|
|
1404
|
+
|
|
1405
|
+
Example:
|
|
1406
|
+
>>> result = client.obtenir_id_chorus_depuis_siret("12345678901234")
|
|
1407
|
+
>>> id_cpp = result["id_structure_cpp"]
|
|
1408
|
+
>>> if id_cpp > 0:
|
|
1409
|
+
... print(f"Structure trouvée: {result['designation_structure']}")
|
|
1410
|
+
"""
|
|
1411
|
+
body = {
|
|
1412
|
+
"siret": siret,
|
|
1413
|
+
"type_identifiant": type_identifiant,
|
|
1414
|
+
}
|
|
1415
|
+
response = self._make_chorus_request("POST", "/structures/obtenir-id-depuis-siret", json_data=body)
|
|
1416
|
+
return response.json()
|
|
1417
|
+
|
|
1418
|
+
def lister_services_structure_chorus(self, id_structure_cpp: int) -> Dict[str, Any]:
|
|
1419
|
+
"""Liste les services d'une structure Chorus Pro.
|
|
1420
|
+
|
|
1421
|
+
Args:
|
|
1422
|
+
id_structure_cpp: ID Chorus Pro de la structure
|
|
1423
|
+
|
|
1424
|
+
Returns:
|
|
1425
|
+
Dict avec liste_services, total, code_retour, libelle
|
|
1426
|
+
|
|
1427
|
+
Example:
|
|
1428
|
+
>>> services = client.lister_services_structure_chorus(12345)
|
|
1429
|
+
>>> for svc in services["liste_services"]:
|
|
1430
|
+
... if svc["est_actif"]:
|
|
1431
|
+
... print(svc["code_service"], svc["libelle_service"])
|
|
1432
|
+
"""
|
|
1433
|
+
response = self._make_chorus_request("GET", f"/structures/{id_structure_cpp}/services")
|
|
1434
|
+
return response.json()
|
|
1435
|
+
|
|
1436
|
+
def soumettre_facture_chorus(
|
|
1437
|
+
self,
|
|
1438
|
+
numero_facture: str,
|
|
1439
|
+
date_facture: str,
|
|
1440
|
+
date_echeance_paiement: str,
|
|
1441
|
+
id_structure_cpp: int,
|
|
1442
|
+
montant_ht_total: str,
|
|
1443
|
+
montant_tva: str,
|
|
1444
|
+
montant_ttc_total: str,
|
|
1445
|
+
piece_jointe_principale_id: Optional[int] = None,
|
|
1446
|
+
piece_jointe_principale_designation: str = "Facture",
|
|
1447
|
+
code_service: Optional[str] = None,
|
|
1448
|
+
numero_engagement: Optional[str] = None,
|
|
1449
|
+
numero_bon_commande: Optional[str] = None,
|
|
1450
|
+
numero_marche: Optional[str] = None,
|
|
1451
|
+
commentaire: Optional[str] = None,
|
|
1452
|
+
) -> Dict[str, Any]:
|
|
1453
|
+
"""Soumet une facture à Chorus Pro.
|
|
1454
|
+
|
|
1455
|
+
**Workflow complet** :
|
|
1456
|
+
1. Obtenir l'id_structure_cpp via rechercher_structure_chorus()
|
|
1457
|
+
2. Vérifier les paramètres obligatoires via consulter_structure_chorus()
|
|
1458
|
+
3. Uploader le PDF via l'API /transverses/ajouter-fichier
|
|
1459
|
+
4. Soumettre la facture avec cette méthode
|
|
1460
|
+
|
|
1461
|
+
Args:
|
|
1462
|
+
numero_facture: Numéro de la facture
|
|
1463
|
+
date_facture: Date de la facture (YYYY-MM-DD)
|
|
1464
|
+
date_echeance_paiement: Date d'échéance (YYYY-MM-DD)
|
|
1465
|
+
id_structure_cpp: ID Chorus Pro du destinataire
|
|
1466
|
+
montant_ht_total: Montant HT total (ex: "1000.00")
|
|
1467
|
+
montant_tva: Montant TVA (ex: "200.00")
|
|
1468
|
+
montant_ttc_total: Montant TTC total (ex: "1200.00")
|
|
1469
|
+
piece_jointe_principale_id: ID de la pièce jointe (optionnel)
|
|
1470
|
+
piece_jointe_principale_designation: Désignation (défaut: "Facture")
|
|
1471
|
+
code_service: Code service (si requis par la structure)
|
|
1472
|
+
numero_engagement: Numéro d'engagement (si requis)
|
|
1473
|
+
numero_bon_commande: Numéro de bon de commande
|
|
1474
|
+
numero_marche: Numéro de marché
|
|
1475
|
+
commentaire: Commentaire libre
|
|
1476
|
+
|
|
1477
|
+
Returns:
|
|
1478
|
+
Dict avec identifiant_facture_cpp, numero_flux_depot, code_retour, libelle
|
|
1479
|
+
|
|
1480
|
+
Example:
|
|
1481
|
+
>>> result = client.soumettre_facture_chorus(
|
|
1482
|
+
... numero_facture="FAC-2025-001",
|
|
1483
|
+
... date_facture="2025-01-15",
|
|
1484
|
+
... date_echeance_paiement="2025-02-15",
|
|
1485
|
+
... id_structure_cpp=12345,
|
|
1486
|
+
... montant_ht_total="1000.00",
|
|
1487
|
+
... montant_tva="200.00",
|
|
1488
|
+
... montant_ttc_total="1200.00",
|
|
1489
|
+
... )
|
|
1490
|
+
>>> print(f"Facture soumise: {result['identifiant_facture_cpp']}")
|
|
1491
|
+
"""
|
|
1492
|
+
body = {
|
|
1493
|
+
"numero_facture": numero_facture,
|
|
1494
|
+
"date_facture": date_facture,
|
|
1495
|
+
"date_echeance_paiement": date_echeance_paiement,
|
|
1496
|
+
"id_structure_cpp": id_structure_cpp,
|
|
1497
|
+
"montant_ht_total": montant_ht_total,
|
|
1498
|
+
"montant_tva": montant_tva,
|
|
1499
|
+
"montant_ttc_total": montant_ttc_total,
|
|
1500
|
+
}
|
|
1501
|
+
if piece_jointe_principale_id:
|
|
1502
|
+
body["piece_jointe_principale_id"] = piece_jointe_principale_id
|
|
1503
|
+
body["piece_jointe_principale_designation"] = piece_jointe_principale_designation
|
|
1504
|
+
if code_service:
|
|
1505
|
+
body["code_service"] = code_service
|
|
1506
|
+
if numero_engagement:
|
|
1507
|
+
body["numero_engagement"] = numero_engagement
|
|
1508
|
+
if numero_bon_commande:
|
|
1509
|
+
body["numero_bon_commande"] = numero_bon_commande
|
|
1510
|
+
if numero_marche:
|
|
1511
|
+
body["numero_marche"] = numero_marche
|
|
1512
|
+
if commentaire:
|
|
1513
|
+
body["commentaire"] = commentaire
|
|
1514
|
+
|
|
1515
|
+
response = self._make_chorus_request("POST", "/factures/soumettre", json_data=body)
|
|
1516
|
+
return response.json()
|
|
1517
|
+
|
|
1518
|
+
def consulter_facture_chorus(self, identifiant_facture_cpp: int) -> Dict[str, Any]:
|
|
1519
|
+
"""Consulte le statut d'une facture Chorus Pro.
|
|
1520
|
+
|
|
1521
|
+
Args:
|
|
1522
|
+
identifiant_facture_cpp: ID Chorus Pro de la facture
|
|
1523
|
+
|
|
1524
|
+
Returns:
|
|
1525
|
+
Dict avec statut_courant, numero_facture, date_facture, montant_ttc_total, etc.
|
|
1526
|
+
|
|
1527
|
+
Example:
|
|
1528
|
+
>>> status = client.consulter_facture_chorus(12345)
|
|
1529
|
+
>>> print(f"Statut: {status['statut_courant']['code']}")
|
|
1530
|
+
"""
|
|
1531
|
+
body = {"identifiant_facture_cpp": identifiant_facture_cpp}
|
|
1532
|
+
response = self._make_chorus_request("POST", "/factures/consulter", json_data=body)
|
|
1533
|
+
return response.json()
|
|
1534
|
+
|
|
1535
|
+
# ==================== AFNOR Directory ====================
|
|
1536
|
+
|
|
1537
|
+
def rechercher_siret_afnor(self, siret: str) -> Dict[str, Any]:
|
|
1538
|
+
"""Recherche une entreprise par SIRET dans l'annuaire AFNOR.
|
|
1539
|
+
|
|
1540
|
+
Args:
|
|
1541
|
+
siret: Numéro SIRET (14 chiffres)
|
|
1542
|
+
|
|
1543
|
+
Returns:
|
|
1544
|
+
Dict avec informations entreprise: raison_sociale, adresse, etc.
|
|
1545
|
+
|
|
1546
|
+
Example:
|
|
1547
|
+
>>> result = client.rechercher_siret_afnor("12345678901234")
|
|
1548
|
+
>>> print(f"Entreprise: {result['raison_sociale']}")
|
|
1549
|
+
"""
|
|
1550
|
+
response = self._make_afnor_request("GET", f"/directory/siret/{siret}")
|
|
1551
|
+
return response.json()
|
|
1552
|
+
|
|
1553
|
+
def rechercher_siren_afnor(self, siren: str) -> Dict[str, Any]:
|
|
1554
|
+
"""Recherche une entreprise par SIREN dans l'annuaire AFNOR.
|
|
1555
|
+
|
|
1556
|
+
Args:
|
|
1557
|
+
siren: Numéro SIREN (9 chiffres)
|
|
1558
|
+
|
|
1559
|
+
Returns:
|
|
1560
|
+
Dict avec informations entreprise et liste des établissements
|
|
1561
|
+
|
|
1562
|
+
Example:
|
|
1563
|
+
>>> result = client.rechercher_siren_afnor("123456789")
|
|
1564
|
+
>>> for etab in result.get('etablissements', []):
|
|
1565
|
+
... print(f"SIRET: {etab['siret']}")
|
|
1566
|
+
"""
|
|
1567
|
+
response = self._make_afnor_request("GET", f"/directory/siren/{siren}")
|
|
1568
|
+
return response.json()
|
|
1569
|
+
|
|
1570
|
+
def lister_codes_routage_afnor(self, siren: str) -> List[Dict[str, Any]]:
|
|
1571
|
+
"""Liste les codes de routage disponibles pour un SIREN.
|
|
1572
|
+
|
|
1573
|
+
Args:
|
|
1574
|
+
siren: Numéro SIREN (9 chiffres)
|
|
1575
|
+
|
|
1576
|
+
Returns:
|
|
1577
|
+
Liste des codes de routage avec leurs paramètres
|
|
1578
|
+
|
|
1579
|
+
Example:
|
|
1580
|
+
>>> codes = client.lister_codes_routage_afnor("123456789")
|
|
1581
|
+
>>> for code in codes:
|
|
1582
|
+
... print(f"Code: {code['code_routage']}")
|
|
1583
|
+
"""
|
|
1584
|
+
response = self._make_afnor_request("GET", f"/directory/siren/{siren}/routing-codes")
|
|
1585
|
+
return response.json()
|
|
1586
|
+
|
|
1587
|
+
# ==================== Validation ====================
|
|
1588
|
+
|
|
1589
|
+
def valider_pdf_facturx(
|
|
1590
|
+
self,
|
|
1591
|
+
pdf_path: Optional[str] = None,
|
|
1592
|
+
pdf_bytes: Optional[bytes] = None,
|
|
1593
|
+
profil: str = "EN16931"
|
|
1594
|
+
) -> Dict[str, Any]:
|
|
1595
|
+
"""Valide un PDF Factur-X.
|
|
1596
|
+
|
|
1597
|
+
Args:
|
|
1598
|
+
pdf_path: Chemin vers le fichier PDF (exclusif avec pdf_bytes)
|
|
1599
|
+
pdf_bytes: Contenu PDF en bytes (exclusif avec pdf_path)
|
|
1600
|
+
profil: Profil Factur-X attendu (MINIMUM, BASIC, EN16931, EXTENDED)
|
|
1601
|
+
|
|
1602
|
+
Returns:
|
|
1603
|
+
Dict avec: est_conforme (bool), erreurs (list), avertissements (list), profil_detecte
|
|
1604
|
+
|
|
1605
|
+
Example:
|
|
1606
|
+
>>> result = client.valider_pdf_facturx("facture.pdf")
|
|
1607
|
+
>>> if result['est_conforme']:
|
|
1608
|
+
... print("PDF Factur-X valide!")
|
|
1609
|
+
>>> else:
|
|
1610
|
+
... for err in result['erreurs']:
|
|
1611
|
+
... print(f"Erreur: {err}")
|
|
1612
|
+
"""
|
|
1613
|
+
if pdf_path:
|
|
1614
|
+
with open(pdf_path, "rb") as f:
|
|
1615
|
+
pdf_bytes = f.read()
|
|
1616
|
+
if not pdf_bytes:
|
|
1617
|
+
raise ValueError("pdf_path ou pdf_bytes requis")
|
|
1618
|
+
|
|
1619
|
+
files = {"fichier_pdf": ("facture.pdf", pdf_bytes, "application/pdf")}
|
|
1620
|
+
data = {"profil": profil}
|
|
1621
|
+
response = self._request("POST", "/traitement/valider-pdf-facturx", files=files, data=data)
|
|
1622
|
+
return response.json()
|
|
1623
|
+
|
|
1624
|
+
def valider_signature_pdf(
|
|
1625
|
+
self,
|
|
1626
|
+
pdf_path: Optional[str] = None,
|
|
1627
|
+
pdf_bytes: Optional[bytes] = None
|
|
1628
|
+
) -> Dict[str, Any]:
|
|
1629
|
+
"""Valide la signature d'un PDF signé.
|
|
1630
|
+
|
|
1631
|
+
Args:
|
|
1632
|
+
pdf_path: Chemin vers le fichier PDF signé
|
|
1633
|
+
pdf_bytes: Contenu PDF en bytes
|
|
1634
|
+
|
|
1635
|
+
Returns:
|
|
1636
|
+
Dict avec: is_signed (bool), signatures (list), etc.
|
|
1637
|
+
|
|
1638
|
+
Example:
|
|
1639
|
+
>>> result = client.valider_signature_pdf("facture_signee.pdf")
|
|
1640
|
+
>>> if result['is_signed']:
|
|
1641
|
+
... print("PDF signé!")
|
|
1642
|
+
... for sig in result.get('signatures', []):
|
|
1643
|
+
... print(f"Signé par: {sig.get('signer_cn')}")
|
|
1644
|
+
"""
|
|
1645
|
+
if pdf_path:
|
|
1646
|
+
with open(pdf_path, "rb") as f:
|
|
1647
|
+
pdf_bytes = f.read()
|
|
1648
|
+
if not pdf_bytes:
|
|
1649
|
+
raise ValueError("pdf_path ou pdf_bytes requis")
|
|
1650
|
+
|
|
1651
|
+
files = {"fichier_pdf": ("document.pdf", pdf_bytes, "application/pdf")}
|
|
1652
|
+
response = self._request("POST", "/traitement/valider-signature-pdf", files=files)
|
|
1653
|
+
return response.json()
|
|
1654
|
+
|
|
1655
|
+
# ==================== Signature ====================
|
|
1656
|
+
|
|
1657
|
+
def signer_pdf(
|
|
1658
|
+
self,
|
|
1659
|
+
pdf_path: Optional[str] = None,
|
|
1660
|
+
pdf_bytes: Optional[bytes] = None,
|
|
1661
|
+
raison: Optional[str] = None,
|
|
1662
|
+
localisation: Optional[str] = None,
|
|
1663
|
+
contact: Optional[str] = None,
|
|
1664
|
+
use_pades_lt: bool = False,
|
|
1665
|
+
use_timestamp: bool = True,
|
|
1666
|
+
output_path: Optional[str] = None
|
|
1667
|
+
) -> Union[bytes, str]:
|
|
1668
|
+
"""Signe un PDF avec le certificat configuré côté serveur.
|
|
1669
|
+
|
|
1670
|
+
Le certificat doit être préalablement configuré dans Django Admin
|
|
1671
|
+
pour le client identifié par le client_uid du JWT.
|
|
1672
|
+
|
|
1673
|
+
Args:
|
|
1674
|
+
pdf_path: Chemin vers le PDF à signer
|
|
1675
|
+
pdf_bytes: Contenu PDF en bytes
|
|
1676
|
+
raison: Raison de la signature (optionnel)
|
|
1677
|
+
localisation: Lieu de signature (optionnel)
|
|
1678
|
+
contact: Email de contact (optionnel)
|
|
1679
|
+
use_pades_lt: Activer PAdES-B-LT archivage long terme (défaut: False)
|
|
1680
|
+
use_timestamp: Activer l'horodatage RFC 3161 (défaut: True)
|
|
1681
|
+
output_path: Si fourni, sauvegarde le PDF signé à ce chemin
|
|
1682
|
+
|
|
1683
|
+
Returns:
|
|
1684
|
+
bytes du PDF signé, ou chemin si output_path fourni
|
|
1685
|
+
|
|
1686
|
+
Example:
|
|
1687
|
+
>>> pdf_signe = client.signer_pdf(
|
|
1688
|
+
... pdf_path="facture.pdf",
|
|
1689
|
+
... raison="Conformité Factur-X",
|
|
1690
|
+
... output_path="facture_signee.pdf"
|
|
1691
|
+
... )
|
|
1692
|
+
"""
|
|
1693
|
+
if pdf_path:
|
|
1694
|
+
with open(pdf_path, "rb") as f:
|
|
1695
|
+
pdf_bytes = f.read()
|
|
1696
|
+
|
|
1697
|
+
if not pdf_bytes:
|
|
1698
|
+
raise ValueError("pdf_path ou pdf_bytes requis")
|
|
1699
|
+
|
|
1700
|
+
files = {
|
|
1701
|
+
"fichier_pdf": ("document.pdf", pdf_bytes, "application/pdf"),
|
|
1702
|
+
}
|
|
1703
|
+
data: Dict[str, Any] = {
|
|
1704
|
+
"use_pades_lt": str(use_pades_lt).lower(),
|
|
1705
|
+
"use_timestamp": str(use_timestamp).lower(),
|
|
1706
|
+
}
|
|
1707
|
+
if raison:
|
|
1708
|
+
data["raison"] = raison
|
|
1709
|
+
if localisation:
|
|
1710
|
+
data["localisation"] = localisation
|
|
1711
|
+
if contact:
|
|
1712
|
+
data["contact"] = contact
|
|
1713
|
+
|
|
1714
|
+
response = self._request("POST", "/traitement/signer-pdf", files=files, data=data)
|
|
1715
|
+
result = response.json()
|
|
1716
|
+
|
|
1717
|
+
# L'API retourne du JSON avec pdf_signe_base64
|
|
1718
|
+
pdf_signe_b64 = result.get("pdf_signe_base64")
|
|
1719
|
+
if not pdf_signe_b64:
|
|
1720
|
+
raise FactPulseValidationError("Réponse de signature invalide")
|
|
1721
|
+
|
|
1722
|
+
import base64
|
|
1723
|
+
pdf_signe = base64.b64decode(pdf_signe_b64)
|
|
1724
|
+
|
|
1725
|
+
if output_path:
|
|
1726
|
+
with open(output_path, "wb") as f:
|
|
1727
|
+
f.write(pdf_signe)
|
|
1728
|
+
return output_path
|
|
1729
|
+
|
|
1730
|
+
return pdf_signe
|
|
1731
|
+
|
|
1732
|
+
def generer_certificat_test(
|
|
1733
|
+
self,
|
|
1734
|
+
cn: str = "Test Organisation",
|
|
1735
|
+
organisation: str = "Test Organisation",
|
|
1736
|
+
email: str = "test@example.com",
|
|
1737
|
+
duree_jours: int = 365,
|
|
1738
|
+
taille_cle: int = 2048,
|
|
1739
|
+
) -> Dict[str, Any]:
|
|
1740
|
+
"""Génère un certificat de test pour la signature (NON PRODUCTION).
|
|
1741
|
+
|
|
1742
|
+
Le certificat généré doit ensuite être configuré dans Django Admin.
|
|
1743
|
+
|
|
1744
|
+
Args:
|
|
1745
|
+
cn: Common Name du certificat
|
|
1746
|
+
organisation: Nom de l'organisation
|
|
1747
|
+
email: Email associé au certificat
|
|
1748
|
+
duree_jours: Durée de validité en jours (défaut: 365)
|
|
1749
|
+
taille_cle: Taille de la clé RSA (2048 ou 4096)
|
|
1750
|
+
|
|
1751
|
+
Returns:
|
|
1752
|
+
Dict avec certificat_pem, cle_privee_pem, pkcs12_base64, etc.
|
|
1753
|
+
|
|
1754
|
+
Example:
|
|
1755
|
+
>>> result = client.generer_certificat_test(
|
|
1756
|
+
... cn="Ma Société - Cachet",
|
|
1757
|
+
... organisation="Ma Société SAS",
|
|
1758
|
+
... email="contact@masociete.fr",
|
|
1759
|
+
... )
|
|
1760
|
+
>>> print(result["certificat_pem"])
|
|
1761
|
+
"""
|
|
1762
|
+
data = {
|
|
1763
|
+
"cn": cn,
|
|
1764
|
+
"organisation": organisation,
|
|
1765
|
+
"email": email,
|
|
1766
|
+
"duree_jours": duree_jours,
|
|
1767
|
+
"taille_cle": taille_cle,
|
|
1768
|
+
}
|
|
1769
|
+
response = self._request("POST", "/traitement/generer-certificat-test", json_data=data)
|
|
1770
|
+
return response.json()
|
|
1771
|
+
|
|
1772
|
+
# ==================== Workflow complet ====================
|
|
1773
|
+
|
|
1774
|
+
def generer_facturx_complet(
|
|
1775
|
+
self,
|
|
1776
|
+
facture: Dict[str, Any],
|
|
1777
|
+
pdf_source_path: Optional[str] = None,
|
|
1778
|
+
pdf_source_bytes: Optional[bytes] = None,
|
|
1779
|
+
profil: str = "EN16931",
|
|
1780
|
+
valider: bool = True,
|
|
1781
|
+
signer: bool = False,
|
|
1782
|
+
soumettre_afnor: bool = False,
|
|
1783
|
+
afnor_flow_name: Optional[str] = None,
|
|
1784
|
+
afnor_tracking_id: Optional[str] = None,
|
|
1785
|
+
output_path: Optional[str] = None,
|
|
1786
|
+
timeout: int = 120000
|
|
1787
|
+
) -> Dict[str, Any]:
|
|
1788
|
+
"""Génère un PDF Factur-X complet avec validation, signature et soumission optionnelles.
|
|
1789
|
+
|
|
1790
|
+
Cette méthode enchaîne automatiquement:
|
|
1791
|
+
1. Génération du PDF Factur-X
|
|
1792
|
+
2. Validation (optionnelle)
|
|
1793
|
+
3. Signature (optionnelle, utilise le certificat côté serveur)
|
|
1794
|
+
4. Soumission à la PDP AFNOR (optionnelle)
|
|
1795
|
+
|
|
1796
|
+
Note: La signature utilise le certificat configuré dans Django Admin
|
|
1797
|
+
pour le client identifié par le client_uid du JWT.
|
|
1798
|
+
|
|
1799
|
+
Args:
|
|
1800
|
+
facture: Données de la facture (format FactureFacturX)
|
|
1801
|
+
pdf_source_path: Chemin vers le PDF source
|
|
1802
|
+
pdf_source_bytes: PDF source en bytes
|
|
1803
|
+
profil: Profil Factur-X (MINIMUM, BASIC, EN16931, EXTENDED)
|
|
1804
|
+
valider: Si True, valide le PDF généré
|
|
1805
|
+
signer: Si True, signe le PDF (certificat côté serveur)
|
|
1806
|
+
soumettre_afnor: Si True, soumet le PDF à la PDP AFNOR
|
|
1807
|
+
afnor_flow_name: Nom du flux AFNOR (défaut: "Facture {numero_facture}")
|
|
1808
|
+
afnor_tracking_id: Tracking ID AFNOR (défaut: numero_facture)
|
|
1809
|
+
output_path: Chemin de sortie pour le PDF final
|
|
1810
|
+
timeout: Timeout en ms pour le polling
|
|
1811
|
+
|
|
1812
|
+
Returns:
|
|
1813
|
+
Dict avec:
|
|
1814
|
+
- pdf_bytes: bytes du PDF final
|
|
1815
|
+
- pdf_path: chemin si output_path fourni
|
|
1816
|
+
- validation: résultat de validation si valider=True
|
|
1817
|
+
- signature: infos signature si signer=True
|
|
1818
|
+
- afnor: résultat soumission AFNOR si soumettre_afnor=True
|
|
1819
|
+
|
|
1820
|
+
Example:
|
|
1821
|
+
>>> result = client.generer_facturx_complet(
|
|
1822
|
+
... facture=ma_facture,
|
|
1823
|
+
... pdf_source_path="devis.pdf",
|
|
1824
|
+
... profil="EN16931",
|
|
1825
|
+
... valider=True,
|
|
1826
|
+
... signer=True,
|
|
1827
|
+
... soumettre_afnor=True,
|
|
1828
|
+
... output_path="facture_finale.pdf"
|
|
1829
|
+
... )
|
|
1830
|
+
>>> if result['validation']['valide']:
|
|
1831
|
+
... print(f"Facture soumise! Flow ID: {result['afnor']['flowId']}")
|
|
1832
|
+
"""
|
|
1833
|
+
result: Dict[str, Any] = {}
|
|
1834
|
+
|
|
1835
|
+
# 1. Génération
|
|
1836
|
+
if pdf_source_path:
|
|
1837
|
+
with open(pdf_source_path, "rb") as f:
|
|
1838
|
+
pdf_source_bytes = f.read()
|
|
1839
|
+
|
|
1840
|
+
pdf_bytes = self.generer_facturx(
|
|
1841
|
+
facture_data=facture,
|
|
1842
|
+
pdf_source=pdf_source_bytes,
|
|
1843
|
+
profil=profil,
|
|
1844
|
+
timeout=timeout
|
|
1845
|
+
)
|
|
1846
|
+
result["pdf_bytes"] = pdf_bytes
|
|
1847
|
+
|
|
1848
|
+
# 2. Validation
|
|
1849
|
+
if valider:
|
|
1850
|
+
validation = self.valider_pdf_facturx(pdf_bytes=pdf_bytes, profil=profil)
|
|
1851
|
+
result["validation"] = validation
|
|
1852
|
+
if not validation.get("est_conforme", False):
|
|
1853
|
+
# Retourne quand même le résultat mais avec les erreurs
|
|
1854
|
+
if output_path:
|
|
1855
|
+
with open(output_path, "wb") as f:
|
|
1856
|
+
f.write(pdf_bytes)
|
|
1857
|
+
result["pdf_path"] = output_path
|
|
1858
|
+
return result
|
|
1859
|
+
|
|
1860
|
+
# 3. Signature (utilise le certificat côté serveur)
|
|
1861
|
+
if signer:
|
|
1862
|
+
pdf_bytes = self.signer_pdf(pdf_bytes=pdf_bytes)
|
|
1863
|
+
result["pdf_bytes"] = pdf_bytes
|
|
1864
|
+
result["signature"] = {"signe": True}
|
|
1865
|
+
|
|
1866
|
+
# 4. Soumission AFNOR
|
|
1867
|
+
if soumettre_afnor:
|
|
1868
|
+
numero_facture = facture.get("numeroFacture", facture.get("numero_facture", "FACTURE"))
|
|
1869
|
+
flow_name = afnor_flow_name or f"Facture {numero_facture}"
|
|
1870
|
+
tracking_id = afnor_tracking_id or numero_facture
|
|
1871
|
+
|
|
1872
|
+
# Soumission directe avec bytes (plus de fichier temporaire nécessaire)
|
|
1873
|
+
afnor_result = self.soumettre_facture_afnor(
|
|
1874
|
+
flow_name=flow_name,
|
|
1875
|
+
pdf_bytes=pdf_bytes,
|
|
1876
|
+
pdf_filename=f"{numero_facture}.pdf",
|
|
1877
|
+
tracking_id=tracking_id,
|
|
1878
|
+
)
|
|
1879
|
+
result["afnor"] = afnor_result
|
|
1880
|
+
|
|
1881
|
+
# Sauvegarde finale
|
|
1882
|
+
if output_path:
|
|
1883
|
+
with open(output_path, "wb") as f:
|
|
1884
|
+
f.write(pdf_bytes)
|
|
1885
|
+
result["pdf_path"] = output_path
|
|
1886
|
+
|
|
1887
|
+
return result
|