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
@@ -25,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Client (M1 Commercial) — entite racine du repertoire clients. Porte le
@@ -171,6 +172,17 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])]
private bool $triageService = false;
// Champ transitoire (NON persiste : aucune colonne ORM) portant l'intention UI
// « ce client depend d'un distributeur / courtier ». Write-only (groupe
// d'ecriture main uniquement, pas de groupe de lecture -> jamais serialise en
// sortie). Sert exclusivement a la validation croisee validateRelationName :
// si une relation est choisie, la FK correspondante (distributor / broker)
// devient obligatoire. Non mappe ORM -> non audite, et toujours null une fois
// l'entite rechargee depuis la base (ne sert qu'au cycle d'une ecriture).
#[Assert\Choice(choices: ['distributeur', 'courtier'], message: 'Le type de relation est invalide.')]
#[Groups(['client:write:main'])]
private ?string $relationType = null;
// RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
// CategoryInterface (resolve_target_entities -> Category).
/** @var Collection<int, CategoryInterface> */
@@ -333,6 +345,45 @@ class Client implements TimestampableInterface, BlamableInterface
return $this;
}
public function getRelationType(): ?string
{
return $this->relationType;
}
public function setRelationType(?string $relationType): static
{
$this->relationType = $relationType;
return $this;
}
/**
* RG-1.03 bis : si l'utilisateur declare une relation (« depend d'un
* distributeur / courtier » via le champ transitoire relationType), la FK
* correspondante est obligatoire. Le back ne peut pas deviner cette intention
* a partir des seules FK nullable (distributor=null ne distingue pas « pas de
* relation » de « relation choisie sans nom »), d'ou relationType qui la porte.
* Violation portee sur distributor / broker (champ fautif cote formulaire), de
* sorte que useFormErrors la mappe inline sous le bon select (ERP-101).
*/
#[Assert\Callback]
public function validateRelationName(ExecutionContextInterface $context): void
{
if ('distributeur' === $this->relationType && null === $this->distributor) {
$context->buildViolation('Le nom du distributeur est obligatoire.')
->atPath('distributor')
->addViolation()
;
}
if ('courtier' === $this->relationType && null === $this->broker) {
$context->buildViolation('Le nom du courtier est obligatoire.')
->atPath('broker')
->addViolation()
;
}
}
public function isTriageService(): bool
{
return $this->triageService;
@@ -129,6 +129,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:write'])]
private bool $isBilling = false;
// Adresse Courtier / Distributeur : types autonomes (comme Prospection),
// exclusifs de tout autre usage (validateExclusiveAddressTypes + CHECK BDD
// chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive).
// Lecture portee par le getter + SerializedName (meme pattern que isProspect).
#[ORM\Column(name: 'is_broker', options: ['default' => false])]
#[Groups(['client_address:write'])]
private bool $isBroker = false;
#[ORM\Column(name: 'is_distributor', options: ['default' => false])]
#[Groups(['client_address:write'])]
private bool $isDistributor = false;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])]
@@ -166,6 +178,15 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmail = null;
// 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire).
// Comme le principal : interdit hors facturation (validateBillingEmailPresence),
// mais jamais obligatoire. Normalise en lowercase par le ClientAddressProcessor.
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email(message: 'L\'email de facturation secondaire n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email de facturation secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmailSecondary = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['client_address:read', 'client_address:write'])]
private int $position = 0;
@@ -223,6 +244,48 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
}
}
/**
* Au moins un type d'adresse est obligatoire (Prospection, Livraison ou
* Facturation) : une adresse sans aucun drapeau pose n'a pas de sens metier.
* La violation est portee sur `isProspect` (meme champ que l'exclusivite) pour
* un mapping inline sous le select « Type d'adresse » cote front (ERP-119).
*/
#[Assert\Callback]
public function validateAddressTypeRequired(ExecutionContextInterface $context): void
{
if (!$this->isProspect && !$this->isDelivery && !$this->isBilling && !$this->isBroker && !$this->isDistributor) {
$context->buildViolation('Le type d\'adresse est obligatoire.')
->atPath('isProspect')
->addViolation()
;
}
}
/**
* Courtier et Distributeur sont des types d'adresse AUTONOMES (comme la
* Prospection) : exclusifs de tout autre usage (Livraison / Facturation /
* Prospection / l'autre type autonome). Mirror applicatif (422) des CHECK
* chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive.
* Violation portee sur `isProspect` (mappee sous le select « Type d'adresse »).
*/
#[Assert\Callback]
public function validateExclusiveAddressTypes(ExecutionContextInterface $context): void
{
if ($this->isBroker && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isDistributor)) {
$context->buildViolation('Une adresse Courtier ne peut pas avoir d\'autre type.')
->atPath('isProspect')
->addViolation()
;
}
if ($this->isDistributor && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isBroker)) {
$context->buildViolation('Une adresse Distributeur ne peut pas avoir d\'autre type.')
->atPath('isProspect')
->addViolation()
;
}
}
/**
* RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK
@@ -254,6 +317,16 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
->addViolation()
;
}
// Le 2e email est OPTIONNEL (jamais requis), mais comme le principal il
// n'a de sens que sur une adresse de facturation.
$hasSecondaryEmail = null !== $this->billingEmailSecondary && '' !== trim($this->billingEmailSecondary);
if (!$this->isBilling && $hasSecondaryEmail) {
$context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.')
->atPath('billingEmailSecondary')
->addViolation()
;
}
}
/**
@@ -343,6 +416,34 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this;
}
#[Groups(['client_address:read'])]
#[SerializedName('isBroker')]
public function isBroker(): bool
{
return $this->isBroker;
}
public function setIsBroker(bool $isBroker): static
{
$this->isBroker = $isBroker;
return $this;
}
#[Groups(['client_address:read'])]
#[SerializedName('isDistributor')]
public function isDistributor(): bool
{
return $this->isDistributor;
}
public function setIsDistributor(bool $isDistributor): static
{
$this->isDistributor = $isDistributor;
return $this;
}
public function getCountry(): string
{
return $this->country;
@@ -415,6 +516,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this;
}
public function getBillingEmailSecondary(): ?string
{
return $this->billingEmailSecondary;
}
public function setBillingEmailSecondary(?string $billingEmailSecondary): static
{
$this->billingEmailSecondary = $billingEmailSecondary;
return $this;
}
public function getPosition(): int
{
return $this->position;
@@ -94,5 +94,6 @@ final class ClientAddressProcessor implements ProcessorInterface
private function normalize(ClientAddress $address): void
{
$address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail()));
$address->setBillingEmailSecondary($this->normalizer->normalizeEmail($address->getBillingEmailSecondary()));
}
}
@@ -219,19 +219,22 @@ final class ColumnCommentsCatalog
] + self::timestampableBlamableComments(),
'client_address' => [
'_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).',
'id' => 'Identifiant interne auto-incremente.',
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
'_table' => 'Adresses d un client (1:n) — types prospect / livraison / facturation (exclusivites RG-1.06/07/08) + Courtier / Distributeur autonomes (exclusifs de tout autre usage), >= 1 site rattache (RG-1.10).',
'id' => 'Identifiant interne auto-incremente.',
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
'is_broker' => 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.',
'is_distributor' => 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
'billing_email_secondary' => '2e email de facturation, optionnel (max 2). Interdit hors facturation, normalise en minuscules (RG-1.21).',
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
] + self::timestampableBlamableComments(),
'client_address_site' => [