From e61e653ea36bcc34780650571dfea4ca5af67b74 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 11:31:53 +0200 Subject: [PATCH 01/10] test(commercial) : reproduit la 500 NonUniqueResult au POST contact sur client peuple (ERP-110) --- .../Api/ClientSubResourceApiTest.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php index c5df358..059661f 100644 --- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -94,6 +94,51 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']); } + /** + * Regression ERP-110 (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']); + } + public function testPatchContactNormalizes(): void { $client = $this->createAdminClient(); -- 2.39.5 From 571d80f75f56096ed4a6c25cb7227eb2f0e43bfb Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 11:33:46 +0200 Subject: [PATCH 02/10] fix(commercial) : POST sous-ressource client en read:false + parent 404 (corrige 500 NonUniqueResult, ERP-110) --- src/Module/Commercial/Domain/Entity/ClientAddress.php | 5 +++++ src/Module/Commercial/Domain/Entity/ClientContact.php | 5 +++++ src/Module/Commercial/Domain/Entity/ClientRib.php | 5 +++++ .../State/Processor/ClientAddressProcessor.php | 10 ++++++++-- .../State/Processor/ClientContactProcessor.php | 10 ++++++++-- .../ApiPlatform/State/Processor/ClientRibProcessor.php | 10 ++++++++-- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index 29ca240..99a998f 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -63,6 +63,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; uriVariables: [ 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT ClientAddress ... WHERE client = :id) et + // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par ClientAddressProcessor::linkParent (404 si absent). + read: false, security: "is_granted('commercial.clients.manage')", normalizationContext: ['groups' => ['client_address:read']], denormalizationContext: ['groups' => ['client_address:write']], diff --git a/src/Module/Commercial/Domain/Entity/ClientContact.php b/src/Module/Commercial/Domain/Entity/ClientContact.php index b28aaa2..80247c8 100644 --- a/src/Module/Commercial/Domain/Entity/ClientContact.php +++ b/src/Module/Commercial/Domain/Entity/ClientContact.php @@ -50,6 +50,11 @@ use Symfony\Component\Validator\Constraints as Assert; uriVariables: [ 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT ClientContact ... WHERE client = :id) et + // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par ClientContactProcessor::linkParent (404 si absent). + read: false, security: "is_granted('commercial.clients.manage')", normalizationContext: ['groups' => ['client_contact:read']], denormalizationContext: ['groups' => ['client_contact:write']], diff --git a/src/Module/Commercial/Domain/Entity/ClientRib.php b/src/Module/Commercial/Domain/Entity/ClientRib.php index 956ee80..c2fb408 100644 --- a/src/Module/Commercial/Domain/Entity/ClientRib.php +++ b/src/Module/Commercial/Domain/Entity/ClientRib.php @@ -54,6 +54,11 @@ use Symfony\Component\Validator\Constraints as Assert; uriVariables: [ 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT ClientRib ... WHERE client = :id) et + // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par ClientRibProcessor::linkParent (404 si absent). + read: false, security: "is_granted('commercial.clients.accounting.manage')", normalizationContext: ['groups' => ['client_rib:read']], denormalizationContext: ['groups' => ['client_rib:write']], diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php index 1720432..01249e9 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php @@ -12,6 +12,7 @@ use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Entity\ClientAddress; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Processor d'ecriture de la sous-ressource Adresse d'un client (M1, § 4.5). @@ -75,9 +76,14 @@ final class ClientAddressProcessor implements ProcessorInterface ? $clientId : $this->em->getRepository(Client::class)->find($clientId); - if ($client instanceof Client) { - $address->setClient($client); + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte client_id NOT NULL). + if (!$client instanceof Client) { + throw new NotFoundHttpException('Client introuvable.'); } + + $address->setClient($client); } /** diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php index 3ddffc0..4806336 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php @@ -14,6 +14,7 @@ use App\Module\Commercial\Domain\Entity\ClientContact; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationList; @@ -88,9 +89,14 @@ final class ClientContactProcessor implements ProcessorInterface ? $clientId : $this->em->getRepository(Client::class)->find($clientId); - if ($client instanceof Client) { - $contact->setClient($client); + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte client_id NOT NULL). + if (!$client instanceof Client) { + throw new NotFoundHttpException('Client introuvable.'); } + + $contact->setClient($client); } /** diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php index baf55ec..39e280c 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php @@ -12,6 +12,7 @@ use App\Module\Commercial\Domain\Entity\ClientRib; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Processor d'ecriture de la sous-ressource RIB d'un client (M1, § 4.5). @@ -77,9 +78,14 @@ final class ClientRibProcessor implements ProcessorInterface ? $clientId : $this->em->getRepository(Client::class)->find($clientId); - if ($client instanceof Client) { - $rib->setClient($client); + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte client_id NOT NULL). + if (!$client instanceof Client) { + throw new NotFoundHttpException('Client introuvable.'); } + + $rib->setClient($client); } /** -- 2.39.5 From 9a1fcad3cb69e791a972f0bd7dee68afaf4be31d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 11:35:26 +0200 Subject: [PATCH 03/10] test(commercial) : verrouille POST adresses/RIB sur client peuple (ERP-110) --- .../Api/ClientSubResourceApiTest.php | 77 ++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php index 059661f..321ac10 100644 --- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Module\Commercial\Api; use App\Module\Commercial\Domain\Entity\Client as ClientEntity; +use App\Module\Commercial\Domain\Entity\ClientAddress; use App\Module\Commercial\Domain\Entity\ClientContact; use App\Module\Commercial\Domain\Entity\ClientRib; use App\Module\Commercial\Domain\Entity\PaymentType; @@ -246,6 +247,35 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(422); } + /** + * Regression ERP-110 : POST d'une adresse sur un client qui en a DEJA >= 2 ne + * doit pas exploser en 500 (NonUniqueResult sur la resolution du parent). Le + * POST porte un site + une categorie (RG-1.10 / RG-1.29) pour etre valide. + */ + public function testPostAddressOnClientWithTwoExistingAddressesReturns201(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Addr Multi'); + $siteIri = $this->firstSiteIri(); + $category = $this->createCategory('SECTEUR'); + $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', + 'sites' => [$siteIri], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + // === RIBs === public function testPostRibByAdminReturns201(): void @@ -284,6 +314,26 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(422); } + /** + * Regression ERP-110 : POST d'un RIB sur un client qui en a DEJA >= 2 ne doit + * pas exploser en 500 (NonUniqueResult sur la resolution du parent). L'admin + * porte commercial.clients.accounting.manage requis par le POST. + */ + 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); + } + public function testDeleteRibNonLcrReturns204(): void { $client = $this->createAdminClient(); @@ -351,13 +401,34 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase } /** - * Seede un ClientRib valide rattache a un client (sans passer par l'API). + * Seede une adresse minimale valide en base (sans passer par l'API) : seules + * les colonnes NOT NULL sont posees (CP / ville / rue). Les M2M sites / + * categories restent vides — non contraints en base, suffisant pour peupler + * un client de plusieurs adresses. */ - private function seedRib(ClientEntity $client): ClientRib + private function seedAddress(ClientEntity $client, string $city): ClientAddress + { + $em = $this->getEm(); + $address = new 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 ClientRib valide rattache a un client (sans passer par l'API). Le + * libelle est parametrable pour seeder plusieurs RIB distincts. + */ + private function seedRib(ClientEntity $client, string $label = 'Seed RIB'): ClientRib { $em = $this->getEm(); $rib = new ClientRib(); - $rib->setLabel('Seed RIB'); + $rib->setLabel($label); $rib->setBic(self::VALID_BIC); $rib->setIban(self::VALID_IBAN); $rib->setClient($client); -- 2.39.5 From c0645233ef8577673093acedb274bc33d25c7c44 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 11:37:05 +0200 Subject: [PATCH 04/10] feat(commercial) : submitRows collecte les erreurs de tous les blocs de collection (ERP-110) --- .../__tests__/useClientFormErrors.spec.ts | 68 +++++++++++++++++++ .../composables/useClientFormErrors.ts | 39 +++++++++++ 2 files changed, 107 insertions(+) diff --git a/frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts b/frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts index c3f8470..ff1bed9 100644 --- a/frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts +++ b/frontend/modules/commercial/composables/__tests__/useClientFormErrors.spec.ts @@ -52,3 +52,71 @@ describe('useClientFormErrors', () => { expect(f.addressErrors.value[0]).toBeUndefined() }) }) + +// Construit une erreur facon useApi : 422 avec violations Hydra. +function http422(path: string, message: string) { + return { response: { status: 422, _data: { violations: [{ propertyPath: path, message }] } } } +} + +/** + * `submitRows` factorise la soumission d'une collection de blocs (contacts / + * adresses / RIB) : on tente TOUS les blocs et on collecte les erreurs par index + * sans stopper au premier echec (ERP-110 / ERP-101). + */ +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('delegue le fallback onUnmappedError pour une erreur non mappable et marque hasError', async () => { + const { ribErrors, submitRows } = useClientFormErrors() + const onUnmapped = vi.fn() + + const hasError = await submitRows( + [{ a: 0 }], + ribErrors, + async () => { throw { response: { status: 500, _data: {} } } }, + onUnmapped, + ) + + expect(hasError).toBe(true) + expect(onUnmapped).toHaveBeenCalledTimes(1) + expect(ribErrors.value[0]).toBeUndefined() + }) + + 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) + }) +}) diff --git a/frontend/modules/commercial/composables/useClientFormErrors.ts b/frontend/modules/commercial/composables/useClientFormErrors.ts index dcb10d6..86551e8 100644 --- a/frontend/modules/commercial/composables/useClientFormErrors.ts +++ b/frontend/modules/commercial/composables/useClientFormErrors.ts @@ -43,6 +43,44 @@ export function useClientFormErrors() { return false } + /** + * 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 + * (decision ERP-110 / ERP-101). Reinitialise le tableau d'erreurs cible, tente + * chaque ligne via `saveRow`, mappe les 422 inline (mapRowError) ou delegue le + * fallback a `onUnmappedError`. `shouldSkip` permet d'ignorer les blocs vides + * (non remplis). Retourne true si au moins un bloc a echoue (le caller ne valide + * alors pas l'onglet et n'affiche pas de toast succes). + */ + async function submitRows( + rows: T[], + target: Ref[]>, + saveRow: (row: T, index: number) => Promise, + onUnmappedError: (error: unknown, index: number) => void, + shouldSkip?: (row: T, index: number) => boolean, + ): Promise { + target.value = [] + let hasError = false + for (let index = 0; index < rows.length; index++) { + // L'index reste borne par rows.length : la ligne existe forcement. + const row = rows[index] as T + if (shouldSkip?.(row, index)) { + continue + } + try { + await saveRow(row, index) + } + catch (error) { + if (!mapRowError(error, target, index)) { + onUnmappedError(error, index) + } + hasError = true + } + } + + return hasError + } + return { mainErrors, informationErrors, @@ -51,5 +89,6 @@ export function useClientFormErrors() { addressErrors, ribErrors, mapRowError, + submitRows, } } -- 2.39.5 From 41d391eebf4405332bf54c23dd1c1694f9204178 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 11:42:08 +0200 Subject: [PATCH 05/10] feat(commercial) : valide tous les blocs contacts/adresses/RIB et affiche les erreurs par bloc (ERP-110) --- .../commercial/pages/clients/[id]/edit.vue | 78 +++++------ .../modules/commercial/pages/clients/new.vue | 128 ++++++++---------- 2 files changed, 94 insertions(+), 112 deletions(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 0dce2ca..9c29de4 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -628,7 +628,7 @@ const { contactErrors, addressErrors, ribErrors, - mapRowError, + submitRows, } = useClientFormErrors() // ── Bloc principal ─────────────────────────────────────────────────────────── @@ -742,11 +742,13 @@ async function submitContacts(): Promise { } removedContactIds.value = [] - for (let index = 0; index < contacts.value.length; index++) { - const contact = contacts.value[index] - if (!isContactNamed(contact)) continue - const body = buildContactPayload(contact) - try { + // On tente TOUS les blocs (collecte des erreurs par index, ERP-110) ; les + // blocs vides (ni nom ni prenom) sont ignores. + const hasError = await submitRows( + contacts.value, + contactErrors, + async (contact) => { + const body = buildContactPayload(contact) if (contact.id === null) { const created = await api.post<{ '@id'?: string, id: number }>( `/clients/${clientId}/contacts`, @@ -759,15 +761,12 @@ async function submitContacts(): Promise { else { await api.patch(`/client_contacts/${contact.id}`, body, { toast: false }) } - } - catch (error) { - // 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe. - if (!mapRowError(error, contactErrors, index)) { - showError(error) - } - return - } - } + }, + error => showError(error), + contact => !isContactNamed(contact), + ) + // Tant qu'un bloc reste en erreur : pas de toast succes. + if (hasError) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } catch (e) { @@ -824,10 +823,12 @@ async function submitAddresses(): Promise { } removedAddressIds.value = [] - for (let index = 0; index < addresses.value.length; index++) { - const address = addresses.value[index] - const body = buildAddressPayload(address, isBillingEmailRequired(address)) - try { + // On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110). + const hasError = await submitRows( + addresses.value, + addressErrors, + async (address) => { + const body = buildAddressPayload(address, isBillingEmailRequired(address)) if (address.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId}/addresses`, @@ -839,14 +840,10 @@ async function submitAddresses(): Promise { else { await api.patch(`/client_addresses/${address.id}`, body, { toast: false }) } - } - catch (error) { - if (!mapRowError(error, addressErrors, index)) { - showError(error) - } - return - } - } + }, + error => showError(error), + ) + if (hasError) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } catch (e) { @@ -905,7 +902,6 @@ async function submitAccounting(): Promise { if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return tabSubmitting.value = true accountingErrors.clearErrors() - ribErrors.value = [] try { // 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). try { @@ -921,12 +917,13 @@ async function submitAccounting(): Promise { } removedRibIds.value = [] - // 2) POST/PATCH des RIB (erreurs inline par ligne). - for (let index = 0; index < ribs.value.length; index++) { - const rib = ribs.value[index] - if (!ribIsComplete(rib)) continue - const body = buildRibPayload(rib) - try { + // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes — + // les blocs RIB incomplets sont ignores). + const ribHasError = await submitRows( + ribs.value, + ribErrors, + async (rib) => { + const body = buildRibPayload(rib) if (rib.id === null) { const created = await api.post<{ id: number }>( `/clients/${clientId}/ribs`, @@ -938,14 +935,11 @@ async function submitAccounting(): Promise { else { await api.patch(`/client_ribs/${rib.id}`, body, { toast: false }) } - } - catch (error) { - if (!mapRowError(error, ribErrors, index)) { - showError(error) - } - return - } - } + }, + error => showError(error), + rib => !ribIsComplete(rib), + ) + if (ribHasError) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } catch (e) { diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index e4680e0..366fb9e 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -441,7 +441,7 @@ const { contactErrors, addressErrors, ribErrors, - mapRowError, + submitRows, } = useClientFormErrors() useHead({ title: t('commercial.clients.form.title') }) @@ -676,23 +676,21 @@ function askRemoveContact(index: number): void { async function submitContacts(): Promise { if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return tabSubmitting.value = true - contactErrors.value = [] try { - for (let index = 0; index < contacts.value.length; index++) { - const contact = contacts.value[index] - // On ignore les blocs totalement vides (ni nom ni prenom). - if (!isContactNamed(contact)) continue - - 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, - } - - try { + // On tente TOUS les blocs (collecte des erreurs par index, ERP-110) ; les + // blocs vides (ni nom ni prenom) sont ignores. + 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( `/clients/${clientId.value}/contacts`, @@ -705,16 +703,12 @@ async function submitContacts(): Promise { else { await api.patch(`/client_contacts/${contact.id}`, body, { toast: false }) } - } - catch (error) { - // 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe - // a la premiere ligne en echec (les suivantes ne sont pas tentees). - if (!mapRowError(error, contactErrors, index)) { - toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }) - } - return - } - } + }, + error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), + contact => !isContactNamed(contact), + ) + // Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes. + if (hasError) return completeTab('contact') toast.success({ title: t('commercial.clients.toast.updateSuccess') }) } @@ -784,26 +778,26 @@ function onAddressDegraded(): void { async function submitAddresses(): Promise { if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return tabSubmitting.value = true - addressErrors.value = [] try { - for (let index = 0; index < addresses.value.length; index++) { - const address = addresses.value[index] - 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, - } - - try { + // On tente TOUS les blocs d'adresse (collecte des erreurs par index, ERP-110). + 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`, @@ -815,14 +809,10 @@ async function submitAddresses(): Promise { else { await api.patch(`/client_addresses/${address.id}`, body, { toast: false }) } - } - catch (error) { - if (!mapRowError(error, addressErrors, index)) { - toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }) - } - return - } - } + }, + 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') }) } @@ -893,7 +883,6 @@ async function submitAccounting(): Promise { if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return tabSubmitting.value = true accountingErrors.clearErrors() - ribErrors.value = [] try { // 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). try { @@ -912,30 +901,29 @@ async function submitAccounting(): Promise { return } - // 2) POST/PATCH des RIB (erreurs inline par ligne). - for (let index = 0; index < ribs.value.length; index++) { - const rib = ribs.value[index] - if (!ribIsComplete(rib)) continue - try { + // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes — + // les blocs RIB incomplets sont ignores). + 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`, - { label: rib.label, bic: rib.bic, iban: rib.iban }, + body, { headers: { Accept: 'application/ld+json' }, toast: false }, ) rib.id = created.id } else { - await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false }) + await api.patch(`/client_ribs/${rib.id}`, body, { toast: false }) } - } - catch (error) { - if (!mapRowError(error, ribErrors, index)) { - toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }) - } - return - } - } + }, + 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') }) -- 2.39.5 From f407c3d46a8e03e7067489032b6dd5641dfd6e21 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 12:01:14 +0200 Subject: [PATCH 06/10] fix(commercial) : ne sauter que les blocs contact/RIB totalement vides (bloc partiel sans nom -> 422 inline, ERP-110) --- .../commercial/pages/clients/[id]/edit.vue | 16 +++-- .../modules/commercial/pages/clients/new.vue | 16 +++-- .../utils/__tests__/clientFormRules.spec.ts | 59 +++++++++++++++++++ .../commercial/utils/clientFormRules.ts | 52 ++++++++++++++++ 4 files changed, 131 insertions(+), 12 deletions(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 9c29de4..afb6275 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -414,7 +414,9 @@ import { hasAtLeastOneValidContact, isBankRequiredForPaymentType, isBillingEmailRequired, + isContactBlank, isContactNamed, + isRibBlank, isRibRequiredForPaymentType, } from '~/modules/commercial/utils/clientFormRules' import { @@ -742,8 +744,9 @@ async function submitContacts(): Promise { } removedContactIds.value = [] - // On tente TOUS les blocs (collecte des erreurs par index, ERP-110) ; les - // blocs vides (ni nom ni prenom) sont ignores. + // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls + // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli + // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. const hasError = await submitRows( contacts.value, contactErrors, @@ -763,7 +766,7 @@ async function submitContacts(): Promise { } }, error => showError(error), - contact => !isContactNamed(contact), + contact => isContactBlank(contact), ) // Tant qu'un bloc reste en erreur : pas de toast succes. if (hasError) return @@ -917,8 +920,9 @@ async function submitAccounting(): Promise { } removedRibIds.value = [] - // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes — - // les blocs RIB incomplets sont ignores). + // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes). + // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. + // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. const ribHasError = await submitRows( ribs.value, ribErrors, @@ -937,7 +941,7 @@ async function submitAccounting(): Promise { } }, error => showError(error), - rib => !ribIsComplete(rib), + rib => isRibBlank(rib), ) if (ribHasError) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 366fb9e..7136d0e 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -385,7 +385,9 @@ import { hasAtLeastOneValidContact, isBankRequiredForPaymentType, isBillingEmailRequired, + isContactBlank, isContactNamed, + isRibBlank, isRibRequiredForPaymentType, } from '~/modules/commercial/utils/clientFormRules' import { @@ -677,8 +679,9 @@ async function submitContacts(): Promise { if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return tabSubmitting.value = true try { - // On tente TOUS les blocs (collecte des erreurs par index, ERP-110) ; les - // blocs vides (ni nom ni prenom) sont ignores. + // On tente TOUS les blocs (collecte des erreurs par index, ERP-110). Seuls + // les blocs TOTALEMENT vides sont ignores : un bloc partiellement rempli + // sans nom (email seul) est soumis -> 422 RG-1.05 inline sous le bloc. const hasError = await submitRows( contacts.value, contactErrors, @@ -705,7 +708,7 @@ async function submitContacts(): Promise { } }, error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), - contact => !isContactNamed(contact), + contact => isContactBlank(contact), ) // Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes. if (hasError) return @@ -901,8 +904,9 @@ async function submitAccounting(): Promise { return } - // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes — - // les blocs RIB incomplets sont ignores). + // 2) POST/PATCH des RIB (erreurs inline par ligne, tous les blocs tentes). + // Seuls les blocs RIB TOTALEMENT vides sont ignores : un RIB partiel (ex. + // IBAN seul) est soumis -> 422 NotBlank (label / bic / iban) inline. const ribHasError = await submitRows( ribs.value, ribErrors, @@ -921,7 +925,7 @@ async function submitAccounting(): Promise { } }, error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), - rib => !ribIsComplete(rib), + rib => isRibBlank(rib), ) if (ribHasError) return diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts index 5c76d00..eda7206 100644 --- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -7,11 +7,27 @@ import { hasAtLeastOneValidContact, isBankRequiredForPaymentType, isBillingEmailRequired, + isBlankRow, + isContactBlank, isContactNamed, + isRibBlank, isRibRequiredForPaymentType, type ContactDraft, + type ContactFillableDraft, } from '../clientFormRules' +/** Bloc contact totalement vide (amorce par defaut). */ +function blankContact(): ContactFillableDraft { + return { + firstName: null, + lastName: null, + jobTitle: null, + phonePrimary: null, + phoneSecondary: null, + email: null, + } +} + describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => { it('inclut l onglet accounting si l utilisateur a accounting.view', () => { expect(buildClientFormTabKeys(true)).toContain('accounting') @@ -59,6 +75,49 @@ describe('isContactNamed (RG-1.05)', () => { }) }) +describe('isBlankRow (primitive : toutes les valeurs vides)', () => { + it('vrai si toutes les valeurs sont nulles / vides / espaces', () => { + expect(isBlankRow([null, undefined, '', ' '])).toBe(true) + expect(isBlankRow([])).toBe(true) + }) + + it('faux des qu une valeur porte un caractere non-espace', () => { + expect(isBlankRow([null, 'x', ''])).toBe(false) + }) +}) + +describe('isRibBlank (bloc RIB totalement vide vs partiellement rempli)', () => { + it('vrai si label / bic / iban sont tous vides', () => { + expect(isRibBlank({ label: null, bic: null, iban: null })).toBe(true) + expect(isRibBlank({ label: ' ', bic: '', iban: null })).toBe(true) + }) + + it('faux si un IBAN seul est saisi (bloc a soumettre -> 422 NotBlank inline)', () => { + expect(isRibBlank({ label: null, bic: null, iban: 'FR1420041010050500013M02606' })).toBe(false) + }) + + it('faux si seul le libelle est saisi', () => { + expect(isRibBlank({ label: 'Compte courant', bic: null, iban: null })).toBe(false) + }) +}) + +describe('isContactBlank (bloc totalement vide vs partiellement rempli)', () => { + it('vrai si aucun champ saisissable n est rempli', () => { + expect(isContactBlank(blankContact())).toBe(true) + expect(isContactBlank({ ...blankContact(), firstName: ' ', email: '' })).toBe(true) + }) + + it('faux si un email seul est saisi (bloc a soumettre -> 422 RG-1.05 inline)', () => { + expect(isContactBlank({ ...blankContact(), email: 'jean@acme.fr' })).toBe(false) + }) + + it('faux si seul un telephone, une fonction ou un nom est saisi', () => { + expect(isContactBlank({ ...blankContact(), phonePrimary: '0612345678' })).toBe(false) + expect(isContactBlank({ ...blankContact(), jobTitle: 'Directeur' })).toBe(false) + expect(isContactBlank({ ...blankContact(), firstName: 'Alice' })).toBe(false) + }) +}) + describe('hasAtLeastOneValidContact (RG-1.14)', () => { it('faux sur une liste vide', () => { expect(hasAtLeastOneValidContact([])).toBe(false) diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index 280248e..f1f6830 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -86,6 +86,58 @@ export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean { return contacts.some(isContactNamed) } +/** + * Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides (null / + * undefined / espaces uniquement). Sert a detecter un bloc de collection + * totalement vide (amorce non remplie). Un bloc qui porte la moindre donnee + * n'est PAS « blank » : il doit etre soumis pour declencher sa 422 inline plutot + * que d'etre saute silencieusement. + */ +export function isBlankRow(values: (string | null | undefined)[]): boolean { + return values.every(value => !isFilled(value)) +} + +/** Champs saisissables d'un bloc contact (pour detecter un bloc totalement vide). */ +export interface ContactFillableDraft extends ContactDraft { + jobTitle: string | null + phonePrimary: string | null + phoneSecondary: string | null + email: string | null +} + +/** + * Vrai si AUCUN champ saisissable du bloc contact n'est rempli. Distingue un bloc + * d'amorce vide (a ignorer au submit) d'un bloc partiellement rempli sans nom + * (email / telephone / fonction seul) : ce dernier doit etre soumis pour + * declencher la 422 RG-1.05 (« prenom ou nom obligatoire ») affichee inline. + */ +export function isContactBlank(contact: ContactFillableDraft): boolean { + return isBlankRow([ + contact.firstName, + contact.lastName, + contact.jobTitle, + contact.phonePrimary, + contact.phoneSecondary, + contact.email, + ]) +} + +/** Champs saisissables d'un bloc RIB (pour detecter un bloc totalement vide). */ +export interface RibFillableDraft { + label: string | null + bic: string | null + iban: string | null +} + +/** + * Vrai si AUCUN champ du bloc RIB n'est rempli. Un RIB partiellement rempli (ex. + * IBAN seul) n'est PAS « blank » : il doit etre soumis pour declencher les 422 + * NotBlank (label / bic / iban) inline plutot que d'etre saute silencieusement. + */ +export function isRibBlank(rib: RibFillableDraft): boolean { + return isBlankRow([rib.label, rib.bic, rib.iban]) +} + /** * RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de * livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni -- 2.39.5 From b564838c2e5d96d927e2e8f57447394093a9438c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 13:33:51 +0200 Subject: [PATCH 07/10] fix(commercial) : reset erreurs RIB en tete et ne sauter que les amorces neuves vides (ERP-110) --- .../modules/commercial/pages/clients/[id]/edit.vue | 14 ++++++++++++-- frontend/modules/commercial/pages/clients/new.vue | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index afb6275..99b9af1 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -766,7 +766,10 @@ async function submitContacts(): Promise { } }, error => showError(error), - contact => isContactBlank(contact), + // On ne saute QUE les amorces neuves (id null) totalement vides. Un + // bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif + // serait perdue en silence avec un faux toast de succes). + contact => contact.id === null && isContactBlank(contact), ) // Tant qu'un bloc reste en erreur : pas de toast succes. if (hasError) return @@ -905,6 +908,10 @@ async function submitAccounting(): Promise { if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return tabSubmitting.value = true accountingErrors.clearErrors() + // Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut + // echouer et `return` avant submitRows (qui porte sinon le reset), laissant + // des erreurs de RIB obsoletes affichees sous les blocs. + ribErrors.value = [] try { // 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). try { @@ -941,7 +948,10 @@ async function submitAccounting(): Promise { } }, error => showError(error), - rib => isRibBlank(rib), + // On ne saute QUE les amorces neuves (id null) totalement vides. Un + // RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif + // serait perdue en silence avec un faux toast de succes). + rib => rib.id === null && isRibBlank(rib), ) if (ribHasError) return toast.success({ title: t('commercial.clients.toast.updateSuccess') }) diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 7136d0e..1b82795 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -708,7 +708,10 @@ async function submitContacts(): Promise { } }, error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), - contact => isContactBlank(contact), + // On ne saute QUE les amorces neuves (id null) totalement vides. Un + // bloc existant vide est soumis -> 422 RG-1.05 inline (sinon la modif + // serait perdue en silence avec un faux toast de succes). + contact => contact.id === null && isContactBlank(contact), ) // Tant qu'un bloc reste en erreur : pas de validation d'onglet ni de toast succes. if (hasError) return @@ -886,6 +889,10 @@ async function submitAccounting(): Promise { if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return tabSubmitting.value = true accountingErrors.clearErrors() + // Reset des erreurs RIB des le debut : l'etape 1 (PATCH scalaires) peut + // echouer et `return` avant submitRows (qui porte sinon le reset), laissant + // des erreurs de RIB obsoletes affichees sous les blocs. + ribErrors.value = [] try { // 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). try { @@ -925,7 +932,10 @@ async function submitAccounting(): Promise { } }, error => toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }), - rib => isRibBlank(rib), + // On ne saute QUE les amorces neuves (id null) totalement vides. Un + // RIB existant vide est soumis -> 422 NotBlank inline (sinon la modif + // serait perdue en silence avec un faux toast de succes). + rib => rib.id === null && isRibBlank(rib), ) if (ribHasError) return -- 2.39.5 From ad32d8147d45bf86bcb662483c1d1664b7d27beb Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 13:34:00 +0200 Subject: [PATCH 08/10] test(commercial) : POST sous-ressource sur client inexistant -> 404 (ERP-110) --- .../Api/ClientSubResourceApiTest.php | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php index 321ac10..88f6921 100644 --- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -140,6 +140,25 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']); } + /** + * ERP-110 : avec read:false sur le POST, un parent introuvable n'est plus + * intercepte au stade lecture. Le 404 est desormais porte par + * ClientContactProcessor::linkParent (sinon 500 au persist sur client_id + * NOT NULL). Le payload est valide pour atteindre le processor (apres la + * validation). + */ + public function testPostContactOnMissingClientReturns404(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/clients/999999/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['firstName' => 'Orphan'], + ]); + + self::assertResponseStatusCodeSame(404); + } + public function testPatchContactNormalizes(): void { $client = $this->createAdminClient(); @@ -276,6 +295,32 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(201); } + /** + * ERP-110 : POST adresse sur un client inexistant -> 404 porte par + * ClientAddressProcessor::linkParent (read:false). Payload valide (site + + * categorie, RG-1.10 / RG-1.29) pour atteindre le processor. + */ + public function testPostAddressOnMissingClientReturns404(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $siteIri = $this->firstSiteIri(); + $category = $this->createCategory('SECTEUR'); + + $client->request('POST', '/api/clients/999999/addresses', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'postalCode' => '75001', + 'city' => 'Paris', + 'street' => '2 rue Neuve', + 'sites' => [$siteIri], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(404); + } + // === RIBs === public function testPostRibByAdminReturns201(): void @@ -334,6 +379,23 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(201); } + /** + * ERP-110 : POST RIB sur un client inexistant -> 404 porte par + * ClientRibProcessor::linkParent (read:false). L'admin porte + * commercial.clients.accounting.manage ; payload valide (BIC / IBAN). + */ + public function testPostRibOnMissingClientReturns404(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/clients/999999/ribs', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['label' => 'Orphan', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN], + ]); + + self::assertResponseStatusCodeSame(404); + } + public function testDeleteRibNonLcrReturns204(): void { $client = $this->createAdminClient(); -- 2.39.5 From c2282fdac56d1f232e70cf5a49916ec7901ea36d Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 4 Jun 2026 14:45:14 +0200 Subject: [PATCH 09/10] =?UTF-8?q?fix(commercial)=20:=20rendre=20obligatoir?= =?UTF-8?q?es=20les=206=20champs=20comptables=20=E2=80=94=20422=20back=20p?= =?UTF-8?q?ar=20champ=20+=20bouton=20=C2=AB=20Valider=20=C2=BB=20gris?= =?UTF-8?q?=C3=A9=20tant=20que=20l'onglet=20est=20incomplet=20(ERP-110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec-front marquait SIREN / N° compte / Mode TVA / N° TVA / Délai / Type de règlement obligatoires, mais ni le back (colonnes nullable, aucun NotBlank) ni le front (canValidateAccounting ne gardait que bank/RIB) ne l'imposaient : on validait un onglet vide. - Back : ClientAccountingCompletenessValidator (calqué sur RG-1.04) invoqué par le ClientProcessor quand les 6 champs sont présents dans le payload (validation d'onglet) → 422 par propertyPath. PATCH partiel ciblé non impacté. - Front : helper hasAllRequiredAccountingFields + canValidateAccounting étendu dans new.vue / edit.vue (cohérent avec les onglets Contact/Adresse). - Spec-back : RG-1.30 documente la règle et résout l'incohérence spec-front/spec-back. --- docs/specs/M1-clients/spec-back.md | 1 + .../commercial/pages/clients/[id]/edit.vue | 2 + .../modules/commercial/pages/clients/new.vue | 4 + .../utils/__tests__/clientFormRules.spec.ts | 34 ++++++++ .../commercial/utils/clientFormRules.ts | 29 +++++++ .../ClientAccountingCompletenessValidator.php | 77 +++++++++++++++++ .../State/Processor/ClientProcessor.php | 34 ++++++++ .../Commercial/Unit/ClientProcessorTest.php | 82 +++++++++++++++++++ 8 files changed, 263 insertions(+) create mode 100644 src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php diff --git a/docs/specs/M1-clients/spec-back.md b/docs/specs/M1-clients/spec-back.md index 0476b45..fb30551 100644 --- a/docs/specs/M1-clients/spec-back.md +++ b/docs/specs/M1-clients/spec-back.md @@ -883,6 +883,7 @@ Cf. § 2.6. Pattern Shared standard. ### Onglet Comptabilité +- **RG-1.30** _(ajoutée — correctif incohérence spec-front/spec-back)_ : à la **validation complète de l'onglet Comptabilité**, les six champs scalaires `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType` sont **obligatoires** (alignement sur spec-front § Onglet Comptabilité). Colonnes `nullable` en base (l'onglet est rempli dans un second temps, et l'onglet principal ne les envoie pas) + validateur contextuel `ClientAccountingCompletenessValidator` invoqué par le `ClientProcessor` — même parti que RG-1.04 (Information). Déclenchement : uniquement quand **les six champs sont présents dans le payload** (le front les envoie toujours ensemble via « Valider ») ; un PATCH ciblant un sous-ensemble de champs comptables (édition ponctuelle) n'est pas soumis à la complétude. Chaque champ manquant → 422 sur son `propertyPath` (mapping inline front, ERP-101). `bank` reste hors complétude (conditionnel RG-1.12). - **RG-1.12** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'`. Validation server-side dans le `ClientProcessor` : si `payment_type.code = VIREMENT` et `bank IS NULL` → 422. - **RG-1.13** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si **au moins un bloc RIB est présent ET** `paymentType.code = 'LCR'`. C'est-à-dire : - Si `paymentType.code = LCR` ET `client.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ». diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 99b9af1..7cbd718 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -411,6 +411,7 @@ import { } from '~/modules/commercial/utils/clientEdit' import { buildClientFormTabKeys, + hasAllRequiredAccountingFields, hasAtLeastOneValidContact, isBankRequiredForPaymentType, isBillingEmailRequired, @@ -878,6 +879,7 @@ function ribIsComplete(rib: { label: string | null, bic: string | null, iban: st } const canValidateAccounting = computed(() => { + if (!hasAllRequiredAccountingFields(accounting)) return false if (isBankRequired.value && accounting.bankIri === null) return false if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false return true diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 1b82795..c248499 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -382,6 +382,7 @@ import { useClientFormErrors } from '~/modules/commercial/composables/useClientF import { buildClientFormTabKeys, CLIENT_FORM_PLACEHOLDER_TABS, + hasAllRequiredAccountingFields, hasAtLeastOneValidContact, isBankRequiredForPaymentType, isBillingEmailRequired, @@ -860,8 +861,11 @@ function ribIsComplete(rib: RibFormDraft): boolean { return filled(rib.label) && filled(rib.bic) && filled(rib.iban) } +// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact / +// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet). // RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR. const canValidateAccounting = computed(() => { + if (!hasAllRequiredAccountingFields(accounting)) return false if (isBankRequired.value && (accounting.bankIri === null)) return false if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false return true diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts index eda7206..34da52c 100644 --- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -4,6 +4,7 @@ import { buildClientFormTabKeys, canSelectDeliveryOrBilling, canSelectProspect, + hasAllRequiredAccountingFields, hasAtLeastOneValidContact, isBankRequiredForPaymentType, isBillingEmailRequired, @@ -209,3 +210,36 @@ describe('regles type de reglement (RG-1.12 / RG-1.13)', () => { expect(isRibRequiredForPaymentType(null)).toBe(false) }) }) + +describe('hasAllRequiredAccountingFields (RG-1.30)', () => { + const complete = { + siren: '123456789', + accountNumber: '00012345678', + nTva: 'FR12345678901', + tvaModeIri: '/api/tva_modes/1', + paymentDelayIri: '/api/payment_delays/1', + paymentTypeIri: '/api/payment_types/1', + } + + it('vrai quand les six champs obligatoires sont remplis', () => { + expect(hasAllRequiredAccountingFields(complete)).toBe(true) + }) + + it('faux si un champ est manquant (null ou vide apres trim)', () => { + expect(hasAllRequiredAccountingFields({ ...complete, siren: null })).toBe(false) + expect(hasAllRequiredAccountingFields({ ...complete, accountNumber: ' ' })).toBe(false) + expect(hasAllRequiredAccountingFields({ ...complete, tvaModeIri: null })).toBe(false) + expect(hasAllRequiredAccountingFields({ ...complete, paymentTypeIri: null })).toBe(false) + }) + + it('faux quand tout est vide (onglet non rempli)', () => { + expect(hasAllRequiredAccountingFields({ + siren: null, + accountNumber: null, + nTva: null, + tvaModeIri: null, + paymentDelayIri: null, + paymentTypeIri: null, + })).toBe(false) + }) +}) diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index f1f6830..7fee6a4 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -208,3 +208,32 @@ export function isBankRequiredForPaymentType(code: string | null | undefined): b export function isRibRequiredForPaymentType(code: string | null | undefined): boolean { return code === PAYMENT_TYPE_LCR } + +/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */ +export interface AccountingRequiredDraft { + siren: string | null + accountNumber: string | null + nTva: string | null + tvaModeIri: string | null + paymentDelayIri: string | null + paymentTypeIri: string | null +} + +/** + * RG-1.30 : les six champs scalaires de l'onglet Comptabilite sont obligatoires + * pour valider l'onglet (SIREN, N de compte, Mode de TVA, N de TVA, Delai de + * reglement, Type de reglement). bank / RIB restent conditionnels (RG-1.12 / + * RG-1.13) et sont evalues a part. Miroir front du + * ClientAccountingCompletenessValidator : meme gate que les onglets Contact / + * Adresse (bouton « Valider » desactive tant que l'onglet n'est pas complet). + */ +export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDraft): boolean { + const filled = (v: string | null): boolean => v !== null && v.trim() !== '' + + return filled(accounting.siren) + && filled(accounting.accountNumber) + && filled(accounting.nTva) + && filled(accounting.tvaModeIri) + && filled(accounting.paymentDelayIri) + && filled(accounting.paymentTypeIri) +} diff --git a/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php b/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php new file mode 100644 index 0000000..32a2a23 --- /dev/null +++ b/src/Module/Commercial/Application/Validator/ClientAccountingCompletenessValidator.php @@ -0,0 +1,77 @@ + valeur courante des champs obligatoires de l'onglet. + $fields = [ + 'siren' => $client->getSiren(), + 'accountNumber' => $client->getAccountNumber(), + 'tvaMode' => $client->getTvaMode(), + 'nTva' => $client->getNTva(), + 'paymentDelay' => $client->getPaymentDelay(), + 'paymentType' => $client->getPaymentType(), + ]; + + $violations = new ConstraintViolationList(); + + foreach ($fields as $property => $value) { + if ($this->isMissing($value)) { + $violations->add(new ConstraintViolation( + 'Ce champ est obligatoire.', + null, + [], + $client, + $property, + $value, + )); + } + } + + if (count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les + * references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que + * lorsqu'elles valent null. + */ + private function isMissing(mixed $value): bool + { + if (null === $value) { + return true; + } + + return is_string($value) && '' === trim($value); + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php index 293900f..e451ee7 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Validator\Exception\ValidationException; use App\Module\Commercial\Application\Service\ClientFieldNormalizer; +use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator; use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator; use App\Module\Commercial\Domain\Entity\Client; use App\Shared\Domain\Contract\BusinessRoleAwareInterface; @@ -75,6 +76,14 @@ final class ClientProcessor implements ProcessorInterface 'paymentType', 'bank', ]; + /** + * Champs comptables obligatoires a la validation complete de l'onglet + * (spec-front § Onglet Comptabilite). bank est exclu : conditionnel (RG-1.12). + */ + private const array ACCOUNTING_REQUIRED_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', + ]; + /** Champ d'archivage (groupe client:write:archive). */ private const string ARCHIVE_FIELD = 'isArchived'; @@ -100,6 +109,7 @@ final class ClientProcessor implements ProcessorInterface private readonly ProcessorInterface $persistProcessor, private readonly ClientFieldNormalizer $normalizer, private readonly ClientInformationCompletenessValidator $informationValidator, + private readonly ClientAccountingCompletenessValidator $accountingValidator, private readonly Security $security, private readonly RequestStack $requestStack, private readonly EntityManagerInterface $em, @@ -125,6 +135,7 @@ final class ClientProcessor implements ProcessorInterface $this->validateDistributorBroker($data); $this->validateAccountingConsistency($data); + $this->validateAccountingCompleteness($data); $this->validateInformationCompleteness($data); try { @@ -486,6 +497,29 @@ final class ClientProcessor implements ProcessorInterface } } + /** + * spec-front § Onglet Comptabilite : a la validation COMPLETE de l'onglet + * (les six champs obligatoires presents dans le payload — le front les envoie + * toujours ensemble), chacun doit etre renseigne, sinon 422 par champ. On ne + * declenche pas sur un PATCH ciblant un sous-ensemble de champs comptables : + * ce n'est pas une validation d'onglet (edition ponctuelle preservee). bank / + * RIB restent geres par validateAccountingConsistency (RG-1.12 / RG-1.13). + * + * Colonnes nullable en base + validateur contextuel (meme parti que RG-1.04) : + * un Assert\NotBlank sur l'entite casserait le POST de l'onglet principal, qui + * n'envoie aucun champ comptable. + */ + private function validateAccountingCompleteness(Client $data): void + { + // Declenche uniquement si TOUS les champs requis sont presents dans le + // payload (= soumission d'onglet, pas un PATCH partiel cible). + if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) { + return; + } + + $this->accountingValidator->validate($data); + } + /** * RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier * Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur diff --git a/tests/Module/Commercial/Unit/ClientProcessorTest.php b/tests/Module/Commercial/Unit/ClientProcessorTest.php index 8a025f3..1ac8905 100644 --- a/tests/Module/Commercial/Unit/ClientProcessorTest.php +++ b/tests/Module/Commercial/Unit/ClientProcessorTest.php @@ -8,11 +8,14 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Validator\Exception\ValidationException; use App\Module\Commercial\Application\Service\ClientFieldNormalizer; +use App\Module\Commercial\Application\Validator\ClientAccountingCompletenessValidator; use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator; use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Entity\ClientRib; +use App\Module\Commercial\Domain\Entity\PaymentDelay; use App\Module\Commercial\Domain\Entity\PaymentType; +use App\Module\Commercial\Domain\Entity\TvaMode; use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor; use App\Shared\Domain\Contract\BusinessRoleAwareInterface; use App\Shared\Domain\Security\BusinessRoles; @@ -280,6 +283,65 @@ final class ClientProcessorTest extends TestCase self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); } + public function testFullAccountingSubmitWithEmptyFieldsIsUnprocessable(): void + { + // spec-front § Onglet Comptabilite : une validation complete de l'onglet + // (les 6 champs presents dans le payload) avec des valeurs vides -> 422. + // C'est le bug corrige : avant, le back acceptait un onglet tout vide. + $client = $this->minimalClient(); // aucun champ comptable renseigne + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: $this->emptyAccountingPayload(), + ); + + $this->expectException(ValidationException::class); + $processor->process($client, $this->operation()); + } + + public function testFullAccountingSubmitWithAllFieldsPasses(): void + { + // Les 6 champs obligatoires renseignes + type de reglement neutre + // (ni VIREMENT ni LCR -> ni banque ni RIB requis) -> 200. + $client = $this->minimalClient(); + $client->setSiren('123456789'); + $client->setAccountNumber('00012345678'); + $client->setTvaMode(new TvaMode()); + $client->setNTva('FR12345678901'); + $client->setPaymentDelay(new PaymentDelay()); + $client->setPaymentType($this->paymentType('CHEQUE')); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: $this->emptyAccountingPayload(), + ); + + self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); + } + + public function testPartialAccountingPatchSkipsCompleteness(): void + { + // Un PATCH ciblant un seul champ comptable n'est pas une validation + // d'onglet : la completude n'est pas exigee (les autres champs restent + // vides) -> 200. Preserve l'edition ponctuelle (ex. Compta corrige le SIREN). + $client = $this->minimalClient(); + $client->setSiren('999999999'); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: ['siren' => '999999999'], + managed: true, + originalData: [ + 'siren' => '111111111', + 'companyName' => 'TEST CO', + 'triageService' => false, + 'isArchived' => false, + ], + ); + + self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); + } + public function testCommercialeIncompleteInformationIsUnprocessable(): void { // RG-1.04 : role Commerciale + onglet Information incomplet -> 422. @@ -379,6 +441,7 @@ final class ClientProcessorTest extends TestCase $persist, new ClientFieldNormalizer(), new ClientInformationCompletenessValidator(), + new ClientAccountingCompletenessValidator(), $security, $requestStack, $em, @@ -398,6 +461,25 @@ final class ClientProcessorTest extends TestCase return $client; } + /** + * Payload simulant une validation complete de l'onglet Comptabilite : les 6 + * champs obligatoires presents (le front les envoie toujours ensemble). Les + * valeurs importent peu — la completude est evaluee sur l'etat de l'entite. + * + * @return array + */ + private function emptyAccountingPayload(): array + { + return [ + 'siren' => null, + 'accountNumber' => null, + 'tvaMode' => null, + 'nTva' => null, + 'paymentDelay' => null, + 'paymentType' => null, + ]; + } + private function paymentType(string $code): PaymentType { $type = new PaymentType(); -- 2.39.5 From 5834d7b225f6f0834d836be15d92617419c07978 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 4 Jun 2026 15:58:01 +0200 Subject: [PATCH 10/10] =?UTF-8?q?feat(commercial)=20:=20onglet=20adresse?= =?UTF-8?q?=20=E2=80=94=20Select=20=C2=AB=20Type=20d'adresse=20=C2=BB=20+?= =?UTF-8?q?=20Sites=20en=20multiselect,=20ligne=201=20r=C3=A9agenc=C3=A9e?= =?UTF-8?q?=20(ERP-110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sites Starseed : 3 cases -> multiselect a tags « Sites » (required). - Usage adresse : 3 cases Prospect/Livraison/Facturation -> Select unique « Type d'adresse » (Prospect / Livraison / Facturation / Adresse + Facturation), obligatoire sans option vide, conditionnant le bouton « Valider ». Pur sucre front : le back recoit toujours isProspect/isDelivery/isBilling (aucune RG modifiee), exclusivite Prospect devenue structurelle. - Email de facturation conditionnel (Facturation / Adresse + Facturation) deplace en ligne 1. - Ligne 1 : Type d'adresse | Sites | Contact rattache | Email ; le reste (Categorie, Pays, CP, Ville, Adresse...) en lignes suivantes. - Email : MalioInputText -> MalioInputEmail (lowercase, ERP-101/RG-1.21) sur facturation ET contact. - Helpers front testables addressFlagsFromType / addressTypeFromFlags + gating canValidateAddresses (type obligatoire) dans new.vue / edit.vue. --- frontend/i18n/locales/fr.json | 7 +- .../components/ClientAddressBlock.vue | 143 ++++++++---------- .../components/ClientContactBlock.vue | 1 + .../commercial/pages/clients/[id]/edit.vue | 4 +- .../modules/commercial/pages/clients/new.vue | 7 +- .../utils/__tests__/clientFormRules.spec.ts | 28 ++++ .../commercial/utils/clientFormRules.ts | 39 +++++ 7 files changed, 146 insertions(+), 83 deletions(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 5744828..cf7bc63 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -168,13 +168,18 @@ "prospect": "Prospect", "delivery": "Adresse de livraison", "billing": "Facturation", + "addressType": "Type d'adresse", + "addressTypeProspect": "Prospect", + "addressTypeDelivery": "Livraison", + "addressTypeBilling": "Facturation", + "addressTypeDeliveryBilling": "Adresse + Facturation", "categories": "Catégorie", "country": "Pays", "postalCode": "Code postal", "city": "Ville", "street": "Adresse", "streetComplement": "Adresse complémentaire", - "sites": "Sites Starseed", + "sites": "Sites", "contacts": "Contact(s) rattaché(s)", "billingEmail": "Email de facturation", "remove": "Supprimer l'adresse", diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index ee6e08b..1745a4b 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -10,34 +10,53 @@ @click="$emit('remove')" /> - - + - - - -