feat(commercial) : messages de validation FR sur les contraintes back + garde-fou (ERP-107) (#59)
Auto Tag Develop / tag (push) Successful in 8s

## Contexte

Résout **ERP-107** — pendant back du mapping d'erreur par champ front (ERP-101). Le front (`useFormErrors` / `mapViolationsToRecord`) affiche sous chaque champ le `message` renvoyé par le back. Ce ticket garantit que ces messages existent, sont en FR et rattachés au bon champ.

## Changements

- **Messages FR explicites** sur toutes les contraintes `#[Assert\*]` des entités métier : `Client`, `ClientContact`, `ClientAddress`, `ClientRib`, `Category`, `Role`, `User` (Email, NotBlank, Length, Bic, Iban, PositiveOrZero, Count…).
- **Contraintes `Assert\Length` manquantes ajoutées**, calées sur le `length` de la colonne ORM (téléphones `VARCHAR(20)`, `siren`, `nTva`, `accountNumber`, `username`…). Évite une erreur Postgres 500 non rattachée au champ → 422 propre.
- **Locale FR globale** (`symfony/translation` + `default_locale: fr`) comme filet pour les messages natifs Symfony non surchargés.
- **Garde-fou** `tests/Architecture/EntityConstraintsHaveFrenchMessageTest` : échoue si une contrainte n'a pas de message FR explicite (comparaison au défaut Symfony) ou si `Assert\Length.max` diverge du `length` ORM. Whitelist justifiée pour les formats auto-bornés (Bic/Iban/Regex CP/couleur hex).
- **Test fonctionnel** du JSON 422 réel : message FR + `propertyPath` consommable par le front.
- **Convention documentée** dans `.claude/rules/backend.md`.

## Décisions

- Stratégie retenue : message FR **explicite sur toutes** les contraintes + locale FR en filet (les deux leviers du ticket).
- Garde-fou `Length == ORM length` : **test bloquant** (anti-dérive).
- RG-1.03 (distributor/broker) : pas de `Assert\Callback` ajouté — le `ClientProcessor` gère **déjà** l'exclusivité (422 + `propertyPath`). Pas de doublon.

## Hors périmètre / à suivre

- **Alignement `nullable`(DB) / `NotBlank`(back) / `required`(front)** : les champs obligatoires existants ont été confirmés, mais aucun changement de nullabilité DB n'a été fait sans arbitrage métier. À recroiser avec les astérisques front (ERP-101 / PR #58) si divergence constatée.

## Vérifications

- `make test` : **469 tests verts** (1793 assertions), 0 échec/erreur.
- `php-cs-fixer` : 0 fichier à corriger.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #59
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #59.
This commit is contained in:
2026-06-04 09:27:32 +00:00
committed by admin malio
parent 90dfc17fcb
commit 597101262d
18 changed files with 1384 additions and 21 deletions
@@ -147,7 +147,7 @@ class Client implements TimestampableInterface, BlamableInterface
// === Formulaire principal ===
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null;
@@ -188,6 +188,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null;
@@ -196,7 +197,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?DateTimeImmutable $foundedAt = null;
#[ORM\Column(nullable: true)]
#[Assert\PositiveOrZero]
#[Assert\PositiveOrZero(message: 'L\'effectif doit être un nombre positif ou nul.')]
#[Groups(['client:read', 'client:write:information'])]
private ?int $employeesCount = null;
@@ -205,6 +206,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $revenueAmount = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:information'])]
private ?string $directorName = null;
@@ -217,10 +219,12 @@ class Client implements TimestampableInterface, BlamableInterface
// futur Provider si l'user a la permission accounting.view). Ecriture via
// `client:write:accounting` (le futur Processor exige accounting.manage).
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $siren = null;
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $accountNumber = null;
@@ -230,6 +234,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?TvaMode $tvaMode = null;
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $nTva = null;
@@ -125,33 +125,39 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
private bool $isBilling = 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'])]
private string $country = 'France';
// RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
// Le Regex borne deja la longueur (≤ 5) : pas de Length redondant.
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $postalCode = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $city = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $streetComplement = null;
// RG-1.11 : obligatoire ssi isBilling (validateBillingEmailPresence + CHECK BDD).
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email]
#[Assert\Email(message: 'L\'email de facturation n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email de facturation ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_address:read', 'client_address:write'])]
private ?string $billingEmail = null;
@@ -88,30 +88,36 @@ class ClientContact implements TimestampableInterface, BlamableInterface
// RG-1.05 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les
// deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $jobTitle = null;
// RG : pas de validation de format telephone (saisie libre), mais une
// Assert\Length calee sur la colonne VARCHAR(20) evite l'erreur Postgres
// (500 non rattachee au champ) au profit d'une 422 propre (ERP-107).
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email]
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $email = null;
@@ -97,20 +97,22 @@ class ClientRib implements TimestampableInterface, BlamableInterface
private ?Client $client = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Assert\NotBlank(message: 'Le libellé du RIB est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $label = null;
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length
// redondant calee sur la colonne (whitelist du garde-fou ERP-107).
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Assert\Bic]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(message: 'Le BIC n\'est pas valide.')]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $bic = null;
#[ORM\Column(length: 34)]
#[Assert\NotBlank]
#[Assert\Iban]
#[Assert\NotBlank(message: 'L\'IBAN est obligatoire.', normalizer: 'trim')]
#[Assert\Iban(message: 'L\'IBAN n\'est pas valide.')]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
private ?string $iban = null;