From e26d2af644eb1e7a98307a6ab25dd359d924f522 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 11:03:19 +0200 Subject: [PATCH 1/7] feat(technique) : ProviderProvider + ProviderProcessor + cloisonnement site (ERP-134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../Service/ProviderFieldNormalizer.php | 82 +++ .../Technique/Domain/Entity/Provider.php | 28 +- .../ProviderRepositoryInterface.php | 15 + .../ProviderReadGroupContextBuilder.php | 76 +++ .../State/Processor/ProviderProcessor.php | 559 ++++++++++++++++++ .../State/Provider/ProviderProvider.php | 256 ++++++++ .../Doctrine/DoctrineProviderRepository.php | 20 + .../Api/AbstractProviderApiTestCase.php | 287 +++++++++ .../Module/Technique/Api/ProviderApiTest.php | 115 ++++ .../Module/Technique/Api/ProviderListTest.php | 83 +++ .../Technique/Api/ProviderRbacGatingTest.php | 159 +++++ .../Technique/Api/ProviderSiteScopeTest.php | 171 ++++++ 12 files changed, 1841 insertions(+), 10 deletions(-) create mode 100644 src/Module/Technique/Application/Service/ProviderFieldNormalizer.php create mode 100644 src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php create mode 100644 src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php create mode 100644 src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php create mode 100644 tests/Module/Technique/Api/AbstractProviderApiTestCase.php create mode 100644 tests/Module/Technique/Api/ProviderApiTest.php create mode 100644 tests/Module/Technique/Api/ProviderListTest.php create mode 100644 tests/Module/Technique/Api/ProviderRbacGatingTest.php create mode 100644 tests/Module/Technique/Api/ProviderSiteScopeTest.php diff --git a/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php new file mode 100644 index 0000000..facc4c0 --- /dev/null +++ b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php @@ -0,0 +1,82 @@ + "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; + } +} diff --git a/src/Module/Technique/Domain/Entity/Provider.php b/src/Module/Technique/Domain/Entity/Provider.php index 1d9a5e0..3b1da19 100644 --- a/src/Module/Technique/Domain/Entity/Provider.php +++ b/src/Module/Technique/Domain/Entity/Provider.php @@ -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 }. ], diff --git a/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php index d5541a7..49f5fb1 100644 --- a/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php +++ b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php @@ -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). diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php b/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php new file mode 100644 index 0000000..0ba501f --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php @@ -0,0 +1,76 @@ +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; + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php new file mode 100644 index 0000000..a439a13 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php @@ -0,0 +1,559 @@ + 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 + */ +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 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 $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 + */ + 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 + */ + 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 $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 $entities + * + * @return list + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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); + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php new file mode 100644 index 0000000..5ed3645 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php @@ -0,0 +1,256 @@ + (prestataires ayant >= 1 categorie + * de ce code, repetable) et ?siteId= (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 cote front). + if (!$this->pagination->isEnabled($operation, $context)) { + /** @var list $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 $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 + */ + 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 + */ + 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; + } +} diff --git a/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php b/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php index ec63c4e..ac5be4a 100644 --- a/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php +++ b/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php @@ -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); diff --git a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php new file mode 100644 index 0000000..5e5d5e3 --- /dev/null +++ b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php @@ -0,0 +1,287 @@ +getEm(); + + $em->createQuery('DELETE FROM '.Provider::class)->execute(); + $em->createQuery('DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix') + ->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute() + ; + $em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix') + ->setParameter('prefix', 'test_%')->execute() + ; + $em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix') + ->setParameter('prefix', 'test_%')->execute() + ; + + parent::tearDown(); + } + + protected function createAdminClient(): Client + { + return $this->authenticatedClient('admin', 'admin'); + } + + /** + * Recupere (ou cree) le type PRESTATAIRE. Idempotent (unicite category_type.code). + */ + protected function providerCategoryType(): CategoryType + { + $em = $this->getEm(); + $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']); + if (null !== $existing) { + return $existing; + } + + $type = new CategoryType(); + $type->setCode('PRESTATAIRE'); + $type->setLabel('Prestataire'); + $em->persist($type); + $em->flush(); + + return $type; + } + + /** + * Fetch-or-create une categorie de type PRESTATAIRE par code (defaut NETTOYAGE). + * Idempotent (lookup par code, aligne sur l'index unique partiel uq_category_code) + * et auto-suffisant. Nom prefixe -> purge par tearDown. + */ + protected function providerCategory(string $code = 'NETTOYAGE'): Category + { + $em = $this->getEm(); + $existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]); + if (null !== $existing) { + return $existing; + } + + $category = new Category(); + $category->setName(self::TEST_CATEGORY_PREFIX.strtolower($code)); + $category->setCode($code); + $category->addCategoryType($this->providerCategoryType()); + $em->persist($category); + $em->flush(); + + return $category; + } + + /** + * Cree une categorie d'un type DIFFERENT de PRESTATAIRE (pour tester le rejet + * RG-3.09). Code unique pour ne pas collisionner avec une categorie existante. + */ + protected function foreignCategory(): Category + { + $em = $this->getEm(); + $suffix = substr(bin2hex(random_bytes(4)), 0, 8); + + $type = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'CLIENT']); + if (null === $type) { + $type = new CategoryType(); + $type->setCode('CLIENT'); + $type->setLabel('Client'); + $em->persist($type); + } + + $category = new Category(); + $category->setName(self::TEST_CATEGORY_PREFIX.'foreign_'.$suffix); + $category->setCode('FOREIGN_'.strtoupper($suffix)); + $category->addCategoryType($type); + $em->persist($category); + $em->flush(); + + return $category; + } + + /** + * Recupere un site fixture par code postal (cf. SitesFixtures). Echoue + * explicitement si absent (fixtures non chargees / module Sites off). + */ + protected function site(string $postalCode): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy(['postalCode' => $postalCode]); + + self::assertNotNull( + $site, + sprintf('Site fixture "%s" introuvable : SitesFixtures charge (make test-db-setup) ?', $postalCode), + ); + + return $site; + } + + /** + * Seede directement un Provider minimal (sans passer par l'API), pour les tests + * de liste / archivage / cloisonnement. Nom stocke en MAJUSCULES pour refleter + * l'etat normalise (RG-3.11) qu'aurait produit le ProviderProcessor. Porte une + * categorie PRESTATAIRE + les sites donnes (par code postal). + * + * @param list $sitePostalCodes codes postaux des sites a rattacher + */ + protected function seedProvider( + string $companyName, + array $sitePostalCodes = [self::SITE_86], + bool $isArchived = false, + string $categoryCode = 'NETTOYAGE', + ?string $siren = null, + ): Provider { + $em = $this->getEm(); + $provider = new Provider(); + $provider->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); + $provider->addCategory($this->providerCategory($categoryCode)); + foreach ($sitePostalCodes as $postalCode) { + $provider->addSite($this->site($postalCode)); + } + if (null !== $siren) { + $provider->setSiren($siren); + } + $provider->setIsArchived($isArchived); + if ($isArchived) { + $provider->setArchivedAt(new DateTimeImmutable()); + } + $em->persist($provider); + $em->flush(); + + return $provider; + } + + /** + * Payload minimal valide du formulaire principal (companyName + 1 categorie + * PRESTATAIRE + sites donnes). Categorie NETTOYAGE par defaut. + * + * @param list $sitePostalCodes + * + * @return array + */ + protected function validMainPayload(string $companyName, array $sitePostalCodes = [self::SITE_86]): array + { + $siteIris = array_map(fn (string $pc): string => '/api/sites/'.$this->site($pc)->getId(), $sitePostalCodes); + + return [ + 'companyName' => $companyName, + 'categories' => ['/api/categories/'.$this->providerCategory()->getId()], + 'sites' => $siteIris, + ]; + } + + /** + * Cree un utilisateur non-admin CLOISONNE : porte les permissions donnees via + * un role jetable, rattache aux seuls sites donnes (par code postal), avec un + * currentSite positionne. N'a PAS `sites.bypass_scope` (sauf si fourni dans + * $permissionCodes) -> sujet ideal des tests de cloisonnement (RG-3.17). + * + * Contrairement a createUserWithPermissions() (parent, qui attache TOUS les + * sites et ne pose pas de currentSite), ce helper controle finement le + * perimetre site de l'user. + * + * @param list $permissionCodes + * @param list $sitePostalCodes sites a rattacher (user_site) + * + * @return array{username: string, password: string} + */ + protected function createScopedUser( + array $permissionCodes, + array $sitePostalCodes, + ?string $currentSitePostalCode = null, + ): array { + $em = $this->getEm(); + + $suffix = substr(bin2hex(random_bytes(4)), 0, 8); + $username = 'test_scoped_'.$suffix; + $password = 'testpass'; + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $role = new Role('test_'.$suffix, 'Test Role '.$suffix, false); + foreach ($permissionCodes as $code) { + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $code]); + self::assertNotNull($permission, sprintf('Permission "%s" introuvable (app:sync-permissions ?).', $code)); + $role->addPermission($permission); + } + $em->persist($role); + + $user = new User(); + $user->setUsername($username); + $user->setIsAdmin(false); + $user->setPassword($hasher->hashPassword($user, $password)); + $user->addRbacRole($role); + + foreach ($sitePostalCodes as $postalCode) { + $user->addSite($this->site($postalCode)); + } + if (null !== $currentSitePostalCode) { + $user->setCurrentSite($this->site($currentSitePostalCode)); + } + + $em->persist($user); + $em->flush(); + $em->clear(); + + return ['username' => $username, 'password' => $password]; + } + + /** + * Indexe les violations d'un corps 422 par propertyPath (assert ciblee). + * + * @param array $body corps decode (toArray(false)) + * + * @return array propertyPath => message + */ + protected function violationsByPath(array $body): array + { + $byPath = []; + foreach ($body['violations'] ?? [] as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + + return $byPath; + } +} diff --git a/tests/Module/Technique/Api/ProviderApiTest.php b/tests/Module/Technique/Api/ProviderApiTest.php new file mode 100644 index 0000000..82c8a14 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderApiTest.php @@ -0,0 +1,115 @@ +createAdminClient(); + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Maintenance Pro', [self::SITE_86]), + ]); + + self::assertSame(201, $response->getStatusCode()); + $body = $response->toArray(); + // RG-3.11 : companyName normalise en MAJUSCULES. + self::assertSame('MAINTENANCE PRO', $body['companyName']); + self::assertArrayHasKey('id', $body); + // sites embarque (relation directe, site:read) avec name/postalCode. + self::assertCount(1, $body['sites']); + self::assertSame('86100', $body['sites'][0]['postalCode']); + } + + public function testPostWithoutSiteIsRejected(): void + { + $client = $this->createAdminClient(); + + $payload = $this->validMainPayload('Sans Site', [self::SITE_86]); + $payload['sites'] = []; + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $payload, + ]); + + // RG-3.03 : au moins un site obligatoire. + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false))); + } + + public function testPostWithoutCategoryIsRejected(): void + { + $client = $this->createAdminClient(); + + $payload = $this->validMainPayload('Sans Categorie', [self::SITE_86]); + $payload['categories'] = []; + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $payload, + ]); + + // RG-3.09 : au moins une categorie obligatoire. + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); + } + + public function testPostWithForeignCategoryTypeIsRejected(): void + { + $client = $this->createAdminClient(); + $foreign = $this->foreignCategory(); + + $payload = $this->validMainPayload('Mauvais Type', [self::SITE_86]); + $payload['categories'] = ['/api/categories/'.$foreign->getId()]; + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $payload, + ]); + + // RG-3.09 : categorie hors type PRESTATAIRE -> 422 sur `categories`. + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); + } + + public function testDuplicateCompanyNameReturns409(): void + { + $this->seedProvider('Doublon Sarl', [self::SITE_86]); + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + // Casse differente : l'unicite est insensible a la casse (LOWER). + 'json' => $this->validMainPayload('doublon sarl', [self::SITE_86]), + ]); + + // RG-3.10 : doublon de nom (case-insensitive) -> 409. + self::assertSame(409, $response->getStatusCode()); + } + + public function testSameNameAfterArchiveIsAllowed(): void + { + // Index partiel : l'unicite ignore les archives -> reutilisation du nom OK. + $this->seedProvider('Recyclage Express', [self::SITE_86], isArchived: true); + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Recyclage Express', [self::SITE_86]), + ]); + + self::assertSame(201, $response->getStatusCode()); + } +} diff --git a/tests/Module/Technique/Api/ProviderListTest.php b/tests/Module/Technique/Api/ProviderListTest.php new file mode 100644 index 0000000..367d253 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderListTest.php @@ -0,0 +1,83 @@ + pas de cloisonnement). + * + * @internal + */ +final class ProviderListTest extends AbstractProviderApiTestCase +{ + public function testListReturnsHydraEnvelopeSortedByName(): void + { + $this->seedProvider('Zeta Services', [self::SITE_86]); + $this->seedProvider('Alpha Nettoyage', [self::SITE_86]); + $this->seedProvider('Mu Maintenance', [self::SITE_86]); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/providers', [ + 'headers' => ['Accept' => self::LD], + ]); + + self::assertSame(200, $response->getStatusCode()); + $body = $response->toArray(); + + // Envelope Hydra : totalItems present + member. + self::assertSame(3, $body['totalItems']); + $names = array_column($body['member'], 'companyName'); + // Tri companyName ASC (RG-3.16) — noms normalises en MAJUSCULES. + self::assertSame(['ALPHA NETTOYAGE', 'MU MAINTENANCE', 'ZETA SERVICES'], $names); + } + + public function testListExcludesArchivedByDefault(): void + { + $this->seedProvider('Actif Sas', [self::SITE_86]); + $this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/providers', [ + 'headers' => ['Accept' => self::LD], + ]); + + self::assertSame(200, $response->getStatusCode()); + $body = $response->toArray(); + self::assertSame(1, $body['totalItems']); + self::assertSame('ACTIF SAS', $body['member'][0]['companyName']); + } + + public function testListIncludeArchivedReintegratesArchived(): void + { + $this->seedProvider('Actif Sas', [self::SITE_86]); + $this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/providers?includeArchived=true', [ + 'headers' => ['Accept' => self::LD], + ]); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(2, $response->toArray()['totalItems']); + } + + public function testListFiltersBySiteIdViaDirectRelation(): void + { + $this->seedProvider('Site 86 Only', [self::SITE_86]); + $this->seedProvider('Site 17 Only', [self::SITE_17]); + + $client = $this->createAdminClient(); + $site17 = $this->site(self::SITE_17); + $response = $client->request('GET', '/api/providers?siteId='.$site17->getId(), [ + 'headers' => ['Accept' => self::LD], + ]); + + self::assertSame(200, $response->getStatusCode()); + $body = $response->toArray(); + self::assertSame(1, $body['totalItems']); + self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']); + } +} diff --git a/tests/Module/Technique/Api/ProviderRbacGatingTest.php b/tests/Module/Technique/Api/ProviderRbacGatingTest.php new file mode 100644 index 0000000..72ca13b --- /dev/null +++ b/tests/Module/Technique/Api/ProviderRbacGatingTest.php @@ -0,0 +1,159 @@ + CurrentSiteProvider::get() = null -> aucun + * cloisonnement, on isole ainsi le comportement RBAC du comportement site. + * + * @internal + */ +final class ProviderRbacGatingTest extends AbstractProviderApiTestCase +{ + public function testAccountingFieldsOmittedWithoutAccountingView(): void + { + $provider = $this->seedProvider('Compta Masquee', [self::SITE_86], siren: '123456789'); + $id = $provider->getId(); + + // Profil type Commerciale : view + manage SANS accounting.view. + $creds = $this->createUserWithPermissions(['technique.providers.view']); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $response->getStatusCode()); + + $body = $response->toArray(); + // Gating par omission : scalaires comptables ET ribs totalement absents. + self::assertArrayNotHasKey('siren', $body); + self::assertArrayNotHasKey('ribs', $body); + // isArchived reste expose (bug #3 M1 : la cle ne doit pas etre droppee). + self::assertArrayHasKey('isArchived', $body); + } + + public function testAccountingFieldsPresentWithAccountingView(): void + { + $provider = $this->seedProvider('Compta Visible', [self::SITE_86], siren: '987654321'); + $id = $provider->getId(); + + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.accounting.view', + ]); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $response->getStatusCode()); + + $body = $response->toArray(); + self::assertSame('987654321', $body['siren']); + // La cle ribs apparait (collection vide ici, mais presente). + self::assertArrayHasKey('ribs', $body); + } + + public function testStrictModeRejectsMixedGroupsForManageOnlyUser(): void + { + $provider = $this->seedProvider('Strict Cible', [self::SITE_86]); + $id = $provider->getId(); + + // Profil type Bureau : manage SANS accounting.manage. + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.manage', + ]); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Renomme', 'siren' => '111222333'], + ]); + + // RG-3.15 : payload melangeant main + accounting sans accounting.manage + // -> 403 sur tout le payload (mode strict, pas de filtrage silencieux). + self::assertSame(403, $response->getStatusCode()); + + // Aucun champ n'a ete persiste (rollback du mode strict). + $this->getEm()->clear(); + $reloaded = $this->getEm()->getRepository(Provider::class)->find($id); + self::assertSame('STRICT CIBLE', $reloaded->getCompanyName()); + self::assertNull($reloaded->getSiren()); + } + + public function testAccountingOnlyUserCanPatchAccountingButNotMain(): void + { + $provider = $this->seedProvider('Compta Editrice', [self::SITE_86]); + $id = $provider->getId(); + + // Profil type Compta : accounting.view + accounting.manage SANS manage. + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + ]); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + // PATCH accounting -> 200. + $ok = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '555666777'], + ]); + self::assertSame(200, $ok->getStatusCode()); + + // PATCH main (companyName) -> 403 (pas de permission manage). + $ko = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Interdit'], + ]); + self::assertSame(403, $ko->getStatusCode()); + } + + public function testArchiveRequiresArchivePermission(): void + { + $provider = $this->seedProvider('A Archiver', [self::SITE_86]); + $id = $provider->getId(); + + // Bureau (manage) sans archive -> 403. + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.manage', + ]); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + + // RG-3.13 : l'archivage exige technique.providers.archive. + self::assertSame(403, $response->getStatusCode()); + } + + public function testAdminCanArchiveAndSetsArchivedAt(): void + { + $provider = $this->seedProvider('Archivable', [self::SITE_86]); + $id = $provider->getId(); + + $client = $this->createAdminClient(); + $response = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + + self::assertSame(200, $response->getStatusCode()); + + $this->getEm()->clear(); + $reloaded = $this->getEm()->getRepository(Provider::class)->find($id); + self::assertTrue($reloaded->isArchived()); + self::assertNotNull($reloaded->getArchivedAt()); + } +} diff --git a/tests/Module/Technique/Api/ProviderSiteScopeTest.php b/tests/Module/Technique/Api/ProviderSiteScopeTest.php new file mode 100644 index 0000000..21259e9 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderSiteScopeTest.php @@ -0,0 +1,171 @@ + 422). + * + * Cloisonnement pilote par l'USER (pas le role) : on cree des users non-admin SANS + * `sites.bypass_scope`, rattaches a un site precis avec un currentSite. L'admin + * (isAdmin -> bypass total) sert de temoin « voit tout ». + * + * @internal + */ +final class ProviderSiteScopeTest extends AbstractProviderApiTestCase +{ + protected function setUp(): void + { + parent::setUp(); + // Pre-requis : le module Sites doit etre actif (sinon currentSite = null, + // cloisonnement no-op et ces tests perdent leur sens). + $this->skipIfSitesModuleDisabled(); + } + + public function testListIsScopedToCurrentSiteForNonBypassUser(): void + { + $this->seedProvider('Presta Site 86', [self::SITE_86]); + $this->seedProvider('Presta Site 17', [self::SITE_17]); + $this->seedProvider('Presta Site 82', [self::SITE_82]); + + $creds = $this->createScopedUser( + ['technique.providers.view'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $response->getStatusCode()); + + $body = $response->toArray(); + // totalItems reflete le PERIMETRE de l'user (filtre avant pagination). + self::assertSame(1, $body['totalItems']); + self::assertSame('PRESTA SITE 86', $body['member'][0]['companyName']); + } + + public function testDetailOutOfScopeReturns404(): void + { + $inScope = $this->seedProvider('Dans Perimetre', [self::SITE_86]); + $outOfScope = $this->seedProvider('Hors Perimetre', [self::SITE_17]); + + $creds = $this->createScopedUser( + ['technique.providers.view'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + // In-scope -> 200. + $ok = $client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $ok->getStatusCode()); + + // Out-of-scope -> 404 (ne pas reveler l'existence hors perimetre). + $ko = $client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertSame(404, $ko->getStatusCode()); + } + + public function testBypassUserSeesAllSites(): void + { + $this->seedProvider('Presta Site 86', [self::SITE_86]); + $this->seedProvider('Presta Site 17', [self::SITE_17]); + $this->seedProvider('Presta Site 82', [self::SITE_82]); + + // Admin = bypass total. + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(3, $response->toArray()['totalItems']); + } + + public function testWriteOutOfScopeSiteRejectedAtIriResolution(): void + { + // User non-bypass / non-read_ref : la resolution de l'IRI du site hors + // perimetre echoue en amont (SiteCollectionScopedExtension : item Site + // « introuvable ») -> 400 anti-enumeration, avant le ProviderProcessor. + $creds = $this->createScopedUser( + ['technique.providers.view', 'technique.providers.manage'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Hors Scope Sas', [self::SITE_17]), + ]); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testWriteOutOfScopeSiteRejectedByProcessorGuard(): void + { + // User `sites.read_ref` : peut RESOUDRE n'importe quel site (referentiel + // transverse) mais n'opere que sur ses user_site. La garde guardSiteScope + // du ProviderProcessor est alors l'enforcement autoritaire de RG-3.17 + // -> 422 sur `sites` (mappable inline, ERP-101). + $creds = $this->createScopedUser( + ['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Hors Scope Guard', [self::SITE_17]), + ]); + + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false))); + } + + public function testWriteAllowsSiteWithinUserScope(): void + { + $creds = $this->createScopedUser( + ['technique.providers.view', 'technique.providers.manage'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + // Site 86 = un des user_site -> 201. + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Dans Scope Sas', [self::SITE_86]), + ]); + + self::assertSame(201, $response->getStatusCode()); + } + + public function testPatchAddingOutOfScopeSiteIsRejected(): void + { + $provider = $this->seedProvider('Patch Sites', [self::SITE_86]); + $id = $provider->getId(); + + // read_ref pour pouvoir resoudre l'IRI du site 17 (sinon 400 en amont) et + // exercer la garde guardSiteScope sur le PATCH. + $creds = $this->createScopedUser( + ['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $site86 = $this->site(self::SITE_86)->getId(); + $site17 = $this->site(self::SITE_17)->getId(); + + $response = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['sites' => ['/api/sites/'.$site86, '/api/sites/'.$site17]], + ]); + + // RG-3.17 : ajouter un site hors user_site -> 422 (garde Processor). + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false))); + } +} -- 2.39.5 From 7f3bc708a4ce27c35071068e282ad53c4eb6c2ad Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 11:32:08 +0200 Subject: [PATCH 2/7] feat(technique) : sous-ressources Contacts / Adresses / RIBs (ERP-135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Domain/Entity/ProviderAddress.php | 55 ++- .../Domain/Entity/ProviderContact.php | 57 ++- .../Technique/Domain/Entity/ProviderRib.php | 54 ++- .../Processor/ProviderAddressProcessor.php | 212 ++++++++++ .../Processor/ProviderContactProcessor.php | 140 +++++++ .../State/Processor/ProviderRibProcessor.php | 113 +++++ .../Api/AbstractProviderApiTestCase.php | 67 +++ .../Api/ProviderSubResourceApiTest.php | 392 ++++++++++++++++++ 8 files changed, 1079 insertions(+), 11 deletions(-) create mode 100644 src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php create mode 100644 src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php create mode 100644 src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php create mode 100644 tests/Module/Technique/Api/ProviderSubResourceApiTest.php diff --git a/src/Module/Technique/Domain/Entity/ProviderAddress.php b/src/Module/Technique/Domain/Entity/ProviderAddress.php index 4b26a5d..bc80254 100644 --- a/src/Module/Technique/Domain/Entity/ProviderAddress.php +++ b/src/Module/Technique/Domain/Entity/ProviderAddress.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Technique\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; @@ -32,11 +39,55 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; * type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType). * * Embarquee sous `provider.addresses` au detail (groupe provider:item:read, - * maillon (a)). L'exposition en SOUS-RESSOURCE API est un ticket ulterieur du M3 : - * pas d'#[ApiResource] ici. + * maillon (a)). + * + * Sous-ressource API (ERP-135, spec § 4.5) : + * - POST /api/providers/{providerId}/addresses : creation rattachee au prestataire + * parent (Link toProperty 'provider'), security technique.providers.manage. + * - PATCH / DELETE /api/provider_addresses/{id} : security technique.providers.manage. + * - GET /api/provider_addresses/{id} : lecture unitaire (security view) — la lecture + * courante reste via le parent. Pas de GET collection autonome. + * Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement + * d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les + * contraintes de l'entite (jouees avant le processor). * * Audite (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('technique.providers.view')", + // site:read + category:read : embarquent les Site / Category lies + // (maillon (c)) plutot que des IRI nus dans le retour. + normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']], + ), + new Post( + uriTemplate: '/providers/{providerId}/addresses', + uriVariables: [ + 'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT ProviderAddress ... WHERE provider = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par ProviderAddressProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']], + denormalizationContext: ['groups' => ['provider:write:addresses']], + processor: ProviderAddressProcessor::class, + ), + new Patch( + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']], + denormalizationContext: ['groups' => ['provider:write:addresses']], + processor: ProviderAddressProcessor::class, + ), + new Delete( + security: "is_granted('technique.providers.manage')", + processor: ProviderAddressProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'provider_address')] #[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])] diff --git a/src/Module/Technique/Domain/Entity/ProviderContact.php b/src/Module/Technique/Domain/Entity/ProviderContact.php index 9abe03e..e286c4c 100644 --- a/src/Module/Technique/Domain/Entity/ProviderContact.php +++ b/src/Module/Technique/Domain/Entity/ProviderContact.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Technique\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\TimestampableInterface; @@ -15,19 +22,59 @@ use Symfony\Component\Validator\Constraints as Assert; /** * Contact d'un prestataire (1:n) — onglet Contacts. Un bloc est valide des qu'au * moins un champ est rempli (RG-3.04) : garantie portee par un CHECK BDD - * (chk_provider_contact_name) + le ProviderProcessor (ERP-134) ; l'entite reste - * permissive (tous les champs nullable). + * (chk_provider_contact_name) + le ProviderContactProcessor (ERP-135) ; l'entite + * reste permissive (tous les champs nullable). * * Embarque sous `provider.contacts` au detail (groupe provider:item:read, * maillon (a) du contrat de serialisation). Maximum 2 telephones * (phonePrimary + phoneSecondary). * - * L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/contacts, PATCH / - * DELETE) est un ticket ulterieur du M3 : pas d'#[ApiResource] ici (l'entite est - * pour l'instant uniquement embarquee via le detail du prestataire). + * Sous-ressource API (ERP-135, spec § 4.5) : + * - POST /api/providers/{providerId}/contacts : creation rattachee au prestataire + * parent (Link toProperty 'provider'), security technique.providers.manage. + * - PATCH / DELETE /api/provider_contacts/{id} : security technique.providers.manage. + * Le DELETE est physique et libre (pas de garde « dernier contact » au M3 — + * RG-3.12 front-driven, la collection peut rester vide cote back). + * - GET /api/provider_contacts/{id} : lecture unitaire (security view) — la lecture + * courante reste via le parent (le prestataire embarque ses contacts). Pas de GET + * collection autonome. + * Tout passe par le ProviderContactProcessor (normalisation RG-3.11, RG-3.04). * * Audite (#[Auditable]) + Timestampable / Blamable (pattern Shared standard). */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('technique.providers.view')", + normalizationContext: ['groups' => ['provider:item:read']], + ), + new Post( + uriTemplate: '/providers/{providerId}/contacts', + uriVariables: [ + 'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT ProviderContact ... WHERE provider = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par ProviderContactProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:item:read']], + denormalizationContext: ['groups' => ['provider:write:contacts']], + processor: ProviderContactProcessor::class, + ), + new Patch( + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:item:read']], + denormalizationContext: ['groups' => ['provider:write:contacts']], + processor: ProviderContactProcessor::class, + ), + new Delete( + security: "is_granted('technique.providers.manage')", + processor: ProviderContactProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'provider_contact')] #[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])] diff --git a/src/Module/Technique/Domain/Entity/ProviderRib.php b/src/Module/Technique/Domain/Entity/ProviderRib.php index c400c67..2b74bcb 100644 --- a/src/Module/Technique/Domain/Entity/ProviderRib.php +++ b/src/Module/Technique/Domain/Entity/ProviderRib.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Technique\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\TimestampableInterface; @@ -15,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert; /** * Coordonnees bancaires d'un prestataire (1:n) — onglet Comptabilite. Au moins un * RIB est obligatoire si le type de reglement est LCR (RG-3.08, verifie au - * ProviderProcessor : refus du DELETE du dernier RIB sous LCR — ERP-134). + * ProviderRibProcessor : refus du DELETE du dernier RIB sous LCR — ERP-135). * * Embarque sous `provider.ribs` UNIQUEMENT si l'user a accounting.view : le * read-group est `provider:read:accounting`, retire du contexte par le @@ -23,14 +30,53 @@ use Symfony\Component\Validator\Constraints as Assert; * piege n°4 du M1). Aucun #[AuditIgnore] sur iban/bic : l'audit etant admin-only, * la tracabilite RIB est conservee (decision M1 reportee, § 2.7). * - * L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/ribs, PATCH / DELETE, - * gating accounting.manage) est un ticket ulterieur du M3 : pas d'#[ApiResource] - * ici. + * Sous-ressource API (ERP-135, spec § 4.5) — gating comptable renforce : + * - POST /api/providers/{providerId}/ribs : creation rattachee au prestataire + * parent (Link toProperty 'provider'), security technique.providers.accounting.manage. + * - PATCH / DELETE /api/provider_ribs/{id} : security technique.providers.accounting.manage. + * Le DELETE refuse la suppression du dernier RIB sous LCR (RG-3.08, 409). + * - GET /api/provider_ribs/{id} : lecture unitaire, security + * technique.providers.accounting.view (donnees bancaires sensibles). Pas de GET + * collection autonome. + * Tout passe par le ProviderRibProcessor (RG-3.08 sur DELETE). * * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle * banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite * (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('technique.providers.accounting.view')", + normalizationContext: ['groups' => ['provider:read:accounting']], + ), + new Post( + uriTemplate: '/providers/{providerId}/ribs', + uriVariables: [ + 'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT ProviderRib ... WHERE provider = :id) et + // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par ProviderRibProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('technique.providers.accounting.manage')", + normalizationContext: ['groups' => ['provider:read:accounting']], + denormalizationContext: ['groups' => ['provider:write:accounting']], + processor: ProviderRibProcessor::class, + ), + new Patch( + security: "is_granted('technique.providers.accounting.manage')", + normalizationContext: ['groups' => ['provider:read:accounting']], + denormalizationContext: ['groups' => ['provider:write:accounting']], + processor: ProviderRibProcessor::class, + ), + new Delete( + security: "is_granted('technique.providers.accounting.manage')", + processor: ProviderRibProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'provider_rib')] #[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])] diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php new file mode 100644 index 0000000..ea60640 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php @@ -0,0 +1,212 @@ += 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 + */ +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 + */ + 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 + */ + 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); + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php new file mode 100644 index 0000000..abf16c4 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php @@ -0,0 +1,140 @@ + + */ +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); + } + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php new file mode 100644 index 0000000..d9ac8f6 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php @@ -0,0 +1,113 @@ + + */ +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.', + ); + } + } +} diff --git a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php index 5e5d5e3..15c6522 100644 --- a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php +++ b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php @@ -7,11 +7,14 @@ namespace App\Tests\Module\Technique\Api; use ApiPlatform\Symfony\Bundle\Test\Client; use App\Module\Catalog\Domain\Entity\Category; use App\Module\Catalog\Domain\Entity\CategoryType; +use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; use App\Module\Sites\Domain\Entity\Site; use App\Module\Technique\Domain\Entity\Provider; +use App\Module\Technique\Domain\Entity\ProviderContact; +use App\Module\Technique\Domain\Entity\ProviderRib; use App\Tests\Module\Core\Api\AbstractApiTestCase; use DateTimeImmutable; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; @@ -48,6 +51,12 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase protected const string SITE_17 = '17400'; // Saint-Jean protected const string SITE_82 = '82400'; // Pommevic + /** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */ + protected const string VALID_IBAN = 'FR1420041010050500013M02606'; + protected const string VALID_BIC = 'BNPAFRPPXXX'; + /** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */ + protected const string FOREIGN_BIC = 'DEUTDEFFXXX'; + protected function tearDown(): void { $em = $this->getEm(); @@ -268,6 +277,64 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase return ['username' => $username, 'password' => $password]; } + /** + * Ajoute un contact a un prestataire deja persiste (seed direct). + */ + protected function addContact( + Provider $provider, + ?string $firstName = 'Marie', + ?string $lastName = 'Martin', + ?string $phonePrimary = null, + ?string $email = null, + int $position = 0, + ): ProviderContact { + $contact = new ProviderContact(); + $contact->setProvider($provider); + $contact->setFirstName($firstName); + $contact->setLastName($lastName); + $contact->setPhonePrimary($phonePrimary); + $contact->setEmail($email); + $contact->setPosition($position); + $provider->addContact($contact); + $this->getEm()->persist($contact); + $this->getEm()->flush(); + + return $contact; + } + + /** + * Ajoute un RIB a un prestataire deja persiste (seed direct). + */ + protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib + { + $rib = new ProviderRib(); + $rib->setProvider($provider); + $rib->setLabel($label); + $rib->setBic(self::VALID_BIC); + $rib->setIban(self::VALID_IBAN); + $provider->addRib($rib); + $this->getEm()->persist($rib); + $this->getEm()->flush(); + + return $rib; + } + + /** + * Recupere un type de reglement seede (CommercialReferentialFixtures) par code + * (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees). + */ + protected function paymentType(string $code): PaymentType + { + $paymentType = $this->getEm()->getRepository(PaymentType::class)->findOneBy(['code' => $code]); + + self::assertNotNull( + $paymentType, + sprintf('Type de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code), + ); + + return $paymentType; + } + /** * Indexe les violations d'un corps 422 par propertyPath (assert ciblee). * diff --git a/tests/Module/Technique/Api/ProviderSubResourceApiTest.php b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php new file mode 100644 index 0000000..7663083 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php @@ -0,0 +1,392 @@ += 1 site sur + * l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse), + * le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`), + * RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de + * garde « dernier contact ») et le gating selon permission (Contacts/Adresses = + * manage, RIB = accounting.manage). Jumeau de SupplierSubResourceApiTest. + * + * @internal + */ +final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase +{ + protected function setUp(): void + { + parent::setUp(); + // seedProvider exige >= 1 site (RG-3.03) : le module Sites doit etre actif. + $this->skipIfSitesModuleDisabled(); + } + + // === Contacts (security: technique.providers.manage) === + + public function testPostContactNormalizesFields(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact Host'); + + $data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'firstName' => 'JEAN', + 'lastName' => 'dupont', + 'phonePrimary' => '06.12.34.56.78', + 'email' => 'Jean.DUPONT@ACME.FR', + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // RG-3.11 : prenom/nom Title Case, telephone chiffres seuls, email lowercase. + self::assertSame('Jean', $data['firstName']); + self::assertSame('Dupont', $data['lastName']); + self::assertSame('0612345678', $data['phonePrimary']); + self::assertSame('jean.dupont@acme.fr', $data['email']); + } + + /** + * RG-3.04 : un bloc sans aucun champ du CHECK (prenom/nom/telephone/email) est + * rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName. + * Ici seul jobTitle est fourni (hors CHECK). + */ + public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact No Name'); + + $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['jobTitle' => 'Directeur'], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false))); + } + + public function testPostContactOnMissingProviderReturns404(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/providers/999999/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['firstName' => 'Orphan'], + ]); + + self::assertResponseStatusCodeSame(404); + } + + public function testPatchContactNormalizesFields(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact Patch'); + $contact = $this->addContact($seed, 'Marie', 'Martin'); + + $data = $client->request('PATCH', '/api/provider_contacts/'.$contact->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['lastName' => 'durand'], + ])->toArray(); + + self::assertResponseStatusCodeSame(200); + // Normalisation aussi sur PATCH : "durand" -> "Durand". + self::assertSame('Durand', $data['lastName']); + } + + public function testDeleteLastContactReturns204(): void + { + // M3 : pas de garde « dernier contact » (RG-3.12 front-driven) — la + // suppression du dernier contact est libre (204). + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact Solo'); + $contact = $this->addContact($seed, 'Unique', 'Contact'); + + $client->request('DELETE', '/api/provider_contacts/'.$contact->getId()); + + self::assertResponseStatusCodeSame(204); + } + + public function testContactWriteWithoutManageReturns403(): void + { + // Un user sans permission technique.providers.manage -> 403 sur la sous-ressource. + $seed = $this->seedProvider('Contact Forbidden'); + $creds = $this->createUserWithPermission('core.users.view'); + $http = $this->authenticatedClient($creds['username'], $creds['password']); + + $http->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['firstName' => 'Nope'], + ]); + self::assertResponseStatusCodeSame(403); + } + + // === Adresses (security: technique.providers.manage) === + + public function testPostAddressWithValidPayloadReturns201(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Address Host'); + $category = $this->providerCategory('NETTOYAGE'); + + $data = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Châtellerault', $data['city']); + } + + public function testPostAddressWithoutSiteReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Address No Site'); + + $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [], + 'categories' => ['/api/categories/'.$this->providerCategory()->getId()], + ], + ]); + + // RG-3.05 (Assert\Count min 1 sur sites). + self::assertResponseStatusCodeSame(422); + } + + public function testPostAddressWithInvalidPostalCodeReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Address Bad CP'); + + $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '123', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], + 'categories' => ['/api/categories/'.$this->providerCategory()->getId()], + ], + ]); + + // RG-3.06 (Assert\Regex ^[0-9]{4,5}$). + self::assertResponseStatusCodeSame(422); + } + + public function testPostAddressWithNonPrestataireCategoryReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Address Bad Cat'); + $foreign = $this->foreignCategory(); // type CLIENT -> interdite (RG-3.09). + + $response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], + 'categories' => ['/api/categories/'.$foreign->getId()], + ], + ]); + + // RG-3.09 -> 422 rattachee a categories. + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); + } + + public function testDeleteAddressReturns204(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Address Delete'); + $category = $this->providerCategory('NETTOYAGE'); + + $created = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ])->toArray(); + + $client->request('DELETE', $created['@id']); + self::assertResponseStatusCodeSame(204); + } + + public function testAddressWriteWithoutManageReturns403(): void + { + $seed = $this->seedProvider('Address Forbidden'); + $creds = $this->createUserWithPermission('core.users.view'); + $http = $this->authenticatedClient($creds['username'], $creds['password']); + + $http->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], + ], + ]); + self::assertResponseStatusCodeSame(403); + } + + /** + * § 2.13 / RG-3.05 (cloisonnement d'ECRITURE sur l'adresse) : un user non-bypass + * `sites.read_ref` (qui peut resoudre n'importe quel IRI de site, sinon 400 en + * amont) ne peut attacher a l'adresse que ses propres user_site. Site hors + * perimetre -> 422 sur `sites` (garde ProviderAddressProcessor). + */ + public function testPostAddressWithOutOfScopeSiteReturns422OnSitesPath(): void + { + $seed = $this->seedProvider('Address Scope', [self::SITE_86]); + $category = $this->providerCategory('NETTOYAGE'); + + $creds = $this->createScopedUser( + ['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'postalCode' => '17400', + 'city' => 'Saint-Jean-d\'Angély', + 'street' => '1 rue du Test', + 'sites' => ['/api/sites/'.$this->site(self::SITE_17)->getId()], // hors user_site + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false))); + } + + // === RIBs (security: technique.providers.accounting.manage) === + + public function testPostRibByAdminReturns201(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Rib Host'); + + $data = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'label' => 'Compte principal', + 'bic' => self::VALID_BIC, + 'iban' => self::VALID_IBAN, + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Compte principal', $data['label']); + } + + public function testPostRibWithInvalidIbanReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Rib Bad Iban'); + + $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'], + ]); + + self::assertResponseStatusCodeSame(422); + } + + /** + * Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et + * un IBAN (FR) valides isolement mais de pays differents -> 422 sur `bic`. + */ + public function testPostRibWithBicIbanCountryMismatchReturns422OnBic(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Rib Pays Mismatch'); + + $response = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN], + ]); + + self::assertResponseStatusCodeSame(422); + $byPath = $this->violationsByPath($response->toArray(false)); + self::assertArrayHasKey('bic', $byPath); + self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']); + } + + public function testDeleteRibNonLcrReturns204(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Rib Non LCR'); + $rib = $this->addRib($seed); + + $client->request('DELETE', '/api/provider_ribs/'.$rib->getId()); + + self::assertResponseStatusCodeSame(204); + } + + public function testDeleteLastRibUnderLcrReturns409(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Rib LCR Solo'); + $rib = $this->addRib($seed); + + // Passe le prestataire en LCR (seed direct). + $em = $this->getEm(); + $managed = $em->getRepository(Provider::class)->find($seed->getId()); + $managed->setPaymentType($this->paymentType('LCR')); + $em->flush(); + + $client->request('DELETE', '/api/provider_ribs/'.$rib->getId()); + + // RG-3.08 : LCR exige >= 1 RIB -> suppression du dernier refusee. + self::assertResponseStatusCodeSame(409); + } + + public function testRibWriteWithoutAccountingManageReturns403(): void + { + // Un user portant seulement technique.providers.manage (sans accounting.manage) + // ne peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5). + $seed = $this->seedProvider('Rib Forbidden'); + $rib = $this->addRib($seed); + $creds = $this->createUserWithPermission('technique.providers.manage'); + $http = $this->authenticatedClient($creds['username'], $creds['password']); + + $http->request('POST', '/api/providers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN], + ]); + self::assertResponseStatusCodeSame(403); + + $http->request('PATCH', '/api/provider_ribs/'.$rib->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['label' => 'Y'], + ]); + self::assertResponseStatusCodeSame(403); + + $http->request('DELETE', '/api/provider_ribs/'.$rib->getId()); + self::assertResponseStatusCodeSame(403); + } +} -- 2.39.5 From 21c5a2bb8903bf09efdacb5cb1a1e3588d3b7385 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 11:51:12 +0200 Subject: [PATCH 3/7] feat(technique) : validations RG comptables server-side (RG-3.07 Virement/banque, RG-3.08 LCR/RIB) (ERP-136) - Provider::validatePaymentTypeConsistency (Assert\Callback, miroir Supplier ERP-89) : RG-3.07 VIREMENT impose une banque (violation sur bank), RG-3.08 LCR impose au moins un RIB (violation sur paymentType). - ProviderProcessor : docblock realigne (RG-3.07/3.08 portees par l'entite). - AbstractProviderApiTestCase::bank() helper referentiel. - ProviderAccountingValidationTest : 4 cas (negatif 422 / positif 200) par RG. Les RG-3.03/3.05/3.09 (contraintes d'entite) et l'ecriture cloisonnee (gardes processors, RG-3.17/2.13) etaient deja posees en ERP-133/134/135 et restent couvertes. --- .../Technique/Domain/Entity/Provider.php | 44 ++++++++++ .../State/Processor/ProviderProcessor.php | 13 +-- .../Api/AbstractProviderApiTestCase.php | 17 ++++ .../Api/ProviderAccountingValidationTest.php | 85 +++++++++++++++++++ 4 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 tests/Module/Technique/Api/ProviderAccountingValidationTest.php diff --git a/src/Module/Technique/Domain/Entity/Provider.php b/src/Module/Technique/Domain/Entity/Provider.php index 3b1da19..8c95e8a 100644 --- a/src/Module/Technique/Domain/Entity/Provider.php +++ b/src/Module/Technique/Domain/Entity/Provider.php @@ -140,6 +140,12 @@ class Provider implements TimestampableInterface, BlamableInterface */ private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE'; + /** Code pivot du type de reglement imposant une banque (RG-3.07). */ + private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT'; + + /** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */ + private const string PAYMENT_TYPE_LCR = 'LCR'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -291,6 +297,44 @@ class Provider implements TimestampableInterface, BlamableInterface } } + /** + * RG-3.07 / RG-3.08 : coherence du type de reglement comptable. Comme au M2 + * (decision figee ERP-89, jumeau Supplier::validatePaymentTypeConsistency), + * ces RG inter-champs passent par une contrainte d'entite (Assert\Callback + + * ->atPath()) et NON par le ProviderProcessor, afin que chaque 422 porte un + * propertyPath exploitable par extractApiViolations (mapping inline sous le + * champ, pas un toast — convention ERP-101). + * - RG-3.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`. + * - RG-3.08 : paymentType = LCR impose au moins un RIB -> violation sur + * `paymentType` (les RIB n'ont pas de champ de formulaire ou s'ancrer quand + * la liste est vide ; l'erreur s'affiche donc sous le select « Type de + * règlement », binde cote front). Le 409 sur DELETE du dernier RIB en LCR est + * porte par le ProviderRibProcessor (ERP-135). + * + * Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui + * n'expose que provider:write:main), la contrainte ne mord en pratique que sur + * le PATCH de l'onglet Comptabilite. + */ + #[Assert\Callback] + public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void + { + $paymentCode = $this->paymentType?->getCode(); + + if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) { + $context->buildViolation('La banque est obligatoire pour le type de règlement Virement.') + ->atPath('bank') + ->addViolation() + ; + } + + if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) { + $context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.') + ->atPath('paymentType') + ->addViolation() + ; + } + } + public function getId(): ?int { return $this->id; diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php index a439a13..0552d13 100644 --- a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php @@ -52,12 +52,13 @@ use Symfony\Component\Validator\ConstraintViolationList; * 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. + * Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et + * RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback + + * ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform + * AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par + * extractApiViolations (mapping inline, pas un toast — convention ERP-101). Le 409 + * sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le + * ProviderRibProcessor (ERP-135). * * @implements ProcessorInterface */ diff --git a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php index 15c6522..b4326ba 100644 --- a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php +++ b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php @@ -7,6 +7,7 @@ namespace App\Tests\Module\Technique\Api; use ApiPlatform\Symfony\Bundle\Test\Client; use App\Module\Catalog\Domain\Entity\Category; use App\Module\Catalog\Domain\Entity\CategoryType; +use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; @@ -335,6 +336,22 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase return $paymentType; } + /** + * Recupere une banque seedee (CommercialReferentialFixtures) par code (ex. SG). + * Echoue explicitement si absente (fixtures non chargees). + */ + protected function bank(string $code): Bank + { + $bank = $this->getEm()->getRepository(Bank::class)->findOneBy(['code' => $code]); + + self::assertNotNull( + $bank, + sprintf('Banque "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code), + ); + + return $bank; + } + /** * Indexe les violations d'un corps 422 par propertyPath (assert ciblee). * diff --git a/tests/Module/Technique/Api/ProviderAccountingValidationTest.php b/tests/Module/Technique/Api/ProviderAccountingValidationTest.php new file mode 100644 index 0000000..aecbd2f --- /dev/null +++ b/tests/Module/Technique/Api/ProviderAccountingValidationTest.php @@ -0,0 +1,85 @@ +createAdminClient(); + $seed = $this->seedProvider('Virement No Bank'); + + $response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD], + 'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId()], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('bank', $this->violationsByPath($response->toArray(false))); + } + + public function testVirementWithBankReturns200(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Virement With Bank'); + + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => [ + 'paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId(), + 'bank' => '/api/banks/'.$this->bank('SG')->getId(), + ], + ]); + + self::assertResponseStatusCodeSame(200); + } + + // === RG-3.08 : LCR impose au moins un RIB (volet ecriture du formulaire) === + + public function testLcrWithoutRibReturns422OnPaymentTypePath(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Lcr No Rib'); + + $response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD], + 'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()], + ]); + + self::assertResponseStatusCodeSame(422); + // Miroir client : violation portee sur `paymentType` (select « Type de + // règlement »), les RIB n'ayant pas de champ de formulaire pour l'ancrer. + self::assertArrayHasKey('paymentType', $this->violationsByPath($response->toArray(false))); + } + + public function testLcrWithRibReturns200(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Lcr With Rib'); + $this->addRib($seed); + + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()], + ]); + + self::assertResponseStatusCodeSame(200); + } + + // violationsByPath() : helper mutualise dans AbstractProviderApiTestCase. +} -- 2.39.5 From 5ced6c8256f0e69c9f54a9d3540925f8094c3018 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 14:22:40 +0200 Subject: [PATCH 4/7] feat(technique) : export XLSX du repertoire prestataires (ProviderExportController, priority:1) (ERP-137) --- .../Controller/ProviderExportController.php | 328 ++++++++++++++++++ .../Api/ProviderExportControllerTest.php | 319 +++++++++++++++++ 2 files changed, 647 insertions(+) create mode 100644 src/Module/Technique/Infrastructure/Controller/ProviderExportController.php create mode 100644 tests/Module/Technique/Api/ProviderExportControllerTest.php diff --git a/src/Module/Technique/Infrastructure/Controller/ProviderExportController.php b/src/Module/Technique/Infrastructure/Controller/ProviderExportController.php new file mode 100644 index 0000000..4085901 --- /dev/null +++ b/src/Module/Technique/Infrastructure/Controller/ProviderExportController.php @@ -0,0 +1,328 @@ +readBool($request->query->get('includeArchived')); + $archivedOnly = $this->readBool($request->query->get('archivedOnly')); + $search = $request->query->getString('search') ?: null; + + // Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur + // unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour + // ne pas lever d'exception sur une valeur scalaire. + $query = $request->query->all(); + $categoryCodes = $this->readStringList($query['categoryCode'] ?? []); + $siteIds = $this->readIntList($query['siteId'] ?? []); + + $qb = $this->repository + ->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly) + ; + + // Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint + // au currentSite pour un user non-bypass (s'intersecte avec un eventuel + // ?siteId du client). No-op pour bypass_scope ou currentSite null. + $scopeSite = $this->siteScopeOrNull(); + if (null !== $scopeSite) { + $this->repository->applySiteScope($qb, (int) $scopeSite->getId()); + } + + /** @var list $providers */ + $providers = $qb->getQuery()->getResult(); + + // Hydratation batchee des collections affichees (§ 2.12) : le QB de + // selection ne fetch-join pas les to-many. On remplit categories + sites en + // lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du + // contact principal) — chacune en requetes IN bornees, anti N+1. + $this->repository->hydrateListCollections($providers); + $this->repository->hydrateContacts($providers); + + $withSiren = $this->security->isGranted('technique.providers.accounting.view'); + + $binary = $this->exporter->export( + 'Répertoire prestataires', + $this->buildHeaders($withSiren), + $this->buildRows($providers, $withSiren), + ); + + return $this->buildResponse($binary); + } + + /** + * 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). Miroir de ProviderProvider::siteScopeOrNull(). + */ + private function siteScopeOrNull(): ?SiteInterface + { + if ($this->security->isGranted('sites.bypass_scope')) { + return null; + } + + return $this->currentSiteProvider->get(); + } + + /** + * Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation, + * uniquement si l'utilisateur a accounting.view. + * + * @return list + */ + private function buildHeaders(bool $withSiren): array + { + $headers = [ + 'Nom prestataire', + 'Contact principal', + 'Téléphone principal', + 'Téléphone secondaire', + 'Email', + 'Catégories', + 'Sites', + ]; + + if ($withSiren) { + $headers[] = 'SIREN'; + } + + $headers[] = 'Date de création'; + + return $headers; + } + + /** + * @param list $providers + * + * @return iterable> + */ + private function buildRows(array $providers, bool $withSiren): iterable + { + foreach ($providers as $provider) { + $contact = $this->principalContact($provider); + + $row = [ + $provider->getCompanyName(), + null !== $contact ? $this->formatContactName($contact) : '', + $contact?->getPhonePrimary() ?? '', + $contact?->getPhoneSecondary() ?? '', + $contact?->getEmail() ?? '', + $this->formatCategories($provider), + $this->formatSites($provider), + ]; + + if ($withSiren) { + $row[] = $provider->getSiren(); + } + + $row[] = $provider->getCreatedAt()?->format('d/m/Y'); + + yield $row; + } + } + + /** + * Contact principal du prestataire : le ProviderContact de plus petit + * `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun + * contact (les colonnes contact restent vides). + */ + private function principalContact(Provider $provider): ?ProviderContact + { + $contacts = $provider->getContacts()->toArray(); + if ([] === $contacts) { + return null; + } + + usort( + $contacts, + static fn (ProviderContact $a, ProviderContact $b): int => $a->getPosition() <=> $b->getPosition(), + ); + + return $contacts[0]; + } + + /** + * Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties + * sont optionnelles (RG-3.04 : au moins l'une des deux), d'ou le trim final. + */ + private function formatContactName(ProviderContact $contact): string + { + return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? '')); + } + + /** + * Libelles des categories du prestataire, dedupliques, tries, joints par + * virgule. + */ + private function formatCategories(Provider $provider): string + { + $names = []; + foreach ($provider->getCategories() as $category) { + // @var CategoryInterface $category + $name = $category->getName(); + if (null !== $name && '' !== $name) { + $names[$name] = true; + } + } + + return $this->joinSorted($names); + } + + /** + * Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 — contrairement + * au fournisseur M2 dont les sites sont portes par les adresses). La colonne + * « Sites » agrege l'union distincte des sites rattaches. + */ + private function formatSites(Provider $provider): string + { + $names = []; + foreach ($provider->getSites() as $site) { + // @var SiteInterface $site + $name = $site->getName(); + if (null !== $name && '' !== $name) { + $names[$name] = true; + } + } + + return $this->joinSorted($names); + } + + /** + * @param array $names ensemble de libelles (cles) + */ + private function joinSorted(array $names): string + { + $list = array_keys($names); + sort($list); + + return implode(', ', $list); + } + + private function buildResponse(string $binary): Response + { + $filename = sprintf('repertoire-prestataires-%s.xlsx', new DateTimeImmutable()->format('Ymd')); + + $response = new Response($binary); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } + + /** + * Lit un flag booleen issu des query params. Accepte true / "true" / "1". + * Aligne sur ProviderProvider pour un comportement identique a la liste. + */ + private function readBool(mixed $raw): bool + { + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } + + /** + * Normalise un filtre en liste de chaines (valeur unique ou liste). + * Aligne sur ProviderProvider pour un comportement identique a la liste. + * + * @return list + */ + 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 (valeur unique + * ou liste). Aligne sur ProviderProvider. + * + * @return list + */ + 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; + } +} diff --git a/tests/Module/Technique/Api/ProviderExportControllerTest.php b/tests/Module/Technique/Api/ProviderExportControllerTest.php new file mode 100644 index 0000000..9c9519e --- /dev/null +++ b/tests/Module/Technique/Api/ProviderExportControllerTest.php @@ -0,0 +1,319 @@ +createAdminClient(); + $this->seedProvider('Export Alpha'); + + $response = $client->request('GET', self::EXPORT_URL); + + self::assertResponseIsSuccessful(); + $headers = $response->getHeaders(false); + self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); + + $disposition = $headers['content-disposition'][0] ?? ''; + self::assertStringContainsString('attachment; filename="repertoire-prestataires-', $disposition); + self::assertMatchesRegularExpression( + '/filename="repertoire-prestataires-\d{8}\.xlsx"/', + $disposition, + ); + + // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes. + $grid = $this->gridFromResponse($response->getContent()); + $headers = $grid[0]; + self::assertSame('Nom prestataire', $headers[0]); + self::assertContains('Contact principal', $headers); + self::assertContains('Téléphone principal', $headers); + self::assertContains('Téléphone secondaire', $headers); + self::assertContains('Email', $headers); + self::assertContains('Catégories', $headers); + self::assertContains('Sites', $headers); + self::assertContains('Date de création', $headers); + } + + public function testExportExcludesArchivedByDefault(): void + { + $client = $this->createAdminClient(); + $this->seedProvider('Active One'); + $this->seedProvider('Archived One', [self::SITE_86], true); + + $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('ACTIVE ONE', $names); + self::assertNotContains('ARCHIVED ONE', $names); + } + + public function testExportRespectsSearchFilter(): void + { + $client = $this->createAdminClient(); + $this->seedProvider('Searchable Alpha'); + $this->seedProvider('Other Beta'); + + $names = $this->companyNames( + $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), + ); + + self::assertContains('SEARCHABLE ALPHA', $names); + self::assertNotContains('OTHER BETA', $names); + } + + /** + * Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact + * de plus petit `position` (decision D2, § 4.6). On seede deux contacts en + * ordre de position inverse pour garantir que c'est bien le principal (et non + * le premier insere) qui alimente la ligne. + */ + public function testExportUsesPrincipalContactColumns(): void + { + $client = $this->createAdminClient(); + $provider = $this->seedProvider('Contact Co'); + + // position 1 (secondaire) insere en premier... + $this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1); + // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner. + $principal = $this->addContact($provider, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0); + // Le telephone secondaire n'est pas porte par le helper de base : on le pose + // directement sur le contact principal pour alimenter la colonne dediee. + $principal->setPhoneSecondary('0698765432'); + $this->getEm()->flush(); + + $row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO'); + + self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.'); + self::assertSame('Principal Alice', $row[1]); + self::assertSame('0612345678', $row[2]); + self::assertSame('0698765432', $row[3]); + self::assertSame('alice@contact.co', $row[4]); + } + + /** + * Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait + * vides sans erreur (cf. ERP-100 cote client). Le site est porte EN DIRECT par + * le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse). + */ + public function testExportPopulatesCategoryAndSiteColumns(): void + { + $client = $this->createAdminClient(); + $this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE'); + + $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent())); + + // Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()). + // Derive du helper de base (idempotent) plutot que de hardcoder le prefixe. + self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat); + // Colonne « Sites » : site rattache en direct au prestataire (RG-3.03). + self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat); + } + + public function testSirenColumnPresentWithAccountingView(): void + { + // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN. + $client = $this->createAdminClient(); + $this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789'); + + $grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('SIREN', $grid[0]); + self::assertStringContainsString('123456789', $this->flatten($grid)); + } + + public function testSirenColumnAbsentWithoutAccountingView(): void + { + // Seed via admin, puis relecture par un user qui n'a QUE providers.view. + $this->createAdminClient(); + $this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321'); + + $creds = $this->createUserWithPermission('technique.providers.view'); + $viewer = $this->authenticatedClient($creds['username'], $creds['password']); + + $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent()); + + self::assertNotContains('SIREN', $grid[0]); + self::assertStringNotContainsString('987654321', $this->flatten($grid)); + } + + /** + * Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) : + * un user minimal portant uniquement technique.providers.view + + * technique.providers.accounting.view voit bien la colonne SIREN et sa valeur. + * Complement de testSirenColumnPresentWithAccountingView (admin), qui ne prouve + * pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). Le pendant + * negatif est couvert par testSirenColumnAbsentWithoutAccountingView. + */ + public function testSirenColumnPresentForMinimalUserWithAccountingView(): void + { + // Seed via admin, puis relecture par un user non-admin a 2 permissions. + $this->createAdminClient(); + $this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123'); + + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.accounting.view', + ]); + $viewer = $this->authenticatedClient($creds['username'], $creds['password']); + + $grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('SIREN', $grid[0]); + self::assertStringContainsString('456789123', $this->flatten($grid)); + } + + /** + * Dedup : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par + * la jointure (selection/hydratation des collections) ; l'export doit le rendre + * sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il + * n'apparait qu'une fois dans la colonne « Nom prestataire ». + */ + public function testExportDeduplicatesProviderWithMultipleCategories(): void + { + $client = $this->createAdminClient(); + $provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE'); + // 2e categorie PRESTATAIRE sur le meme prestataire. + $provider->addCategory($this->providerCategory('SECURITE')); + $this->getEm()->flush(); + + $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); + + $occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name)); + self::assertSame( + 1, + $occurrences, + 'Un prestataire multi-categories doit apparaitre sur une seule ligne (dedup).', + ); + } + + /** + * Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur + * le site 86 n'exporte QUE les prestataires rattaches au site 86 — les + * prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant + * export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser. + */ + public function testExportIsScopedToCurrentSiteForNonBypassUser(): void + { + // Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement + // no-op et ce test perd son sens). + $this->skipIfSitesModuleDisabled(); + + $this->createAdminClient(); + $this->seedProvider('Presta Site 86', [self::SITE_86]); + $this->seedProvider('Presta Site 17', [self::SITE_17]); + $this->seedProvider('Presta Site 82', [self::SITE_82]); + + $creds = $this->createScopedUser( + ['technique.providers.view'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('PRESTA SITE 86', $names); + self::assertNotContains('PRESTA SITE 17', $names); + self::assertNotContains('PRESTA SITE 82', $names); + } + + public function testForbiddenWithoutProvidersViewPermission(): void + { + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnauthorizedWhenAnonymous(): void + { + $client = self::createClient(); + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. + * + * @return array> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Extrait la colonne « Nom prestataire » (1re colonne) des lignes de donnees. + * + * @return list + */ + private function companyNames(string $binary): array + { + $grid = $this->gridFromResponse($binary); + $rows = array_slice($grid, 1); // saute l'en-tete + + return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows)); + } + + /** + * Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName. + * + * @return null|array + */ + private function rowFor(string $binary, string $companyName): ?array + { + foreach (array_slice($this->gridFromResponse($binary), 1) as $row) { + if ((string) ($row[0] ?? '') === $companyName) { + return $row; + } + } + + return null; + } + + /** + * Aplatit toute la grille en une chaine, pour les assertions de presence. + * + * @param array> $grid + */ + private function flatten(array $grid): string + { + return implode('|', array_map( + static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)), + $grid, + )); + } +} -- 2.39.5 From 17f6c2f28f7ac4b5f9bebaa5b71f41b557440fd6 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 14:51:07 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat(technique)=20:=20c=C3=A2bler=20le=20RB?= =?UTF-8?q?AC=20technique.providers.*=20(3=20sources=20+=20matrice=20r?= =?UTF-8?q?=C3=B4les=20+=20bypass=5Fscope)=20(ERP-138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Câble les permissions du module Technique dans toutes les sources RBAC (règle ABSOLUE n°8, dans le même commit) : - RbacSeeder::MATRIX : bureau/compta/commerciale reçoivent technique.providers.* selon la matrice § 2.9 + sites.bypass_scope (visibilité multi-site, § 2.13) ; usine = technique.providers.view seul, SANS bypass (cloisonnée à son site). - config/sidebar.php : nouvelle section Technique + item Répertoire prestataires (/providers, module technique, permission technique.providers.view). - personas.ts + SeedE2ECommand.php : 5 perms technique.providers.* sur le persona user-full (porte déjà sites.bypass_scope) — pas de nouveau persona (règle n°7). - i18n fr.json : clés sidebar.technique.section / sidebar.technique.providers. Test : ProviderRBACMatrixTest (miroir SupplierRBACMatrixTest) valide la matrice rôle×verbe via app:seed-rbac, dont le cloisonnement par site de l'Usine (détail hors site → 404). 8 tests, 65 assertions. --- config/sidebar.php | 17 ++ frontend/i18n/locales/fr.json | 4 + frontend/tests/e2e/_fixtures/personas.ts | 11 + .../Core/Application/Rbac/RbacSeeder.php | 41 ++- .../Infrastructure/Console/SeedE2ECommand.php | 9 + .../Technique/Api/ProviderRBACMatrixTest.php | 279 ++++++++++++++++++ 6 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 tests/Module/Technique/Api/ProviderRBACMatrixTest.php diff --git a/config/sidebar.php b/config/sidebar.php index 9be6d4a..c193aa6 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -61,6 +61,23 @@ return [ ], ], ], + // Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le + // repertoire prestataires. L'item est gate par `technique.providers.view` ; + // la section disparait automatiquement (SidebarProvider) si le module + // `technique` est desactive ou si l'user n'a pas la permission. + [ + 'label' => 'sidebar.technique.section', + 'icon' => 'mdi:wrench-outline', + 'items' => [ + [ + 'label' => 'sidebar.technique.providers', + 'to' => '/providers', + 'icon' => 'mdi:account-wrench-outline', + 'module' => 'technique', + 'permission' => 'technique.providers.view', + ], + ], + ], // Section "Administration" : regroupe toutes les pages de configuration // applicative (RBAC, users, sites, audit log). // diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 92f783e..33d5aea 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -30,6 +30,10 @@ "clients": "Répertoire clients", "suppliers": "Répertoire fournisseurs" }, + "technique": { + "section": "Technique", + "providers": "Répertoire prestataires" + }, "core": { "roles": "Gestion des rôles", "users": "Utilisateurs", diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index 73afbd4..ededce8 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -84,6 +84,17 @@ export const personas: Record = { 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', 'commercial.suppliers.archive', + // Technique — Repertoire prestataires (M3, ERP-138). Meme logique que + // clients/fournisseurs : mappe sur le persona "tout", pas de nouveau + // persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope, + // donc il voit les prestataires de tous les sites (M3 § 2.13). + // technique.providers.view n'ajoute pas de lien dans la section + // Administration, donc expectedAdminLinks reste inchange. + 'technique.providers.view', + 'technique.providers.manage', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + 'technique.providers.archive', ], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], }, diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php index 20669e9..fd882a6 100644 --- a/src/Module/Core/Application/Rbac/RbacSeeder.php +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -50,11 +50,19 @@ final class RbacSeeder /** * Definition unique des 4 roles + matrice § 2.7. La cle est le code du role, * `label` le libelle FR affichable, `permissions` la liste des codes RBAC a - * attacher (vide pour usine : aucun acces ; admin n'apparait pas car il - * bypass tout via isAdmin ; `commercial.clients.archive` et - * `commercial.suppliers.archive` ne sont attaches a aucun role metier — + * attacher (admin n'apparait pas car il bypass tout via isAdmin ; + * `commercial.clients.archive`, `commercial.suppliers.archive` et + * `technique.providers.archive` ne sont attaches a aucun role metier — * admin seul). * + * Cloisonnement par site des prestataires (M3 § 2.13) : la permission + * `sites.bypass_scope` est attribuee par defaut a Bureau / Compta / + * Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et + * reste cloisonnee a son site courant. Admin a le bypass total via isAdmin. + * C'est un cloisonnement pilote par user/permission, pas par code de role : + * pour cloisonner Bureau/Commerciale, il suffit de retirer la permission + * ici, aucun autre code a changer. + * * @var array}> */ private const array MATRIX = [ @@ -66,6 +74,11 @@ final class RbacSeeder // Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite). 'commercial.suppliers.view', 'commercial.suppliers.manage', + // Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite). + 'technique.providers.view', + 'technique.providers.manage', + // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. + 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', @@ -82,6 +95,13 @@ final class RbacSeeder 'commercial.suppliers.view', 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', + // Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement + // (pas de manage global -> ne peut pas creer un prestataire). + 'technique.providers.view', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. + 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', @@ -96,14 +116,25 @@ final class RbacSeeder // (onglet Comptabilite masque/filtre pour la Commerciale). 'commercial.suppliers.view', 'commercial.suppliers.manage', + // Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting + // (onglet Comptabilite masque/filtre pour la Commerciale). + 'technique.providers.view', + 'technique.providers.manage', + // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. + 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', ], ], self::ROLE_USINE => [ - 'label' => 'Usine', - 'permissions' => [], + 'label' => 'Usine', + // Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule, + // SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site + // courant. Aucun autre acces metier. + 'permissions' => [ + 'technique.providers.view', + ], ], ]; diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php index 7e7545b..bbef32d 100644 --- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php +++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php @@ -203,6 +203,15 @@ final class SeedE2ECommand extends Command 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', 'commercial.suppliers.archive', + // Technique — Repertoire prestataires (M3, ERP-138). Meme + // logique : mappe sur le persona "tout". user-full porte deja + // sites.bypass_scope -> voit les prestataires de tous les + // sites (M3 § 2.13). Miroir de personas.ts. + 'technique.providers.view', + 'technique.providers.manage', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + 'technique.providers.archive', ], ], [ diff --git a/tests/Module/Technique/Api/ProviderRBACMatrixTest.php b/tests/Module/Technique/Api/ProviderRBACMatrixTest.php new file mode 100644 index 0000000..438f179 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderRBACMatrixTest.php @@ -0,0 +1,279 @@ +setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame( + 0, + $exit, + 'app:seed-rbac a echoue : les permissions technique.providers.* sont-elles synchronisees (app:sync-permissions) ?', + ); + + self::ensureKernelShutdown(); + } + + public function testBureauHasViewAndManageButNoAccountingNoArchive(): void + { + $seed = $this->seedProvider('Bureau Cible'); + $client = $this->authAs('bureau'); + + // view + $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // manage : creation OK (bypass_scope -> peut attacher le site 86) + $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Bureau Cree'), + ]); + self::assertResponseStatusCodeSame(201); + + // manage : edition onglet principal OK + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Bureau Renomme'], + ]); + self::assertResponseStatusCodeSame(200); + + // PAS accounting : edition onglet Comptabilite refusee + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testBureauDetailHasNoAccountingFields(): void + { + // Bureau a view mais PAS accounting.view : les champs comptables sont + // ABSENTS du JSON (gating par omission, pas null). + $provider = $this->seedProvider('Bureau Gating Co', [self::SITE_86], siren: '123456789'); + $client = $this->authAs('bureau'); + + $data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayNotHasKey('siren', $data); + self::assertArrayNotHasKey('accountNumber', $data); + self::assertArrayNotHasKey('nTva', $data); + self::assertArrayNotHasKey('tvaMode', $data); + self::assertArrayNotHasKey('paymentType', $data); + self::assertArrayNotHasKey('ribs', $data); + } + + public function testComptaCanEditAccountingOnly(): void + { + $seed = $this->seedProvider('Compta Cible'); + $client = $this->authAs('compta'); + + // view + $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // PAS manage : creation refusee + $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Compta Post'), + ]); + self::assertResponseStatusCodeSame(403); + + // accounting.manage : edition onglet Comptabilite OK + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(200); + + // PAS manage : edition onglet principal refusee (mode strict RG-3.15) + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Compta Renomme'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testComptaDetailHasAccountingFields(): void + { + // Compta a accounting.view : siren + ribs presents dans le JSON. + $provider = $this->seedProvider('Compta View Co', [self::SITE_86], siren: '987654321'); + $this->addRib($provider); + $client = $this->authAs('compta'); + + $data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayHasKey('siren', $data); + self::assertSame('987654321', $data['siren']); + self::assertArrayHasKey('ribs', $data); + self::assertNotEmpty($data['ribs']); + } + + public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void + { + $seed = $this->seedProvider('Commerciale Cible'); + $client = $this->authAs('commerciale'); + + // view + $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // manage : creation OK + $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Commerciale Cree'), + ]); + self::assertResponseStatusCodeSame(201); + + // PAS accounting : edition onglet Comptabilite refusee + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testCommercialeDetailHasNoAccountingFields(): void + { + $provider = $this->seedProvider('Commerciale Gating Co', [self::SITE_86], siren: '123456789'); + $client = $this->authAs('commerciale'); + + $data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + self::assertArrayNotHasKey('siren', $data); + self::assertArrayNotHasKey('accountNumber', $data); + self::assertArrayNotHasKey('nTva', $data); + self::assertArrayNotHasKey('tvaMode', $data); + self::assertArrayNotHasKey('paymentType', $data); + self::assertArrayNotHasKey('ribs', $data); + } + + public function testUsineHasReadOnlyAccessScopedToItsSite(): void + { + // Usine a view (lecture seule), SANS manage / accounting / archive, et + // SANS bypass_scope -> cloisonnee a son site courant (Chatellerault, + // site 86, pose par ensureDemoUsers). + $inScope = $this->seedProvider('Usine InScope', [self::SITE_86]); + $client = $this->authAs('usine'); + + // view : liste OK (pas un 403 comme au M2) + $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // view : detail d'un prestataire de SON site OK + $client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // PAS manage : creation refusee + $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Usine Post'), + ]); + self::assertResponseStatusCodeSame(403); + + // PAS manage : edition onglet principal refusee + $client->request('PATCH', '/api/providers/'.$inScope->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Renomme Par Usine'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS accounting : edition onglet Comptabilite refusee + $client->request('PATCH', '/api/providers/'.$inScope->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '123456789'], + ]); + self::assertResponseStatusCodeSame(403); + + // PAS archive : archivage refuse + $client->request('PATCH', '/api/providers/'.$inScope->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testUsineCannotSeeProviderOutOfItsSite(): void + { + // Cloisonnement § 2.13 : un prestataire hors du site courant de l'Usine + // (site 17, l'Usine est sur le site 86) -> 404 (ne pas reveler la ligne). + $outOfScope = $this->seedProvider('Usine OutOfScope', [self::SITE_17]); + $client = $this->authAs('usine'); + + $client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(404); + } + + private function authAs(string $role): Client + { + return $this->authenticatedClient($role, self::PWD); + } +} -- 2.39.5 From d6ed4f5fafbe38b5e5691a22f7c72bd9ff2dc73b Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 15:29:41 +0200 Subject: [PATCH 6/7] =?UTF-8?q?test(technique)=20:=20couvrir=20RG-3.x=20PH?= =?UTF-8?q?PUnit=20+=20capturer=20le=20contrat=20JSON=20(ProviderSerializa?= =?UTF-8?q?tionContractTest,=20ProviderAuditTest,=20fixtures=20d=C3=A9mo)?= =?UTF-8?q?=20(ERP-139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute provider:read:accounting sur les réfs comptables partagées (TvaMode/PaymentDelay/PaymentType/Bank) pour embarquer {id,code,label} au lieu d un IRI nu (réplique fix ERP-92). Helper seedCompleteProvider, anti-N+1 + pagination=false + filtre typeCode, restauration conflit 409, fixtures démo idempotentes. Captures JSON réelles collées dans spec § 4.0.bis. --- docs/specs/M3-prestataires/spec-back.md | 188 ++++++--- src/Module/Commercial/Domain/Entity/Bank.php | 9 +- .../Commercial/Domain/Entity/PaymentDelay.php | 9 +- .../Commercial/Domain/Entity/PaymentType.php | 9 +- .../Commercial/Domain/Entity/TvaMode.php | 9 +- .../DataFixtures/ProviderFixtures.php | 393 ++++++++++++++++++ .../Api/AbstractProviderApiTestCase.php | 118 ++++++ .../Technique/Api/ProviderAuditTest.php | 162 ++++++++ .../Module/Technique/Api/ProviderListTest.php | 87 ++++ .../Technique/Api/ProviderRbacGatingTest.php | 17 + .../Api/ProviderSerializationContractTest.php | 365 ++++++++++++++++ 11 files changed, 1299 insertions(+), 67 deletions(-) create mode 100644 src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php create mode 100644 tests/Module/Technique/Api/ProviderAuditTest.php create mode 100644 tests/Module/Technique/Api/ProviderSerializationContractTest.php diff --git a/docs/specs/M3-prestataires/spec-back.md b/docs/specs/M3-prestataires/spec-back.md index b9a1dbd..54a573a 100644 --- a/docs/specs/M3-prestataires/spec-back.md +++ b/docs/specs/M3-prestataires/spec-back.md @@ -624,67 +624,153 @@ Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamab > 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`). > 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`). -`GET /api/providers` (liste, ADMIN — un membre, forme attendue) : +> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front. + +`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) : ```json { - "@context": "/api/contexts/Provider", - "@id": "/api/providers", - "@type": "Collection", - "totalItems": 1, - "member": [ - { - "@id": "/api/providers/1", "@type": "Provider", "id": 1, - "companyName": "MAINTENANCE PRO SAS", - "categories": [ - {"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE", - "categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}} - ], - "sites": [ - {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"} - ], - "siren": "987654321", "accountNumber": "P0001", - "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, - "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, - "ribs": [ - {"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"} - ], - "updatedAt": "2026-06-11T10:00:00+02:00", - "isArchived": false - } - ], - "view": {"@id": "/api/providers", "@type": "PartialCollectionView"} + "@context": "/api/contexts/Provider", + "@id": "/api/providers", + "@type": "Collection", + "totalItems": 1, + "member": [ + { + "@id": "/api/providers/572", + "@type": "Provider", + "id": 572, + "companyName": "DOD21AADC 0E3CCE", + "categories": [ + { + "@type": "Category", + "@id": "/api/categories/3006", + "id": 3006, + "name": "test_prov_cat_nettoyage", + "code": "NETTOYAGE", + "categoryTypes": [ + {"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00" + } + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "siren": "987654321", + "accountNumber": "P0001", + "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00987654321", + "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"}, + "ribs": [ + {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00", + "isArchived": false + } + ], + "view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"} } ``` -> Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre. +> Les `sites[]` de la liste sont la **relation directe** `provider.sites` (formulaire principal — RG-3.03), objet `Site` entier (pas un IRI nu). Les catégories embarquent `code` + `name`. Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour un profil **sans `accounting.view`** (ex. Commerciale), `siren`/`accountNumber`/`tvaMode`/`nTva`/`paymentDelay`/`paymentType`/`bank`/`ribs` **disparaissent** de chaque membre (gating par omission — cf. détail restreint ci-dessous). -`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) : +`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) : ```json { - "@id": "/api/providers/1", "@type": "Provider", "id": 1, - "companyName": "MAINTENANCE PRO SAS", - "categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}], - "sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], - "siren": "987654321", "accountNumber": "P0001", - "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, - "nTva": "FR00987654321", - "paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"}, - "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, - "contacts": [ - {"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"} - ], - "addresses": [ - {"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", - "sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], - "contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}], - "categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]} - ], - "ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}], - "isArchived": false + "@context": "/api/contexts/Provider", + "@id": "/api/providers/572", + "@type": "Provider", + "id": 572, + "companyName": "DOD21AADC 0E3CCE", + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "siren": "987654321", + "accountNumber": "P0001", + "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00987654321", + "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"}, + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "addresses": [ + { + "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35, + "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00" + } + ], + "ribs": [ + {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00", + "isArchived": false } ``` -> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (gating par omission — à confirmer par test). +`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) : +```json +{ + "@context": "/api/contexts/Provider", + "@id": "/api/providers/572", + "@type": "Provider", + "id": 572, + "companyName": "DOD21AADC 0E3CCE", + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "addresses": [ + { + "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35, + "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00" + } + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00", + "isArchived": false +} +``` + +> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`. +> +> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`. ### 4.1 `GET /api/providers` — Liste @@ -923,7 +1009,7 @@ Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur ` - [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0) - [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only** -- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front) +- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139) - [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15) - [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés - [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`) diff --git a/src/Module/Commercial/Domain/Entity/Bank.php b/src/Module/Commercial/Domain/Entity/Bank.php index 1ee4542..1108cdd 100644 --- a/src/Module/Commercial/Domain/Entity/Bank.php +++ b/src/Module/Commercial/Domain/Entity/Bank.php @@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * `client:read:accounting` permet l'embarquement dans la reponse Client ; - * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -48,15 +49,15 @@ class Bank #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php index 5e8be75..94f55af 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentDelay.php +++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php @@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * `client:read:accounting` permet l'embarquement dans la reponse Client ; - * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -48,15 +49,15 @@ class PaymentDelay #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php index af045c9..564b31a 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentType.php +++ b/src/Module/Commercial/Domain/Entity/PaymentType.php @@ -24,7 +24,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * `client:read:accounting` permet l'embarquement dans la reponse Client ; - * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -51,15 +52,15 @@ class PaymentType #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php index e28072f..4cd01c1 100644 --- a/src/Module/Commercial/Domain/Entity/TvaMode.php +++ b/src/Module/Commercial/Domain/Entity/TvaMode.php @@ -25,7 +25,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le * groupe `client:read:accounting` permet d'embarquer le mode dans la reponse * d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting` - * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0). + * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -55,15 +56,15 @@ class TvaMode #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php b/src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php new file mode 100644 index 0000000..998ec15 --- /dev/null +++ b/src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php @@ -0,0 +1,393 @@ += 1 site sur le formulaire principal (RG-3.03), >= 1 + * contact, >= 1 adresse multi-sites (RG-3.05), comptabilite + RIB ; + * - reglement LCR avec RIB (RG-3.08) ; reglement VIREMENT avec banque (RG-3.07) ; + * - 1 prestataire archive (isArchived + archivedAt) pour l'exclusion de la liste + * (RG-3.16) ; + * - prestataires repartis sur des sites DIFFERENTS (86 / 17 / 82) pour exercer le + * cloisonnement par site (RG-3.17) ; + * - mono et multi-categories de type PRESTATAIRE (RG-3.09). + * + * Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) : + * - categories resolues via le contrat Shared CategoryInterface ; + * - sites resolus via le contrat Shared SiteProviderInterface. + * + * Normalisation : valeurs fournies BRUTES, normalisees par ProviderFieldNormalizer + * avant persist, exactement comme le ferait le ProviderProcessor via l'API + * (companyName UPPERCASE, first/last Capitalize, telephones chiffres seuls, emails + * lowercase — RG-3.11). + * + * Idempotence : lookup par companyName normalise (coherent avec l'index unique + * partiel uq_provider_company_name_active). Un prestataire deja present n'est pas + * reconstruit (sous-collections non redupliquees). Rejouable sans doublon. + * + * Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la + * fixture ne charge rien : les tests seedent et nettoient leurs propres + * prestataires et comptent sur une table `provider` vierge. Meme garde-fou que + * SupplierFixtures / CategoryFixtures. + */ +class ProviderFixtures extends Fixture implements DependentFixtureInterface +{ + /** + * Type de categorie exige pour un prestataire et ses adresses (RG-3.09). + * Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1). + */ + private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE'; + + /** Cache des categories resolues par nom. */ + private array $categoryCache = []; + + /** Cache des sites resolus par nom. */ + private array $siteCache = []; + + /** ObjectManager courant, capture en debut de load. */ + private ObjectManager $manager; + + public function __construct( + private readonly ProviderFieldNormalizer $normalizer, + private readonly SiteProviderInterface $siteProvider, + #[Autowire('%kernel.environment%')] + private readonly string $environment, + ) {} + + /** + * @return array + */ + public function getDependencies(): array + { + return [ + CategoryFixtures::class, + SitesFixtures::class, + CommercialReferentialFixtures::class, + ]; + } + + public function load(ObjectManager $manager): void + { + // Donnees de demo : dev uniquement. En test, on laisse la table vierge. + if ('test' === $this->environment) { + return; + } + + $this->manager = $manager; + + // === Prestataire COMPLET — VIREMENT + banque (RG-3.07), compta + RIB, === + // === multi-sites sur le formulaire principal ET sur l'adresse. === + [$maintenance, $isNew] = $this->ensureProvider($manager, 'Maintenance Pro SAS', ['Maintenance industrielle'], ['Chatellerault', 'Saint-Jean']); + if ($isNew) { + $maintenance->setSiren('841611054'); + $maintenance->setAccountNumber('P0001'); + $maintenance->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $maintenance->setNTva('FR12841611054'); + $maintenance->setPaymentDelay($this->paymentDelay($manager, 'J30')); + $maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT')); + $maintenance->setBank($this->bank($manager, 'SG')); + $this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr'); + $this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']); + $this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0); + } + + // === LCR avec RIB (RG-3.08) — site Pommevic === + [$nettoyage, $isNew] = $this->ensureProvider($manager, 'Nettoyage Sud-Ouest', ['Nettoyage'], ['Pommevic']); + if ($isNew) { + $nettoyage->setSiren('775680459'); + $nettoyage->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES')); + $nettoyage->setPaymentDelay($this->paymentDelay($manager, 'J15')); + $nettoyage->setPaymentType($this->paymentType($manager, 'LCR')); + $this->addContact($nettoyage, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@nettoyage-so.fr', 0); + $this->addContact($nettoyage, 'Marc', 'Girard', 'Chef d\'equipe', '05 56 10 20 31', null, 'marc.girard@nettoyage-so.fr', 1); + $this->addAddress($nettoyage, ['Pommevic'], '82400', 'Pommevic', '8 route des Prestations'); + $this->addRib($nettoyage, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0); + } + + // === Multi-categories PRESTATAIRE + reglement CHEQUE (sans banque ni RIB) === + [$transport, $isNew] = $this->ensureProvider($manager, 'Transport Express Atlantique', ['Transport', 'Maintenance industrielle'], ['Saint-Jean']); + if ($isNew) { + $transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION')); + $transport->setPaymentType($this->paymentType($manager, 'CHEQUE')); + $this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr'); + $this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']); + } + + // === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 === + [$petit, $isNew] = $this->ensureProvider($manager, 'Atelier Soudure Locale', ['Maintenance industrielle'], ['Chatellerault']); + if ($isNew) { + $this->addContact($petit, null, 'Caron', 'Gerant', '05 49 81 82 83', null, 'contact@atelier-soudure.fr'); + $this->addAddress($petit, ['Chatellerault'], '86100', 'Châtellerault', '6 chemin de l\'Atelier'); + } + + // === Prestataire archive (RG-3.16) === + [$ancien, $isNew] = $this->ensureProvider($manager, 'Ancien Prestataire Ferme', ['Nettoyage'], ['Chatellerault'], isArchived: true); + if ($isNew) { + $this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-prestataire.fr'); + $this->addAddress($ancien, ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée'); + } + + $manager->flush(); + } + + /** + * Cree un prestataire (base normalisee + categories PRESTATAIRE + sites directs) + * s'il n'existe pas, sinon retourne l'existant. Retourne [Provider, isNew] : + * isNew=false bloque la reconstruction des sous-collections (idempotence). + * + * @param list $categoryNames categories de type PRESTATAIRE (RG-3.09) + * @param list $siteNames sites du formulaire principal (RG-3.03, >= 1) + * + * @return array{0: Provider, 1: bool} + */ + private function ensureProvider( + ObjectManager $manager, + string $companyName, + array $categoryNames, + array $siteNames, + bool $isArchived = false, + ): array { + $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName); + + $existing = $manager->getRepository(Provider::class)->findOneBy(['companyName' => $normalizedName]); + if ($existing instanceof Provider) { + return [$existing, false]; + } + + $provider = new Provider(); + $provider->setCompanyName($normalizedName); + + foreach ($categoryNames as $categoryName) { + $provider->addCategory($this->category($manager, $categoryName)); + } + foreach ($siteNames as $siteName) { + $provider->addSite($this->site($siteName)); + } + + if ($isArchived) { + $provider->setIsArchived(true); + $provider->setArchivedAt(new DateTimeImmutable()); + } + + $manager->persist($provider); + + return [$provider, true]; + } + + /** + * Ajoute un contact normalise au prestataire (cascade persist via + * Provider.contacts). Au moins un champ est rempli (RG-3.04). + */ + private function addContact( + Provider $provider, + ?string $firstName, + ?string $lastName, + ?string $jobTitle, + ?string $phonePrimary, + ?string $phoneSecondary, + ?string $email, + int $position = 0, + ): void { + $contact = new ProviderContact(); + $contact->setProvider($provider); + $contact->setFirstName($this->normalizer->normalizePersonName($firstName)); + $contact->setLastName($this->normalizer->normalizePersonName($lastName)); + $contact->setJobTitle($jobTitle); + $contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary)); + $contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary)); + $contact->setEmail($this->normalizer->normalizeEmail($email)); + $contact->setPosition($position); + + $provider->addContact($contact); + } + + /** + * Ajoute une adresse au prestataire (cascade persist via Provider.addresses). + * Adresse simplifiee M3 : PAS de addressType / bennes / triageProvider. Au + * moins un site est rattache (RG-3.05) ; categories d'adresse de type + * PRESTATAIRE (RG-3.09). + * + * @param list $siteNames au moins un site (RG-3.05) + * @param list $categoryNames categories de type PRESTATAIRE (RG-3.09) + */ + private function addAddress( + Provider $provider, + array $siteNames, + string $postalCode, + string $city, + string $street, + ?string $streetComplement = null, + array $categoryNames = [], + int $position = 0, + ): void { + $address = new ProviderAddress(); + $address->setProvider($provider); + $address->setCountry('France'); + $address->setPostalCode($postalCode); + $address->setCity($city); + $address->setStreet($street); + $address->setStreetComplement($streetComplement); + $address->setPosition($position); + + foreach ($siteNames as $siteName) { + $address->addSite($this->site($siteName)); + } + foreach ($categoryNames as $categoryName) { + $address->addCategory($this->category($this->manager, $categoryName)); + } + + $provider->addAddress($address); + } + + /** + * Ajoute un RIB au prestataire (cascade persist via Provider.ribs). + */ + private function addRib(Provider $provider, string $label, string $bic, string $iban, int $position = 0): void + { + $rib = new ProviderRib(); + $rib->setProvider($provider); + $rib->setLabel($label); + $rib->setBic($bic); + $rib->setIban($iban); + $rib->setPosition($position); + + $provider->addRib($rib); + } + + /** + * Resout une categorie par son nom via le contrat Shared CategoryInterface, + * sans importer le module Catalog (regle n°1). Verifie le type PRESTATAIRE + * (RG-3.09). Mise en cache par nom. + */ + private function category(ObjectManager $manager, string $name): CategoryInterface + { + if (isset($this->categoryCache[$name])) { + return $this->categoryCache[$name]; + } + + $candidates = $manager->getRepository(CategoryInterface::class)->findBy([ + 'name' => $name, + 'deletedAt' => null, + ]); + + foreach ($candidates as $candidate) { + if ($candidate instanceof CategoryInterface + && in_array(self::PROVIDER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) { + return $this->categoryCache[$name] = $candidate; + } + } + + throw new RuntimeException(sprintf( + 'Categorie PRESTATAIRE "%s" introuvable : CategoryFixtures doit tourner avant ProviderFixtures.', + $name, + )); + } + + /** + * Resout un site par son nom via le contrat Shared SiteProviderInterface, sans + * importer le module Sites (regle n°1). Mise en cache par nom. + */ + private function site(string $name): SiteInterface + { + if (isset($this->siteCache[$name])) { + return $this->siteCache[$name]; + } + + $site = $this->siteProvider->findByName($name); + + if (!$site instanceof SiteInterface) { + throw new RuntimeException(sprintf( + 'Site "%s" introuvable : SitesFixtures doit tourner avant ProviderFixtures.', + $name, + )); + } + + return $this->siteCache[$name] = $site; + } + + private function tvaMode(ObjectManager $manager, string $code): TvaMode + { + $mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]); + + if (!$mode instanceof TvaMode) { + throw new RuntimeException(sprintf( + 'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.', + $code, + )); + } + + return $mode; + } + + private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay + { + $delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]); + + if (!$delay instanceof PaymentDelay) { + throw new RuntimeException(sprintf( + 'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.', + $code, + )); + } + + return $delay; + } + + private function paymentType(ObjectManager $manager, string $code): PaymentType + { + $type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]); + + if (!$type instanceof PaymentType) { + throw new RuntimeException(sprintf( + 'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.', + $code, + )); + } + + return $type; + } + + private function bank(ObjectManager $manager, string $code): Bank + { + $bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]); + + if (!$bank instanceof Bank) { + throw new RuntimeException(sprintf( + 'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.', + $code, + )); + } + + return $bank; + } +} diff --git a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php index b4326ba..2992694 100644 --- a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php +++ b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php @@ -8,12 +8,15 @@ use ApiPlatform\Symfony\Bundle\Test\Client; use App\Module\Catalog\Domain\Entity\Category; use App\Module\Catalog\Domain\Entity\CategoryType; 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\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; use App\Module\Sites\Domain\Entity\Site; use App\Module\Technique\Domain\Entity\Provider; +use App\Module\Technique\Domain\Entity\ProviderAddress; use App\Module\Technique\Domain\Entity\ProviderContact; use App\Module\Technique\Domain\Entity\ProviderRib; use App\Tests\Module\Core\Api\AbstractApiTestCase; @@ -320,6 +323,121 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase return $rib; } + /** + * Seede un prestataire COMPLET (sans passer par l'API — validations applicatives + * non rejouees mais CHECK BDD respectes) : bloc comptable non nul (SIREN + refs), + * >= 1 RIB, >= 2 sites en relation DIRECTE (formulaire principal, RG-3.03), >= 1 + * adresse multi-sites (>= 2 sites) avec >= 1 categorie PRESTATAIRE et >= 1 contact, + * >= 1 contact et >= 1 categorie sur le prestataire. Socle du contrat de + * serialisation et de la DoD (§ 4.0.bis), jumeau de seedCompleteSupplier (M2) + * mais SANS onglet Information (absent au M3) et AVEC sites directs sur le + * prestataire (NOUVEAU M3 — la liste affiche provider.sites, pas un agregat + * d'adresses). + * + * @param string $paymentTypeCode code du type de reglement a poser (defaut LCR, + * coherent avec le RIB seede ; RG-3.08) + */ + protected function seedCompleteProvider(string $companyName, string $paymentTypeCode = 'LCR'): Provider + { + $em = $this->getEm(); + + // Nom unique parmi les actifs (index partiel uq_provider_company_name_active). + $suffix = substr(bin2hex(random_bytes(3)), 0, 6); + + $provider = new Provider(); + $provider->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); + $provider->addCategory($this->providerCategory('NETTOYAGE')); + + // Bloc comptable non nul (gating par omission cote sans accounting.view). + $provider->setSiren('987654321'); + $provider->setAccountNumber('P0001'); + $provider->setNTva('FR00987654321'); + $provider->setTvaMode($this->tvaMode('FRANCE_VENTES')); + $provider->setPaymentDelay($this->paymentDelay('J30')); + $provider->setPaymentType($this->paymentType($paymentTypeCode)); + if ('VIREMENT' === $paymentTypeCode) { + $provider->setBank($this->bank('SG')); + } + + // >= 2 sites fixtures : relation DIRECTE provider.sites (RG-3.03) pour la + // LISTE + reutilises sur l'adresse multi-sites pour le DETAIL. + $sites = $em->getRepository(Site::class)->findBy([], null, 2); + self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).'); + foreach ($sites as $site) { + $provider->addSite($site); + } + $em->persist($provider); + + $contact = new ProviderContact(); + $contact->setProvider($provider); + $contact->setFirstName('Marie'); + $contact->setLastName('Martin'); + $contact->setJobTitle('Responsable'); + $contact->setPhonePrimary('0612345678'); + $contact->setEmail('marie.martin@seed.test'); + $provider->addContact($contact); + $em->persist($contact); + + // Adresse simplifiee M3 (PAS de addressType / bennes / triageProvider). + $address = new ProviderAddress(); + $address->setProvider($provider); + $address->setCountry('France'); + $address->setPostalCode('86000'); + $address->setCity('Poitiers'); + $address->setStreet('12 rue des Acacias'); + foreach ($sites as $site) { + $address->addSite($site); + } + $address->addCategory($this->providerCategory('NETTOYAGE')); + $address->addContact($contact); + $provider->addAddress($address); + $em->persist($address); + + $rib = new ProviderRib(); + $rib->setProvider($provider); + $rib->setLabel('Compte principal'); + $rib->setBic(self::VALID_BIC); + $rib->setIban(self::VALID_IBAN); + $provider->addRib($rib); + $em->persist($rib); + + $em->flush(); + + return $provider; + } + + /** + * Recupere un mode de TVA seede (CommercialReferentialFixtures) par code (ex. + * FRANCE_VENTES). Echoue explicitement si absent (fixtures non chargees). + */ + protected function tvaMode(string $code): TvaMode + { + $tvaMode = $this->getEm()->getRepository(TvaMode::class)->findOneBy(['code' => $code]); + + self::assertNotNull( + $tvaMode, + sprintf('Mode de TVA "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code), + ); + + return $tvaMode; + } + + /** + * Recupere un delai de reglement seede (CommercialReferentialFixtures) par code + * (ex. J30). Echoue explicitement si absent (fixtures non chargees). + */ + protected function paymentDelay(string $code): PaymentDelay + { + $paymentDelay = $this->getEm()->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]); + + self::assertNotNull( + $paymentDelay, + sprintf('Delai de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code), + ); + + return $paymentDelay; + } + /** * Recupere un type de reglement seede (CommercialReferentialFixtures) par code * (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees). diff --git a/tests/Module/Technique/Api/ProviderAuditTest.php b/tests/Module/Technique/Api/ProviderAuditTest.php new file mode 100644 index 0000000..60ab5ac --- /dev/null +++ b/tests/Module/Technique/Api/ProviderAuditTest.php @@ -0,0 +1,162 @@ + ligne audit_log entity_type='technique.Provider' + * avec l'action et le diff attendus ; + * - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles + * DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1/M2) ; + * - M2M `sites` : un PATCH du formulaire principal qui modifie les sites trace la + * relation many-to-many (audit M2M automatique, § 2.7). + * + * @internal + */ +final class ProviderAuditTest extends AbstractProviderApiTestCase +{ + private const string PROVIDER_TYPE = 'technique.Provider'; + private const string RIB_TYPE = 'technique.ProviderRib'; + + private ?Connection $auditConnection = null; + + protected function setUp(): void + { + parent::setUp(); + self::bootKernel(); + + /** @var Connection $conn */ + $conn = self::getContainer()->get('doctrine.dbal.audit_connection'); + $this->auditConnection = $conn; + } + + protected function tearDown(): void + { + if (null !== $this->auditConnection) { + $this->auditConnection->close(); + } + parent::tearDown(); + } + + public function testPostProviderIsAudited(): void + { + $admin = $this->createAdminClient(); + $payload = $this->validMainPayload('Audit Created Co', [self::SITE_86]); + + $created = $admin->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $payload, + ])->toArray(); + self::assertResponseStatusCodeSame(201); + + self::assertGreaterThanOrEqual( + 1, + $this->countAudit(self::PROVIDER_TYPE, (string) $created['id'], 'create'), + 'Un audit_log "create" doit etre genere pour le prestataire.', + ); + } + + public function testPatchProviderIsAudited(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedProvider('Audit Patch Co', [self::SITE_86]); + + $admin->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Audit Patch Renamed'], + ]); + self::assertResponseStatusCodeSame(200); + + self::assertGreaterThanOrEqual( + 1, + $this->countAudit(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'), + 'Un audit_log "update" doit etre genere pour le PATCH.', + ); + } + + public function testArchiveProviderIsAudited(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedProvider('Audit Archive Co', [self::SITE_86]); + + $admin->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(200); + + $changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'); + self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.'); + } + + public function testPatchSitesIsAuditedAsManyToMany(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedProvider('Audit Sites Co', [self::SITE_86]); + + // PATCH du formulaire principal : on passe a 2 sites (86 + 17). L'audit M2M + // automatique (§ 2.7) doit tracer la relation `sites` dans le diff. + $admin->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['sites' => [ + '/api/sites/'.$this->site(self::SITE_86)->getId(), + '/api/sites/'.$this->site(self::SITE_17)->getId(), + ]], + ]); + self::assertResponseStatusCodeSame(200); + + $changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'); + self::assertArrayHasKey('sites', $changes, 'La modification M2M des sites doit etre tracee.'); + } + + public function testRibCreateAuditIncludesIbanAndBic(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedProvider('Rib Audit Host', [self::SITE_86]); + + $rib = $admin->request('POST', '/api/providers/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'label' => 'Compte audite', + 'bic' => self::VALID_BIC, + 'iban' => self::VALID_IBAN, + ], + ])->toArray(); + self::assertResponseStatusCodeSame(201); + + $changes = $this->latestChanges(self::RIB_TYPE, (string) $rib['id'], 'create'); + self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).'); + self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).'); + self::assertSame(self::VALID_IBAN, $changes['iban']); + self::assertSame(self::VALID_BIC, $changes['bic']); + } + + /** + * Decode le `changes` (diff) de la derniere ligne audit_log correspondante. + * + * @return array + */ + private function latestChanges(string $type, string $id, string $action): array + { + $rows = $this->auditConnection->fetchAllAssociative( + 'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC', + ['type' => $type, 'id' => $id, 'action' => $action], + ); + self::assertGreaterThanOrEqual(1, count($rows), sprintf('Un audit_log "%s" doit exister pour %s#%s.', $action, $type, $id)); + + return json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR); + } + + private function countAudit(string $type, string $id, string $action): int + { + return (int) $this->auditConnection->fetchOne( + 'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action', + ['type' => $type, 'id' => $id, 'action' => $action], + ); + } +} diff --git a/tests/Module/Technique/Api/ProviderListTest.php b/tests/Module/Technique/Api/ProviderListTest.php index 367d253..836cf6e 100644 --- a/tests/Module/Technique/Api/ProviderListTest.php +++ b/tests/Module/Technique/Api/ProviderListTest.php @@ -80,4 +80,91 @@ final class ProviderListTest extends AbstractProviderApiTestCase self::assertSame(1, $body['totalItems']); self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']); } + + public function testPaginationDisabledReturnsFullCollection(): void + { + $token = $this->token(); + for ($i = 0; $i < 3; ++$i) { + $this->seedProvider($token.' Item'.$i, [self::SITE_86]); + } + + $client = $this->createAdminClient(); + // ?pagination=false : echappatoire pour alimenter un