Compare commits

...

1 Commits

Author SHA1 Message Date
Matthieu e1b8f8a28d feat(commercial) : add Client API Platform provider + processor + business rules
Branche l'API REST du repertoire clients (M1) sur l'entite Client preparee en
ERP-54. Operations GetCollection / Get / Post / Patch (pas de Delete au M1 :
l'archivage passe par PATCH isArchived).

ClientProvider :
- liste paginee (Paginator ORM, aligne sur la convention ERP-72) + echappatoire
  ?pagination=false
- exclut archives + soft-deletes par defaut (RG-1.24), ?includeArchived=true
  reintegre les archives (RG-1.25)
- tri companyName ASC (RG-1.26), filtres ?search (fuzzy companyName/lastName/
  email) et ?categoryType=<code>
- detail : 404 sur soft-delete, embarque contacts/adresses/ribs

ClientProcessor :
- normalisation serveur via ClientFieldNormalizer (RG-1.18 a 1.21)
- 409 sur doublon de nom de societe (RG-1.16) ; 409 dedie sur conflit de
  restauration (RG-1.23)
- gating par onglet : champ comptable -> accounting.manage, isArchived ->
  archive, mode strict 403 sur tout le payload (RG-1.28) ; archivage exclusif
  (RG-1.22) + pose/retrait archivedAt
- regles metier RG-1.01 (prenom/nom), RG-1.03 (distributor/broker exclusifs +
  controle du type de categorie), RG-1.12 (Virement -> banque), RG-1.13 (LCR ->
  >= 1 RIB), RG-1.04 (completude Information pour le role Commerciale)

Lecture comptable conditionnelle : ClientReadGroupContextBuilder ajoute le
groupe client:read:accounting selon commercial.clients.accounting.view.

Resolution des references categorie : CategoryReferenceDenormalizer resout les
IRI vers Category quand la propriete est type-hintee par le contrat
CategoryInterface (denormalisation impossible sur une interface sinon).

Contrats Shared :
- CategoryInterface::getCategoryTypeCode() (implemente par Category) pour la
  verification de type sans import inter-modules
- BusinessRoleAwareInterface (implemente par User) + BusinessRoles::COMMERCIALE
  pour detecter le role metier ; le code de role sera seede par ERP-74 et
  reutilise par ERP-59/60. RG-1.04 reste dormante tant qu'aucun user ne porte
  ce role.

Coordination stack :
- chaines de permission commercial.clients.* referencees ici, declarees en
  ERP-59 (tests RBAC complets en ERP-60)
- config globale de pagination (itemsPerPage client, max 50) portee par ERP-72
- referentiels comptables (PaymentType/Bank/...) exposes en ERP-56

Tests : 31 tests Commercial (integration admin sur les regles metier + unitaires
sur le gating, RG-1.04/1.12/1.13 et le context builder). Suite complete verte
(339 tests).
2026-05-29 16:41:40 +02:00
17 changed files with 1808 additions and 4 deletions
@@ -153,6 +153,16 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
return $this;
}
/**
* Implemente CategoryInterface : code du type rattache (ou null). Permet
* aux modules tiers de filtrer/valider par type metier sans dependre de
* Catalog.
*/
public function getCategoryTypeCode(): ?string
{
return $this->categoryType?->getCode();
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Service;
/**
* Normalisation serveur des champs texte d'un Client / ClientContact, appliquee
* par le ClientProcessor (et plus tard le ClientContactProcessor) AVANT
* persistance. Cf. spec-back M1 § 2.9 + RG-1.18 a RG-1.21.
*
* - companyName : UPPERCASE integral (RG-1.18)
* - firstName / lastName (personnes) : Title Case (RG-1.19)
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-1.20).
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
* - email : lowercase integral (RG-1.21)
*
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
* apres trim devient null (evite de persister "" dans des colonnes nullable).
*/
final class ClientFieldNormalizer
{
/**
* Nom de societe en majuscules (RG-1.18). Conserve null tel quel ; une
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
*/
public function normalizeCompanyName(?string $value): ?string
{
if (null === $value) {
return null;
}
return mb_strtoupper(trim($value), 'UTF-8');
}
/**
* Nom/prenom de personne en Title Case (RG-1.19) : "JEAN dupont" ->
* "Jean Dupont". Une chaine vide apres trim devient null.
*/
public function normalizePersonName(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
}
/**
* Email en minuscules (RG-1.21). Une chaine vide apres trim devient null.
*/
public function normalizeEmail(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Telephone reduit aux chiffres (RG-1.20) : "06.12.34.56.78" ->
* "0612345678". Une valeur sans aucun chiffre devient null.
*/
public function normalizePhone(?string $value): ?string
{
if (null === $value) {
return null;
}
$digits = preg_replace('/\D+/', '', $value) ?? '';
return '' === $digits ? null : $digits;
}
}
@@ -0,0 +1,76 @@
<?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 : pour un utilisateur portant le role metier
* Commerciale, TOUS les champs de l'onglet Information deviennent obligatoires
* lors d'un PATCH touchant le groupe `client:write:information`.
*
* Invoque par le ClientProcessor UNIQUEMENT quand les deux conditions sont
* reunies (role Commerciale + payload touchant l'onglet Information). Pour les
* autres roles, ces champs restent optionnels — le validator n'est pas appele.
*
* Tant qu'aucun user ne porte le role `commerciale` (seede par ERP-74,
* cf. App\Shared\Domain\Security\BusinessRoles::COMMERCIALE), cette regle reste
* DORMANTE : aucun appelant ne la declenche.
*
* 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(
sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
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);
}
}
+76 -3
View File
@@ -4,6 +4,13 @@ declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\ClientProvider;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
@@ -15,6 +22,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
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;
/**
@@ -36,9 +44,62 @@ use Symfony\Component\Validator\Constraints as Assert;
* - categories : M2M vers Category (module Catalog) via le contrat
* CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct).
*
* Aucun ApiResource au M1.1 (ERP-54) : les operations API (Provider + Processor,
* normalisation, archivage, accounting conditionnel) sont branchees en ERP-55.
* Operations API (Provider + Processor) branchees en ERP-55 :
* - GetCollection / Get : security commercial.clients.view. La liste expose le
* groupe client:read ; le detail embarque en plus contacts/adresses/ribs
* (groupe client:item:read). Les champs comptables (client:read:accounting)
* sont ajoutes DYNAMIQUEMENT par ClientReadGroupContextBuilder si l'user a
* la permission accounting.view (§ 2.7 / § 4.1 / § 4.2).
* - Post / Patch : security commercial.clients.manage ; le ClientProcessor
* applique normalisation, gating accounting/archive et regles metier.
* - Pas de Delete au M1 (HP-M2-1) : l'archivage passe par PATCH isArchived.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['client:read', 'default:read']],
provider: ClientProvider::class,
),
new Get(
security: "is_granted('commercial.clients.view')",
// Detail : client + sous-collections embarquees. Le groupe
// client:read:accounting est ajoute par le context builder selon la
// permission, donc absent ici volontairement.
normalizationContext: ['groups' => [
'client:read',
'client:item:read',
'client_contact:read',
'client_address:read',
'client_rib:read',
'default:read',
]],
provider: ClientProvider::class,
),
new Post(
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client:read', 'default:read']],
denormalizationContext: ['groups' => ['client:write:main']],
processor: ClientProcessor::class,
),
new Patch(
security: "is_granted('commercial.clients.manage')",
// Le ClientProcessor inspecte les champs reellement envoyes pour
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
// champs accounting exigent accounting.manage, isArchived exige
// archive.
normalizationContext: ['groups' => ['client:read', 'default:read']],
denormalizationContext: ['groups' => [
'client:write:main',
'client:write:information',
'client:write:accounting',
'client:write:archive',
]],
provider: ClientProvider::class,
processor: ClientProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
#[ORM\Table(name: 'client')]
// Index nommes pour matcher la migration (Version20260601000000). L'index
@@ -202,8 +263,13 @@ class Client implements TimestampableInterface, BlamableInterface
private Collection $ribs;
// === Archive / Soft delete ===
// Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH
// archive). Le groupe de LECTURE est declare sur le getter isArchived()
// avec SerializedName('isArchived') : sans cela, Symfony strip le prefixe
// "is" et exposerait la cle JSON "archived" (meme pattern que User::isAdmin
// et Role::isSystem).
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
#[Groups(['client:read', 'client:write:archive'])]
#[Groups(['client:write:archive'])]
private bool $isArchived = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
@@ -526,6 +592,7 @@ class Client implements TimestampableInterface, BlamableInterface
}
/** @return Collection<int, ClientContact> */
#[Groups(['client:item:read'])]
public function getContacts(): Collection
{
return $this->contacts;
@@ -551,6 +618,7 @@ class Client implements TimestampableInterface, BlamableInterface
}
/** @return Collection<int, ClientAddress> */
#[Groups(['client:item:read'])]
public function getAddresses(): Collection
{
return $this->addresses;
@@ -576,6 +644,7 @@ class Client implements TimestampableInterface, BlamableInterface
}
/** @return Collection<int, ClientRib> */
#[Groups(['client:item:read'])]
public function getRibs(): Collection
{
return $this->ribs;
@@ -600,6 +669,10 @@ class Client implements TimestampableInterface, BlamableInterface
return $this;
}
// Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
// exposerait la cle "archived" (strip du prefixe "is" sur les getters).
#[Groups(['client:read'])]
#[SerializedName('isArchived')]
public function isArchived(): bool
{
return $this->isArchived;
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\Metadata\IriConverterInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* Denormalise un IRI (`/api/categories/{id}`) vers la Category concrete quand la
* propriete cible est type-hintee par le contrat CategoryInterface (ex:
* Client::$categories, ClientAddress::$categories).
*
* Pourquoi ce denormalizer : API Platform deduit le type de l'element de
* collection depuis le phpdoc `@var Collection<int, CategoryInterface>`, donc
* l'INTERFACE. Or le serializer ne sait pas denormaliser un IRI vers une
* interface (« Could not denormalize object of type CategoryInterface[] ») : il
* lui faut une classe-ressource concrete. On resout donc l'IRI via l'IriConverter
* (qui retourne la Category mappee a la route) sans importer Category — la regle
* ABSOLUE n°1 reste respectee (dependance au seul contrat Shared + API Platform).
*
* En lecture (normalisation), aucun probleme : l'objet reel EST une Category,
* resource a part entiere, serialisee en IRI par le normalizer standard.
*/
final class CategoryReferenceDenormalizer implements DenormalizerInterface
{
public function __construct(
private readonly IriConverterInterface $iriConverter,
) {}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?CategoryInterface
{
if (!is_string($data) || '' === $data) {
return null;
}
// getResourceFromIri leve une exception sur IRI invalide -> 400, ce qui
// est le comportement attendu pour une reference cassee.
$resource = $this->iriConverter->getResourceFromIri($data);
return $resource instanceof CategoryInterface ? $resource : null;
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
// Support base sur le seul type cible : l'ArrayDenormalizer (collection
// `CategoryInterface[]`) interroge le support en passant le TABLEAU
// complet comme $data avant de deleguer element par element. Tester
// is_string($data) ici casserait donc la chaine pour les collections.
return CategoryInterface::class === $type;
}
/**
* @return array<class-string|string, bool>
*/
public function getSupportedTypes(?string $format): array
{
return [CategoryInterface::class => true];
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\State\SerializerContextBuilderInterface;
use App\Module\Commercial\Domain\Entity\Client;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\Request;
/**
* Decore le context builder de serialisation d'API Platform pour ajouter
* DYNAMIQUEMENT le groupe de lecture `client:read:accounting` sur les ressources
* Client, uniquement si l'utilisateur courant a la permission
* `commercial.clients.accounting.view` (cf. spec-back M1 § 2.7 / § 4.1 / § 4.2).
*
* Pourquoi un context builder et pas le Provider : un Provider retourne des
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
* de normalisation est construit ici, en amont du serializer — c'est le point
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
* l'utilisateur. Realise l'intention « ajout conditionnel du groupe accounting »
* de la spec.
*
* S'applique aux operations de LECTURE (normalization) sur Client : liste ET
* detail. Sans la permission, les champs comptables (siren, accountNumber,
* tvaMode, nTva, paymentDelay, paymentType, bank) ne sont jamais serialises.
*/
#[AsDecorator('api_platform.serializer.context_builder')]
final readonly class ClientReadGroupContextBuilder implements SerializerContextBuilderInterface
{
public function __construct(
#[AutowireDecorated]
private SerializerContextBuilderInterface $decorated,
private Security $security,
) {}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
// Uniquement en lecture, sur la ressource Client, avec la permission.
if (!$normalization) {
return $context;
}
if (Client::class !== ($context['resource_class'] ?? null)) {
return $context;
}
if (!$this->security->isGranted('commercial.clients.accounting.view')) {
return $context;
}
$groups = $context['groups'] ?? [];
if (!in_array('client:read:accounting', $groups, true)) {
$groups[] = 'client:read:accounting';
}
$context['groups'] = $groups;
return $context;
}
}
@@ -0,0 +1,362 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
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 JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 /
* § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28.
*
* Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet (le `security` de
* l'operation a deja exige commercial.clients.manage) :
* - champ comptable dans le payload -> exige accounting.manage (RG-1.28, 403) ;
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
* interdit toute autre modification dans la meme requete (RG-1.22, 422).
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.01 (prenom/nom), 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 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
* restauration).
*
* Note : la validation Symfony (Assert\NotBlank, Assert\Email, Assert\Count sur
* categories...) est jouee par API Platform AVANT ce processor ; on n'y traite
* donc que les regles non exprimables en simples contraintes d'attribut.
*
* @implements ProcessorInterface<Client, Client>
*/
final class ClientProcessor implements ProcessorInterface
{
/** Champs de l'onglet principal (groupe client:write:main). */
private const array MAIN_FIELDS = [
'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary',
'email', 'distributor', 'broker', 'triageService', 'categories',
];
/** Champs de l'onglet Information (groupe client:write:information). */
private const array INFORMATION_FIELDS = [
'description', 'competitors', 'foundedAt', 'employeesCount',
'revenueAmount', 'directorName', 'profitAmount',
];
/** Champs de l'onglet Comptabilite (groupe client:write:accounting). */
private const array ACCOUNTING_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
'paymentType', 'bank',
];
/** Champ d'archivage (groupe client:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage';
private const string PERM_ARCHIVE = 'commercial.clients.archive';
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly ClientFieldNormalizer $normalizer,
private readonly ClientInformationCompletenessValidator $informationValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Client) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
$payloadKeys = $this->payloadKeys();
$isArchiveRequest = $this->guardArchive($data, $payloadKeys);
$this->guardAccounting($payloadKeys);
$this->normalize($data);
$this->validateMainContact($data);
$this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data);
$this->validateInformationCompleteness($data, $payloadKeys);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Le seul index unique partiel est uq_client_company_name_active
// (LOWER(company_name) parmi non-archives/non-deletes — decision Q4).
if ($isArchiveRequest && false === $data->isArchived()) {
// RG-1.23 : restauration en conflit avec un homonyme actif.
throw new ConflictHttpException(
'Restauration impossible : un autre client a pris le nom entre-temps.',
$e,
);
}
// RG-1.16 : doublon de nom de societe.
throw new ConflictHttpException(
sprintf('Un client nommé "%s" existe déjà.', (string) $data->getCompanyName()),
$e,
);
}
}
/**
* RG-1.22 / RG-1.23 : si le payload porte isArchived, exige la permission
* archive (403), interdit toute autre modification (422) et pose/retire
* archivedAt. Retourne true si la requete est une requete d'archivage.
*
* @param list<string> $payloadKeys
*/
private function guardArchive(Client $data, array $payloadKeys): bool
{
if (!in_array(self::ARCHIVE_FIELD, $payloadKeys, true)) {
return false;
}
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
self::ARCHIVE_FIELD,
self::PERM_ARCHIVE,
));
}
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
if ([] !== array_diff($payloadKeys, [self::ARCHIVE_FIELD])) {
throw new UnprocessableEntityHttpException(
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
);
}
// RG-1.22 (true -> now) / RG-1.23 (false -> null).
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
return true;
}
/**
* RG-1.28 : un champ comptable dans le payload exige accounting.manage,
* sinon 403 sur l'ensemble du payload (mode strict, pas de filtrage
* silencieux). Le message precise le premier champ fautif.
*
* @param list<string> $payloadKeys
*/
private function guardAccounting(array $payloadKeys): void
{
$touched = array_values(array_intersect($payloadKeys, self::ACCOUNTING_FIELDS));
if ([] === $touched) {
return;
}
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$touched[0],
self::PERM_ACCOUNTING_MANAGE,
));
}
}
/**
* Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables
* (companyName, email, phonePrimary) ne sont touches que si une valeur est
* presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
*/
private function normalize(Client $data): void
{
if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
}
if (null !== $data->getEmail()) {
$data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail()));
}
if (null !== $data->getPhonePrimary()) {
$data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary()));
}
$data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName()));
$data->setLastName($this->normalizer->normalizePersonName($data->getLastName()));
$data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary()));
}
/**
* RG-1.01 : au moins le prenom OU le nom du contact principal.
*/
private function validateMainContact(Client $data): void
{
if (null === $data->getFirstName() && null === $data->getLastName()) {
$this->throwViolation(
'firstName',
'Le prénom ou le nom du contact principal est obligatoire.',
$data,
);
}
}
/**
* RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor
* doit referencer un client de categorie DISTRIBUTEUR (idem broker ->
* COURTIER).
*/
private function validateDistributorBroker(Client $data): void
{
$distributor = $data->getDistributor();
$broker = $data->getBroker();
if (null !== $distributor && null !== $broker) {
$this->throwViolation(
'distributor',
'Un client ne peut pas être rattaché à la fois à un distributeur et à un courtier.',
$data,
);
}
if (null !== $distributor && !$this->hasCategoryType($distributor, 'DISTRIBUTEUR')) {
$this->throwViolation(
'distributor',
'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.',
$data,
);
}
if (null !== $broker && !$this->hasCategoryType($broker, 'COURTIER')) {
$this->throwViolation(
'broker',
'Le courtier référencé doit être un client de catégorie COURTIER.',
$data,
);
}
}
/**
* RG-1.12 : Virement -> banque obligatoire. RG-1.13 : LCR -> au moins un RIB.
*/
private function validateAccountingConsistency(Client $data): void
{
$paymentCode = $data->getPaymentType()?->getCode();
if ('VIREMENT' === $paymentCode && null === $data->getBank()) {
$this->throwViolation(
'bank',
'La banque est obligatoire pour le type de règlement Virement.',
$data,
);
}
if ('LCR' === $paymentCode && $data->getRibs()->isEmpty()) {
$this->throwViolation(
'paymentType',
'Au moins un RIB est obligatoire pour le type de règlement LCR.',
$data,
);
}
}
/**
* RG-1.04 : si l'utilisateur porte le role metier Commerciale ET que le
* payload touche l'onglet Information, tous les champs Information sont
* obligatoires. Dormant tant qu'aucun user ne porte le role `commerciale`.
*
* @param list<string> $payloadKeys
*/
private function validateInformationCompleteness(Client $data, array $payloadKeys): void
{
$touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS);
if ($touchesInformation && $this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
}
}
/**
* Vrai si au moins une categorie du client porte le type donne. S'appuie
* sur CategoryInterface::getCategoryTypeCode() (pas d'import de Category).
*/
private function hasCategoryType(Client $client, string $typeCode): bool
{
foreach ($client->getCategories() as $category) {
if ($category instanceof CategoryInterface && $category->getCategoryTypeCode() === $typeCode) {
return true;
}
}
return false;
}
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies ;
* c'est ce qui permet le gating par onglet (RG-1.22 / RG-1.28) et le
* declenchement conditionnel de RG-1.04.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une ValidationException (HTTP 422) portant une violation unique sur
* la propriete visee — meme rendu Hydra que les contraintes Symfony.
*
* @return never
*/
private function throwViolation(string $property, string $message, Client $root): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation($message, null, [], $root, $property, null));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider du repertoire clients (M1). Cf. spec-back M1 § 4.1 / § 4.2.
*
* Collection (GET /api/clients) :
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
* (deleted_at IS NOT NULL) — RG-1.24 ;
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
* exclus au M1) — RG-1.25 ;
* - tri par defaut companyName ASC — RG-1.26 ;
* - filtres ?search=... (fuzzy companyName + lastName + email) et
* ?categoryType=<code> (clients ayant >= 1 categorie de ce type) ;
* - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ;
* echappatoire ?pagination=false pour alimenter un <select> sans pagination.
*
* Item (GET /api/clients/{id} + provider de PATCH) :
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
* M1) ; les archives restent consultables/restaurables en detail.
*
* Le filtrage des champs comptables en lecture (groupe client:read:accounting)
* n'est PAS fait ici mais dans ClientReadGroupContextBuilder (le provider ne
* peut pas influencer les groupes de serialisation).
*
* @implements ProviderInterface<Client>
*/
final class ClientProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
private readonly ClientRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Client>|Paginator<Client>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$qb = $this->repository->createListQueryBuilder($includeArchived);
$this->applySearch($qb, $filters['search'] ?? null);
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
// Echappatoire ?pagination=false : collection complete sans Paginator
// (cf. convention ERP-72 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Client> $result */
return $qb->getQuery()->getResult();
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// fetchJoinCollection: true pour un COUNT correct des que des JOINs
// to-many seront ajoutes (sous-collections embarquees en detail).
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Client
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$client = $this->repository->findById((int) $id);
if (null === $client) {
return null;
}
// Soft-delete : jamais expose au M1 (HP-M2-1) — 404 via retour null.
// Les archives restent visibles en detail (consultation + restauration).
if (null !== $client->getDeletedAt()) {
return null;
}
return $client;
}
/**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
* litteraux.
*/
private function applySearch(QueryBuilder $qb, mixed $search): void
{
if (!is_string($search) || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere(
'LOWER(c.companyName) LIKE :search '
.'OR LOWER(c.lastName) LIKE :search '
.'OR LOWER(c.email) LIKE :search',
)->setParameter('search', $pattern);
}
/**
* Restreint aux clients possedant au moins une categorie du type donne.
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
*/
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
{
if (!is_string($categoryType) || '' === trim($categoryType)) {
return;
}
$sub = $this->repository->createQueryBuilder('c2')
->select('c2.id')
->join('c2.categories', 'cat2')
->join('cat2.categoryType', 'ct2')
->where('ct2.code = :categoryType')
;
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
->setParameter('categoryType', trim($categoryType))
;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/
private function readBool(mixed $raw): bool
{
if (is_bool($raw)) {
return $raw;
}
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
}
+19 -1
View File
@@ -22,6 +22,7 @@ use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// C'est le pattern officiel Doctrine pour les bounded contexts DDD.
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
@@ -75,7 +76,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
#[Auditable]
class User implements UserInterface, PasswordAuthenticatedUserInterface
class User implements UserInterface, PasswordAuthenticatedUserInterface, BusinessRoleAwareInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -337,6 +338,23 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $keys;
}
/**
* 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.
*/
public function hasBusinessRole(string $roleCode): bool
{
foreach ($this->rbacRoles as $role) {
if ($role->getCode() === $roleCode) {
return true;
}
}
return false;
}
public function getPassword(): ?string
{
return $this->password;
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Expose, sans coupler a la classe concrete User (module Core), le moyen de
* savoir si un utilisateur porte un role METIER donne (par son code, cf.
* 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).
*
* Distinct de UserInterface::getRoles() (roles SYSTEME Symfony ROLE_*, derives
* de is_admin) : ici on parle des roles RBAC metier rattaches a l'utilisateur.
*/
interface BusinessRoleAwareInterface
{
/**
* Vrai si l'utilisateur porte le role RBAC metier identifie par $roleCode
* (compare au champ Role::code).
*/
public function hasBusinessRole(string $roleCode): bool;
}
@@ -19,4 +19,14 @@ interface CategoryInterface
public function getId(): ?int;
public function getName(): ?string;
/**
* Code du type de categorie rattache (CategoryType::code), ou null si la
* categorie n'a pas de type. Expose pour permettre a un module tiers de
* raisonner sur le type metier (ex: M1 Commercial — RG-1.03 : un distributor
* doit referencer un client categorise DISTRIBUTEUR ; RG-1.29 : categorie
* d'adresse limitee a SECTEUR/AUTRE) sans importer la classe concrete
* Category (regle ABSOLUE n°1).
*/
public function getCategoryTypeCode(): ?string;
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Security;
/**
* Codes des roles METIER MALIO partages entre modules.
*
* 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).
*
* 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
* une constante du module Commercial (regle ABSOLUE n°1 : pas d'import
* inter-modules) ;
* - le ClientProcessor (module Commercial) detecte le role Commerciale via ce
* meme code, sans dependre de Core.
*
* 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.
*/
final class BusinessRoles
{
/**
* Role metier « Commerciale » — code de Role RBAC (champ Role::code,
* snake_case impose par la regex Role). Conditionne RG-1.04.
*/
public const string COMMERCIALE = 'commerciale';
private function __construct()
{
// Classe de constantes : non instanciable.
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
/**
* Base des tests fonctionnels du module Commercial (M1 — repertoire clients).
*
* Etend la base Core : ajoute des factories pour seeder vite des categories
* typees (DISTRIBUTEUR / COURTIER / SECTEUR) et des clients, plus un helper
* d'authentification admin.
*
* Cleanup : tearDown purge clients, categories `test_cli_cat_*` et users/roles
* `test_*`. Les category_types business sont fetch-or-create (idempotents) et
* laisses en place (pas de DELETE pour ne pas entrer en conflit avec d'autres
* suites). Pas de DAMA en local -> purge manuelle obligatoire.
*
* @internal
*/
abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
{
protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_';
protected function tearDown(): void
{
$this->cleanupCommercialTestData();
parent::tearDown();
}
protected function createAdminClient(): Client
{
return $this->authenticatedClient('admin', 'admin');
}
/**
* Recupere (ou cree) un CategoryType par son code metier. Idempotent : la
* contrainte d'unicite sur category_type.code interdit les doublons.
*/
protected function createCategoryType(string $code): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => $code]);
if (null !== $existing) {
return $existing;
}
$type = new CategoryType();
$type->setCode($code);
$type->setLabel(ucfirst(strtolower($code)));
$em->persist($type);
$em->flush();
return $type;
}
/**
* Cree une Category de test rattachee a un type metier donne (code).
*/
protected function createCategory(string $typeCode = 'SECTEUR'): Category
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.$suffix);
$category->setCategoryType($this->createCategoryType($typeCode));
$em->persist($category);
$em->flush();
return $category;
}
/**
* Seede directement un Client en base (sans passer par l'API), pour les
* tests de liste / archivage. Le client porte une categorie SECTEUR.
*/
protected function seedClient(string $companyName, bool $isArchived = false, string $categoryTypeCode = 'SECTEUR'): ClientEntity
{
$em = $this->getEm();
$client = new ClientEntity();
// Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait
// produit le ClientProcessor via l'API.
$client->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$client->setLastName('Seed');
$client->setPhonePrimary('0102030405');
$client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test');
$client->addCategory($this->createCategory($categoryTypeCode));
$client->setIsArchived($isArchived);
if ($isArchived) {
$client->setArchivedAt(new DateTimeImmutable());
}
$em->persist($client);
$em->flush();
return $client;
}
private function cleanupCommercialTestData(): void
{
$em = $this->getEm();
// Clients d'abord (la jointure client_category est purgee par
// ON DELETE CASCADE ; les auto-references distributor/broker sont
// ON DELETE SET NULL).
$em->createQuery('DELETE FROM '.ClientEntity::class)->execute();
// Categories de test ensuite (FK client_category deja purgee).
$em->createQuery(
'DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix',
)->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute();
// Users / roles jetables.
$em->createQuery(
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix',
)->setParameter('prefix', 'test_%')->execute();
$em->createQuery(
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix',
)->setParameter('prefix', 'test_%')->execute();
}
}
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests fonctionnels de l'API /api/clients (M1) — branche ERP-55.
*
* Authentifies en ADMIN (bypass RBAC via isAdmin) : on valide ici les regles
* METIER (normalisation, unicite, distributor/broker, archivage, liste). Le
* gating par permission (accounting.manage / archive / RG-1.28 strict, RG-1.04
* Commerciale) est couvert par les tests unitaires du ClientProcessor : il
* exige des users non-admin portant des permissions `commercial.clients.*` qui
* ne sont declarees qu'en ERP-59 (tests RBAC complets en ERP-60).
*
* @internal
*/
final class ClientApiTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
public function testPostNormalizesTextFields(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$response = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'acme sas',
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
'categories' => ['/api/categories/'.$cat->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
// RG-1.18 / 1.19 / 1.20 / 1.21
self::assertSame('ACME SAS', $data['companyName']);
self::assertSame('Jean', $data['firstName']);
self::assertSame('Dupont', $data['lastName']);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
self::assertFalse($data['isArchived']);
}
public function testPostDuplicateCompanyNameReturns409(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$iri = '/api/categories/'.$cat->getId();
$payload = [
'companyName' => 'Doublon SARL',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'dup@test.fr',
'categories' => [$iri],
];
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(201);
// Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16).
$payload['email'] = 'dup2@test.fr';
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(409);
}
public function testPostWithoutFirstOrLastNameReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Contact Name',
'phonePrimary' => '0102030405',
'email' => 'nc@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
]);
// RG-1.01
self::assertResponseStatusCodeSame(422);
}
public function testPostWithoutCategoryReturns422(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Category',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'nocat@test.fr',
'categories' => [],
],
]);
// Assert\Count(min: 1)
self::assertResponseStatusCodeSame(422);
}
public function testPostWithDistributorAndBrokerReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$distributor = $this->seedClient('Distrib Mutex', false, 'DISTRIBUTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Mutex Client',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'mutex@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(),
'broker' => '/api/clients/'.$distributor->getId(),
],
]);
// RG-1.03 (exclusivite)
self::assertResponseStatusCodeSame(422);
}
public function testPostDistributorReferencingNonDistributorReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$notDistro = $this->seedClient('Pas Un Distrib', false, 'SECTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Bad Distrib Ref',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'baddistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$notDistro->getId(),
],
]);
// RG-1.03 (le distributor doit etre categorise DISTRIBUTEUR)
self::assertResponseStatusCodeSame(422);
}
public function testPostValidDistributorReturns201(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$distributor = $this->seedClient('Vrai Distrib', false, 'DISTRIBUTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Client Avec Distrib',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'okdistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testListSortedByCompanyNameAscAndExcludesArchived(): void
{
$client = $this->createAdminClient();
$this->seedClient('Zebra Co');
$this->seedClient('Alpha Co');
$this->seedClient('Archivé Co', true);
$names = $client->request('GET', '/api/clients?pagination=false', [
'headers' => ['Accept' => self::LD],
])->toArray()['member'];
$companyNames = array_map(static fn (array $c): string => $c['companyName'], $names);
// RG-1.24 : l'archive est exclue par defaut.
self::assertNotContains('ARCHIVÉ CO', $companyNames);
// RG-1.26 : tri companyName ASC (Alpha avant Zebra).
$alpha = array_search('ALPHA CO', $companyNames, true);
$zebra = array_search('ZEBRA CO', $companyNames, true);
self::assertNotFalse($alpha);
self::assertNotFalse($zebra);
self::assertLessThan($zebra, $alpha);
}
public function testListIncludeArchivedReturnsArchived(): void
{
$client = $this->createAdminClient();
$this->seedClient('Hidden Archived', true);
$members = $client->request('GET', '/api/clients?includeArchived=true&pagination=false', [
'headers' => ['Accept' => self::LD],
])->toArray()['member'];
$names = array_map(static fn (array $c): string => $c['companyName'], $members);
// RG-1.25
self::assertContains('HIDDEN ARCHIVED', $names);
}
public function testCollectionIsPaginated(): void
{
$client = $this->createAdminClient();
$this->seedClient('Paginated One');
// Collection Hydra avec total (la cle `view` n'apparait qu'a partir de
// 2 pages cote API Platform 4, donc non assertable sur page unique).
$page1 = $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('totalItems', $page1);
self::assertNotEmpty($page1['member']);
// Preuve que la pagination serveur est bien engagee : la page 2 d'un jeu
// tenant sur une page est vide (un provider non pagine ignorerait `page`
// et renverrait quand meme les items).
$page2 = $client->request('GET', '/api/clients?page=2', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame([], $page2['member']);
}
public function testPatchArchiveSetsArchivedAtThenRestore(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('To Archive');
$iri = '/api/clients/'.$seed->getId();
// Archive (RG-1.22) : admin a la permission archive via bypass isAdmin.
$archived = $client->request('PATCH', $iri, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isArchived' => true],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertTrue($archived['isArchived']);
self::assertNotNull($archived['archivedAt']);
// Restauration (RG-1.23) : archivedAt repasse a null.
$restored = $client->request('PATCH', $iri, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isArchived' => false],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertFalse($restored['isArchived']);
self::assertNull($restored['archivedAt']);
}
public function testPatchArchiveWithOtherFieldReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Archive Plus Field');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['isArchived' => true, 'companyName' => 'Renamed'],
]);
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
self::assertResponseStatusCodeSame(422);
}
public function testGetDetailEmbedsSubCollections(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Detail Embed');
$data = $client->request('GET', '/api/clients/'.$seed->getId(), [
'headers' => ['Accept' => self::LD],
])->toArray();
// § 4.2 : le detail embarque contacts / adresses / ribs.
self::assertArrayHasKey('contacts', $data);
self::assertArrayHasKey('addresses', $data);
self::assertArrayHasKey('ribs', $data);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires de la normalisation serveur (RG-1.18 a RG-1.21).
*
* @internal
*/
final class ClientFieldNormalizerTest extends TestCase
{
private ClientFieldNormalizer $normalizer;
protected function setUp(): void
{
$this->normalizer = new ClientFieldNormalizer();
}
public function testCompanyNameIsUppercased(): void
{
// RG-1.18
self::assertSame('ACME SAS', $this->normalizer->normalizeCompanyName(' acme sas '));
self::assertNull($this->normalizer->normalizeCompanyName(null));
}
public function testPersonNameIsTitleCased(): void
{
// RG-1.19
self::assertSame('Jean', $this->normalizer->normalizePersonName('JEAN'));
self::assertSame('Dupont', $this->normalizer->normalizePersonName('dupont'));
self::assertNull($this->normalizer->normalizePersonName(' '));
self::assertNull($this->normalizer->normalizePersonName(null));
}
public function testEmailIsLowercased(): void
{
// RG-1.21
self::assertSame('jean.dupont@acme.fr', $this->normalizer->normalizeEmail(' Jean.DUPONT@ACME.FR '));
self::assertNull($this->normalizer->normalizeEmail(null));
self::assertNull($this->normalizer->normalizeEmail(' '));
}
public function testPhoneKeepsOnlyDigits(): void
{
// RG-1.20
self::assertSame('0612345678', $this->normalizer->normalizePhone('06.12.34.56.78'));
self::assertSame('0612345678', $this->normalizer->normalizePhone('06 12 34 56 78'));
self::assertNull($this->normalizer->normalizePhone('----'));
self::assertNull($this->normalizer->normalizePhone(null));
}
}
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientRib;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Tests unitaires du ClientProcessor : gating par permission (accounting.manage
* / archive / RG-1.28 strict) et regles metier non testables en HTTP admin
* (RG-1.04 Commerciale, RG-1.12 Virement, RG-1.13 LCR), grace a un Security et
* un RequestStack stubbes.
*
* @internal
*/
final class ClientProcessorTest extends TestCase
{
public function testAccountingFieldWithoutPermissionIsForbidden(): void
{
// RG-1.28 : un champ comptable sans accounting.manage -> 403.
$processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']);
$this->expectException(AccessDeniedHttpException::class);
$processor->process($this->minimalClient(), $this->operation());
}
public function testStrictMixWithAccountingFieldIsForbidden(): void
{
// RG-1.28 : payload mixant main + accounting sans la permission -> 403
// sur l'ensemble (pas de filtrage silencieux).
$processor = $this->makeProcessor(
granted: [],
payload: ['companyName' => 'X', 'siren' => '123456789'],
);
$this->expectException(AccessDeniedHttpException::class);
$processor->process($this->minimalClient(), $this->operation());
}
public function testArchiveWithoutPermissionIsForbidden(): void
{
// RG-1.22 : isArchived sans la permission archive -> 403.
$processor = $this->makeProcessor(granted: [], payload: ['isArchived' => true]);
$this->expectException(AccessDeniedHttpException::class);
$processor->process($this->minimalClient(), $this->operation());
}
public function testArchiveWithOtherFieldIsUnprocessable(): void
{
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
$processor = $this->makeProcessor(
granted: ['commercial.clients.archive'],
payload: ['isArchived' => true, 'companyName' => 'X'],
);
$this->expectException(UnprocessableEntityHttpException::class);
$processor->process($this->minimalClient(), $this->operation());
}
public function testVirementWithoutBankIsUnprocessable(): void
{
// RG-1.12
$client = $this->minimalClient();
$client->setPaymentType($this->paymentType('VIREMENT'));
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: ['paymentType' => '/api/payment_types/1'],
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testVirementWithBankPasses(): void
{
// RG-1.12 satisfait : Virement + banque.
$client = $this->minimalClient();
$client->setPaymentType($this->paymentType('VIREMENT'));
$client->setBank(new Bank());
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: ['paymentType' => '/api/payment_types/1', 'bank' => '/api/banks/1'],
);
$result = $processor->process($client, $this->operation());
self::assertInstanceOf(Client::class, $result);
}
public function testLcrWithoutRibIsUnprocessable(): void
{
// RG-1.13
$client = $this->minimalClient();
$client->setPaymentType($this->paymentType('LCR'));
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: ['paymentType' => '/api/payment_types/2'],
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testLcrWithRibPasses(): void
{
// RG-1.13 satisfait : LCR + au moins un RIB.
$client = $this->minimalClient();
$client->setPaymentType($this->paymentType('LCR'));
$client->addRib(new ClientRib());
$processor = $this->makeProcessor(
granted: ['commercial.clients.accounting.manage'],
payload: ['paymentType' => '/api/payment_types/2'],
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
public function testCommercialeIncompleteInformationIsUnprocessable(): void
{
// RG-1.04 : role Commerciale + onglet Information incomplet -> 422.
$client = $this->minimalClient();
$client->setDescription('Une description'); // les autres champs Information restent null
$processor = $this->makeProcessor(
granted: [],
payload: ['description' => 'Une description'],
user: $this->commercialeUser(),
);
$this->expectException(ValidationException::class);
$processor->process($client, $this->operation());
}
public function testNonCommercialeSkipsInformationCompleteness(): void
{
// Meme payload incomplet, mais user non-Commerciale -> aucun blocage.
$client = $this->minimalClient();
$client->setDescription('Une description');
$processor = $this->makeProcessor(
granted: [],
payload: ['description' => 'Une description'],
user: null,
);
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
}
/**
* @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete
*/
private function makeProcessor(array $granted, array $payload, ?UserInterface $user = null): ClientProcessor
{
$persist = new class implements ProcessorInterface {
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
return $data;
}
};
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
);
$security->method('getUser')->willReturn($user);
$requestStack = new RequestStack();
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
return new ClientProcessor(
$persist,
new ClientFieldNormalizer(),
new ClientInformationCompletenessValidator(),
$security,
$requestStack,
);
}
/**
* Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) — suffisant
* pour atteindre les validations testees.
*/
private function minimalClient(): Client
{
$client = new Client();
$client->setCompanyName('Test Co');
$client->setLastName('Dupont');
$client->setPhonePrimary('0102030405');
$client->setEmail('t@test.fr');
return $client;
}
private function paymentType(string $code): PaymentType
{
$type = new PaymentType();
$type->setCode($code);
$type->setLabel($code);
return $type;
}
private function operation(): Operation
{
return $this->createStub(Operation::class);
}
private function commercialeUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return BusinessRoles::COMMERCIALE === $roleCode;
}
public function getRoles(): array
{
return ['ROLE_USER'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'commerciale-test';
}
};
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\State\SerializerContextBuilderInterface;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Infrastructure\ApiPlatform\Serializer\ClientReadGroupContextBuilder;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
/**
* Tests unitaires du context builder qui ajoute conditionnellement le groupe
* de lecture `client:read:accounting` selon la permission accounting.view
* (§ 2.7 / § 4.1 / § 4.2).
*
* @internal
*/
final class ClientReadGroupContextBuilderTest extends TestCase
{
public function testAddsAccountingGroupForClientReadWhenGranted(): void
{
$builder = $this->builder(
baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']],
granted: true,
);
$context = $builder->createFromRequest(new Request(), true);
self::assertContains('client:read:accounting', $context['groups']);
}
public function testDoesNotAddAccountingGroupWhenNotGranted(): void
{
$builder = $this->builder(
baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']],
granted: false,
);
$context = $builder->createFromRequest(new Request(), true);
self::assertNotContains('client:read:accounting', $context['groups']);
}
public function testDoesNotAddAccountingGroupOnWrite(): void
{
$builder = $this->builder(
baseContext: ['resource_class' => Client::class, 'groups' => ['client:write:main']],
granted: true,
);
// normalization = false -> ecriture : pas de groupe de lecture ajoute.
$context = $builder->createFromRequest(new Request(), false);
self::assertNotContains('client:read:accounting', $context['groups']);
}
public function testIgnoresOtherResources(): void
{
$builder = $this->builder(
baseContext: ['resource_class' => 'App\Other\Resource', 'groups' => ['other:read']],
granted: true,
);
$context = $builder->createFromRequest(new Request(), true);
self::assertSame(['other:read'], $context['groups']);
}
/**
* @param array<string, mixed> $baseContext
*/
private function builder(array $baseContext, bool $granted): ClientReadGroupContextBuilder
{
$decorated = $this->createStub(SerializerContextBuilderInterface::class);
$decorated->method('createFromRequest')->willReturn($baseContext);
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturn($granted);
return new ClientReadGroupContextBuilder($decorated, $security);
}
}