Files
Starseed/docs/specs/M1-clients/2026-06-04-validation-blocs-collection-design.md
T
matthieu 597101262d
Auto Tag Develop / tag (push) Successful in 8s
feat(commercial) : messages de validation FR sur les contraintes back + garde-fou (ERP-107) (#59)
## Contexte

Résout **ERP-107** — pendant back du mapping d'erreur par champ front (ERP-101). Le front (`useFormErrors` / `mapViolationsToRecord`) affiche sous chaque champ le `message` renvoyé par le back. Ce ticket garantit que ces messages existent, sont en FR et rattachés au bon champ.

## Changements

- **Messages FR explicites** sur toutes les contraintes `#[Assert\*]` des entités métier : `Client`, `ClientContact`, `ClientAddress`, `ClientRib`, `Category`, `Role`, `User` (Email, NotBlank, Length, Bic, Iban, PositiveOrZero, Count…).
- **Contraintes `Assert\Length` manquantes ajoutées**, calées sur le `length` de la colonne ORM (téléphones `VARCHAR(20)`, `siren`, `nTva`, `accountNumber`, `username`…). Évite une erreur Postgres 500 non rattachée au champ → 422 propre.
- **Locale FR globale** (`symfony/translation` + `default_locale: fr`) comme filet pour les messages natifs Symfony non surchargés.
- **Garde-fou** `tests/Architecture/EntityConstraintsHaveFrenchMessageTest` : échoue si une contrainte n'a pas de message FR explicite (comparaison au défaut Symfony) ou si `Assert\Length.max` diverge du `length` ORM. Whitelist justifiée pour les formats auto-bornés (Bic/Iban/Regex CP/couleur hex).
- **Test fonctionnel** du JSON 422 réel : message FR + `propertyPath` consommable par le front.
- **Convention documentée** dans `.claude/rules/backend.md`.

## Décisions

- Stratégie retenue : message FR **explicite sur toutes** les contraintes + locale FR en filet (les deux leviers du ticket).
- Garde-fou `Length == ORM length` : **test bloquant** (anti-dérive).
- RG-1.03 (distributor/broker) : pas de `Assert\Callback` ajouté — le `ClientProcessor` gère **déjà** l'exclusivité (422 + `propertyPath`). Pas de doublon.

## Hors périmètre / à suivre

- **Alignement `nullable`(DB) / `NotBlank`(back) / `required`(front)** : les champs obligatoires existants ont été confirmés, mais aucun changement de nullabilité DB n'a été fait sans arbitrage métier. À recroiser avec les astérisques front (ERP-101 / PR #58) si divergence constatée.

## Vérifications

- `make test` : **469 tests verts** (1793 assertions), 0 échec/erreur.
- `php-cs-fixer` : 0 fichier à corriger.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #59
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-04 09:27:32 +00:00

147 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).