cpro-client 0.2.5 → 0.2.7

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: fcf97fbee94114c1241fb76a3b3ec620dd7d8c2e40435bebfe146ce7b65a3e92
4
- data.tar.gz: 0b3e4f80fe36110c60a46d7adc951cb0bd662093a30e39a0f19fb9e4c252096d
3
+ metadata.gz: 56e12349c80b29ebc7190354cefea789a6a9114d42260bc40e2851529255a043
4
+ data.tar.gz: 35ec2d79aace35593b9493dd6e07b3ede842726ba9b2cc9b63491ca269844272
5
5
  SHA512:
6
- metadata.gz: 4f010893e86d5f3dec2742aca27ea56606f6facb294c23cc613f91073d4ebf3b3f56dc1fdea650121b76a706680a65bd4921c875230a0e00beb428d4306f7876
7
- data.tar.gz: 65724ca9625aa0328e6d3b14f10005f087614e589587f73e706b17d515b1091f3633f960bf9036a8031e30abdab360814856d076de551eaa0f5a860759b3a0a9
6
+ metadata.gz: e3372fac6b533cb8b24b08dbae14c2dce8f495a93d4b53161d553b874ab208bee3099f53efa0af7159e4690cd5aea8414c9da2fef33a38c8cd875796aff99d60
7
+ data.tar.gz: fae64931447cd4708e173bca2790890bad33ed65bbc370ad49dc40e60a1f12185af579d947eec2a766fbb79f1dd5da07f77e1c99d18a54678532866b664ae206
data/CHANGELOG.md CHANGED
@@ -1,6 +1,40 @@
1
1
  ## [Unreleased]
2
2
 
3
3
 
4
+ ## [0.2.7] - 2026-09-07
5
+
6
+ ### Ajouté
7
+ - `Cpro::Suivi#orientation` nomme la situation d'un dépôt en une valeur sur laquelle brancher —
8
+ `:en_attente`, `:enveloppe_irrecevable`, `:facture_introuvable`, `:en_cours`, `:rejetee`,
9
+ `:non_transmise`, `:erreur_routage`, `:transmise` — en aplatissant l'étape du suivi et l'état de
10
+ la facture. Ne repose que sur des codes : statut, code motif, étape. Un statut d'acheminement
11
+ l'emporte sur un `NON_TRANSMISE` devenu caduc, et c'est l'incident courant qui décide.
12
+
13
+ Deux distinctions restent à l'appelant : séparer les deux `NON_TRANSMISE` supposerait de lire le
14
+ texte d'une note rédigée par l'AIFE, et séparer un rejet corrigeable d'un rejet définitif repose
15
+ sur une lecture de la spécification que nous n'avons pas mesurée.
16
+ - `CHORUS_PRO.md` — les séquences de statuts et de motifs renvoyées par la plateforme, scénario par
17
+ scénario, en distinguant les relevés sandbox des comportements seulement documentés.
18
+
19
+
20
+ ## [0.2.6] - 2026-09-05
21
+
22
+ ### Modifié
23
+ - `Cpro::Resources::Annuaire#find_identifiant_actif` devient **`#find_identifiant_adressage`**, et
24
+ ne filtre plus sur le statut de plateforme : il le fait départager. À proximité égale une adresse
25
+ active l'emporte, mais faute d'adresse active la plus proche est rendue quand même.
26
+
27
+ L'ancien comportement interdisait de facturer un destinataire non raccordé, alors que le dossier
28
+ de spécifications externes pose l'inverse : une telle facture est *déposée*, avec le motif
29
+ `NON_TRANSMISE`, et ce statut « permet à l'entité publique de justifier qu'elle a bien émis une
30
+ facture électronique et qu'elle a donc respecté ses obligations réglementaires ». L'émetteur doit
31
+ remettre un duplicata à son client, mais l'obligation réglementaire est remplie — la gem n'a pas
32
+ à s'y opposer.
33
+
34
+ L'appelant lit `plateforme_active?` sur la ligne rendue pour distinguer les deux cas, et `nil` ne
35
+ signifie plus qu'une chose : aucune adresse à aucun niveau.
36
+
37
+
4
38
  ## [0.2.5] - 2026-09-05
5
39
 
