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
@@ -137,6 +137,27 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
return $client;
}
/**
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
* visee etait cassee pour une autre raison. Mutualise ici (et non dans la
* sous-classe Supplier) pour etre accessible a tous les tests Commercial.
*
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
private function cleanupCommercialTestData(): void
{
$em = $this->getEm();
@@ -298,26 +298,6 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
return $this->referential(Bank::class, $code);
}
/**
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
* visee etait cassee pour une autre raison.
*
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
/**
* Recupere un referentiel comptable seede (CommercialReferentialFixtures) par
* code. Echoue explicitement si absent (fixtures non chargees).
@@ -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).
*/
@@ -85,4 +85,77 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
self::assertNotNull($persisted);
self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName());
}
/**
* RG-1.03 bis : declarer une relation « depend d'un distributeur »
* (relationType, champ transitoire) sans renseigner la FK distributor doit
* produire une 422 portee sur `distributor`. Le back ne peut pas deviner
* l'intention depuis la seule FK nullable (distributor=null = client
* independant), d'ou relationType qui la transporte.
*/
public function testRelationDistributeurSansDistributeurEst422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Relation Sans Distrib SARL',
'categories' => ['/api/categories/'.$cat->getId()],
'relationType' => 'distributeur',
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('distributor', $byPath);
self::assertSame('Le nom du distributeur est obligatoire.', $byPath['distributor']);
}
/** Idem courtier : relationType=courtier sans broker -> 422 portee sur `broker`. */
public function testRelationCourtierSansCourtierEst422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$body = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Relation Sans Courtier SARL',
'categories' => ['/api/categories/'.$cat->getId()],
'relationType' => 'courtier',
],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($body);
self::assertArrayHasKey('broker', $byPath);
self::assertSame('Le nom du courtier est obligatoire.', $byPath['broker']);
}
/**
* Le champ transitoire relationType ne casse pas la creation nominale : avec
* la FK correspondante renseignee, le client se cree (201) et relationType
* n'est jamais serialise en sortie (write-only, aucun groupe de lecture).
*/
public function testRelationDistributeurAvecDistributeurEst201(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$distributor = $this->seedClient('Distrib Cible', false, 'DISTRIBUTEUR');
$data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Relation Ok SARL',
'categories' => ['/api/categories/'.$cat->getId()],
'relationType' => 'distributeur',
'distributor' => '/api/clients/'.$distributor->getId(),
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertArrayNotHasKey('relationType', $data);
}
}
@@ -59,12 +59,18 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas
self::assertArrayHasKey('isProspect', $address);
self::assertArrayHasKey('isDelivery', $address);
self::assertArrayHasKey('isBilling', $address);
// Memes garanties pour les types Courtier / Distributeur (ERP-119, meme
// pattern getter + SerializedName).
self::assertArrayHasKey('isBroker', $address);
self::assertArrayHasKey('isDistributor', $address);
// L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
// Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
self::assertFalse($address['isProspect']);
self::assertTrue($address['isDelivery']);
self::assertTrue($address['isBilling']);
self::assertFalse($address['isBroker']);
self::assertFalse($address['isDistributor']);
}
// === #80 — Gating des RIB par accounting.view ===
@@ -89,10 +89,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('email', $byPath, 'La violation email doit porter propertyPath=email (mapping front).');
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
@@ -135,10 +132,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('email', $byPath);
self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']);
}
@@ -237,6 +231,7 @@ final class ClientSubResourceApiTest 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',
@@ -258,6 +253,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '123',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
@@ -287,6 +283,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '75001',
'city' => 'Paris',
'street' => '2 rue Neuve',
@@ -313,6 +310,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients/999999/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '75001',
'city' => 'Paris',
'street' => '2 rue Neuve',
@@ -382,10 +380,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);