cpro-client 0.1.0 → 0.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a797bddcc8e42f168e7c6ac7e18f6a02de3f45b6dbe6bc03f4eb7f11a3854800
4
- data.tar.gz: 25effc0a7fd95d27528d48c20d1289e236717ad7efbe3318d9a3284033ecf720
3
+ metadata.gz: 2bf1dbeeee478ffe838e25c8a2f7e08322fdd537a95fbe65aade589a212a9ce9
4
+ data.tar.gz: 60059eb5e04f2bc331bd8e17d17e891a24e4dfafa66c0fcfd3c4400f7f6d25cf
5
5
  SHA512:
6
- metadata.gz: '095e98ac8c3752f4a117aab59f74bb77a15cd506bd5621637185e857261c8471d53850cbf5d42e2fcf72f054435ced5d060a26c659942224d80d3561813ea189'
7
- data.tar.gz: e74b20cba8005d0d7b14b178d8e8e80e48b8f96a84f6c5cd859f7465abdaa1d1ed58d787c659a378b0896a592de9a61ee645266128d1241922a3665211992df2
6
+ metadata.gz: a420acaad23efa1b36a692ea7e29c74ea3039b3fab7b25e97c033e39b1e0e91d2eae2905fdf98dadf84bc61fd5d47e42e3ec5335c7c36246f1b7ee7311d77f05
7
+ data.tar.gz: 7e8ab7879a739be73e5b27cedbd8d57a47486c96ae7c8bb99e3abfad37743e8200115e6c5b561f5b851e42ae107b6bc06da44c09fe5a101705ab9d49d070110b
data/.gitignore CHANGED
@@ -8,3 +8,4 @@
8
8
  /tmp/
9
9
  /.idea/
10
10
  /local/
11
+ *.gem
data/.rubocop.yml CHANGED
@@ -17,6 +17,11 @@ Style/Documentation:
17
17
  Layout/LineLength:
18
18
  Max: 120
19
19
 
20
+ # Poser rubygems_mfa_required exigerait d'activer la MFA sur le compte RubyGems qui publie ;
21
+ # ce n'est pas le mode de publication retenu ici.
22
+ Gemspec/RequireMFA:
23
+ Enabled: false
24
+
20
25
  # Le fichier doit porter le nom de la gem pour que `require "cpro-client"` fonctionne.
21
26
  Naming/FileName:
22
27
  Exclude:
data/CHANGELOG.md CHANGED
@@ -1,7 +1,33 @@
1
1
  ## [Unreleased]
2
2
 
3
+
4
+ ## [0.2.0] - 2026-08-27
5
+
6
+ ### Corrigé
7
+ - Les filtres `siret` et `siren` des recherches d'annuaire partaient avec l'opérateur `contient`,
8
+ déclaré par le swagger mais refusé par la plateforme : tout appel les portant échouait en
9
+ `400 Failed to read HTTP message`. Ils utilisent désormais `strict`. `search_siret` et
10
+ `search_siren` étaient inutilisables sur ces critères en 0.1.0.
11
+
12
+ ### Ajouté
13
+ - Recherche de codes routage : `Cpro::Resources::Annuaire#search_code_routage` et l'entité
14
+ `Cpro::Entities::CodeRoutage`. Seul accès au libellé lisible d'un code routage
15
+ (`libelleCodeRoutage`) et à son `gestionEngagementJuridique` — ni la consultation d'un
16
+ établissement ni celle d'un code routage ne les servent.
17
+ - Tests sandbox couvrant les chemins de recherche de l'Annuaire, et garde-fou sautant un test
18
+ plutôt que de le faire échouer lorsque sa cassette est absente.
19
+
20
+ ### Documentation
21
+ - README : la consultation et la recherche de l'Annuaire retournent des champs disjoints, ce qui
22
+ n'est pas une limite du jeu de données sandbox mais de la consultation. Adressage des structures
23
+ privées à la maille SIREN, et divergence d'opérateur avec le swagger.
24
+
25
+
26
+ ## [0.1.0] - 2026-08-27
27
+
28
+ - Initial release
3
29
  - Authentification OAuth2 PISTE (`client_credentials`) avec cache du jeton, renouvellement anticipé
