# Validation « tous les blocs » sur les onglets à blocs dynamiques (Client M1) > Date : 2026-06-04 · Module : Commercial (M1 Clients) · Tickets liés : ERP-101 / ERP-107 > Écrans : `clients/new.vue`, `clients/[id]/edit.vue` · Onglets concernés : Contacts, Adresses, RIB ## 1. Problème À la soumission des onglets à **blocs d'ajout dynamiques** (Contacts / Adresses / RIB), la validation par champ ne s'affiche pas correctement. Deux causes **distinctes et cumulées** : ### Cause A — 500 back qui court-circuite la validation (cause racine) Les opérations `Post` des sous-ressources sont déclarées ainsi : ```php new Post( uriTemplate: '/clients/{clientId}/contacts', uriVariables: ['clientId' => new Link(fromClass: Client::class, toProperty: 'client')], processor: ClientContactProcessor::class, ) ``` Au stade « read » du POST, API Platform résout `clientId` via `LinksHandlerTrait` (branche `toProperty`, `vendor/api-platform/doctrine-orm/State/LinksHandlerTrait.php:134-141`). La requête générée porte sur l'entité **enfant** : ```sql SELECT o FROM ClientContact o INNER JOIN o.client c WHERE c.id = :clientId ``` exécutée via `ItemProvider::provide` → `getOneOrNullResult()` (`vendor/api-platform/doctrine-orm/State/ItemProvider.php:89`). Donc : | Nb d'enfants du client | Lignes retournées | Résultat | |---|---|---| | 0 | 0 | `null` → OK (cas du test CI actuel) | | 1 | 1 | OK | | **≥ 2** | **≥ 2** | **`NonUniqueResultException` → HTTP 500** | Conséquence : un client à ≥2 contacts (resp. adresses, RIB) ne peut plus en recevoir un nouveau. La 500 survient **avant** la déserialisation/validation → aucune 422 n'est produite → `mapRowError` (qui ne mappe que les 422) retombe sur un toast générique. Les **3** sous-ressources ont strictement la même config → même bug latent (contacts est juste le premier à sauter car les clients de démo ont 3 contacts). ### Cause B — la boucle front s'arrête au premier bloc en erreur `submitContacts` / `submitAddresses` / boucle RIB de `submitAccounting` (dans `new.vue` ET `edit.vue`) font `return` dans le `catch` du premier bloc en échec : ```js catch (error) { if (!mapRowError(error, contactErrors, index)) { toast(...) } return // ← stoppe : les blocs suivants ne sont jamais validés ni affichés } ``` → même une fois le 500 corrigé, seules les erreurs du **premier** bloc fautif s'afficheraient. ## 2. Objectif À la validation d'un onglet collection, **tenter tous les blocs** et **afficher l'erreur inline sous chaque champ fautif, pour chaque bloc**, en un seul aller-retour de soumission. Pas de toast récapitulatif (décision : inline seul, cohérent ERP-101). Pas de toast succès tant qu'au moins un bloc reste en erreur. Hors périmètre : le workflow incrémental (créer le client, puis débloquer les onglets) reste inchangé ; les onglets scalaires (Principal / Information / Comptabilité-scalaires) fonctionnent déjà et ne sont pas touchés. ## 3. Conception ### 3.1 Back — supprimer le read cassé du POST (cause racine) Sur les opérations `Post` de `ClientContact`, `ClientAddress`, `ClientRib` : - Ajouter **`read: false`**. Le stade « read » est inutile : le `*Processor::linkParent` rattache déjà le parent manuellement via `$em->getRepository(Client::class)->find($clientId)`. Pattern déjà employé dans le projet (`Sites/.../CurrentSiteResource.php`). - Durcir les 3 `linkParent` : si `find($clientId)` renvoie `null`, lever `Symfony\Component\HttpKernel\Exception\NotFoundHttpException` (préserve le **404** sur parent inexistant — sans le read, on régresserait sinon en 500 au persist sur `client_id NOT NULL`). Effet : plus de `getOneOrNullResult` foireux → déserialisation + validation Symfony s'exécutent → **422 propre par champ** avec `violations[].propertyPath` (déjà garanti par ERP-107 : messages FR explicites). Aucune autre modification (security, normalizationContext, processor restant) n'est nécessaire. ### 3.2 Front — collecter les erreurs de tous les blocs Dans `submitContacts`, `submitAddresses`, et la boucle RIB de `submitAccounting`, **dans `new.vue` ET `edit.vue`** : - Conserver la réinitialisation du tableau d'erreurs en début de submit (`xxxErrors.value = []`). - Introduire un drapeau local `hasError`. Dans le `catch`, remplacer `return` par `hasError = true; continue` → la boucle tente/valide **tous** les blocs ; chaque 422 se mappe sur `xxxErrors[index]` via `mapRowError` (mécanique existante, inchangée). - Après la boucle : si `hasError` → **ne pas** appeler `completeTab(...)`, **pas** de toast succès. Sinon → comportement actuel (`completeTab` + toast succès). - Les blocs déjà créés (id non-null) repassent en `PATCH` au resubmit → idempotent, pas de doublon. - Awaits **séquentiels** conservés (volume faible, ordre des blocs préservé, pas de course). Le binding inline est déjà en place côté template (`:errors="contactErrors[index]"` / `:error="ribErrors[index]?.iban"` …). Aucun changement de composant `Malio*` requis. ### 3.3 Réutilisation / isolation Le bloc « boucle de soumission d'une collection avec collecte d'erreurs par index » est dupliqué 3× × 2 pages. Pour rester testable et DRY, extraire un helper de soumission de collection (ex. `submitCollection(rows, { buildBody, post, patch, errors })` retournant `{ hasError }`) consommé par les 6 sites d'appel. À acter dans le plan d'implémentation (option : garder inline si l'extraction dégrade la lisibilité — décision lors du plan). ## 4. Tests ### Back (TDD — échouent d'abord) Dans `tests/Module/Commercial/Api/ClientSubResourceApiTest` : - `testPostContactToClientWithTwoExistingContactsReturns201` : seed un client + 2 contacts, POST un 3ᵉ → attendu **201** (rouge aujourd'hui : 500). - `testPostContactInvalidEmailOnClientWithExistingContactsReturns422` : même seed, POST email invalide → **422** avec `propertyPath=email` et message FR (vérifie que la validation est bien atteinte). - Variantes germes pour adresses et RIB (au moins une chacune) pour verrouiller les 3 sous-ressources. Pré-requis : helper de seed de contacts/adresses/RIB dans `AbstractCommercialApiTestCase` (ajouter si absent). ### Front (Vitest) - Si helper `submitCollection` extrait : test unitaire « 3 blocs, le 2ᵉ renvoie 422 → les erreurs du 2ᵉ sont mappées, les blocs 1 et 3 sont tentés, `hasError = true`, tab non complété ». - Sinon : test de composant sur `ClientContactBlock` + page, vérifiant l'affichage inline multi-blocs. ### Vérifications finales `make test` + `make php-cs-fixer-allow-risky` (back), `make nuxt-test` (front). Golden path manuel : client à 3 contacts, ajouter un 4ᵉ avec email invalide → 422 inline sous l'email du bon bloc, pas de 500. ## 5. Impact / risques - API contract : POST sous-ressource passe de 500→201/422 (correction) ; 404 préservé sur parent inexistant. Pas de changement de payload ni de réponse de succès. - Le test fonctionnel CI actuel (POST sur client à 0 contact) reste vert. - Régression possible si un consommateur dépendait du read implicite du parent au POST : aucun identifié (les 3 processors gèrent déjà le rattachement manuellement).