# 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).