4
- et rejeu unique d'un appel refusé en 401.
30
+ et rejeu unique d'un appel refusé en 401.
5
31
  - Compte technique `cpro-account` scopé par appel via `Cpro::Client#with_account`.
6
32
  - API Annuaire G2B : consultation par SIRET et par SIREN, recherche multi-critères, lignes
7
33
  d'annuaire paginées.
@@ -23,7 +49,3 @@
23
49
  enregistrement contre la vraie sandbox PISTE (`CPRO_RECORD=1`).
24
50
  - Hiérarchie d'erreurs `Cpro::Error` exposant statut HTTP, corps de réponse et identifiant de
25
51
  corrélation PISTE.
26
-
27
- ## [0.1.0] - 2026-08-14
28
-
29
- - Initial release
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cpro-client (0.1.0)
4
+ cpro-client (0.2.0)
5
5
  faraday (~> 2.0)
6
6
 
7
7
  GEM
@@ -25,6 +25,8 @@ GEM
25
25
  language_server-protocol (3.17.0.6)
26
26
  lint_roller (1.1.0)
27
27
  minitest (5.26.1)
28
+ nokogiri (1.15.7-arm64-darwin)
29
+ racc (~> 1.4)
28
30
  nokogiri (1.15.7-x86_64-darwin)
29
31
  racc (~> 1.4)
30
32
  parallel (1.28.0)
@@ -64,6 +66,7 @@ GEM
64
66
  hashdiff (>= 0.4.0, < 2.0.0)
65
67
 
66
68
  PLATFORMS
69
+ arm64-darwin-25
67
70
  x86_64-darwin-21
68
71
 
69
72
  DEPENDENCIES
data/README.md CHANGED
@@ -52,11 +52,26 @@ Le compte technique doit être rattaché à une structure publique, faute de quo
52
52
 
53
53
  ## Annuaire
54
54
 
55
+ **Consultation et recherche ne retournent pas les mêmes champs, et ne se remplacent pas.** C'est
56
+ le point le moins intuitif de cette API :
57
+
58
+ | | consultation (`find_by_…`) | recherche (`search_…`) |
59
+ | --- | :-: | :-: |
60
+ | identité — dénomination, adresse, état administratif | ✗ | ✓ |
61
+ | `donneesB2gComplementaires` — engagement juridique, gestion du code service… | ✗ | ✓ |
62
+ | `lignesAnnuaire` — les identifiants d'adressage | ✓ | ✗ |
63
+
64
+ Il faut donc les deux appels pour avoir toute l'information d'une structure. La gem ne les
65
+ enchaîne pas à votre place : c'est un état de la plateforme, pas une règle métier, et il changera.
66
+
67
+ ### Consultation
68
+
69
+ Elle sert les lignes d'annuaire, c'est-à-dire les identifiants nécessaires à l'adressage d'une
70
+ facture :
71
+
55
72
  ```ruby
56
73
  etablissement = client.annuaire.find_by_siret("70204275500240")
57
- etablissement.denomination
58
- etablissement.publique?
59
- etablissement.gestion_engagement_juridique? # numéro d'engagement obligatoire ?
74
+ etablissement.lignes_annuaire.map(&:identifiant_adressage)
60
75
  etablissement.lignes_annuaire.map(&:identifiant_routage)
61
76
 
62
77
  unite = client.annuaire.find_by_siren("702042755")
@@ -70,7 +85,13 @@ lignes.nombre_total
70
85
  lignes.suite? # reste-t-il des lignes au-delà de cette page ?
71
86
  ```
72
87
 
73
- Recherche multi-critères l'opérateur de comparaison (`contient` ou `strict`) est déduit du champ :
88
+ Une structure sans ligne d'annuaire à la maille SIRET répond **404** en consultation par SIRET,
89
+ tout en étant présente dans l'annuaire : c'est le cas des structures privées, dont l'adressage se
90
+ fait à la maille SIREN. Cherchez-les alors par `search_siret`, ou consultez leur SIREN.
91
+
92
+ ### Recherche
93
+
94
+ Elle sert l'identité et les données B2G :
74
95
 
