## 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>
7.1 KiB
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 :
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 :
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 :
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::linkParentrattache 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: sifind($clientId)renvoienull, leverSymfony\Component\HttpKernel\Exception\NotFoundHttpException(préserve le 404 sur parent inexistant — sans le read, on régresserait sinon en 500 au persist surclient_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 lecatch, remplacerreturnparhasError = true; continue→ la boucle tente/valide tous les blocs ; chaque 422 se mappe surxxxErrors[index]viamapRowError(mécanique existante, inchangée). - Après la boucle : si
hasError→ ne pas appelercompleteTab(...), pas de toast succès. Sinon → comportement actuel (completeTab+ toast succès). - Les blocs déjà créés (id non-null) repassent en
PATCHau 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 avecpropertyPath=emailet 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
submitCollectionextrait : 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).