From 756c9dba47e73a69d997f4f10e7b958be4a53161 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 13:43:19 +0200 Subject: [PATCH] feat(commercial) : add client sub-resources processors (contacts/addresses/ribs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose les sous-ressources Contacts / Adresses / RIB du repertoire clients (M1, spec § 4.5) : - 3 Processors dedies (ClientContactProcessor, ClientAddressProcessor, ClientRibProcessor) : normalisation serveur reutilisant ClientFieldNormalizer (RG-1.19 capitalize, RG-1.20 telephones chiffres, RG-1.21 emails/billingEmail lowercase) + regles metier. - Operations API Platform : - POST /api/clients/{id}/contacts|addresses, PATCH/DELETE /api/client_contacts|addresses/{id} (security commercial.clients.manage) - POST /api/clients/{id}/ribs, PATCH/DELETE /api/client_ribs/{id} (security commercial.clients.accounting.manage) - GET item par sous-ressource (lecture unitaire) ; pas de GET collection autonome (lecture via le parent, non concernee par la pagination ERP-72). - Regles de gestion : - RG-1.13 : DELETE du dernier RIB d'un client en reglement LCR -> 409. - RG-1.14 : DELETE du dernier contact d'un client -> 409 (completude front au M1). - RG-1.05 : prenom OU nom du contact obligatoire -> 422. - Validations deja portees par l'entite et desormais exercees : Assert\Count(min:1) sur ClientAddress.sites (RG-1.10), Assert\Regex code postal (RG-1.09), Assert\Iban / Assert\Bic sur ClientRib. - SiteReferenceDenormalizer : resout les IRIs /api/sites vers SiteInterface (meme pattern que CategoryReferenceDenormalizer, sans import cross-module). - Ajout de symfony/intl, requis par Assert\Bic. Tests : ClientSubResourceApiTest (13 cas) couvrant CRUD, normalisation, RG-1.13/1.14, gating 403 sur client_ribs sans accounting.manage. Suite back complete au vert (383 tests). --- composer.json | 1 + composer.lock | 91 ++++- .../Domain/Entity/ClientAddress.php | 48 ++- .../Domain/Entity/ClientContact.php | 50 ++- .../Commercial/Domain/Entity/ClientRib.php | 48 ++- .../Serializer/SiteReferenceDenormalizer.php | 71 ++++ .../Processor/ClientAddressProcessor.php | 92 +++++ .../Processor/ClientContactProcessor.php | 151 +++++++++ .../State/Processor/ClientRibProcessor.php | 104 ++++++ .../Api/ClientSubResourceApiTest.php | 320 ++++++++++++++++++ 10 files changed, 967 insertions(+), 9 deletions(-) create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SiteReferenceDenormalizer.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php create mode 100644 src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php create mode 100644 tests/Module/Commercial/Api/ClientSubResourceApiTest.php diff --git a/composer.json b/composer.json index eb9a778..1d80d05 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "symfony/expression-language": "8.0.*", "symfony/flex": "^2", "symfony/framework-bundle": "8.0.*", + "symfony/intl": "8.0.*", "symfony/mime": "8.0.*", "symfony/monolog-bundle": "^4.0", "symfony/property-access": "8.0.*", diff --git a/composer.lock b/composer.lock index d6dc421..af355c0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d65a546151abb6b977fbf7f1c86d14fe", + "content-hash": "2410dcfdb94553f520e1186a73fa98c5", "packages": [ { "name": "api-platform/doctrine-common", @@ -5172,6 +5172,95 @@ ], "time": "2026-03-31T21:14:05+00:00" }, + { + "name": "symfony/intl", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/intl.git", + "reference": "604a1dbbd67471e885e93274379cadd80dc33535" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/intl/zipball/604a1dbbd67471e885e93274379cadd80dc33535", + "reference": "604a1dbbd67471e885e93274379cadd80dc33535", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "conflict": { + "symfony/string": "<7.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Intl\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Eriksen Costa", + "email": "eriksen.costa@infranology.com.br" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides access to the localization data of the ICU library", + "homepage": "https://symfony.com", + "keywords": [ + "i18n", + "icu", + "internationalization", + "intl", + "l10n", + "localization" + ], + "support": { + "source": "https://github.com/symfony/intl/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/mime", "version": "v8.0.8", diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index 26e5f8d..ca2eac8 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientAddressProcessor; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -28,11 +35,46 @@ use Symfony\Component\Validator\Constraints as Assert; * - sites : SiteInterface (module Sites) via resolve_target_entities * - contacts : ClientContact (meme module) * - categories : CategoryInterface (module Catalog) via resolve_target_entities - * — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, futur Processor) + * — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, hors ERP-57) * - * Audite (#[Auditable]) + Timestampable/Blamable. Aucun ApiResource au M1.1 - * (sous-ressources branchees a un ticket dedie). + * Audite (#[Auditable]) + Timestampable/Blamable. + * + * Sous-ressource API (ERP-57, spec § 4.5) : + * - POST /api/clients/{clientId}/addresses : creation rattachee au client parent + * (Link toProperty 'client'), security commercial.clients.manage. + * - PATCH / DELETE /api/client_addresses/{id} : security commercial.clients.manage. + * - GET /api/client_addresses/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent. Pas de GET collection autonome. + * Tout passe par le ClientAddressProcessor (normalisation RG-1.21 billingEmail). */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['client_address:read']], + ), + new Post( + uriTemplate: '/clients/{clientId}/addresses', + uriVariables: [ + 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), + ], + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client_address:read']], + denormalizationContext: ['groups' => ['client_address:write']], + processor: ClientAddressProcessor::class, + ), + new Patch( + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client_address:read']], + denormalizationContext: ['groups' => ['client_address:write']], + processor: ClientAddressProcessor::class, + ), + new Delete( + security: "is_granted('commercial.clients.manage')", + processor: ClientAddressProcessor::class, + ), + ], +)] #[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)] #[ORM\Table(name: 'client_address')] #[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])] diff --git a/src/Module/Commercial/Domain/Entity/ClientContact.php b/src/Module/Commercial/Domain/Entity/ClientContact.php index 1b04ef8..06565e2 100644 --- a/src/Module/Commercial/Domain/Entity/ClientContact.php +++ b/src/Module/Commercial/Domain/Entity/ClientContact.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientContactProcessor; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -16,13 +23,50 @@ use Symfony\Component\Validator\Constraints as Assert; /** * Contact d'un client (1:n) — onglet Contact. Au moins firstName OU lastName * doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD - * (chk_client_contact_name) et validee dans le futur ClientContactProcessor ; + * (chk_client_contact_name) et validee dans le ClientContactProcessor ; * l'entite reste permissive (les deux champs sont nullable). * * Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard). - * Les operations CRUD (sous-ressources POST/PATCH/DELETE) sont branchees au - * ticket dedie des sous-ressources — aucun ApiResource au M1.1 (ERP-54). + * + * Sous-ressource API (ERP-57, spec § 4.5) : + * - POST /api/clients/{clientId}/contacts : creation rattachee au client parent + * (Link toProperty 'client'), security commercial.clients.manage. + * - PATCH / DELETE /api/client_contacts/{id} : security commercial.clients.manage. + * Le DELETE est physique (sous-collection, pas le client) ; le processor + * refuse la suppression du dernier contact (RG-1.14, 409). + * - GET /api/client_contacts/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent (client embarque ses contacts). Pas de + * GET collection autonome : non concernee par la pagination ERP-72. + * Tout passe par le ClientContactProcessor (normalisation RG-1.19/1.20/1.21). */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['client_contact:read']], + ), + new Post( + uriTemplate: '/clients/{clientId}/contacts', + uriVariables: [ + 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), + ], + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client_contact:read']], + denormalizationContext: ['groups' => ['client_contact:write']], + processor: ClientContactProcessor::class, + ), + new Patch( + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client_contact:read']], + denormalizationContext: ['groups' => ['client_contact:write']], + processor: ClientContactProcessor::class, + ), + new Delete( + security: "is_granted('commercial.clients.manage')", + processor: ClientContactProcessor::class, + ), + ], +)] #[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)] #[ORM\Table(name: 'client_contact')] #[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])] diff --git a/src/Module/Commercial/Domain/Entity/ClientRib.php b/src/Module/Commercial/Domain/Entity/ClientRib.php index f1c589d..63a9447 100644 --- a/src/Module/Commercial/Domain/Entity/ClientRib.php +++ b/src/Module/Commercial/Domain/Entity/ClientRib.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientRibProcessor; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -16,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert; /** * Coordonnees bancaires d'un client (1:n) — onglet Comptabilite. Au moins un * RIB est obligatoire si le type de reglement du client est LCR (RG-1.13, - * verifie au futur Processor). + * verifie au ClientRibProcessor : refus du DELETE du dernier RIB sous LCR). * * Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et * `bic` — AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 : @@ -25,8 +32,45 @@ use Symfony\Component\Validator\Constraints as Assert; * * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1 * (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable - * standard. Aucun ApiResource au M1.1 (sous-ressource branchee ulterieurement). + * standard. + * + * Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce : + * - POST /api/clients/{clientId}/ribs : creation rattachee au client parent + * (Link toProperty 'client'), security commercial.clients.accounting.manage. + * - PATCH / DELETE /api/client_ribs/{id} : security commercial.clients.accounting.manage. + * - GET /api/client_ribs/{id} : lecture unitaire, security + * commercial.clients.accounting.view (donnees bancaires sensibles). Pas de + * GET collection autonome. + * Tout passe par le ClientRibProcessor (RG-1.13 sur DELETE). */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('commercial.clients.accounting.view')", + normalizationContext: ['groups' => ['client_rib:read']], + ), + new Post( + uriTemplate: '/clients/{clientId}/ribs', + uriVariables: [ + 'clientId' => new Link(fromClass: Client::class, toProperty: 'client'), + ], + security: "is_granted('commercial.clients.accounting.manage')", + normalizationContext: ['groups' => ['client_rib:read']], + denormalizationContext: ['groups' => ['client_rib:write']], + processor: ClientRibProcessor::class, + ), + new Patch( + security: "is_granted('commercial.clients.accounting.manage')", + normalizationContext: ['groups' => ['client_rib:read']], + denormalizationContext: ['groups' => ['client_rib:write']], + processor: ClientRibProcessor::class, + ), + new Delete( + security: "is_granted('commercial.clients.accounting.manage')", + processor: ClientRibProcessor::class, + ), + ], +)] #[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)] #[ORM\Table(name: 'client_rib')] #[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])] diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SiteReferenceDenormalizer.php b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SiteReferenceDenormalizer.php new file mode 100644 index 0000000..7c59022 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/SiteReferenceDenormalizer.php @@ -0,0 +1,71 @@ +`, + * donc l'INTERFACE. Le serializer ne sait pas denormaliser un IRI vers une + * interface (« Could not denormalize object of type SiteInterface[] ») ; on + * resout l'IRI via l'IriConverter (qui retourne le Site mappe a la route) sans + * importer la classe Site du module Sites — la regle ABSOLUE n°1 (pas d'import + * cross-module) reste respectee : dependance au seul contrat Shared + API Platform. + * + * En lecture (normalisation), aucun probleme : l'objet reel EST un Site, + * ressource a part entiere, serialise en IRI par le normalizer standard. + */ +final class SiteReferenceDenormalizer implements DenormalizerInterface +{ + public function __construct( + private readonly IriConverterInterface $iriConverter, + ) {} + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?SiteInterface + { + 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); + + // IRI syntaxiquement valide mais pointant sur une autre ressource : on + // refuse explicitement plutot que de retourner null silencieusement. + if (!$resource instanceof SiteInterface) { + throw new UnexpectedValueException(sprintf( + 'L\'IRI "%s" ne référence pas un site.', + $data, + )); + } + + return $resource; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + // Support base sur le seul type cible : l'ArrayDenormalizer (collection + // `SiteInterface[]`) interroge le support en passant le TABLEAU complet + // comme $data avant de deleguer element par element. Tester + // is_string($data) ici casserait la chaine pour les collections. + return SiteInterface::class === $type; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [SiteInterface::class => true]; + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php new file mode 100644 index 0000000..1720432 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php @@ -0,0 +1,92 @@ += 1 site) + * par des contraintes Assert sur l'entite, RG-1.06/07/08/11 par des CHECK BDD. + * - DELETE : aucune regle metier specifique (suppression physique directe). + * + * La security de l'operation (commercial.clients.manage) est deja appliquee par + * API Platform, de meme que la validation Symfony des contraintes d'attribut. + * + * @implements ProcessorInterface + */ +final class ClientAddressProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly ClientFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof ClientAddress) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->normalize($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache l'adresse au client parent de la sous-ressource POST + * (/clients/{clientId}/addresses) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(ClientAddress $address, array $uriVariables): void + { + if (null !== $address->getClient()) { + return; + } + + $clientId = $uriVariables['clientId'] ?? null; + if (null === $clientId) { + return; + } + + $client = $clientId instanceof Client + ? $clientId + : $this->em->getRepository(Client::class)->find($clientId); + + if ($client instanceof Client) { + $address->setClient($client); + } + } + + /** + * Normalisation serveur (RG-1.21) : email de facturation en minuscules. La + * methode est null-safe — une adresse non facturable (billingEmail null) + * reste null. + */ + private function normalize(ClientAddress $address): void + { + $address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail())); + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php new file mode 100644 index 0000000..3ddffc0 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientContactProcessor.php @@ -0,0 +1,151 @@ + + */ +final class ClientContactProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly ClientFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof ClientContact) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + $this->guardLastContactDeletion($data); + + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->normalize($data); + $this->validateName($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le contact au client parent de la sous-ressource POST + * (/clients/{clientId}/contacts). La relation n'est pas peuplee + * automatiquement par le Link sur une operation d'ecriture : on resout donc + * le parent depuis l'uri variable. Sur PATCH (entite existante), le client + * est deja present -> no-op. + */ + private function linkParent(ClientContact $contact, array $uriVariables): void + { + if (null !== $contact->getClient()) { + return; + } + + $clientId = $uriVariables['clientId'] ?? null; + if (null === $clientId) { + return; + } + + $client = $clientId instanceof Client + ? $clientId + : $this->em->getRepository(Client::class)->find($clientId); + + if ($client instanceof Client) { + $contact->setClient($client); + } + } + + /** + * Normalisation serveur (RG-1.19 / 1.20 / 1.21). Toutes les methodes du + * normalizer sont null-safe : une chaine vide apres trim devient null. + */ + private function normalize(ClientContact $contact): void + { + $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName())); + $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName())); + $contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary())); + $contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary())); + $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail())); + } + + /** + * RG-1.05 : au moins le prenom OU le nom est obligatoire (double garde avec + * le CHECK BDD chk_client_contact_name — leve un 422 propre plutot qu'une + * erreur SQL). Joue apres normalisation, donc les chaines vides sont deja + * ramenees a null. + */ + private function validateName(ClientContact $contact): void + { + if (null === $contact->getFirstName() && null === $contact->getLastName()) { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Le prénom ou le nom du contact est obligatoire.', + null, + [], + $contact, + 'firstName', + null, + )); + + throw new ValidationException($violations); + } + } + + /** + * RG-1.14 : refuse la suppression du dernier contact d'un client (409). La + * collection inclut le contact en cours de suppression : un effectif <= 1 + * signifie qu'il ne resterait aucun contact. Sans client rattache (cas + * theorique), on laisse passer. + */ + private function guardLastContactDeletion(ClientContact $contact): void + { + $client = $contact->getClient(); + if (null === $client) { + return; + } + + if ($client->getContacts()->count() <= 1) { + throw new ConflictHttpException( + 'Impossible de supprimer le dernier contact du client : au moins un contact est requis.', + ); + } + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php new file mode 100644 index 0000000..baf55ec --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientRibProcessor.php @@ -0,0 +1,104 @@ + + */ +final class ClientRibProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof ClientRib) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + $this->guardLastRibDeletionUnderLcr($data); + + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le RIB au client parent de la sous-ressource POST + * (/clients/{clientId}/ribs) : la relation n'est pas peuplee automatiquement + * par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(ClientRib $rib, array $uriVariables): void + { + if (null !== $rib->getClient()) { + return; + } + + $clientId = $uriVariables['clientId'] ?? null; + if (null === $clientId) { + return; + } + + $client = $clientId instanceof Client + ? $clientId + : $this->em->getRepository(Client::class)->find($clientId); + + if ($client instanceof Client) { + $rib->setClient($client); + } + } + + /** + * RG-1.13 : un client dont le type de reglement est LCR doit conserver au + * moins un RIB. La collection inclut le RIB en cours de suppression : un + * effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre + * type de reglement, les RIBs sont optionnels (suppression libre). + */ + private function guardLastRibDeletionUnderLcr(ClientRib $rib): void + { + $client = $rib->getClient(); + if (null === $client) { + return; + } + + if ('LCR' === $client->getPaymentType()?->getCode() && $client->getRibs()->count() <= 1) { + throw new ConflictHttpException( + 'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.', + ); + } + } +} diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php new file mode 100644 index 0000000..dd7538c --- /dev/null +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -0,0 +1,320 @@ + 409) et RG-1.14 (DELETE + * dernier contact -> 409), plus le gating comptable (POST/PATCH/DELETE de + * client_ribs sans accounting.manage -> 403). + * + * @internal + */ +final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase +{ + private const string LD = 'application/ld+json'; + private const string MERGE = 'application/merge-patch+json'; + private const string VALID_IBAN = 'FR1420041010050500013M02606'; + private const string VALID_BIC = 'BNPAFRPPXXX'; + + // === Contacts === + + public function testPostContactNormalizesFields(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact Host'); + + $data = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'firstName' => 'JEAN', + 'lastName' => 'dupont', + 'phonePrimary' => '06.12.34.56.78', + 'email' => 'Jean.DUPONT@ACME.FR', + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // RG-1.19 / 1.20 / 1.21 + self::assertSame('Jean', $data['firstName']); + self::assertSame('Dupont', $data['lastName']); + self::assertSame('0612345678', $data['phonePrimary']); + self::assertSame('jean.dupont@acme.fr', $data['email']); + } + + public function testPostContactWithoutNameReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact No Name'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['jobTitle' => 'Directeur'], + ]); + + // RG-1.05 + self::assertResponseStatusCodeSame(422); + } + + public function testPatchContactNormalizes(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact Patch'); + $contact = $this->seedContact($seed, 'Paul'); + + $data = $client->request('PATCH', '/api/client_contacts/'.$contact->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['lastName' => 'martin'], + ])->toArray(); + + self::assertResponseStatusCodeSame(200); + self::assertSame('Martin', $data['lastName']); + } + + public function testDeleteContactWhenSeveralReturns204(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact Multi'); + $this->seedContact($seed, 'Premier'); + $second = $this->seedContact($seed, 'Second'); + + $client->request('DELETE', '/api/client_contacts/'.$second->getId()); + + self::assertResponseStatusCodeSame(204); + } + + public function testDeleteLastContactReturns409(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Contact Solo'); + $only = $this->seedContact($seed, 'Unique'); + + $client->request('DELETE', '/api/client_contacts/'.$only->getId()); + + // RG-1.14 + self::assertResponseStatusCodeSame(409); + } + + // === Adresses === + + public function testPostAddressNormalizesBillingEmail(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Host'); + $siteIri = $this->firstSiteIri(); + + $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'isBilling' => true, + 'billingEmail' => 'Facturation@ACME.FR', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$siteIri], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // RG-1.21 + self::assertSame('facturation@acme.fr', $data['billingEmail']); + } + + public function testPostAddressWithoutSiteReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address No Site'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [], + ], + ]); + + // RG-1.10 (Assert\Count min 1) + self::assertResponseStatusCodeSame(422); + } + + public function testPostAddressWithInvalidPostalCodeReturns422(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Bad CP'); + $siteIri = $this->firstSiteIri(); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '123', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$siteIri], + ], + ]); + + // RG-1.09 (Assert\Regex ^[0-9]{4,5}$) + self::assertResponseStatusCodeSame(422); + } + + // === RIBs === + + public function testPostRibByAdminReturns201(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Rib Host'); + + $data = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'label' => 'Compte principal', + 'bic' => self::VALID_BIC, + 'iban' => self::VALID_IBAN, + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Compte principal', $data['label']); + } + + public function testPostRibWithInvalidIbanReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Rib Bad Iban'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'label' => 'Compte invalide', + 'bic' => self::VALID_BIC, + 'iban' => 'INVALID-IBAN', + ], + ]); + + // Assert\Iban + self::assertResponseStatusCodeSame(422); + } + + public function testDeleteRibNonLcrReturns204(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Rib Non LCR'); + $rib = $this->seedRib($seed); + + $client->request('DELETE', '/api/client_ribs/'.$rib->getId()); + + self::assertResponseStatusCodeSame(204); + } + + public function testDeleteLastRibUnderLcrReturns409(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Rib LCR Solo'); + $this->setPaymentType($seed, 'LCR'); + $rib = $this->seedRib($seed); + + $client->request('DELETE', '/api/client_ribs/'.$rib->getId()); + + // RG-1.13 + self::assertResponseStatusCodeSame(409); + } + + public function testRibWriteWithoutAccountingManageReturns403(): void + { + // Un utilisateur portant seulement commercial.clients.manage (sans + // accounting.manage) ne peut ni creer, ni modifier, ni supprimer un RIB. + $seed = $this->seedClient('Rib Forbidden'); + $rib = $this->seedRib($seed); + $credentials = $this->createUserWithPermission('commercial.clients.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + + $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/client_ribs/'.$rib->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['label' => 'Y'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('DELETE', '/api/client_ribs/'.$rib->getId()); + self::assertResponseStatusCodeSame(403); + } + + // === Helpers === + + /** + * Seede un ClientContact rattache a un client (sans passer par l'API). + */ + private function seedContact(ClientEntity $client, string $firstName): ClientContact + { + $em = $this->getEm(); + $contact = new ClientContact(); + $contact->setFirstName($firstName); + $contact->setClient($client); + $em->persist($contact); + $em->flush(); + + return $contact; + } + + /** + * Seede un ClientRib valide rattache a un client (sans passer par l'API). + */ + private function seedRib(ClientEntity $client): ClientRib + { + $em = $this->getEm(); + $rib = new ClientRib(); + $rib->setLabel('Seed RIB'); + $rib->setBic(self::VALID_BIC); + $rib->setIban(self::VALID_IBAN); + $rib->setClient($client); + $em->persist($rib); + $em->flush(); + + return $rib; + } + + /** + * Affecte un type de reglement (par code) au client seede. + */ + private function setPaymentType(ClientEntity $client, string $code): void + { + $em = $this->getEm(); + $type = $em->getRepository(PaymentType::class)->findOneBy(['code' => $code]); + self::assertNotNull($type, sprintf('PaymentType "%s" introuvable (fixtures).', $code)); + + $managed = $em->getRepository(ClientEntity::class)->find($client->getId()); + $managed->setPaymentType($type); + $em->flush(); + } + + /** + * Retourne l'IRI du premier site seede (fixtures Sites). Skip en amont si le + * module Sites est desactive. + */ + private function firstSiteIri(): string + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); + self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.'); + + return '/api/sites/'.$site->getId(); + } +}