597101262d
Auto Tag Develop / tag (push) Successful in 8s
## 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>
147 lines
7.1 KiB
Markdown
147 lines
7.1 KiB
Markdown
# 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).
|