ERP-119 : revue validation front clients + évolutions écran client (types d'adresse, 2e email, saisies manuelles, redirection) (#80)
Auto Tag Develop / tag (push) Successful in 7s

## Contexte
Branche ERP-119 — revue de la validation des formulaires clients (déclencheur : écran « Ajouter un client »), accompagnée de plusieurs évolutions de l'écran client (M1).

## Contenu

### Validation front (clients)
- Boutons « Valider » toujours actifs (retrait du gating de validité) : c'est le back qui renvoie les 422, mappées en rouge par champ.
- Champs requis adossés à une colonne non-nullable : la clé est omise du payload si vide (companyName, RIB, adresse) → 422 NotBlank au lieu d'un 400 de type.
- Onglet Contact : au moins un contact requis (l'amorce vide est soumise → 422 RG-1.05).
- Onglet Adresse : affichage inline des erreurs type / sites / catégories + RG back « au moins un type d'adresse obligatoire ».

### Nouveaux types d'adresse
- Courtier / Distributeur, types autonomes exclusifs : colonnes `is_broker` / `is_distributor` (migration + CHECK miroir d'exclusivité), entité + Callback, et front (select, drapeaux, payloads).

### Saisies manuelles
- Adresse : `allow-create` sur le champ Adresse → saisie libre si la BAN ne propose rien.
- Date de création : `MalioDate :editable` → saisie clavier JJ/MM/AAAA en plus du calendrier.

### 2e email de facturation
- Colonne `billing_email_secondary` (optionnel, max 2), miroir du téléphone secondaire. Bump `@malio/layer-ui` 1.7.8 (prop `addable`).

### Fin d'ajout d'un client
- Redirection vers la liste à la validation du dernier onglet remplissable par le rôle (Adresse pour Bureau/Commerciale, Comptabilité pour Admin) + toast « Client ajouté ». Dérivé de `tabKeys`, sans règle RBAC custom.

## Vérifications
- Back : suites Module/Commercial + Architecture vertes (Client : 124/124). Migrations appliquées (dev + test).
- Front : Vitest vert (272), ESLint OK.

> Note : le hook pré-commit flake aléatoirement (JWT 401 / timeout DB) sur des tests sans rapport (Supplier) ; les commits ont été faits après vérification des suites concernées en isolation.

Reviewed-on: #80
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #80.
This commit is contained in:
2026-06-09 19:47:40 +00:00
committed by Autin
parent b3ab23ee8f
commit 8490de99da
25 changed files with 1011 additions and 245 deletions
@@ -146,6 +146,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'isBilling' => false,
'billingEmail' => 'parasite@test.fr',
'postalCode' => '86100',
@@ -174,6 +175,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'isBilling' => false,
'billingEmail' => '',
'postalCode' => '86100',
@@ -187,6 +189,62 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(201);
}
/**
* ERP-119 : une adresse de facturation accepte un 2e email (optionnel, max 2).
*/
public function testBillingAddressAcceptsTwoEmails(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Billing Two Emails');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isBilling' => true,
'billingEmail' => 'facturation@test.fr',
'billingEmailSecondary' => 'compta@test.fr',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* ERP-119 : le 2e email de facturation, comme le principal, n'est autorise que
* sur une adresse de facturation -> 422 avec violation sur billingEmailSecondary.
*/
public function testSecondaryBillingEmailRejectedOnNonBillingAddress(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Secondary Email Non Billing');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isDelivery' => true,
'billingEmailSecondary' => 'compta@test.fr',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('billingEmailSecondary', $byPath);
}
/**
* RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422
* avec violation sur le champ `categories`.
@@ -201,6 +259,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -229,6 +288,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -253,6 +313,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -277,6 +338,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -301,6 +363,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -311,6 +374,115 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422);
}
/**
* RG (ERP-119) : au moins un type d'adresse (Prospection / Livraison /
* Facturation) est obligatoire. POST sans aucun drapeau de type -> 422, avec
* une violation portee sur `isProspect` (mappee sous le select « Type
* d'adresse » cote front via ClientAddressBlock).
*/
public function testAddressRequiresAtLeastOneType(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address No Type');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('isProspect', $byPath);
self::assertSame('Le type d\'adresse est obligatoire.', $byPath['isProspect']);
}
/**
* Nouveaux types d'adresse (ERP-119) : Courtier et Distributeur sont acceptes
* comme types autonomes (avec site + categorie). is_broker / is_distributor.
*/
public function testBrokerAddressAccepted(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Type');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isBroker' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testDistributorAddressAccepted(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Distributor Type');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDistributor' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* Courtier / Distributeur sont des types AUTONOMES exclusifs : les combiner avec
* un autre usage (ici Livraison) -> 422, violation sur isProspect (mappee sous le
* select Type d'adresse). Miroir applicatif du CHECK chk_client_address_broker_exclusive.
*/
public function testExclusiveAddressTypeRejected(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Mix');
$category = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isBroker' => true,
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('isProspect', $byPath);
self::assertSame('Une adresse Courtier ne peut pas avoir d\'autre type.', $byPath['isProspect']);
}
/**
* Retourne l'IRI du premier site seede (fixtures Sites).
*/