Correctifs écran Client (ERP-115) (#76)
Auto Tag Develop / tag (push) Successful in 7s

Lot de correctifs sur l'écran Client (M1), + un retrait de règle métier et une petite fonctionnalité.

## Formulaire client (création / édition)
- Boutons « ajouter un bloc » (Adresse, RIB) désactivés tant que le dernier bloc n'est pas valide.
- Onglet Information : bouton Valider désactivé si aucun champ rempli (création) ; onglet Contact accessible dès la création (Information facultatif).
- Champs « Relation » (Distributeur/Courtier) et « Prestation de triage » masqués par défaut, révélés seulement si une catégorie ordinaire (≠ Distributeur/Courtier) est sélectionnée.
- Bloc RIB affiché uniquement si le type de règlement est LCR (création, édition, consultation) ; plus de RIB fantôme soumis.
- Alignement du bas du textarea « Description » sur les autres champs.

## Recherche d'adresse (BAN)
- Une erreur de l'API ne bloque plus définitivement la recherche : chaque frappe réessaie (le mode dégradé restait verrouillé).
- Garde minimum 3 caractères avant l'appel à l'API.

## Répertoire client
- Titres de colonne en noir 16px, corps + tags de site en 14px.

## Navigation
- L'onglet actif est conservé au passage consultation ↔ édition (via history.state, hors URL).

## Règle métier
- Retrait de RG-1.04 : l'onglet Information n'est plus obligatoire pour le rôle Commerciale — facultatif pour tous (back + tests + docs).

Tests : suites front (Vitest) et back (PHPUnit) vertes hormis flakes d'infra connus.
Reviewed-on: #76
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #76.
This commit is contained in:
2026-06-08 14:40:18 +00:00
committed by Autin
parent 843e4b0a0c
commit b8dc3cb696
41 changed files with 652 additions and 431 deletions
@@ -16,9 +16,9 @@ use Symfony\Component\Validator\ConstraintViolationList;
* Type de reglement). La banque reste conditionnelle (RG-1.12) et les RIB aussi
* (RG-1.13) : ils ne sont pas couverts ici.
*
* Calque sur ClientInformationCompletenessValidator (RG-1.04) : colonnes nullable
* en base + validateur contextuel, plutot qu'un Assert\NotBlank sur l'entite (qui
* casserait le POST de l'onglet principal, lequel n'envoie aucun champ comptable).
* Parti pris : colonnes nullable en base + validateur contextuel, plutot qu'un
* Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet principal,
* lequel n'envoie aucun champ comptable).
*
* Invoque par le ClientProcessor uniquement quand le payload porte les six champs
* (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ comptable.
@@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Client;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-1.04 (durcie ERP-74) : pour un utilisateur portant le
* role metier Commerciale, TOUS les champs de l'onglet Information sont
* obligatoires sur POST comme sur tout PATCH, independamment des champs
* reellement envoyes.
*
* Invoque par le ClientProcessor des que l'utilisateur courant porte le role
* Commerciale (plus de condition d'intersection avec l'onglet Information).
* Pour les autres roles, ces champs restent optionnels — le validator n'est
* pas appele.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform.
*/
final class ClientInformationCompletenessValidator
{
public function validate(Client $client): void
{
// Map champ -> valeur courante de l'onglet Information.
$fields = [
'description' => $client->getDescription(),
'competitors' => $client->getCompetitors(),
'foundedAt' => $client->getFoundedAt(),
'employeesCount' => $client->getEmployeesCount(),
'revenueAmount' => $client->getRevenueAmount(),
'directorName' => $client->getDirectorName(),
'profitAmount' => $client->getProfitAmount(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
// Pas de nom de champ technique dans le message : la violation est
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
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 zeros numeriques (employeesCount = 0, profitAmount = "0.00") sont des
* valeurs valides : on ne les considere pas manquants.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -10,7 +10,7 @@ use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-2.03 (jumeau du ClientInformationCompletenessValidator M1) :
* Validator metier RG-2.03 (completude Information cote fournisseur) :
* pour un utilisateur portant le role metier Commerciale, TOUS les champs de
* l'onglet Information sont obligatoires sur POST comme sur tout PATCH,
* independamment des champs reellement envoyes.
@@ -9,11 +9,8 @@ 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;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
@@ -44,8 +41,8 @@ use Symfony\Component\Validator\ConstraintViolationList;
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
* et tout PATCH pour le role Commerciale).
* RG-1.13 (LCR -> >= 1 RIB). L'onglet Information est entierement facultatif
* (RG-1.04 retiree : plus d'obligation, y compris pour le role Commerciale).
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
@@ -108,7 +105,6 @@ final class ClientProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly ClientFieldNormalizer $normalizer,
private readonly ClientInformationCompletenessValidator $informationValidator,
private readonly ClientAccountingCompletenessValidator $accountingValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
@@ -142,7 +138,6 @@ final class ClientProcessor implements ProcessorInterface
$this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data);
$this->validateAccountingCompleteness($data);
$this->validateInformationCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -511,9 +506,9 @@ final class ClientProcessor implements ProcessorInterface
* 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.
* Colonnes nullable en base + validateur contextuel : 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
{
@@ -526,21 +521,6 @@ final class ClientProcessor implements ProcessorInterface
$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
* POST comme sur TOUT PATCH — independamment des champs reellement envoyes
* (plus de condition d'intersection avec INFORMATION_FIELDS). Garantit qu'un
* client cree/edite par une Commerciale ne reste jamais avec un onglet
* Information incomplet.
*/
private function validateInformationCompleteness(Client $data): void
{
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
}
}
/**
* Vrai si au moins une categorie du client porte le code donne. S'appuie sur
* CategoryInterface::getCode() (pas d'import de Category — regle ABSOLUE n°1).
@@ -556,21 +536,12 @@ final class ClientProcessor implements ProcessorInterface
return false;
}
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
}
/**
* Cles ecrivables effectivement presentes dans le payload : on retire les
* cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un
* groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) et du
* declenchement conditionnel de RG-1.04 — sans elles, un PATCH
* « representation complete » porteur de @id ferait croire a une
* modification multi-onglets.
* groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22)
* sans elles, un PATCH « representation complete » porteur de @id ferait
* croire a une modification multi-onglets.
*
* @return list<string>
*/
@@ -234,7 +234,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$this->addAddress($services, ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Conseil', isDelivery: true);
}
// === Onglet Information complet (RG-1.04) ===
// === Onglet Information complet (exemple de fiche renseignee) ===
[$holding, $isNew] = $this->ensureClient(
$manager,
companyName: 'Holding Premium Invest',
@@ -36,8 +36,8 @@ final class RbacSeeder
{
/**
* Codes des roles metier (snake_case, regex Role respectee). `commerciale`
* reference la constante Shared deja consommee par le ClientProcessor
* (RG-1.04) pour eviter tout drift : un seul litteral pour ce code.
* reference la constante Shared pour eviter tout drift : un seul litteral
* pour ce code.
*/
public const string ROLE_BUREAU = 'bureau';
public const string ROLE_COMPTA = 'compta';
+2 -2
View File
@@ -344,8 +344,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Busines
/**
* Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC
* rattaches porte le code donne. Permet aux modules tiers de detecter un
* role metier (ex: `commerciale` pour RG-1.04 du M1 Clients) sans importer
* cette classe. Comparaison stricte sur Role::code.
* role metier (ex: `commerciale`) sans importer cette classe. Comparaison
* stricte sur Role::code.
*/
public function hasBusinessRole(string $roleCode): bool
{
@@ -10,7 +10,6 @@ namespace App\Shared\Domain\Contract;
* App\Shared\Domain\Security\BusinessRoles).
*
* Implementee par App\Module\Core\Domain\Entity\User. Permet a un module tiers
* (ex: Commercial — RG-1.04, completude Information pour le role Commerciale)
* de raisonner sur les roles metier via Security::getUser() sans importer User
* (regle ABSOLUE n°1 : pas d'import inter-modules).
*
+8 -6
View File
@@ -10,9 +10,11 @@ namespace App\Shared\Domain\Security;
* Distincts des roles SYSTEME (cf. App\Module\Core\Domain\Security\SystemRoles :
* `admin` / `user`). Un role metier porte une intention fonctionnelle (poste de
* travail) et conditionne certaines regles de gestion au-dela des permissions
* RBAC pures — ex : RG-1.04 du M1 Clients rend l'onglet Information obligatoire
* pour le seul role Commerciale, alors que Commerciale et Bureau partagent les
* memes permissions (commercial.clients.view + manage, cf. spec-back M1 § 5.2).
* RBAC pures (Commerciale et Bureau partagent par ex. les memes permissions
* commercial.clients.view + manage mais peuvent porter des regles de gestion
* propres au poste). NB : l'ancienne RG-1.04 du M1 (« Information obligatoire
* pour Commerciale ») a ete retiree — l'onglet Information est facultatif pour
* tous ; la machinerie de role metier reste disponible pour de futures regles.
*
* Ces constantes vivent dans Shared (et non dans un module) pour que :
* - le seed des roles cote Core (ERP-74) reference le meme code sans importer
@@ -24,14 +26,14 @@ namespace App\Shared\Domain\Security;
* Coordination stack M1 :
* - ERP-74 seede le role `commerciale` avec ce code exact.
* - ERP-59 / ERP-60 (declaration RBAC + tests personas) le reutilisent.
* - ERP-55 (ici) ne fait que le REFERENCER : tant qu'aucun user ne porte le
* role `commerciale`, la validation de completude Information reste dormante.
* - ERP-55 le referencait pour la completude Information (M1 RG-1.04), regle
* depuis retiree ; le code reste utilise par le seed RBAC et les personas.
*/
final class BusinessRoles
{
/**
* Role metier « Commerciale » — code de Role RBAC (champ Role::code,
* snake_case impose par la regex Role). Conditionne RG-1.04.
* snake_case impose par la regex Role).
*/
public const string COMMERCIALE = 'commerciale';
@@ -180,13 +180,13 @@ final class ColumnCommentsCatalog
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.',
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).',
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).',
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).',
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).',
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
'description' => 'Onglet Information : description libre. Facultatif.',
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Facultatif.',
'founded_at' => 'Onglet Information : date de creation de l entreprise. Facultatif.',
'employees_count' => 'Onglet Information : effectif (entier >= 0). Facultatif.',
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Facultatif.',
'director_name' => 'Onglet Information : nom du dirigeant. Facultatif.',
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Facultatif.',
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).',
'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.',
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.',