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

7.1 KiB
Raw Blame History

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::providegetOneOrNullResult() (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::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 hasErrorne 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).