feat(technique) : sous-ressources Contacts / Adresses / RIBs (ERP-135)
Expose les sous-collections du prestataire en #[ApiResource] (POST sur le
parent + PATCH/DELETE/GET unitaires), edition complete par onglet (pas de
POST-only, RETEX M1/M2) :
- ProviderContact : POST /providers/{id}/contacts, PATCH/DELETE
/provider_contacts/{id} (security technique.providers.manage).
ProviderContactProcessor : normalisation RG-3.11 (nom/prenom Title Case,
telephones chiffres, email lowercase) + RG-3.04 (au moins un champ parmi
prenom/nom/telephone/email, miroir du CHECK chk_provider_contact_name -> 422).
- ProviderAddress : POST /providers/{id}/addresses, PATCH/DELETE
/provider_addresses/{id} (security technique.providers.manage).
ProviderAddressProcessor : rattachement parent + cloisonnement d'ecriture des
sites de l'adresse (RG-3.05 / § 2.13 : site hors user_site -> 422 sur sites).
- ProviderRib : POST /providers/{id}/ribs, PATCH/DELETE /provider_ribs/{id}
(security technique.providers.accounting.manage). ProviderRibProcessor :
RG-3.08 (DELETE du dernier RIB sous LCR -> 409).
Tests : ProviderSubResourceApiTest (19 cas) — CRUD chaque sous-ressource, 403
selon permission (Contacts/Adresses=manage, RIB=accounting.manage), 409 dernier
RIB LCR, 422 cloisonnement site adresse. Helpers addContact/addRib/paymentType
ajoutes a AbstractProviderApiTestCase.
This commit is contained in:
+212
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Adresse d'un prestataire (M3,
|
||||
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2), recentre sur le
|
||||
* perimetre ERP-135, AVEC une garde supplementaire propre au M3 : le
|
||||
* cloisonnement d'ECRITURE des sites de l'adresse (§ 2.13).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au prestataire parent puis cloisonnement des
|
||||
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
|
||||
* garanties en amont par des contraintes sur l'entite, jouees par API Platform
|
||||
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
|
||||
* Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
|
||||
* ProviderAddress::validateCategoryType).
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* La security de l'operation (technique.providers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
|
||||
*
|
||||
* @implements ProcessorInterface<ProviderAddress, null|ProviderAddress>
|
||||
*/
|
||||
final class ProviderAddressProcessor implements ProcessorInterface
|
||||
{
|
||||
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
|
||||
|
||||
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 Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ProviderAddress) {
|
||||
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->guardSiteScope($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache l'adresse au prestataire parent de la sous-ressource POST
|
||||
* (/providers/{providerId}/addresses) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(ProviderAddress $address, array $uriVariables): void
|
||||
{
|
||||
if (null !== $address->getProvider()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$providerId = $uriVariables['providerId'] ?? null;
|
||||
if (null === $providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $providerId instanceof Provider
|
||||
? $providerId
|
||||
: $this->em->getRepository(Provider::class)->find($providerId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte provider_id NOT NULL).
|
||||
if (!$provider instanceof Provider) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
|
||||
$address->setProvider($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.05 / § 2.13 (cloisonnement d'ECRITURE — decision Matthieu 11/06) : un
|
||||
* user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse que des
|
||||
* sites figurant dans ses propres `user_site`. Tout site hors perimetre -> 422
|
||||
* sur `sites` (propertyPath consommable inline, convention ERP-101). Un user
|
||||
* `bypass_scope` (Admin) peut attacher n'importe quel site. Miroir de
|
||||
* ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse.
|
||||
*
|
||||
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
|
||||
* sites obligatoires RG-3.05) ou PATCH portant la cle `sites`. Un PATCH qui ne
|
||||
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
|
||||
* pose). La validation porte sur l'ETAT RESULTANT (address.getSites()).
|
||||
*/
|
||||
private function guardSiteScope(ProviderAddress $address): void
|
||||
{
|
||||
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// sites non soumis sur un PATCH : rien a cloisonner.
|
||||
if ($this->em->contains($address) && !in_array('sites', $this->payloadKeys(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedSiteIds = $this->currentUserSiteIds();
|
||||
|
||||
foreach ($address->getSites() as $site) {
|
||||
if (!$site instanceof SiteInterface) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($site->getId(), $allowedSiteIds, true)) {
|
||||
$this->throwSitesViolation($address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
|
||||
* Vide si pas d'user authentifie (cas defensif : la security d'operation
|
||||
* garantit deja l'authentification).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function currentUserSiteIds(): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($user->getSites() as $site) {
|
||||
if ($site instanceof SiteInterface && null !== $site->getId()) {
|
||||
$ids[] = $site->getId();
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles de premier niveau effectivement envoyees par le client (payload JSON
|
||||
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @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 422 portant une violation unique sur `sites` — meme rendu Hydra que
|
||||
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function throwSitesViolation(ProviderAddress $address): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
|
||||
null,
|
||||
[],
|
||||
$address,
|
||||
'sites',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Contact d'un prestataire (M3,
|
||||
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
|
||||
* perimetre ERP-135.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au prestataire parent, normalisation serveur
|
||||
* (RG-3.11 : prenom/nom Title Case, telephones reduits aux chiffres, email
|
||||
* lowercase) via le ProviderFieldNormalizer partage, puis validation RG-3.04
|
||||
* (au moins un champ parmi prenom / nom / telephone principal / email) avant
|
||||
* persistance.
|
||||
* - DELETE : aucune garde « dernier contact » au M3 — la collection peut rester
|
||||
* vide cote back (RG-3.12 front-driven, spec § 4.5). Suppression physique directe.
|
||||
*
|
||||
* La security de l'operation (technique.providers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
|
||||
* (Assert\Email, Assert\Length...).
|
||||
*
|
||||
* @implements ProcessorInterface<ProviderContact, null|ProviderContact>
|
||||
*/
|
||||
final class ProviderContactProcessor 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 ProviderFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ProviderContact) {
|
||||
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);
|
||||
$this->validateName($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le contact au prestataire parent de la sous-ressource POST
|
||||
* (/providers/{providerId}/contacts). La relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une operation d'ecriture : on resout le
|
||||
* parent depuis l'uri variable. Sur PATCH (entite existante), le prestataire
|
||||
* est deja present -> no-op.
|
||||
*/
|
||||
private function linkParent(ProviderContact $contact, array $uriVariables): void
|
||||
{
|
||||
if (null !== $contact->getProvider()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$providerId = $uriVariables['providerId'] ?? null;
|
||||
if (null === $providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $providerId instanceof Provider
|
||||
? $providerId
|
||||
: $this->em->getRepository(Provider::class)->find($providerId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte provider_id NOT NULL).
|
||||
if (!$provider instanceof Provider) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
|
||||
$contact->setProvider($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur (RG-3.11). Toutes les methodes du normalizer sont
|
||||
* null-safe : une chaine vide apres trim devient null.
|
||||
*/
|
||||
private function normalize(ProviderContact $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-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
|
||||
* nom / telephone principal / email est renseigne (double garde avec le CHECK
|
||||
* BDD chk_provider_contact_name — leve une 422 propre rattachee au champ
|
||||
* `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
||||
* chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja
|
||||
* ramenees a null et ne suffisent pas a valider le bloc.
|
||||
*/
|
||||
private function validateName(ProviderContact $contact): void
|
||||
{
|
||||
if (null === $contact->getFirstName()
|
||||
&& null === $contact->getLastName()
|
||||
&& null === $contact->getPhonePrimary()
|
||||
&& null === $contact->getEmail()) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Au moins un champ du contact est obligatoire (nom, prénom, téléphone ou email).',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'firstName',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderRib;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource RIB d'un prestataire (M3, spec-back
|
||||
* § 4.5). Jumeau du SupplierRibProcessor (M2), recentre sur le perimetre ERP-135.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au prestataire parent. 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 M1/M2 reportee, spec § 2.7).
|
||||
* - DELETE : RG-3.08 — si le prestataire 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 (technique.providers.accounting.manage) est appliquee
|
||||
* par API Platform en amont : un utilisateur sans cette permission recoit 403 sur
|
||||
* POST/PATCH/DELETE avant d'atteindre ce processor — c'est le niveau de gating
|
||||
* renforce des donnees bancaires (distinct de manage, spec § 4.5).
|
||||
*
|
||||
* @implements ProcessorInterface<ProviderRib, null|ProviderRib>
|
||||
*/
|
||||
final class ProviderRibProcessor 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 ProviderRib) {
|
||||
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 prestataire parent de la sous-ressource POST
|
||||
* (/providers/{providerId}/ribs) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(ProviderRib $rib, array $uriVariables): void
|
||||
{
|
||||
if (null !== $rib->getProvider()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$providerId = $uriVariables['providerId'] ?? null;
|
||||
if (null === $providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $providerId instanceof Provider
|
||||
? $providerId
|
||||
: $this->em->getRepository(Provider::class)->find($providerId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte provider_id NOT NULL).
|
||||
if (!$provider instanceof Provider) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
|
||||
$rib->setProvider($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.08 : un prestataire 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(ProviderRib $rib): void
|
||||
{
|
||||
$provider = $rib->getProvider();
|
||||
if (null === $provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('LCR' === $provider->getPaymentType()?->getCode() && $provider->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