feat(technique) : ProviderProvider + ProviderProcessor + cloisonnement site (ERP-134)
Coeur API du repertoire prestataires (M3), jumeau du M2 fournisseurs : - ProviderProvider : liste paginee (Paginator ORM), filtres search/categoryCode/siteId/includeArchived, tri companyName ASC, exclusion archives + soft-deletes (RG-3.16). Cloisonnement par site pilote par l'utilisateur (RG-3.17 / § 2.13) : liste restreinte au currentSite avant pagination (totalItems = perimetre), detail hors perimetre -> 404, bypass via sites.bypass_scope. - ProviderProcessor : normalisation companyName (RG-3.11), POST formulaire principal (companyName + categories + sites), PATCH partiels par groupe en mode strict (RG-3.15, 403 sur tout le payload), archivage (RG-3.13/3.14), 409 doublon de nom (RG-3.10), garde d'ecriture cloisonnee des sites (RG-3.03/3.17, 422 sur sites pour les users sites.read_ref). - ProviderReadGroupContextBuilder : gating comptabilite par AJOUT du groupe provider:read:accounting si accounting.view (jamais par retrait). - ProviderFieldNormalizer : miroir SupplierFieldNormalizer. - ApiResource cable (provider + processor) sur l'entite Provider. Tests : ProviderApiTest, ProviderListTest, ProviderRbacGatingTest, ProviderSiteScopeTest (26 tests). Suite complete verte (612 tests).
This commit is contained in:
+559
@@ -0,0 +1,559 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
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 prestataires (M3). Cf. spec-back M3 § 4.3 /
|
||||
* § 4.4 + RG-3.10 / RG-3.13 / RG-3.14 / RG-3.15 / RG-3.17. Jumeau du
|
||||
* SupplierProcessor (M2), avec deux differences structurantes (§ 3.1) :
|
||||
* - PAS d'onglet Information (aucun champ description / competitors / ...) ni de
|
||||
* validation de completude comptable -> le prestataire est minimal ;
|
||||
* - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au
|
||||
* CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans
|
||||
* `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`.
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La
|
||||
* security d'operation du PATCH est elargie a `manage` OU `accounting.manage`
|
||||
* pour laisser entrer le role Compta ; ce processor re-gate alors finement :
|
||||
* - champ comptable modifie dans le payload -> exige accounting.manage (403) ;
|
||||
* - champ main (companyName / categories / sites) modifie -> exige manage
|
||||
* (guardManage, 403) : empeche Compta d'editer un autre onglet ;
|
||||
* - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et
|
||||
* interdit toute autre modification dans la meme requete (RG-3.13, 422).
|
||||
* 2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache
|
||||
* hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`.
|
||||
* 3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName).
|
||||
* 4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null).
|
||||
* 5. Persistance via le persist_processor Doctrine, avec traduction des
|
||||
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
|
||||
* restauration).
|
||||
*
|
||||
* La RG-3.09 (categorie de type PRESTATAIRE) est portee par un Assert\Callback +
|
||||
* ->atPath() sur l'entite Provider (joue par API Platform AVANT ce processor),
|
||||
* pour que la 422 porte un propertyPath consommable par extractApiViolations
|
||||
* (mapping inline, pas un toast — convention ERP-101). Les RG-3.07 (Virement ->
|
||||
* banque) et RG-3.08 (LCR -> RIB) relevent de l'onglet Comptabilite / sous-ressource
|
||||
* RIB (ticket dedie) et ne sont pas portees ici.
|
||||
*
|
||||
* @implements ProcessorInterface<Provider, Provider>
|
||||
*/
|
||||
final class ProviderProcessor implements ProcessorInterface
|
||||
{
|
||||
/** Champs de l'onglet principal (groupe provider:write:main). */
|
||||
private const array MAIN_FIELDS = [
|
||||
'companyName', 'categories', 'sites',
|
||||
];
|
||||
|
||||
/** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */
|
||||
private const array ACCOUNTING_FIELDS = [
|
||||
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
|
||||
'paymentType', 'bank',
|
||||
];
|
||||
|
||||
/** Champ d'archivage (groupe provider:write:archive). */
|
||||
private const string ARCHIVE_FIELD = 'isArchived';
|
||||
|
||||
private const string PERM_MANAGE = 'technique.providers.manage';
|
||||
private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage';
|
||||
private const string PERM_ARCHIVE = 'technique.providers.archive';
|
||||
|
||||
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
|
||||
|
||||
/**
|
||||
* Memoisation du dernier corps de requete decode, clos par le contenu brut
|
||||
* (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete,
|
||||
* on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune
|
||||
* fuite entre requetes sur ce service partage.
|
||||
*/
|
||||
private ?string $decodedContent = null;
|
||||
|
||||
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
|
||||
private array $decodedPayloadKeys = [];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly ProviderFieldNormalizer $normalizer,
|
||||
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 Provider) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Reinitialisation de la memoisation du payload : le service est partage
|
||||
// (stateful), on repart du corps de LA requete courante.
|
||||
$this->decodedContent = null;
|
||||
$this->decodedPayloadKeys = [];
|
||||
|
||||
$writableKeys = $this->writablePayloadKeys();
|
||||
|
||||
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
|
||||
$this->guardAccounting($data);
|
||||
$this->guardSiteScope($data);
|
||||
|
||||
$this->normalize($data);
|
||||
|
||||
// guardManage apres normalize : la comparaison « change vs etat persiste »
|
||||
// des champs texte (companyName) se fait sur des valeurs normalisees des
|
||||
// deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Le seul index unique partiel est uq_provider_company_name_active
|
||||
// (LOWER(company_name) parmi non-archives/non-deletes — § 2.6).
|
||||
if ($isArchiveRequest && false === $data->isArchived()) {
|
||||
// RG-3.14 : restauration en conflit avec un homonyme actif.
|
||||
throw new ConflictHttpException(
|
||||
'Restauration impossible : un autre prestataire a pris le nom entre-temps.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
// RG-3.10 : doublon de nom de societe.
|
||||
throw new ConflictHttpException(
|
||||
sprintf('Un prestataire nommé "%s" existe déjà.', (string) $data->getCompanyName()),
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.13 / RG-3.14 : si le payload bascule reellement 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. Restreint a la mise a jour d'un prestataire existant ET au seul
|
||||
* cas ou isArchived change vraiment (cf. SupplierProcessor).
|
||||
*
|
||||
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
|
||||
*/
|
||||
private function guardArchive(Provider $data, array $writableKeys): bool
|
||||
{
|
||||
// POST / entite non geree : l'archivage est une action de mise a jour.
|
||||
if (!$this->em->contains($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// isArchived inchange par rapport a l'etat persiste : pas une requete
|
||||
// d'archivage (cas du PATCH representation complete).
|
||||
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
|
||||
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-3.13 : une requete d'archivage ne modifie aucun autre champ ecrivable.
|
||||
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
|
||||
throw new UnprocessableEntityHttpException(
|
||||
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
|
||||
);
|
||||
}
|
||||
|
||||
// RG-3.13 (true -> now) / RG-3.14 (false -> null).
|
||||
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.15 : la modification effective d'un champ comptable exige
|
||||
* accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de
|
||||
* filtrage silencieux). On ne gate que si un champ change reellement par
|
||||
* rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables
|
||||
* inchanges ne declenche pas de 403 parasite). Le message precise le premier
|
||||
* champ fautif.
|
||||
*/
|
||||
private function guardAccounting(Provider $data): void
|
||||
{
|
||||
$changed = $this->changedAccountingFields($data);
|
||||
|
||||
if ([] === $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
$changed[0],
|
||||
self::PERM_ACCOUNTING_MANAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet
|
||||
* principal : companyName / categories / sites) exige
|
||||
* `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du
|
||||
* payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le
|
||||
* role Compta — qui entre dans le PATCH via `accounting.manage` (security
|
||||
* d'operation elargie) — d'editer autre chose que l'onglet Comptabilite.
|
||||
*
|
||||
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
|
||||
* deja gardee par la security d'operation `manage`.
|
||||
*/
|
||||
private function guardManage(Provider $data): void
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changed = $this->changedBusinessFields($data);
|
||||
|
||||
if ([] === $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_MANAGE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
$changed[0],
|
||||
self::PERM_MANAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE — § 2.13) : un user SANS
|
||||
* `sites.bypass_scope` ne peut attacher au prestataire 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 auto) peut attacher n'importe quel site.
|
||||
*
|
||||
* Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user
|
||||
* sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site
|
||||
* hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400
|
||||
* anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement
|
||||
* AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref`
|
||||
* (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne
|
||||
* doit rattacher que ses propres sites), et une defense en profondeur sinon.
|
||||
*
|
||||
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
|
||||
* sites obligatoires RG-3.03) 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 (data.getSites()).
|
||||
*/
|
||||
private function guardSiteScope(Provider $data): void
|
||||
{
|
||||
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// sites non soumis sur un PATCH : rien a cloisonner.
|
||||
if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedSiteIds = $this->currentUserSiteIds();
|
||||
|
||||
foreach ($data->getSites() as $site) {
|
||||
if (!$site instanceof SiteInterface) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($site->getId(), $allowedSiteIds, true)) {
|
||||
$this->throwSitesViolation($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs « metier » (onglet principal : companyName / categories / sites) dont
|
||||
* la valeur courante differe de l'etat persiste. Scalaires compares par valeur ;
|
||||
* collections M2M (categories / sites) comparees par ensemble d'identifiants
|
||||
* (cf. collectionChanged) — la simple presence dans le payload ne suffit pas,
|
||||
* sous peine de 403 parasite sur un PATCH representation complete.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function changedBusinessFields(Provider $data): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) {
|
||||
$changed[] = 'companyName';
|
||||
}
|
||||
|
||||
if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) {
|
||||
$changed[] = 'categories';
|
||||
}
|
||||
|
||||
if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) {
|
||||
$changed[] = 'sites';
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si une collection M2M (`categories` ou `sites`) differe reellement de
|
||||
* l'etat persiste. Ces collections ne sont pas tracees par
|
||||
* getOriginalEntityData : on compare par identifiants (independamment de
|
||||
* l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat
|
||||
* courant (apres application du payload). Symetrique des scalaires : seul un
|
||||
* changement effectif compte, pas la simple presence dans le payload.
|
||||
*
|
||||
* - POST / entite non geree : fournir la collection est un acte metier
|
||||
* (branche defensive, guardManage ne s'execute que sur entite geree).
|
||||
* - cle absente du payload (PATCH partiel) : aucun changement.
|
||||
*
|
||||
* @param array<int, object> $current
|
||||
*/
|
||||
private function collectionChanged(Provider $data, string $field, array $current): bool
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!in_array($field, $this->payloadKeys(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$collection = 'categories' === $field ? $data->getCategories() : $data->getSites();
|
||||
|
||||
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat
|
||||
// persiste comparable, on se rabat sur la presence payload.
|
||||
if (!$collection instanceof PersistentCollection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->idSet($current) !== $this->idSet($collection->getSnapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensemble trie des identifiants d'une liste d'entites — pour une comparaison
|
||||
* par valeur independante de l'ordre.
|
||||
*
|
||||
* @param array<int, object> $entities
|
||||
*
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private function idSet(array $entities): array
|
||||
{
|
||||
$ids = array_map(
|
||||
static fn (object $entity): mixed => method_exists($entity, 'getId')
|
||||
? $entity->getId()
|
||||
: spl_object_id($entity),
|
||||
array_values($entities),
|
||||
);
|
||||
sort($ids);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
|
||||
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
|
||||
* identite d'objet : l'identity map Doctrine renvoie la meme instance tant que
|
||||
* la reference est inchangee.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function changedAccountingFields(Provider $data): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach (self::ACCOUNTING_FIELDS as $field) {
|
||||
$newValue = match ($field) {
|
||||
'siren' => $data->getSiren(),
|
||||
'accountNumber' => $data->getAccountNumber(),
|
||||
'tvaMode' => $data->getTvaMode(),
|
||||
'nTva' => $data->getNTva(),
|
||||
'paymentDelay' => $data->getPaymentDelay(),
|
||||
'paymentType' => $data->getPaymentType(),
|
||||
'bank' => $data->getBank(),
|
||||
};
|
||||
|
||||
if ($this->fieldChanged($data, $field, $newValue)) {
|
||||
$changed[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
|
||||
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
|
||||
* non-null est alors un changement.
|
||||
*/
|
||||
private function fieldChanged(Provider $data, string $field, mixed $newValue): bool
|
||||
{
|
||||
$original = $this->originalData($data);
|
||||
|
||||
return $newValue !== ($original[$field] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
|
||||
* application du payload). Vide pour une entite non geree (POST).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function originalData(Provider $data): array
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est
|
||||
* porte par le Provider (les champs de contact sont normalises par le processor
|
||||
* de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est
|
||||
* touche que si une valeur est presente, pour ne jamais ecraser l'existant lors
|
||||
* d'un PATCH partiel.
|
||||
*/
|
||||
private function normalize(Provider $data): void
|
||||
{
|
||||
if (null !== $data->getCompanyName()) {
|
||||
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
|
||||
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
|
||||
* connu. Base du 422 d'archivage (RG-3.13).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function writablePayloadKeys(): array
|
||||
{
|
||||
$writable = array_merge(
|
||||
self::MAIN_FIELDS,
|
||||
self::ACCOUNTING_FIELDS,
|
||||
[self::ARCHIVE_FIELD],
|
||||
);
|
||||
|
||||
return array_values(array_intersect($this->payloadKeys(), $writable));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles de premier niveau effectivement envoyees par le client (payload JSON
|
||||
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function payloadKeys(): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (null === $request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
|
||||
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
|
||||
if ($content === $this->decodedContent) {
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
$this->decodedContent = $content;
|
||||
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
|
||||
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractPayloadKeys(string $content): array
|
||||
{
|
||||
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(Provider $root): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
|
||||
null,
|
||||
[],
|
||||
$root,
|
||||
'sites',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\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\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider du repertoire prestataires (M3). Cf. spec-back M3 § 4.1 / § 4.2 +
|
||||
* RG-3.16 / RG-3.17. Jumeau du SupplierProvider (M2), augmente du cloisonnement
|
||||
* par site pilote par l'utilisateur (§ 2.13).
|
||||
*
|
||||
* Collection (GET /api/providers) :
|
||||
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
|
||||
* (deleted_at IS NOT NULL) — RG-3.16 ;
|
||||
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
|
||||
* exclus au M3) — RG-3.16 ;
|
||||
* - tri par defaut companyName ASC — RG-3.16 ;
|
||||
* - filtres ?search=... (fuzzy companyName + contacts lies : firstName /
|
||||
* lastName / email), ?categoryCode=<code> (prestataires ayant >= 1 categorie
|
||||
* de ce code, repetable) et ?siteId=<id> (prestataires rattaches a ce site
|
||||
* via la relation DIRECTE provider.sites, repetable) ;
|
||||
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
|
||||
* ?pagination=false pour alimenter un <select> sans pagination.
|
||||
*
|
||||
* Cloisonnement par site (RG-3.17, § 2.13) — applique ICI (le QueryBuilder du
|
||||
* repository ne connait pas l'user courant) :
|
||||
* - si l'user N'A PAS `sites.bypass_scope` ET que CurrentSiteProvider::get()
|
||||
* retourne un site -> la liste est restreinte aux prestataires dont
|
||||
* provider.sites contient le currentSite (repository::applySiteScope), AVANT
|
||||
* pagination : totalItems reflete le perimetre de l'user ;
|
||||
* - le DETAIL (Get / provider de PATCH) d'un prestataire hors perimetre renvoie
|
||||
* 404 (null) — ne pas reveler l'existence d'une ligne hors site ;
|
||||
* - user `bypass_scope` (Admin auto, profils consolidation) -> aucun filtre ;
|
||||
* - currentSite = null (module Sites off / user sans site) -> no-op lecture
|
||||
* (aligne site-aware.md § 5).
|
||||
*
|
||||
* Item (GET /api/providers/{id} + provider de PATCH) :
|
||||
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
|
||||
* M3) ; les archives restent consultables/restaurables en detail ;
|
||||
* - 404 si hors perimetre site (cloisonnement, cf. ci-dessus).
|
||||
*
|
||||
* Le filtrage des champs comptables en lecture (groupe provider:read:accounting)
|
||||
* n'est PAS fait ici mais dans ProviderReadGroupContextBuilder : un Provider
|
||||
* retourne des donnees mais ne peut pas influencer les groupes de serialisation.
|
||||
*
|
||||
* @implements ProviderInterface<Provider>
|
||||
*/
|
||||
final class ProviderProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||
private readonly ProviderRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
private readonly Security $security,
|
||||
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
|
||||
// CurrentSiteProvider dans le service et ajouter la clause WHERE
|
||||
// manuellement » pour les cas multi-site non couverts par
|
||||
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<Provider>|Paginator<Provider>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
// categoryCode accepte un code unique (?categoryCode=NETTOYAGE, selects)
|
||||
// OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
|
||||
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($filters['siteId'] ?? []);
|
||||
|
||||
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
$categoryCodes,
|
||||
$siteIds,
|
||||
$archivedOnly,
|
||||
);
|
||||
|
||||
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
|
||||
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
|
||||
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null !== $scopeSite) {
|
||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||
}
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (regle n°13 — utile pour un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
/** @var list<Provider> $providers */
|
||||
$providers = $qb->getQuery()->getResult();
|
||||
// Hydratation batchee des collections affichees (§ 2.12) : evite le
|
||||
// N+1 si la serialisation touche categories/sites, sans cartesien.
|
||||
$this->repository->hydrateListCollections($providers);
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le
|
||||
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
|
||||
// puis on hydrate ses collections en lot (memes entites managees).
|
||||
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
$this->repository->hydrateListCollections(iterator_to_array($paginator));
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?Provider
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = $this->repository->findById((int) $id);
|
||||
if (null === $provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose au M3 (HP-M4) — 404 via retour null.
|
||||
// Les archives restent visibles en detail (consultation + restauration).
|
||||
if (null !== $provider->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
|
||||
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
|
||||
// currentSite null.
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
|
||||
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
|
||||
* / user sans currentSite, aligne site-aware.md § 5).
|
||||
*/
|
||||
private function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le prestataire est rattache (relation directe provider.sites) au
|
||||
* site d'id donne. Comparaison en memoire sur l'entite deja chargee (detail).
|
||||
*/
|
||||
private function providerHasSite(Provider $provider, int $siteId): bool
|
||||
{
|
||||
foreach ($provider->getSites() as $site) {
|
||||
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines. Tolere un code unique (string)
|
||||
* ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
|
||||
* valeur unique ou une liste (?key[]=1&key[]=2).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user