75
96
  ```ruby
76
97
  resultat = client.annuaire.search_siret(
@@ -79,10 +100,37 @@ resultat = client.annuaire.search_siret(
79
100
  limite: 20
80
101
  )
81
102
 
82
- resultat.nombre_total
83
- resultat.map(&:siret)
103
+ resultat.nombre_total # nil si l'API ne le renseigne pas — ce n'est pas zéro
104
+ resultat.first.denomination
105
+ resultat.first.gestion_engagement_juridique?
106
+ ```
107
+
108
+ L'opérateur de comparaison est déduit du champ. Les libellés se cherchent en `contient`, **les
109
+ identifiants en `strict`** : le swagger déclare `contient` sur `siret` et `siren`, mais le serveur
110
+ refuse cette forme par un `400 Failed to read HTTP message`. Un filtre inconnu lève une
111
+ `ArgumentError` avant tout appel réseau.
112
+
113
+ ```ruby
114
+ client.annuaire.search_siret(filtres: { siret: "70204275500240" }) # strict, implicitement
115
+ ```
116
+
117
+ ### Codes routage
118
+
119
+ Un code routage est le troisième niveau de l'annuaire — le service, sous l'établissement. Sa
120
+ recherche est le **seul** accès à son libellé lisible et à son engagement juridique : ni la
121
+ consultation de l'établissement ni celle du code routage lui-même ne les servent.
122
+
123
+ ```ruby
124
+ codes = client.annuaire.search_code_routage(filtres: { siret: "71915767420316" })
125
+
126
+ codes.map(&:to_s) # => ["FACTURES_PUBLIQUES — Service des factures publiques", …]
127
+ codes.first.libelle # "Service des factures publiques"
128
+ codes.first.gestion_engagement_juridique? # numéro d'engagement obligatoire pour ce service ?
129
+ codes.first.actif?
84
130
  ```
85
131
 
132
+ Filtrez sur `siret`, faute de quoi la réponse balaie tout l'annuaire.
133
+
86
134
  ## Transmission d'une facture Factur-X
87
135
 
88
136
  ```ruby
@@ -289,22 +337,50 @@ Chaîne complète validée le 14/08/2026 : dépôt d'un Factur-X accepté (`uidF
289
337
  - La **recevabilité porte sur l'enveloppe**, pas sur le contenu métier : un flux dont le vendeur est
290
338
  inconnu du destinataire ressort quand même `RECEVABLE`. Le sort de la facture elle-même se lit via
291
339
  l'API Recherche Factures G2B, à partir du `nomFlux`.
292
-
293
- ### Limite actuelle de l'Annuaire
294
-
295
- En sandbox, `find_by_siret` et `find_by_siren` ne renvoient que le bloc `lignesAnnuaire` : `siret`,
340
+ - Les filtres `siret` et `siren` des recherches d'annuaire n'acceptent que l'opérateur `strict`,
341
+ alors que le swagger déclare `contient` : cette forme est refusée par un
342
+ `400 Failed to read HTTP message`, une erreur de désérialisation levée avant tout traitement.
343
+ - La **recherche d'annuaire est plus riche que la consultation**, et réciproquement : identité et
344
+ données B2G d'un côté, lignes d'annuaire de l'autre, sans recouvrement.
345
+ - Le libellé d'un code routage et son `gestionEngagementJuridique` ne sont servis que par
346
+ `search_code_routage`. La consultation unitaire d'un code routage retourne moins que
347
+ `find_by_siret`, ce qui est la raison pour laquelle la gem ne l'expose pas.
348
+
349
+ ### Ce que la consultation ne dit pas
350
+
351
+ `find_by_siret` et `find_by_siren` ne renvoient que le bloc `lignesAnnuaire` : `siret`,
296
352
  `denomination`, `adresse`, `uniteLegale` et `donneesB2gComplementaires` ressortent à `nil`, y
