feat(commercial) : add client sub-resources processors (contacts/addresses/ribs)
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).
This commit is contained in:
@@ -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'])]
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
|
||||
|
||||
use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
|
||||
/**
|
||||
* Denormalise un IRI (`/api/sites/{id}`) vers le Site concret quand la propriete
|
||||
* cible est type-hintee par le contrat SiteInterface (ClientAddress::$sites).
|
||||
*
|
||||
* Meme mecanisme que CategoryReferenceDenormalizer : API Platform deduit le type
|
||||
* d'element de collection depuis le phpdoc `@var Collection<int, SiteInterface>`,
|
||||
* 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<class-string|string, bool>
|
||||
*/
|
||||
public function getSupportedTypes(?string $format): array
|
||||
{
|
||||
return [SiteInterface::class => true];
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Adresse d'un client (M1, § 4.5).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : normalisation serveur du billingEmail en lowercase (RG-1.21)
|
||||
* via le ClientFieldNormalizer partage. Les autres regles de l'onglet Adresse
|
||||
* sont deja garanties en amont : RG-1.09 (code postal) et RG-1.10 (>= 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<ClientAddress, null|ClientAddress>
|
||||
*/
|
||||
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()));
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Contact d'un client (M1, § 4.5).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : normalisation serveur (RG-1.19 prenom/nom capitalize,
|
||||
* RG-1.20 telephones reduits aux chiffres, RG-1.21 email lowercase) via le
|
||||
* ClientFieldNormalizer partage (reutilise d'ERP-55), puis validation RG-1.05
|
||||
* (au moins prenom OU nom) avant persistance.
|
||||
* - DELETE : RG-1.14 — la suppression du DERNIER contact d'un client est
|
||||
* refusee (409). Au M1, la completude de l'onglet Contact est purement front
|
||||
* (pas de state machine back) : on garantit seulement qu'un client deja dote
|
||||
* d'un contact n'en soit jamais vide via l'API.
|
||||
*
|
||||
* La security de l'operation (commercial.clients.manage) est deja appliquee par
|
||||
* API Platform en amont. La validation Symfony des contraintes d'attribut
|
||||
* (Assert\Email, Assert\Length...) est jouee avant ce processor.
|
||||
*
|
||||
* @implements ProcessorInterface<ClientContact, null|ClientContact>
|
||||
*/
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource RIB d'un client (M1, § 4.5).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : aucune normalisation specifique. La validite de l'IBAN et du
|
||||
* BIC est garantie par Assert\Iban / Assert\Bic sur l'entite (jouees en amont
|
||||
* par API Platform). Aucun #[AuditIgnore] sur iban/bic : la tracabilite
|
||||
* comptable est volontaire (decision Matthieu 29/05, spec § 6.1).
|
||||
* - DELETE : RG-1.13 — si le client est en reglement LCR, la suppression de son
|
||||
* DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
|
||||
*
|
||||
* La security de l'operation (commercial.clients.accounting.manage) est deja
|
||||
* appliquee par API Platform en amont : un utilisateur sans cette permission
|
||||
* recoit 403 sur POST/PATCH/DELETE avant d'atteindre ce processor.
|
||||
*
|
||||
* @implements ProcessorInterface<ClientRib, null|ClientRib>
|
||||
*/
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user