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:
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Application\Service;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un Provider / ProviderContact,
|
||||
* appliquee par le ProviderProcessor (et les processors de sous-ressources,
|
||||
* ticket ulterieur M3) AVANT persistance. Cf. spec-back M3 § 2.11 + RG-3.11.
|
||||
* Jumeau de SupplierFieldNormalizer (M2) — duplique volontairement (isolation
|
||||
* Commercial / Technique, decision § 2.1).
|
||||
*
|
||||
* - companyName : UPPERCASE integral (RG-3.11)
|
||||
* - firstName / lastName (personnes, sur ProviderContact) : Title Case (RG-3.11)
|
||||
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-3.11).
|
||||
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
|
||||
* - email : lowercase integral (RG-3.11)
|
||||
*
|
||||
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
|
||||
* apres trim devient null (evite de persister "" dans des colonnes nullable).
|
||||
*/
|
||||
final class ProviderFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Nom de societe en majuscules (RG-3.11). Conserve null tel quel ; une
|
||||
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
|
||||
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
|
||||
*/
|
||||
public function normalizeCompanyName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_strtoupper(trim($value), 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom/prenom de personne en Title Case (RG-3.11) : "JEAN dupont" ->
|
||||
* "Jean Dupont". Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizePersonName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Email en minuscules (RG-3.11). Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizeEmail(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
|
||||
* "0612345678". Une valeur sans aucun chiffre devient null.
|
||||
*/
|
||||
public function normalizePhone(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $value) ?? '';
|
||||
|
||||
return '' === $digits ? null : $digits;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderProcessor;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
|
||||
use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
@@ -53,11 +55,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* reference de donnees de reference, pas de logique inter-module.
|
||||
*
|
||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups
|
||||
* sont poses ICI (source unique). L'#[ApiResource] est ici un SQUELETTE (operations
|
||||
* + contextes + security) ; le ProviderProvider (liste paginee anti-N+1, exclusion
|
||||
* archives, cloisonnement site, gating accounting) et le ProviderProcessor
|
||||
* (normalisation, archivage, 409 doublon, RG-3.07 / RG-3.08) sont cables au ticket
|
||||
* suivant (ERP-134) — ils ne sont volontairement PAS references ici.
|
||||
* sont poses ICI (source unique). L'#[ApiResource] cable (ERP-134) le ProviderProvider
|
||||
* (liste paginee anti-N+1, exclusion archives, cloisonnement site lecture + detail
|
||||
* 404) et le ProviderProcessor (normalisation, archivage, mode strict par groupe,
|
||||
* cloisonnement site ecriture, 409 doublon). Le groupe provider:read:accounting est
|
||||
* ajoute dynamiquement au contexte par le ProviderReadGroupContextBuilder selon la
|
||||
* permission accounting.view (ERP-134) — jamais pose en dur sur l'operation.
|
||||
*
|
||||
* Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) +
|
||||
* Timestampable / Blamable via le trait Shared.
|
||||
@@ -69,17 +72,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
// La liste embarque les categories (code/name, groupe category:read) et
|
||||
// les sites du prestataire (name/postalCode, groupe site:read — relation
|
||||
// DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read +
|
||||
// site:read presents dans le contexte. L'hydratation anti-N+1 sera
|
||||
// cablee par le ProviderProvider (ERP-134, cf. DoctrineProviderRepository).
|
||||
// site:read presents dans le contexte. Hydratation anti-N+1 cablee par
|
||||
// le ProviderProvider (cf. DoctrineProviderRepository::hydrateListCollections).
|
||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||
provider: ProviderProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// Detail : prestataire + sous-collections embarquees (contacts, adresses
|
||||
// + leurs sites/categories/contacts) + RIB (gates compta). Le groupe
|
||||
// provider:read:accounting est volontairement ABSENT : il sera ajoute au
|
||||
// contexte par le ProviderProvider / ReadGroupContextBuilder selon la
|
||||
// permission accounting.view (ERP-134, parade fuite IBAN/BIC — bug #4 M1).
|
||||
// provider:read:accounting est volontairement ABSENT : il est ajoute au
|
||||
// contexte par le ProviderReadGroupContextBuilder selon la permission
|
||||
// accounting.view (parade fuite IBAN/BIC — bug #4 M1).
|
||||
normalizationContext: ['groups' => [
|
||||
'provider:read',
|
||||
'provider:item:read',
|
||||
@@ -87,11 +91,13 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: ProviderProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:main']],
|
||||
processor: ProviderProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
// Security elargie : `manage` OU `accounting.manage` — le role Compta n'a
|
||||
@@ -105,6 +111,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'provider:write:accounting',
|
||||
'provider:write:archive',
|
||||
]],
|
||||
provider: ProviderProvider::class,
|
||||
processor: ProviderProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
|
||||
],
|
||||
|
||||
@@ -13,6 +13,21 @@ interface ProviderRepositoryInterface
|
||||
|
||||
public function save(Provider $provider): void;
|
||||
|
||||
/**
|
||||
* Restreint un QueryBuilder de liste aux prestataires rattaches au site donne
|
||||
* (relation DIRECTE provider.sites). Sert le cloisonnement par site pilote par
|
||||
* l'utilisateur (RG-3.17, § 2.13) : le ProviderProvider resout le site courant
|
||||
* (CurrentSiteProvider) puis appelle cette methode quand l'user n'a pas
|
||||
* `sites.bypass_scope`. Decouple ainsi la DECISION (Provider, qui connait
|
||||
* l'user) du DQL (repository, qui ne connait que l'id de site).
|
||||
*
|
||||
* Sous-requete IN (et non JOIN sur la M2M) pour ne pas perturber le
|
||||
* DISTINCT / ORDER BY / pagination du QueryBuilder de selection — meme parti
|
||||
* pris que les filtres ?categoryCode / ?siteId. Applique AVANT la pagination
|
||||
* (le COUNT du Paginator reflete alors le perimetre de l'user).
|
||||
*/
|
||||
public function applySiteScope(QueryBuilder $qb, int $siteId): void;
|
||||
|
||||
/**
|
||||
* Construit un QueryBuilder de liste pour le repertoire prestataires.
|
||||
* - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16).
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\Serializer;
|
||||
|
||||
use ApiPlatform\State\SerializerContextBuilderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Decore le context builder de serialisation d'API Platform pour ajouter
|
||||
* DYNAMIQUEMENT le groupe de lecture `provider:read:accounting` sur les
|
||||
* ressources Provider, uniquement si l'utilisateur courant a la permission
|
||||
* `technique.providers.accounting.view` (cf. spec-back M3 § 2.9 / § 4.1 /
|
||||
* § 4.2). Jumeau de SupplierReadGroupContextBuilder (M2).
|
||||
*
|
||||
* Pourquoi un context builder et pas le Provider : un Provider retourne des
|
||||
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
|
||||
* de normalisation est construit ici, en amont du serializer — c'est le point
|
||||
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
|
||||
* l'utilisateur. Realise l'intention « gating du groupe accounting » de la spec
|
||||
* (le groupe n'est jamais pose par defaut sur l'operation : il est AJOUTE ici si
|
||||
* la permission est presente — resultat identique au « retrait » decrit en spec).
|
||||
*
|
||||
* S'applique aux operations de LECTURE (normalization) sur Provider : liste ET
|
||||
* detail. Sans la permission, les champs comptables (siren, accountNumber,
|
||||
* tvaMode, nTva, paymentDelay, paymentType, bank) ET les RIB embarques (groupe
|
||||
* provider:read:accounting porte par getRibs()) ne sont jamais serialises — la
|
||||
* cle est totalement absente du JSON (gating par omission, parade bug #4 M1).
|
||||
*
|
||||
* Priorite de decoration -20 : on s'empile APRES les decorateurs Commercial
|
||||
* (ClientReadGroupContextBuilder priorite 0, SupplierReadGroupContextBuilder
|
||||
* priorite -10) sur le meme service `api_platform.serializer.context_builder`.
|
||||
* Chaque decorateur passe la main pour toute ressource autre que la sienne :
|
||||
* l'ordre de chainage n'a donc aucun effet fonctionnel, la priorite explicite ne
|
||||
* sert qu'a lever l'ambiguite de plusieurs decorateurs sur un meme service.
|
||||
*/
|
||||
#[AsDecorator(decorates: 'api_platform.serializer.context_builder', priority: -20)]
|
||||
final readonly class ProviderReadGroupContextBuilder implements SerializerContextBuilderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[AutowireDecorated]
|
||||
private SerializerContextBuilderInterface $decorated,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
|
||||
{
|
||||
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
|
||||
|
||||
// Uniquement en lecture, sur la ressource Provider, avec la permission.
|
||||
if (!$normalization) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
if (Provider::class !== ($context['resource_class'] ?? null)) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('technique.providers.accounting.view')) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$groups = $context['groups'] ?? [];
|
||||
if (!in_array('provider:read:accounting', $groups, true)) {
|
||||
$groups[] = 'provider:read:accounting';
|
||||
}
|
||||
$context['groups'] = $groups;
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,26 @@ class DoctrineProviderRepository extends ServiceEntityRepository implements Prov
|
||||
;
|
||||
}
|
||||
|
||||
public function applySiteScope(QueryBuilder $qb, int $siteId): void
|
||||
{
|
||||
// Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires
|
||||
// dont provider.sites contient le site donne. Sous-requete IN (alias p5
|
||||
// distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du
|
||||
// QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds.
|
||||
// Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour
|
||||
// que les deux clauses puissent coexister (intersection) sans collision.
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('p5.id')
|
||||
->from(Provider::class, 'p5')
|
||||
->join('p5.sites', 'site5')
|
||||
->where('site5.id = :scopeSiteId')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
|
||||
->setParameter('scopeSiteId', $siteId)
|
||||
;
|
||||
}
|
||||
|
||||
public function hydrateContacts(array $providers): void
|
||||
{
|
||||
$ids = $this->collectIds($providers);
|
||||
|
||||
Reference in New Issue
Block a user