297
- compris pour la structure du compte appelant et même en précisant `champs`. Le mapping suit le
298
- swagger et se remplira si l'implémentation se complète ; en attendant, l'information exploitable est
299
- `lignes_annuaire`, qui porte les `identifiantAdressage` nécessaires à l'adressage de la facture.
353
+ compris pour la structure du compte appelant et même en précisant `champs`.
354
+
355
+ Ce n'est **pas** une limite du jeu de données : la recherche, elle, sert ces champs — mesuré sur la
356
+ même structure, au même instant. C'est la consultation qui est incomplète. Passez donc par
357
+ `search_siret` pour l'identité, et par la consultation pour les identifiants d'adressage :
300
358
 
301
359
  ```ruby
302
360
  client.annuaire.find_by_siret("71915767420316").lignes_annuaire.map(&:identifiant_adressage)
303
361
  # => ["719157674_71915767420316",
304
362
  # "719157674_71915767420316_FACTURES_PUBLIQUES",
305
363
  # "719157674_71915767420316_SERVICE_PUBLIQUE_1_71915767420316"]
364
+
365
+ client.annuaire.search_siret(filtres: { siret: "71915767420316" }).first.denomination
366
+ # => "Destinataire 71915767420316"
306
367
  ```
307
368
 
369
+ ### Adressage des structures privées
370
+
371
+ En G2B, le destinataire est une entreprise privée. Sur le matelas de qualification, ces structures
372
+ n'ont **aucune ligne d'annuaire à la maille SIRET** : leur seul identifiant d'adressage est le SIREN
373
+ nu, et leur `statutPlateforme` vaut « Pas de plateforme ». Leur SIRET reste consultable par
374
+ recherche, mais pas par `find_by_siret`, qui répond 404.
375
+
376
+ ```ruby
377
+ client.annuaire.find_by_siren("474775418").lignes_annuaire.map(&:identifiant_adressage)
378
+ # => ["474775418"]
379
+ ```
380
+
381
+ C'est cet identifiant qui doit alimenter BT-49 sur la facture et MDT-73 sur le cycle de vie.
382
+ Vérifiez `plateforme_active?` avant de compter sur un acheminement.
383
+
308
384
  ## Licence
309
385
 
