[ERP-55] ClientProvider + ClientProcessor + RG métier (M1) — stackée sur ERP-54 #31
Reference in New Issue
Block a user
Delete Branch "feature/ERP-55-impl-provider-processor-client"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
MR stackée sur ERP-54 — cible =
feature/ERP-54-creer-entites-client-m1(PASdevelop). Tristan validera le stack en fin de chaîne.Branche l'API REST du répertoire clients (M1) sur l'entité
Clientd'ERP-54.Périmètre
?pagination=false), exclusion archives+soft-delete par défaut (RG-1.24),?includeArchived=true(RG-1.25), tricompanyName ASC(RG-1.26), filtres?search(fuzzy) +?categoryType, détail 404 si soft-deleted + embarque contacts/adresses/ribs.accounting.manage/archive+ mode strict 403 (RG-1.28), archivage exclusif +archivedAt(RG-1.22), RG-1.01 / RG-1.03 (mutex + type catégorie) / RG-1.12 / RG-1.13 / RG-1.04.client:read:accountingseloncommercial.clients.accounting.view.Category(dénormalisation impossible sur l'interface sinon).CategoryInterface::getCategoryTypeCode(),BusinessRoleAwareInterface+BusinessRoles::COMMERCIALE.Coordination stack
commercial.clients.*référencées ici, déclarées en ERP-59 (tests RBAC en ERP-60).commercialeseedé par ERP-74 (RG-1.04 dormante d'ici là).Tests
31 tests Commercial (intégration admin sur les RG métier + unitaires sur le gating / RG-1.04 / RG-1.12 / RG-1.13 / context builder). Suite complète verte (343 tests). Règle n°1 respectée (aucun import inter-modules dans Commercial).
matthieu referenced this pull request2026-06-01 07:47:35 +00:00
Revue de code ERP-55 (Provider + Processor + RG métier Client), stackée sur ERP-54.
Gros morceau, très bien construit et très bien testé (unitaires Processor/Normalizer/ContextBuilder + fonctionnels API). Points vérifiés OK :
hasBusinessRole()itèrerbacRoles(EAGER) en contexte requête → le garde-fou « ne jamais itérer pendant le refresh JWT » ne concerne quegetRoles(), donc pas de souci. Reste dormant tant qu'aucun user ne portecommerciale.isArchived: groupe write sur la propriété +SerializedName('isArchived')+ groupe read sur le getter → même pattern queUser::isAdmin, lecture/écriture correctes.%/_/\) et bindée → safe.ClientReadGroupContextBuilder: décoration idiomatique ducontext_builder, conditionneclient:read:accountingsur la permission. Bien.categoryTypevia IN (pas de JOIN) pour préserver DISTINCT/ORDER BY de la pagination → bon réflexe.4 remarques ci-dessous (1 à regarder de près sur le gating POST, 3 mineures). Aucun crash.
@@ -0,0 +40,4 @@// est le comportement attendu pour une reference cassee.$resource = $this->iriConverter->getResourceFromIri($data);return $resource instanceof CategoryInterface ? $resource : null;Un IRI valide mais de mauvais type est silencieusement ignoré.
Contexte :
denormalize()retournenullquand la ressource résolue n'est pas uneCategoryInterface.Cause : un payload
categories: ['/api/clients/5']résout unClient(IRI valide),instanceof CategoryInterfaceest faux →null→ l'élément est silencieusement retiré de la collection, sans 400. Si tous les éléments tombent,Assert\Count(min:1)rattrape ; mais un mélange (1 bon IRI + 1 mauvais) passe en perdant silencieusement la mauvaise référence — erreur client masquée.Recommandation : lever une erreur (400 /
UnexpectedValueException) sur incompatibilité de type plutôt que retournernull, pour un retour explicite au client. Mineur.@@ -0,0 +132,4 @@** @param list<string> $payloadKeys*/private function guardArchive(Client $data, array $payloadKeys): boolLe gating se base sur les clés brutes du payload → 403/422 parasites sur POST ou PATCH « objet complet ».
Contexte :
guardArchive(etguardAccounting) lisentpayloadKeys()= les clés JSON top-level brutes, indépendamment de l'opération et des groupes de dénormalisation.Cause : sur un POST,
isArchivedn'est pas dans le groupeclient:write:main(donc ignoré à la dénormalisation) mais reste visible parpayloadKeys(). Si le front envoieisArchived: falseavec les autres champs (réutilisation du shape de lecture),guardArchivese déclenche :array_diffnon vide → 422, ou 403 si l'user n'a pasarchive. Un POST légitime échoue alors qu'archiver à la création n'a aucun sens. Même problème pour un PATCH qui renvoie toute la représentation (GET → modifier → PATCH complet) :isArchivedinchangé + autres champs → 422 ; champs accounting ré-émis → 403 sansaccounting.managemême sans changement ; et les clés d'enveloppe JSON-LD (@id/@context) comptent aussi comme « autres champs ».Recommandation : restreindre
guardArchiveau PATCH, et/ou ne considérer une requête d'archivage que siisArchiveddiffère de l'état persisté ; ignorer les clés non-écrivables /@*. À défaut, documenter explicitement que le front doit envoyer des merge-patch minimaux. Le jeu de tests actuel ne couvre pas ce cas (les POST de test n'incluent jamaisisArchived).@@ -0,0 +285,4 @@** @param list<string> $payloadKeys*/private function validateInformationCompleteness(Client $data, array $payloadKeys): voidRG-1.04 : un Commerciale qui crée un client SANS aucun champ Information échappe au contrôle de complétude.
Contexte :
validateInformationCompletenessne se déclenche que sipayloadKeysintersecteINFORMATION_FIELDS.Cause : un POST (ou PATCH) d'un user Commerciale qui n'envoie aucun champ de l'onglet Information ne « touche » pas l'onglet → la validation est sautée → un client est créé avec un onglet Information vide alors que la règle veut qu'il soit complet pour ce rôle. Si l'intention de RG-1.04 est « un Commerciale ne peut jamais laisser l'onglet Information incomplet » (et pas seulement « quand il l'édite »), il y a un trou.
Recommandation : confirmer l'intention exacte de RG-1.04 dans la spec § 2.9. Si la complétude est requise inconditionnellement pour le rôle Commerciale, déclencher la validation aussi sur POST indépendamment des champs envoyés. Règle dormante (aucun user
commercialeavant ERP-74) → pas urgent, mais à trancher avant le seed du rôle.@@ -0,0 +144,4 @@return;}$sub = $this->repository->createQueryBuilder('c2')Fuite d'abstraction :
createQueryBuilder()n'est pas sur l'interface du repository.Contexte :
applyCategoryTypeappelle$this->repository->createQueryBuilder('c2'), mais$this->repositoryest typéClientRepositoryInterface, qui ne déclare quefindById/save/createListQueryBuilder.Cause : ça marche au runtime (l'implémentation concrète
DoctrineClientRepositoryhérite deServiceEntityRepository), mais l'appel sort du contrat de l'interface →make testpasse, en revanche une analyse statique (PHPStan/Psalm) signalerait un appel de méthode indéfinie, et ça couple le Provider à l'implémentation Doctrine.Recommandation : exposer la construction de cette sous-requête derrière une méthode du
ClientRepositoryInterface(ex.createCategoryTypeFilterDQL()ou unexistsCategoryTypecôté repo), ou récupérer le QueryBuilder via l'EntityManager. Mineur, cohérence DDD.Suite review (merci) — corrections poussées en
13eb072(fix-forward, branche rebasée sur ERP-55 corrigé) :13eb072.ClientProcessorinjecte maintenant l'EntityManager:guardArchivene gate que sur une mise à jour d'entité gérée (plus sur POST) ET seulement siisArchivedchange réellement vs l'état persisté (getOriginalEntityData).guardAccountingne gate que si un champ comptable change réellement. Les clés@*et non-écrivables sont filtrées (writablePayloadKeys). 3 tests ajoutés (POSTisArchived:false, PATCH représentation complète avec@id, champ comptable inchangé).13eb072.CategoryReferenceDenormalizerlève désormaisUnexpectedValueException(→ 400) au lieu de retournernull. Nouveau fichier de test (CategoryReferenceDenormalizerTest, 3 cas).createQueryBuilder(): ✅ corrigé en13eb072. La sous-requêteapplyCategoryTypeest construite via$this->em->createQueryBuilder()->from(Client::class, …). L'interfaceClientRepositoryInterfacen'est pas touchée (le fix reste dans ERP-55, ne déborde pas sur ERP-54).client:write:information») + RG-1.14 (« complétude purement front au M1 ») → l'implémentation actuelle est intentionnelle. Règle dormante (aucun usercommercialeavant ERP-74) ; décision à trancher avant ERP-74 si on veut en faire un invariant « Commerciale » plus fort.