## 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>
26 KiB
Validation « tous les blocs » — onglets à blocs dynamiques (Client M1) — Plan d'implémentation
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Permettre la validation 422 par champ sur TOUS les blocs des onglets Contacts / Adresses / RIB d'un client (création + édition), en supprimant la 500 NonUniqueResultException qui les bloque dès ≥2 enfants et en ne stoppant plus la boucle front au premier bloc en erreur.
Architecture: Côté back, on retire le stade « read » inutile du POST des 3 sous-ressources (read: false) — le parent est déjà rattaché manuellement par le processor — et on durcit ce rattachement (404 si parent absent). Côté front, on factorise la boucle de soumission de collection dans useClientFormErrors().submitRows(...) qui tente tous les blocs et collecte les erreurs par index, puis on branche les 6 sites d'appel (new.vue + edit.vue × contacts/adresses/RIB).
Tech Stack: Symfony 8 / API Platform 4 (PHP 8.4, PHPUnit) ; Nuxt 4 / Vue 3 / TypeScript / Vitest.
Spec de référence : docs/superpowers/specs/2026-06-04-client-collection-blocks-validation-design.md
Pré-vol : make start (containers up), branche de travail = celle de la MR (feat/erp-107-validation-messages-fr) ou une branche dédiée selon décision utilisateur.
Structure des fichiers
Back — modifiés :
src/Module/Commercial/Domain/Entity/ClientContact.php—read: falsesurPostsrc/Module/Commercial/Domain/Entity/ClientAddress.php—read: falsesurPostsrc/Module/Commercial/Domain/Entity/ClientRib.php—read: falsesurPostsrc/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php—linkParent→ 404.../Processor/ClientAddressProcessor.php— idem.../Processor/ClientRibProcessor.php— idemtests/Module/Commercial/Api/AbstractCommercialApiTestCase.php— helperseedContact()tests/Module/Commercial/Api/ClientSubResourceApiTest.php— tests de non-régression
Front — modifiés :
frontend/modules/commercial/composables/useClientFormErrors.ts— méthodesubmitRows()frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts— créé (test unitaire)frontend/modules/commercial/pages/clients/new.vue— branchements (3 submits)frontend/modules/commercial/pages/clients/[id]/edit.vue— branchements (3 submits)
Task 1 : Back — test rouge (POST sur client à ≥2 enfants)
Files:
-
Modify:
tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php -
Test:
tests/Module/Commercial/Api/ClientSubResourceApiTest.php -
Step 1 : Ajouter un helper de seed de contact à la base de test
Dans AbstractCommercialApiTestCase.php, ajouter (sous seedClient, avant cleanupCommercialTestData) :
/**
* Seede directement un ClientContact en base (sans passer par l'API), pour
* preparer un client deja dote de N contacts. Au moins le prenom est pose
* (RG-1.05 / CHECK chk_client_contact_name).
*/
protected function seedContact(ClientEntity $client, string $firstName): \App\Module\Commercial\Domain\Entity\ClientContact
{
$em = $this->getEm();
$contact = new \App\Module\Commercial\Domain\Entity\ClientContact();
$contact->setClient($client);
$contact->setFirstName($firstName);
$em->persist($contact);
$em->flush();
return $contact;
}
- Step 2 : Écrire les tests rouges
Dans ClientSubResourceApiTest.php, ajouter dans la section // === Contacts === :
/**
* Regression ERP (bug subresource Link toProperty) : POST d'un contact sur un
* client qui en a DEJA >= 2 ne doit pas exploser en 500
* (NonUniqueResultException sur la resolution du parent), mais creer (201).
*/
public function testPostContactOnClientWithTwoExistingContactsReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Contact Multi');
$this->seedContact($seed, 'Alpha');
$this->seedContact($seed, 'Beta');
$client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Gamma'],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* Meme contexte (>= 2 contacts existants) : un email invalide doit produire
* une 422 par champ (la validation est bien atteinte), pas une 500.
*/
public function testPostInvalidContactOnPopulatedClientReturns422OnField(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Contact Multi Bad');
$this->seedContact($seed, 'Alpha');
$this->seedContact($seed, 'Beta');
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Gamma', 'email' => 'pas-un-email'],
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
self::assertArrayHasKey('email', $byPath);
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
}
- Step 3 : Lancer les tests, vérifier qu'ils échouent (500 au lieu de 201/422)
Run : make test (ou ciblé dans le container : docker exec php-starseed-fpm php bin/phpunit --filter ClientSubResourceApiTest)
Expected : les 2 nouveaux tests ÉCHOUENT (HTTP 500 NonUniqueResultException). testPostContactOnClient... reçoit 500, pas 201.
- Step 4 : Commit (test rouge)
git add tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php tests/Module/Commercial/Api/ClientSubResourceApiTest.php
git commit -m "test(commercial) : reproduit la 500 NonUniqueResult au POST contact sur client peuple (ERP-107)"
Task 2 : Back — fix (read:false + linkParent durci) → tests verts
Files:
-
Modify:
src/Module/Commercial/Domain/Entity/ClientContact.php:48-57 -
Modify:
src/Module/Commercial/Domain/Entity/ClientAddress.php:61-70 -
Modify:
src/Module/Commercial/Domain/Entity/ClientRib.php:52-61 -
Modify:
.../State/Processor/ClientContactProcessor.php:76-94 -
Modify:
.../State/Processor/ClientAddressProcessor.php:63-81 -
Modify:
.../State/Processor/ClientRibProcessor.php:65-83 -
Step 1 :
read: falsesur les 3 opérationsPost
ClientContact.php, opération Post — ajouter la ligne read: false, :
new Post(
uriTemplate: '/clients/{clientId}/contacts',
uriVariables: [
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
],
// read:false : pas de stade lecture du parent (le Link toProperty
// resoudrait l'enfant et casse en NonUniqueResult des >= 2 enfants).
// Le parent est rattache par ClientContactProcessor::linkParent.
read: false,
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client_contact:read']],
denormalizationContext: ['groups' => ['client_contact:write']],
processor: ClientContactProcessor::class,
),
ClientAddress.php — idem dans son Post (security: commercial.clients.manage, processor ClientAddressProcessor), commentaire pointant ClientAddressProcessor::linkParent.
ClientRib.php — idem dans son Post (security: commercial.clients.accounting.manage, processor ClientRibProcessor), commentaire pointant ClientRibProcessor::linkParent.
- Step 2 : Durcir les 3
linkParent(404 si parent absent)
Dans chaque processor, ajouter l'import en tête de fichier :
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
ClientContactProcessor::linkParent — remplacer le bloc final par :
if (null === $clientId) {
return;
}
$client = $clientId instanceof Client
? $clientId
: $this->em->getRepository(Client::class)->find($clientId);
// read:false sur le POST : sans stade lecture, un parent introuvable
// n'est plus intercepte en amont -> 404 explicite (sinon 500 au persist
// sur client_id NOT NULL).
if (!$client instanceof Client) {
throw new NotFoundHttpException('Client introuvable.');
}
$contact->setClient($client);
ClientAddressProcessor::linkParent — idem avec $address->setClient($client);.
ClientRibProcessor::linkParent — idem avec $rib->setClient($client);.
- Step 3 : Lancer les tests, vérifier qu'ils passent
Run : make test
Expected : les 2 tests de Task 1 PASSENT (201 + 422 propertyPath=email). Aucun test existant cassé (notamment testPostContactInvalidEmailReturns422WithFrenchMessageOnField et les tests d'archi ERP-107 restent verts).
- Step 4 : Lint PHP
Run : make php-cs-fixer-allow-risky
Expected : 0 fichier à corriger (ou corrections appliquées et re-vérifiées).
- Step 5 : Commit (fix back)
git add src/Module/Commercial/Domain/Entity/ClientContact.php src/Module/Commercial/Domain/Entity/ClientAddress.php src/Module/Commercial/Domain/Entity/ClientRib.php src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php
git commit -m "fix(commercial) : POST sous-ressource client en read:false + parent 404 (corrige 500 NonUniqueResult, ERP-107)"
Task 3 : Back — germes adresses + RIB (verrouille les 3 sous-ressources)
Files:
-
Modify:
tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php(helpersseedAddress,seedRib) -
Test:
tests/Module/Commercial/Api/ClientSubResourceApiTest.php -
Step 1 : Helpers de seed adresse + RIB
Dans AbstractCommercialApiTestCase.php, ajouter :
/** Seede une adresse minimale valide (RG : CP/ville/rue requis). */
protected function seedAddress(ClientEntity $client, string $city): \App\Module\Commercial\Domain\Entity\ClientAddress
{
$em = $this->getEm();
$address = new \App\Module\Commercial\Domain\Entity\ClientAddress();
$address->setClient($client);
$address->setPostalCode('33000');
$address->setCity($city);
$address->setStreet('1 rue du Test');
$em->persist($address);
$em->flush();
return $address;
}
/** Seede un RIB valide (BIC/IBAN conformes). */
protected function seedRib(ClientEntity $client, string $label): \App\Module\Commercial\Domain\Entity\ClientRib
{
$em = $this->getEm();
$rib = new \App\Module\Commercial\Domain\Entity\ClientRib();
$rib->setClient($client);
$rib->setLabel($label);
$rib->setBic('BNPAFRPPXXX');
$rib->setIban('FR1420041010050500013M02606');
$em->persist($rib);
$em->flush();
return $rib;
}
Note : si une propriété est non-nullable et absente ci-dessus (ex.
position, flags d'adresse), poser les setters correspondants avec une valeur par défaut neutre — vérifier les entitésClientAddress/ClientRibau moment de l'écriture.
- Step 2 : Tests de non-régression adresses + RIB
Dans ClientSubResourceApiTest.php, section adresses puis RIB :
public function testPostAddressOnClientWithTwoExistingAddressesReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Addr Multi');
$this->seedAddress($seed, 'Bordeaux');
$this->seedAddress($seed, 'Lyon');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['postalCode' => '75001', 'city' => 'Paris', 'street' => '2 rue Neuve'],
]);
self::assertResponseStatusCodeSame(201);
}
public function testPostRibOnClientWithTwoExistingRibsReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Rib Multi');
$this->seedRib($seed, 'Compte 1');
$this->seedRib($seed, 'Compte 2');
$client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte 3', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(201);
}
Le POST RIB exige
commercial.clients.accounting.manage—admin(ROLE_ADMIN) l'a. Si une 403 apparaît, vérifier le compte de test.
- Step 3 : Lancer, vérifier vert
Run : make test
Expected : PASS (les 2 nouveaux tests verts grâce au fix de Task 2).
- Step 4 : Commit
git add tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php tests/Module/Commercial/Api/ClientSubResourceApiTest.php
git commit -m "test(commercial) : verrouille POST adresses/RIB sur client peuple (ERP-107)"
Task 4 : Front — helper submitRows + test unitaire
Files:
-
Modify:
frontend/modules/commercial/composables/useClientFormErrors.ts -
Create:
frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts -
Step 1 : Écrire le test rouge
Créer useClientFormErrors.spec.ts :
import { describe, it, expect, vi } from 'vitest'
import { useClientFormErrors } from '../useClientFormErrors'
// Construit une erreur facon useApi : 422 avec violations Hydra.
function http422(path: string, message: string) {
return { response: { status: 422, _data: { violations: [{ propertyPath: path, message }] } } }
}
describe('useClientFormErrors.submitRows', () => {
it('tente TOUS les blocs et mappe les erreurs par index, sans stopper au premier echec', async () => {
const { contactErrors, submitRows } = useClientFormErrors()
const seen: number[] = []
const onUnmapped = vi.fn()
const saveRow = async (_row: unknown, index: number) => {
seen.push(index)
if (index === 1) throw http422('email', 'Email invalide')
}
const hasError = await submitRows(
[{ a: 0 }, { a: 1 }, { a: 2 }],
contactErrors,
saveRow,
onUnmapped,
)
expect(seen).toEqual([0, 1, 2]) // tous les blocs tentes
expect(hasError).toBe(true)
expect(contactErrors.value[1]).toEqual({ email: 'Email invalide' })
expect(contactErrors.value[0]).toBeUndefined()
expect(onUnmapped).not.toHaveBeenCalled() // 422 mappee, pas de fallback
})
it('saute les lignes filtrees par shouldSkip et renvoie false si tout passe', async () => {
const { contactErrors, submitRows } = useClientFormErrors()
const saved: number[] = []
const hasError = await submitRows(
[{ skip: true }, { skip: false }],
contactErrors,
async (_row, index) => { saved.push(index) },
vi.fn(),
(row: { skip: boolean }) => row.skip,
)
expect(saved).toEqual([1])
expect(hasError).toBe(false)
})
})
- Step 2 : Lancer, vérifier l'échec
Run : make nuxt-test (ou ciblé : docker exec <node> npx vitest run useClientFormErrors)
Expected : FAIL — submitRows n'existe pas encore.
- Step 3 : Implémenter
submitRows
Dans useClientFormErrors.ts, ajouter la méthode (dans la fonction, après mapRowError) et l'exposer dans le return :
/**
* Soumet TOUS les blocs d'une collection (contacts/adresses/RIB) en collectant
* les erreurs par index : on n'arrete PAS au premier bloc en echec (ERP-101).
* Reinitialise le tableau d'erreurs cible, tente chaque ligne via `saveRow`,
* mappe les 422 inline (mapRowError) ou delegue le fallback a `onUnmappedError`.
* Retourne true si au moins un bloc a echoue (le caller ne valide alors pas l'onglet).
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
if (shouldSkip?.(rows[index], index)) {
continue
}
try {
await saveRow(rows[index], index)
}
catch (error) {
if (!mapRowError(error, target, index)) {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
Ajouter submitRows à l'objet retourné par useClientFormErrors.
- Step 4 : Lancer, vérifier vert
Run : make nuxt-test
Expected : PASS (les 2 cas verts).
- Step 5 : Commit
git add frontend/modules/commercial/composables/useClientFormErrors.ts frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts
git commit -m "feat(commercial) : submitRows collecte les erreurs de tous les blocs de collection (ERP-101)"
Task 5 : Front — brancher submitRows dans new.vue + edit.vue
Files:
-
Modify:
frontend/modules/commercial/pages/clients/new.vue(submitContacts,submitAddresses, boucle RIB desubmitAccounting) -
Modify:
frontend/modules/commercial/pages/clients/[id]/edit.vue(les 3 équivalents) -
Step 1 : Récupérer
submitRowsdu composable
Dans new.vue ET edit.vue, ajouter submitRows à la déstructuration de useClientFormErrors() :
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
submitRows,
} = useClientFormErrors()
- Step 2 : Réécrire
submitContacts(new.vue)
Remplacer le corps de la boucle par un appel à submitRows :
async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true
try {
const hasError = await submitRows(
contacts.value,
contactErrors,
async (contact) => {
const body = {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
phonePrimary: contact.phonePrimary || null,
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null,
}
if (contact.id === null) {
const created = await api.post<ContactResponse>(
`/clients/${clientId.value}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
},
(error) => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
(contact) => !isContactNamed(contact),
)
if (hasError) return
completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
- Step 3 : Réécrire
submitAddresses(new.vue)
async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true
try {
const hasError = await submitRows(
addresses.value,
addressErrors,
async (address) => {
const body = {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
}
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
},
(error) => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
)
if (hasError) return
completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
finally {
tabSubmitting.value = false
}
}
- Step 4 : Réécrire la boucle RIB de
submitAccounting(new.vue)
Garder le PATCH scalaire inchangé (1) ; remplacer la boucle (2) :
// 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs).
const ribHasError = await submitRows(
ribs.value,
ribErrors,
async (rib) => {
const body = { label: rib.label, bic: rib.bic, iban: rib.iban }
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId.value}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
},
(error) => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }),
(rib) => !ribIsComplete(rib),
)
if (ribHasError) return
completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
Retirer le
ribErrors.value = []désormais fait parsubmitRows. LeaccountingErrors.clearErrors()du PATCH scalaire reste.
- Step 5 : Mirror dans edit.vue
Appliquer les mêmes réécritures aux submitContacts / submitAddresses / boucle RIB de submitAccounting d'edit.vue. Conserver le fallback d'erreur propre à edit.vue (si edit.vue utilise showError(...) au lieu de toast.error(...), passer ce fallback comme onUnmappedError). Vérifier les noms des refs (clientId peut y être l'id de route).
- Step 6 : Vérifier le typecheck + tests front
Run : make nuxt-test
Expected : PASS. Aucune régression des specs existantes (ClientContactBlock.spec.ts, etc.).
- Step 7 : Commit
git add frontend/modules/commercial/pages/clients/new.vue "frontend/modules/commercial/pages/clients/[id]/edit.vue"
git commit -m "feat(commercial) : valide tous les blocs contacts/adresses/RIB et affiche les erreurs par bloc (ERP-101)"
Task 6 : Vérification finale + golden path manuel
- Step 1 : Suite complète back
Run : make test puis make php-cs-fixer-allow-risky
Expected : tout vert, 0 fichier à corriger.
- Step 2 : Suite complète front
Run : make nuxt-test
Expected : tout vert.
- Step 3 : Golden path manuel (
make dev-nuxt, port 3004)
Scénario : ouvrir un client à 3 contacts (compte admin), onglet Contacts, ajouter un bloc avec email invalide + un autre bloc avec prénom/nom vides → Valider.
Attendu : pas de 500 ; « L'adresse email n'est pas valide. » sous l'email du bon bloc ET « Le prénom ou le nom du contact est obligatoire. » sous le prénom de l'autre bloc, affichés simultanément. L'onglet ne se valide pas tant qu'une erreur subsiste. Idem à vérifier rapidement sur Adresses et RIB.
- Step 4 : Si une vérif échoue ou ne peut être lancée, le dire explicitement (ne pas annoncer « fini »).
Self-review (auteur du plan)
- Couverture spec §3.1 (back) : Task 2 (read:false + linkParent 404) ✓ ; §3.2 (front collect-all) : Tasks 4-5 ✓ ; §3.3 (helper réutilisable) : Task 4
submitRows✓ ; §4 tests : Tasks 1, 3 (back), 4 (front) + Task 6 golden path ✓. - Périmètre 3 sous-ressources : contacts (Task 1-2), adresses + RIB (Task 3 + branchements Task 5) ✓.
- Décision « inline seul » : aucun toast succès si
hasError; pas de toast récap ✓. - Pas de placeholder : le seul point ouvert est la note Task 3 Step 1 (setters non-nullables éventuels d'adresse/RIB à compléter en lisant les entités) — à lever à l'écriture. Cohérence des noms :
submitRowsutilisé identiquement en Task 4 et Task 5.