310
386
  Disponible en open source sous les termes de la [licence MIT](https://opensource.org/licenses/MIT).
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ module Entities
5
+ class CodeRoutage < Base
6
+ ETAT_ACTIF = "A"
7
+
8
+ def identifiant_routage
9
+ payload["identifiantRoutage"]
10
+ end
11
+
12
+ def libelle
13
+ payload["libelleCodeRoutage"]
14
+ end
15
+
16
+ def siret
17
+ payload["siret"]
18
+ end
19
+
20
+ def type_identifiant_routage
21
+ payload["typeIdentifiantRoutage"]
22
+ end
23
+
24
+ def etat_administratif
25
+ payload["etatAdministratif"]
26
+ end
27
+
28
+ def actif?
29
+ etat_administratif == ETAT_ACTIF
30
+ end
31
+
32
+ # La recherche de codes routage est le seul accès à cette information : ni la consultation
33
+ # d'un établissement ni celle d'un code routage ne la servent.
34
+ def gestion_engagement_juridique?
35
+ payload["gestionEngagementJuridique"]
36
+ end
37
+
38
+ def adresse
39
+ payload["adresse"] || {}
40
+ end
41
+
42
+ def code_postal
43
+ adresse["codePostal"]
44
+ end
45
+
46
+ def localite
47
+ adresse["localite"]
48
+ end
49
+
50
+ def etablissement
51
+ return nil unless payload["etablissement"]
52
+
53
+ @etablissement ||= Etablissement.new(payload["etablissement"])
54
+ end
55
+
56
+ def unite_legale
57
+ return nil unless payload["uniteLegale"]
58
+
59
+ @unite_legale ||= UniteLegale.new(payload["uniteLegale"])
60
+ end
61
+
62
+ def to_s
63
+ [identifiant_routage, libelle].compact.join(" — ")
64
+ end
65
+ end
66
+ end
67
+ end
@@ -8,11 +8,17 @@ module Cpro
8
8
  SIRET_LENGTH = 14
9
9
  SIREN_LENGTH = 9
10
10
 
11
- # Chaque champ filtrable n'admet qu'un seul opérateur dans le swagger : l'appelant fournit
12
- # `siret: "702042755"` et l'opérateur attendu est déduit d'ici.
11
+ # Chaque champ filtrable n'admet qu'un seul opérateur : l'appelant fournit
12
+ # `siret: "70204275500240"` et l'opérateur attendu est déduit d'ici.
13
+ #
14
+ # `siret` et `siren` divergent du swagger, qui les déclare en `contient`. Le serveur refuse
15
+ # cette forme par un 400 « Failed to read HTTP message » — une erreur de désérialisation,
16
+ # levée avant tout traitement — et n'accepte que `strict`, sur les trois recherches de
17
+ # l'Annuaire. Vérifié en sandbox le 27/08/2026 ; ce sont des identifiants, une recherche
18
+ # partielle n'y a de toute façon pas de sens.
13
19
  OPERATEURS_SIRET = {
14
- "siret" => "contient",
15
- "siren" => "contient",
20
+ "siret" => "strict",
21
+ "siren" => "strict",
16
22
  "denomination" => "contient",
17
23
  "localite" => "contient",
18
24
  "codePostal" => "contient",
@@ -23,12 +29,22 @@ module Cpro
23
29
  }.freeze
24
30
 
25
31
  OPERATEURS_SIREN = {
26
- "siren" => "contient",
32
+ "siren" => "strict",
27
33
  "raisonSociale" => "contient",
28
34
  "typeEntite" => "strict",
29
35
  "etatAdministratif" => "strict"
30
36
  }.freeze
31
37
 
38
+ OPERATEURS_CODE_ROUTAGE = {
39
+ "siret" => "strict",
40
+ "identifiantRoutage" => "contient",
41
+ "libelleCodeRoutage" => "contient",
42
+ "localite" => "contient",
43
+ "codePostal" => "contient",
44
+ "lignesAdresse" => "contient",
45
+ "etatAdministratif" => "strict"
46
+ }.freeze
47
+
32
48
  def find_by_siret(siret, **options)
33
49
  path = "siret/code-insee:#{normalize(siret, SIRET_LENGTH, "SIRET")}"
34
50
  Entities::Etablissement.new(get(path, query(options)))
@@ -52,6 +68,15 @@ module Cpro
52
68
  Entities::ResultatRecherche.new(payload, item_class: Entities::UniteLegale, total_key: TOTAL_KEY)
53
69
  end
54
70
 
71
+ # Seul accès au libellé d'un code routage et à son `gestionEngagementJuridique` : la
72
+ # consultation unitaire `GET /code-routage/siret:…/code:…` ne retourne que ses lignes
73
+ # d'annuaire, soit moins que `find_by_siret`. Filtrer sur `siret` pour se limiter à une
74
+ # structure, faute de quoi la réponse balaie tout l'annuaire.
75
+ def search_code_routage(filtres: {}, **options)
76
+ payload = post("code-routage/recherche", search_body(OPERATEURS_CODE_ROUTAGE, filtres, options))
77
+ Entities::ResultatRecherche.new(payload, item_class: Entities::CodeRoutage, total_key: TOTAL_KEY)
78
+ end
79
+
55
80
  def healthcheck
56
81
  get("healthcheck")
57
82
  end
data/lib/cpro/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cpro
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/cpro.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "cpro/entities/ligne_annuaire"
16
16
  require_relative "cpro/entities/lignes_annuaire"
17
17
  require_relative "cpro/entities/unite_legale"
18
18
  require_relative "cpro/entities/etablissement"
19
+ require_relative "cpro/entities/code_routage"
19
20
  require_relative "cpro/entities/note_statut"
20
21
  require_relative "cpro/entities/motif_rejet"
21
22
  require_relative "cpro/entities/changement_statut"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cpro-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hôtentic
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-27 00:00:00.000000000 Z
11
+ date: 2026-08-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -57,6 +57,7 @@ files:
57
57
  - lib/cpro/connection.rb
58
58
  - lib/cpro/entities/base.rb
59
59
  - lib/cpro/entities/changement_statut.rb
60
+ - lib/cpro/entities/code_routage.rb
60
61
  - lib/cpro/entities/depot_flux.rb
61
62
  - lib/cpro/entities/etablissement.rb
62
63
  - lib/cpro/entities/facture.rb