6
40
  ### Ajouté
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cpro-client (0.2.5)
4
+ cpro-client (0.2.7)
5
5
  faraday (>= 0.9, < 3.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -142,27 +142,40 @@ BT-49, l'adresse électronique de l'acheteur, ne se transmet pas à l'API : elle
142
142
  Factur-X, et c'est d'elle que le PPF déduit l'acheminement. Une facture qui la porte mal est
143
143
  rejetée `REJ_ADR` — « le destinataire n'est pas trouvable » — plusieurs minutes après le dépôt.
144
144
 
145
- `find_identifiant_actif` fait la résolution en un appel. Il accepte les quatre formes d'une valeur
146
- d'adressage — SIREN, SIRET, SIRET_SERVICE, SIREN_SIRET_SERVICE — et remonte la hiérarchie
147
- SIREN → SIRET → service jusqu'à trouver une ligne dont la plateforme est active :
145
+ `find_identifiant_adressage` fait la résolution en un appel. Il accepte les quatre formes d'une
146
+ valeur d'adressage — SIREN, SIRET, SIRET_SERVICE, SIREN_SIRET_SERVICE — et remonte la hiérarchie
147
+ SIREN → SIRET → service pour rendre l'adresse la plus proche que l'annuaire connaisse :
148
148
 
149
149
  ```ruby
150
- ligne = client.annuaire.find_identifiant_actif("71915767420316")
150
+ ligne = client.annuaire.find_identifiant_adressage("71915767420316")
151
151
  ligne.to_s # => "719157674_71915767420316", à porter en BT-49
152
- ligne.identifiant_routage # => "" l'adresse de base de l'établissement
152
+ ligne.plateforme_active? # => la facture sera-t-elle transmise ?
153
153
 
154
- client.annuaire.find_identifiant_actif("71915767420316_SERVICE_INEXISTANT").to_s
154
+ client.annuaire.find_identifiant_adressage("71915767420316_SERVICE_INEXISTANT").to_s
155
155
  # => "719157674_71915767420316" — service inconnu, on retombe sur son parent
156
156
  ```
157
157
 
158
- `nil` signifie qu'aucun niveau n'est adressable, donc qu'on ne peut pas facturer. Pour savoir
159
- *pourquoi*, `find_lignes_adressage` rend les mêmes lignes sans filtrer : une unité légale inconnue
160
- lève une `Cpro::NotFoundError`, tandis qu'une unité connue mais non raccordée rend ses lignes avec
161
- `plateforme_active?` à faux.
158
+ **Le statut de plateforme départage, il n'exclut pas.** À égalité de proximité, une adresse dont la
159
+ plateforme est active l'emporte ; mais si aucune ne l'est, l'adresse est rendue quand même. Elle
160
+ reste la bonne : une facture destinée à un non-raccordé est bien *déposée*, avec le motif
161
+ `NON_TRANSMISE`, et ce statut « permet à l'entité publique de justifier qu'elle a bien émis une
162
+ facture électronique et qu'elle a donc respecté ses obligations réglementaires ». L'émetteur devra
163
+ remettre un duplicata à son client, mais l'obligation est remplie.
162
164
 
163
- Deux limites à connaître. La résolution ne redescend jamais vers les enfants : un SIREN nu ne
164
- remonte rien si la structure n'a d'adresses qu'au niveau SIRET. Et une adresse active n'est pas une
165
- promesse d'acheminement elle garantit que le destinataire est identifiable, pas qu'il recevra.
165
+ L'appelant lit donc `plateforme_active?` sur la ligne rendue pour savoir dans quel cas il est :
166
+
167
+ | Résultat | Ce qui se passera |
168
+ | --- | --- |
169
+ | ligne, `plateforme_active?` vrai | la facture est transmise au destinataire |
170
+ | ligne, `plateforme_active?` faux | déposée et conforme, mais non transmise — duplicata à prévoir |
171
+ | `nil` | aucune adresse à aucun niveau : on ne peut pas facturer |
172
+
173
+ Pour distinguer « unité légale inconnue » de « connue mais sans adresse exploitable »,
174
+ `find_lignes_adressage` rend les mêmes lignes sans les départager et laisse remonter la
175
+ `Cpro::NotFoundError`.
176
+
177
+ Une limite à connaître : la résolution ne redescend jamais vers les enfants. Un SIREN nu ne remonte
178
+ rien si la structure n'a d'adresses qu'au niveau SIRET.
166
179
 
167
180
  ## Transmission d'une facture Factur-X
168
181
 
@@ -270,6 +283,24 @@ suivi.facture # la facture et son historique, à l'étape :facture_connue
270
283
  suivi.statut_flux # le statut brut du flux, dès qu'il est connu
271
284
  ```
272
285
 
286
+ Pour brancher directement sans reconstruire le même `if` dans chaque application,
287
+ `orientation` aplatit l'étape et l'état de la facture en une valeur :
288
+
289
+ ```ruby
290
+ case suivi.orientation
291
+ when :en_attente, :en_cours, :facture_introuvable then reprogrammer_le_suivi
292
+ when :enveloppe_irrecevable, :rejetee then signaler(suivi.motifs)
293
+ when :non_transmise then preparer_un_duplicata
294
+ when :erreur_routage then alerter
295
+ when :transmise then classer
296
+ end
297
+ ```
298
+
299
+ Elle ne repose que sur des codes — statut, code motif, étape — jamais sur le texte d'une note.
300
+ Deux distinctions restent donc à l'appelant, et [CHORUS_PRO.md](CHORUS_PRO.md) explique comment les
301
+ obtenir : séparer les deux causes de `:non_transmise`, et distinguer un rejet corrigeable d'un rejet
302
+ définitif.
303
+
273
304
  `motifs` cumule tout l'historique — un incident réglé y côtoie l'incident courant. Pour n'avoir que
274
305
  le plus récent, avec le statut qui le portait et sa date :
275
306
 
@@ -456,6 +487,12 @@ client.annuaire.find_by_siren("474775418").lignes_annuaire.map(&:identifiant_adr
456
487
  C'est cet identifiant qui doit alimenter BT-49 sur la facture et MDT-73 sur le cycle de vie.
457
488
  Vérifiez `plateforme_active?` avant de compter sur un acheminement.
458
489
 
490
+ ## Comportements de la plateforme
491
+
492
+ [CHORUS_PRO.md](CHORUS_PRO.md) rassemble, scénario par scénario, la séquence de statuts et de motifs
493
+ que renvoie réellement la plateforme — enveloppe irrecevable, adressage introuvable, destinataire
494
+ non raccordé, doublon — en distinguant ce qui a été mesuré en sandbox de ce qui n'est que documenté.
495
+
459
496
  ## Licence
460
497
 
461
498
  Disponible en open source sous les termes de la [licence MIT](https://opensource.org/licenses/MIT).
@@ -77,23 +77,25 @@ module Cpro
77
77
  Entities::ResultatRecherche.new(payload, item_class: Entities::CodeRoutage, total_key: TOTAL_KEY)
78
78
  end
79
79
 
80
- # Adresse active la plus proche d'une valeur d'adressage : la valeur elle-même si elle porte
81
- # une plateforme active, sinon son parent le plus proche, et ainsi de suite jusqu'au SIREN.
82
- # Rend la ligne d'annuaire, dont `to_s` donne l'identifiant à porter en BT-49.
80
+ # Adresse à porter en BT-49 pour une valeur d'adressage : la plus proche que l'annuaire
81
+ # connaisse, en privilégiant celles dont la plateforme est active. Rend la ligne d'annuaire,
82
+ # dont `to_s` donne l'identifiant.
83
83
  #
84
- # `nil` a un sens unique aucun niveau n'est adressable, donc on ne peut pas facturer. Pour
85
- # distinguer « SIREN inconnu » de « connu mais sans plateforme active », voir
86
- # `#find_lignes_adressage`, qui ne filtre pas et laisse remonter le 404.
84
+ # Le statut de plateforme départage, il n'exclut pas. Une adresse sans plateforme reste
85
+ # utilisable : le dossier de spécifications externes pose qu'une facture destinée à un
86
+ # destinataire non raccordé prend le statut « Déposée » avec le motif NON_TRANSMISE, et que
87
+ # ce statut « permet à l'entité publique de justifier qu'elle a bien émis une facture
88
+ # électronique et qu'elle a donc respecté ses obligations réglementaires ». L'émetteur devra
89
+ # remettre un duplicata à son client, mais la facture est valide et l'obligation remplie.
90
+ #
91
+ # L'appelant lit `plateforme_active?` sur la ligne rendue pour savoir dans lequel des deux
92
+ # cas il se trouve. `nil` ne signifie plus qu'une chose : aucune adresse, à aucun niveau.
87
93
  #
88
94
  # Un seul appel réseau : `GET /siren/code-insee:` retourne aussi les lignes SIRET et service.
89
- def find_identifiant_actif(valeur)
95
+ def find_identifiant_adressage(valeur)
90
96
  adressage = Adressage.parse(valeur)
91
- actives = find_lignes_adressage(adressage).select(&:plateforme_active?)
92
- adressage.paliers.each do |palier|
93
- ligne = actives.find { |active| palier.correspond?(active) }
94
- return ligne if ligne
95
- end
96
- nil
97
+ lignes = find_lignes_adressage(adressage)
98
+ plus_proche(adressage, lignes.select(&:plateforme_active?)) || plus_proche(adressage, lignes)
97
99
  rescue NotFoundError
98
100
  nil
99
101
  end
@@ -110,6 +112,16 @@ module Cpro
110
112
 
111
113
  private
112
114
 
115
+ # Descend les paliers du plus précis au plus général et rend la première ligne qui
116
+ # corresponde. Appelée deux fois : sur les adresses actives d'abord, sur toutes ensuite.
117
+ def plus_proche(adressage, lignes)
118
+ adressage.paliers.each do |palier|
119
+ ligne = lignes.find { |candidate| palier.correspond?(candidate) }
120
+ return ligne if ligne
121
+ end
122
+ nil
123
+ end
124
+
113
125
  def normalize(value, length, label)
114
126
  digits = value.to_s.gsub(/\s+/, "")
115
127
  return digits if digits.match?(/\A\d{#{length}}\z/)
data/lib/cpro/suivi.rb ADDED
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cpro
4
+ # Photographie du parcours d'un dépôt, à un instant donné. Elle enchaîne les appels que l'hôte
5
+ # devrait sinon écrire lui-même — statut du flux, recherche de la facture, consultation avec son
6
+ # historique — et s'arrête dès qu'un palier ne permet pas d'aller plus loin.
7
+ #
8
+ # Elle n'attend pas : la cadence d'interrogation appartient à l'appelant. Et elle n'avale rien,
9
+ # `statut_flux` et `facture` restant accessibles pour ce qu'elle n'interprète pas.
10
+ class Suivi
11
+ ETAPES = %i[flux_en_attente flux_irrecevable facture_introuvable facture_connue].freeze
12
+
13
+ ORIENTATIONS = %i[en_attente enveloppe_irrecevable facture_introuvable en_cours
14
+ rejetee non_transmise erreur_routage transmise].freeze
15
+
16
+ # Statuts qui prouvent que la facture a quitté Chorus Pro : d'après la colonne « Sens flux »
17
+ # de l'annexe des statuts, ils proviennent de la plateforme du destinataire ou constatent
18
+ # l'émission par la plateforme.
19
+ #
20
+ # `APPROUVEE`, `REFUSEE` et `VISEE` circulent dans les deux sens. Ils figurent ici parce que
21
+ # les recevoir est le cas courant, et que les omettre ferait passer une facture refusée par
22
+ # l'acheteur pour une facture encore en cours. Une application qui les émet elle-même le sait.
23
+ STATUTS_TRANSMISE = %w[
24
+ EMISE_PAR_LA_PLATEFORME RECUE_DE_LA_PLATEFORME MISE_A_DISPOSITION PRISE_EN_CHARGE
25
+ APPROUVEE APPROUVEE_PARTIELLEMENT EN_LITIGE SUSPENDUE PAIEMENT_TRANSMIS REFUSEE VISEE
26
+ ].freeze
27
+
28
+ MOTIF_NON_TRANSMISE = "NON_TRANSMISE"
29
+ STATUT_ERREUR_ROUTAGE = "ERREUR_ROUTAGE"
30
+
31
+ attr_reader :etape, :statut_flux, :facture
32
+
33
+ # Un 404 sur le flux et une recherche vide sont des états du parcours, pas des erreurs : ils
34
+ # deviennent des étapes. Tout le reste — 401, 403, 5xx — continue de lever.
35
+ def self.pour(client, depot)
36
+ statut = client.flux.status(depot)
37
+ return new(etape: :flux_en_attente) if statut.nil?
38
+ return new(etape: :flux_irrecevable, statut_flux: statut) unless statut.recevable?
39
+
40
+ facture = premiere_facture(client, statut)
41
+ return new(etape: :facture_introuvable, statut_flux: statut) if facture.nil?
42
+
43
+ new(etape: :facture_connue, statut_flux: statut, facture: facture)
44
+ end
45
+
46
+ # `find` seul renseigne l'historique, et c'est lui qui porte les motifs.
47
+ def self.premiere_facture(client, statut)
48
+ return nil if statut.nom_flux.nil? || statut.nom_flux.empty?
49
+
50
+ resultat = client.factures.search_by_nom_flux(statut).first
51
+ resultat && client.factures.find(resultat)
52
+ end
53
+ private_class_method :premiere_facture
54
+
55
+ def initialize(etape:, statut_flux: nil, facture: nil)
56
+ raise ArgumentError, "étape inconnue : #{etape.inspect}" unless ETAPES.include?(etape)
57
+
58
+ @etape = etape
59
+ @statut_flux = statut_flux
60
+ @facture = facture
61
+ end
62
+
63
+ def en_attente?
64
+ etape == :flux_en_attente
65
+ end
66
+
67
+ def irrecevable?
68
+ etape == :flux_irrecevable
69
+ end
70
+
71
+ # Ambigu par nature : l'API répond 204 aussi bien parce que la facture n'est pas encore
72
+ # indexée que parce que le compte technique n'est rattaché à aucune des deux structures.
73
+ def introuvable?
74
+ etape == :facture_introuvable
75
+ end
76
+
77
+ def connue?
78
+ etape == :facture_connue
79
+ end
80
+
81
+ # Vrai que l'incident soit survenu au niveau de l'enveloppe ou à celui de la facture.
82
+ def en_echec?
83
+ irrecevable? || facture&.en_echec? || false
84
+ end
85
+
86
+ # Les motifs vivent à deux endroits selon l'endroit où le dépôt s'est arrêté : l'appelant n'a
87
+ # pas à savoir lequel.
88
+ def motifs
89
+ return statut_flux.motifs_rejet if irrecevable?
90
+
91
+ facture ? facture.motifs_rejet : []
92
+ end
93
+
94
+ # Le dernier changement de statut porteur de motifs. Nil à l'étape `:flux_irrecevable` : il
95
+ # n'y a alors pas de facture, et les motifs sont portés par le flux — `motifs` reste l'accès
96
+ # uniforme, quelle que soit l'étape atteinte.
97
+ def dernier_incident
98
+ facture&.dernier_incident
99
+ end
100
+
101
+ # Nomme la situation en une valeur sur laquelle brancher, l'étape du suivi et l'état de la
102
+ # facture étant aplatis ensemble. Ne repose que sur des codes — statut, code motif, étape.
103
+ #
104
+ # Deux distinctions sont délibérément laissées à l'appelant, parce que les porter ici serait
105
+ # fragile :
106
+ #
107
+ # - séparer les deux `NON_TRANSMISE` — destinataire sans plateforme, qui ne recevra jamais, ou
108
+ # plateforme non encore raccordée, qui recevra plus tard — suppose de lire le texte d'une
109
+ # note rédigée par l'AIFE. L'annuaire le dit plus sûrement avant le dépôt, par
110
+ # `Annuaire#find_identifiant_adressage(siret).statut_plateforme` ;
111
+ # - séparer un rejet corrigeable d'un rejet définitif repose sur l'idée qu'un rejet de
112
+ # conformité n'intègre pas la facture et ne consomme donc pas son numéro. La spécification
113
+ # le laisse entendre, nous ne l'avons pas mesuré.
114
+ #
115
+ # `motifs` et `dernier_incident` restent disponibles pour qui veut trancher lui-même.
116
+ def orientation
117
+ case etape
118
+ when :flux_en_attente then :en_attente
119
+ when :flux_irrecevable then :enveloppe_irrecevable
120
+ when :facture_introuvable then :facture_introuvable
121
+ else orientation_facture
122
+ end
123
+ end
124
+
125
+ def nom_flux
126
+ statut_flux&.nom_flux
127
+ end
128
+
129
+ def to_s
130
+ [etape, facture&.statut, motifs.map(&:code).join(", ")].reject { |v| v.nil? || v.to_s.empty? }
131
+ .join(" — ")
132
+ end
133
+
134
+ private
135
+
136
+ # L'ordre compte : un statut d'acheminement l'emporte sur un NON_TRANSMISE devenu caduc, et
137
+ # c'est l'incident courant — non le cumul de l'historique — qui décrit la situation présente.
138
+ def orientation_facture
139
+ return :erreur_routage if facture.statut == STATUT_ERREUR_ROUTAGE
140
+ return :rejetee if facture.rejetee?
141
+ return :transmise if STATUTS_TRANSMISE.include?(facture.statut)
142
+ return :non_transmise if non_transmise?
143
+
144
+ :en_cours
145
+ end
146
+
147
+ def non_transmise?
148
+ dernier_incident&.motifs_rejet.to_a.any? { |motif| motif.code == MOTIF_NON_TRANSMISE }
149
+ end
150
+ end
151
+ 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.2.5"
4
+ VERSION = "0.2.7"
5
5
  end
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.2.5
4
+ version: 0.2.7
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-09-05 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -85,6 +85,7 @@ files:
85
85
  - lib/cpro/resources/base.rb
86
86
  - lib/cpro/resources/factures.rb
87
87
  - lib/cpro/resources/flux.rb
88
+ - lib/cpro/suivi.rb
88
89
  - lib/cpro/version.rb
89
90
  homepage: https://hotentic.com
90
91
  licenses: