Compare commits

..

3 Commits

Author SHA1 Message Date
Matthieu 9a6ec71981 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.
2026-06-12 11:51:12 +02:00
Matthieu 9a0da4de63 feat(technique) : sous-ressources Contacts / Adresses / RIBs (ERP-135)
Expose les sous-collections du prestataire en #[ApiResource] (POST sur le
parent + PATCH/DELETE/GET unitaires), edition complete par onglet (pas de
POST-only, RETEX M1/M2) :

- ProviderContact : POST /providers/{id}/contacts, PATCH/DELETE
  /provider_contacts/{id} (security technique.providers.manage).
  ProviderContactProcessor : normalisation RG-3.11 (nom/prenom Title Case,
  telephones chiffres, email lowercase) + RG-3.04 (au moins un champ parmi
  prenom/nom/telephone/email, miroir du CHECK chk_provider_contact_name -> 422).
- ProviderAddress : POST /providers/{id}/addresses, PATCH/DELETE
  /provider_addresses/{id} (security technique.providers.manage).
  ProviderAddressProcessor : rattachement parent + cloisonnement d'ecriture des
  sites de l'adresse (RG-3.05 / § 2.13 : site hors user_site -> 422 sur sites).
- ProviderRib : POST /providers/{id}/ribs, PATCH/DELETE /provider_ribs/{id}
  (security technique.providers.accounting.manage). ProviderRibProcessor :
  RG-3.08 (DELETE du dernier RIB sous LCR -> 409).

Tests : ProviderSubResourceApiTest (19 cas) — CRUD chaque sous-ressource, 403
selon permission (Contacts/Adresses=manage, RIB=accounting.manage), 409 dernier
RIB LCR, 422 cloisonnement site adresse. Helpers addContact/addRib/paymentType
ajoutes a AbstractProviderApiTestCase.
2026-06-12 11:32:08 +02:00
Matthieu 0ca1fb159a feat(technique) : ProviderProvider + ProviderProcessor + cloisonnement site (ERP-134)
Coeur API du repertoire prestataires (M3), jumeau du M2 fournisseurs :

- ProviderProvider : liste paginee (Paginator ORM), filtres
  search/categoryCode/siteId/includeArchived, tri companyName ASC,
  exclusion archives + soft-deletes (RG-3.16). Cloisonnement par site
  pilote par l'utilisateur (RG-3.17 / § 2.13) : liste restreinte au
  currentSite avant pagination (totalItems = perimetre), detail hors
  perimetre -> 404, bypass via sites.bypass_scope.
- ProviderProcessor : normalisation companyName (RG-3.11), POST formulaire
  principal (companyName + categories + sites), PATCH partiels par groupe
  en mode strict (RG-3.15, 403 sur tout le payload), archivage
  (RG-3.13/3.14), 409 doublon de nom (RG-3.10), garde d'ecriture cloisonnee
  des sites (RG-3.03/3.17, 422 sur sites pour les users sites.read_ref).
- ProviderReadGroupContextBuilder : gating comptabilite par AJOUT du groupe
  provider:read:accounting si accounting.view (jamais par retrait).
- ProviderFieldNormalizer : miroir SupplierFieldNormalizer.
- ApiResource cable (provider + processor) sur l'entite Provider.

Tests : ProviderApiTest, ProviderListTest, ProviderRbacGatingTest,
ProviderSiteScopeTest (26 tests). Suite complete verte (612 tests).
2026-06-12 11:03:19 +02:00
20 changed files with 3067 additions and 21 deletions
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Application\Service;
/**
* Normalisation serveur des champs texte d'un Provider / ProviderContact,
* appliquee par le ProviderProcessor (et les processors de sous-ressources,
* ticket ulterieur M3) AVANT persistance. Cf. spec-back M3 § 2.11 + RG-3.11.
* Jumeau de SupplierFieldNormalizer (M2) — duplique volontairement (isolation
* Commercial / Technique, decision § 2.1).
*
* - companyName : UPPERCASE integral (RG-3.11)
* - firstName / lastName (personnes, sur ProviderContact) : Title Case (RG-3.11)
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-3.11).
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
* - email : lowercase integral (RG-3.11)
*
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
* apres trim devient null (evite de persister "" dans des colonnes nullable).
*/
final class ProviderFieldNormalizer
{
/**
* Nom de societe en majuscules (RG-3.11). Conserve null tel quel ; une
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
*/
public function normalizeCompanyName(?string $value): ?string
{
if (null === $value) {
return null;
}
return mb_strtoupper(trim($value), 'UTF-8');
}
/**
* Nom/prenom de personne en Title Case (RG-3.11) : "JEAN dupont" ->
* "Jean Dupont". Une chaine vide apres trim devient null.
*/
public function normalizePersonName(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
}
/**
* Email en minuscules (RG-3.11). Une chaine vide apres trim devient null.
*/
public function normalizeEmail(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
* "0612345678". Une valeur sans aucun chiffre devient null.
*/
public function normalizePhone(?string $value): ?string
{
if (null === $value) {
return null;
}
$digits = preg_replace('/\D+/', '', $value) ?? '';
return '' === $digits ? null : $digits;
}
}
+62 -10
View File
@@ -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 }.
],
@@ -132,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]
@@ -283,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;
@@ -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'])]
@@ -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'])]
@@ -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'])]
@@ -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).
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\State\SerializerContextBuilderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\Request;
/**
* Decore le context builder de serialisation d'API Platform pour ajouter
* DYNAMIQUEMENT le groupe de lecture `provider:read:accounting` sur les
* ressources Provider, uniquement si l'utilisateur courant a la permission
* `technique.providers.accounting.view` (cf. spec-back M3 § 2.9 / § 4.1 /
* § 4.2). Jumeau de SupplierReadGroupContextBuilder (M2).
*
* Pourquoi un context builder et pas le Provider : un Provider retourne des
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
* de normalisation est construit ici, en amont du serializer — c'est le point
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
* l'utilisateur. Realise l'intention « gating du groupe accounting » de la spec
* (le groupe n'est jamais pose par defaut sur l'operation : il est AJOUTE ici si
* la permission est presente — resultat identique au « retrait » decrit en spec).
*
* S'applique aux operations de LECTURE (normalization) sur Provider : liste ET
* detail. Sans la permission, les champs comptables (siren, accountNumber,
* tvaMode, nTva, paymentDelay, paymentType, bank) ET les RIB embarques (groupe
* provider:read:accounting porte par getRibs()) ne sont jamais serialises — la
* cle est totalement absente du JSON (gating par omission, parade bug #4 M1).
*
* Priorite de decoration -20 : on s'empile APRES les decorateurs Commercial
* (ClientReadGroupContextBuilder priorite 0, SupplierReadGroupContextBuilder
* priorite -10) sur le meme service `api_platform.serializer.context_builder`.
* Chaque decorateur passe la main pour toute ressource autre que la sienne :
* l'ordre de chainage n'a donc aucun effet fonctionnel, la priorite explicite ne
* sert qu'a lever l'ambiguite de plusieurs decorateurs sur un meme service.
*/
#[AsDecorator(decorates: 'api_platform.serializer.context_builder', priority: -20)]
final readonly class ProviderReadGroupContextBuilder implements SerializerContextBuilderInterface
{
public function __construct(
#[AutowireDecorated]
private SerializerContextBuilderInterface $decorated,
private Security $security,
) {}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
// Uniquement en lecture, sur la ressource Provider, avec la permission.
if (!$normalization) {
return $context;
}
if (Provider::class !== ($context['resource_class'] ?? null)) {
return $context;
}
if (!$this->security->isGranted('technique.providers.accounting.view')) {
return $context;
}
$groups = $context['groups'] ?? [];
if (!in_array('provider:read:accounting', $groups, true)) {
$groups[] = 'provider:read:accounting';
}
$context['groups'] = $groups;
return $context;
}
}
@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Adresse d'un prestataire (M3,
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2), recentre sur le
* perimetre ERP-135, AVEC une garde supplementaire propre au M3 : le
* cloisonnement d'ECRITURE des sites de l'adresse (§ 2.13).
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent puis cloisonnement des
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
* garanties en amont par des contraintes sur l'entite, jouees par API Platform
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
* Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
* ProviderAddress::validateCategoryType).
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* La security de l'operation (technique.providers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
*
* @implements ProcessorInterface<ProviderAddress, null|ProviderAddress>
*/
final class ProviderAddressProcessor implements ProcessorInterface
{
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderAddress) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->guardSiteScope($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache l'adresse au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/addresses) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ProviderAddress $address, array $uriVariables): void
{
if (null !== $address->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
$address->setProvider($provider);
}
/**
* RG-3.05 / § 2.13 (cloisonnement d'ECRITURE — decision Matthieu 11/06) : un
* user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse que des
* sites figurant dans ses propres `user_site`. Tout site hors perimetre -> 422
* sur `sites` (propertyPath consommable inline, convention ERP-101). Un user
* `bypass_scope` (Admin) peut attacher n'importe quel site. Miroir de
* ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse.
*
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
* sites obligatoires RG-3.05) ou PATCH portant la cle `sites`. Un PATCH qui ne
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
* pose). La validation porte sur l'ETAT RESULTANT (address.getSites()).
*/
private function guardSiteScope(ProviderAddress $address): void
{
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
return;
}
// sites non soumis sur un PATCH : rien a cloisonner.
if ($this->em->contains($address) && !in_array('sites', $this->payloadKeys(), true)) {
return;
}
$allowedSiteIds = $this->currentUserSiteIds();
foreach ($address->getSites() as $site) {
if (!$site instanceof SiteInterface) {
continue;
}
if (!in_array($site->getId(), $allowedSiteIds, true)) {
$this->throwSitesViolation($address);
}
}
}
/**
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
* Vide si pas d'user authentifie (cas defensif : la security d'operation
* garantit deja l'authentification).
*
* @return list<int>
*/
private function currentUserSiteIds(): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
$ids = [];
foreach ($user->getSites() as $site) {
if ($site instanceof SiteInterface && null !== $site->getId()) {
$ids[] = $site->getId();
}
}
return $ids;
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
*
* @return never
*/
private function throwSitesViolation(ProviderAddress $address): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
null,
[],
$address,
'sites',
null,
));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Contact d'un prestataire (M3,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-135.
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent, normalisation serveur
* (RG-3.11 : prenom/nom Title Case, telephones reduits aux chiffres, email
* lowercase) via le ProviderFieldNormalizer partage, puis validation RG-3.04
* (au moins un champ parmi prenom / nom / telephone principal / email) avant
* persistance.
* - DELETE : aucune garde « dernier contact » au M3 — la collection peut rester
* vide cote back (RG-3.12 front-driven, spec § 4.5). Suppression physique directe.
*
* La security de l'operation (technique.providers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
* (Assert\Email, Assert\Length...).
*
* @implements ProcessorInterface<ProviderContact, null|ProviderContact>
*/
final class ProviderContactProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderContact) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->validateName($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le contact au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/contacts). La relation n'est pas peuplee
* automatiquement par le Link sur une operation d'ecriture : on resout le
* parent depuis l'uri variable. Sur PATCH (entite existante), le prestataire
* est deja present -> no-op.
*/
private function linkParent(ProviderContact $contact, array $uriVariables): void
{
if (null !== $contact->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
$contact->setProvider($provider);
}
/**
* Normalisation serveur (RG-3.11). Toutes les methodes du normalizer sont
* null-safe : une chaine vide apres trim devient null.
*/
private function normalize(ProviderContact $contact): void
{
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
}
/**
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
* nom / telephone principal / email est renseigne (double garde avec le CHECK
* BDD chk_provider_contact_name — leve une 422 propre rattachee au champ
* `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja
* ramenees a null et ne suffisent pas a valider le bloc.
*/
private function validateName(ProviderContact $contact): void
{
if (null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Au moins un champ du contact est obligatoire (nom, prénom, téléphone ou email).',
null,
[],
$contact,
'firstName',
null,
));
throw new ValidationException($violations);
}
}
}
@@ -0,0 +1,560 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture du repertoire prestataires (M3). Cf. spec-back M3 § 4.3 /
* § 4.4 + RG-3.10 / RG-3.13 / RG-3.14 / RG-3.15 / RG-3.17. Jumeau du
* SupplierProcessor (M2), avec deux differences structurantes (§ 3.1) :
* - PAS d'onglet Information (aucun champ description / competitors / ...) ni de
* validation de completude comptable -> le prestataire est minimal ;
* - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au
* CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans
* `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`.
*
* Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La
* security d'operation du PATCH est elargie a `manage` OU `accounting.manage`
* pour laisser entrer le role Compta ; ce processor re-gate alors finement :
* - champ comptable modifie dans le payload -> exige accounting.manage (403) ;
* - champ main (companyName / categories / sites) modifie -> exige manage
* (guardManage, 403) : empeche Compta d'editer un autre onglet ;
* - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et
* interdit toute autre modification dans la meme requete (RG-3.13, 422).
* 2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache
* hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`.
* 3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName).
* 4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
* restauration).
*
* 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<Provider, Provider>
*/
final class ProviderProcessor implements ProcessorInterface
{
/** Champs de l'onglet principal (groupe provider:write:main). */
private const array MAIN_FIELDS = [
'companyName', 'categories', 'sites',
];
/** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */
private const array ACCOUNTING_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
'paymentType', 'bank',
];
/** Champ d'archivage (groupe provider:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_MANAGE = 'technique.providers.manage';
private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage';
private const string PERM_ARCHIVE = 'technique.providers.archive';
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
/**
* Memoisation du dernier corps de requete decode, clos par le contenu brut
* (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete,
* on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune
* fuite entre requetes sur ce service partage.
*/
private ?string $decodedContent = null;
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
private array $decodedPayloadKeys = [];
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Provider) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Reinitialisation de la memoisation du payload : le service est partage
// (stateful), on repart du corps de LA requete courante.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
$this->guardAccounting($data);
$this->guardSiteScope($data);
$this->normalize($data);
// guardManage apres normalize : la comparaison « change vs etat persiste »
// des champs texte (companyName) se fait sur des valeurs normalisees des
// deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Le seul index unique partiel est uq_provider_company_name_active
// (LOWER(company_name) parmi non-archives/non-deletes — § 2.6).
if ($isArchiveRequest && false === $data->isArchived()) {
// RG-3.14 : restauration en conflit avec un homonyme actif.
throw new ConflictHttpException(
'Restauration impossible : un autre prestataire a pris le nom entre-temps.',
$e,
);
}
// RG-3.10 : doublon de nom de societe.
throw new ConflictHttpException(
sprintf('Un prestataire nommé "%s" existe déjà.', (string) $data->getCompanyName()),
$e,
);
}
}
/**
* RG-3.13 / RG-3.14 : si le payload bascule reellement isArchived, exige la
* permission archive (403), interdit toute autre modification (422) et
* pose/retire archivedAt. Retourne true si la requete est une requete
* d'archivage. Restreint a la mise a jour d'un prestataire existant ET au seul
* cas ou isArchived change vraiment (cf. SupplierProcessor).
*
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
*/
private function guardArchive(Provider $data, array $writableKeys): bool
{
// POST / entite non geree : l'archivage est une action de mise a jour.
if (!$this->em->contains($data)) {
return false;
}
// isArchived inchange par rapport a l'etat persiste : pas une requete
// d'archivage (cas du PATCH representation complete).
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
return false;
}
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
self::ARCHIVE_FIELD,
self::PERM_ARCHIVE,
));
}
// RG-3.13 : une requete d'archivage ne modifie aucun autre champ ecrivable.
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
throw new UnprocessableEntityHttpException(
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
);
}
// RG-3.13 (true -> now) / RG-3.14 (false -> null).
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
return true;
}
/**
* RG-3.15 : la modification effective d'un champ comptable exige
* accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de
* filtrage silencieux). On ne gate que si un champ change reellement par
* rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables
* inchanges ne declenche pas de 403 parasite). Le message precise le premier
* champ fautif.
*/
private function guardAccounting(Provider $data): void
{
$changed = $this->changedAccountingFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_ACCOUNTING_MANAGE,
));
}
}
/**
* § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet
* principal : companyName / categories / sites) exige
* `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du
* payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le
* role Compta — qui entre dans le PATCH via `accounting.manage` (security
* d'operation elargie) — d'editer autre chose que l'onglet Comptabilite.
*
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
* deja gardee par la security d'operation `manage`.
*/
private function guardManage(Provider $data): void
{
if (!$this->em->contains($data)) {
return;
}
$changed = $this->changedBusinessFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_MANAGE,
));
}
}
/**
* RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE — § 2.13) : un user SANS
* `sites.bypass_scope` ne peut attacher au prestataire que des sites figurant
* dans ses propres `user_site`. Tout site hors perimetre -> 422 sur `sites`
* (propertyPath consommable inline, convention ERP-101). Un user `bypass_scope`
* (Admin auto) peut attacher n'importe quel site.
*
* Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user
* sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site
* hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400
* anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement
* AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref`
* (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne
* doit rattacher que ses propres sites), et une defense en profondeur sinon.
*
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
* sites obligatoires RG-3.03) ou PATCH portant la cle `sites`. Un PATCH qui ne
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
* pose). La validation porte sur l'ETAT RESULTANT (data.getSites()).
*/
private function guardSiteScope(Provider $data): void
{
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
return;
}
// sites non soumis sur un PATCH : rien a cloisonner.
if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) {
return;
}
$allowedSiteIds = $this->currentUserSiteIds();
foreach ($data->getSites() as $site) {
if (!$site instanceof SiteInterface) {
continue;
}
if (!in_array($site->getId(), $allowedSiteIds, true)) {
$this->throwSitesViolation($data);
}
}
}
/**
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
* Vide si pas d'user authentifie (cas defensif : la security d'operation
* garantit deja l'authentification).
*
* @return list<int>
*/
private function currentUserSiteIds(): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
$ids = [];
foreach ($user->getSites() as $site) {
if ($site instanceof SiteInterface && null !== $site->getId()) {
$ids[] = $site->getId();
}
}
return $ids;
}
/**
* Champs « metier » (onglet principal : companyName / categories / sites) dont
* la valeur courante differe de l'etat persiste. Scalaires compares par valeur ;
* collections M2M (categories / sites) comparees par ensemble d'identifiants
* (cf. collectionChanged) — la simple presence dans le payload ne suffit pas,
* sous peine de 403 parasite sur un PATCH representation complete.
*
* @return list<string>
*/
private function changedBusinessFields(Provider $data): array
{
$changed = [];
if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) {
$changed[] = 'companyName';
}
if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) {
$changed[] = 'categories';
}
if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) {
$changed[] = 'sites';
}
return $changed;
}
/**
* Vrai si une collection M2M (`categories` ou `sites`) differe reellement de
* l'etat persiste. Ces collections ne sont pas tracees par
* getOriginalEntityData : on compare par identifiants (independamment de
* l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat
* courant (apres application du payload). Symetrique des scalaires : seul un
* changement effectif compte, pas la simple presence dans le payload.
*
* - POST / entite non geree : fournir la collection est un acte metier
* (branche defensive, guardManage ne s'execute que sur entite geree).
* - cle absente du payload (PATCH partiel) : aucun changement.
*
* @param array<int, object> $current
*/
private function collectionChanged(Provider $data, string $field, array $current): bool
{
if (!$this->em->contains($data)) {
return true;
}
if (!in_array($field, $this->payloadKeys(), true)) {
return false;
}
$collection = 'categories' === $field ? $data->getCategories() : $data->getSites();
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat
// persiste comparable, on se rabat sur la presence payload.
if (!$collection instanceof PersistentCollection) {
return true;
}
return $this->idSet($current) !== $this->idSet($collection->getSnapshot());
}
/**
* Ensemble trie des identifiants d'une liste d'entites — pour une comparaison
* par valeur independante de l'ordre.
*
* @param array<int, object> $entities
*
* @return list<mixed>
*/
private function idSet(array $entities): array
{
$ids = array_map(
static fn (object $entity): mixed => method_exists($entity, 'getId')
? $entity->getId()
: spl_object_id($entity),
array_values($entities),
);
sort($ids);
return $ids;
}
/**
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
* identite d'objet : l'identity map Doctrine renvoie la meme instance tant que
* la reference est inchangee.
*
* @return list<string>
*/
private function changedAccountingFields(Provider $data): array
{
$changed = [];
foreach (self::ACCOUNTING_FIELDS as $field) {
$newValue = match ($field) {
'siren' => $data->getSiren(),
'accountNumber' => $data->getAccountNumber(),
'tvaMode' => $data->getTvaMode(),
'nTva' => $data->getNTva(),
'paymentDelay' => $data->getPaymentDelay(),
'paymentType' => $data->getPaymentType(),
'bank' => $data->getBank(),
};
if ($this->fieldChanged($data, $field, $newValue)) {
$changed[] = $field;
}
}
return $changed;
}
/**
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
* non-null est alors un changement.
*/
private function fieldChanged(Provider $data, string $field, mixed $newValue): bool
{
$original = $this->originalData($data);
return $newValue !== ($original[$field] ?? null);
}
/**
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
* application du payload). Vide pour une entite non geree (POST).
*
* @return array<string, mixed>
*/
private function originalData(Provider $data): array
{
if (!$this->em->contains($data)) {
return [];
}
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
}
/**
* Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est
* porte par le Provider (les champs de contact sont normalises par le processor
* de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est
* touche que si une valeur est presente, pour ne jamais ecraser l'existant lors
* d'un PATCH partiel.
*/
private function normalize(Provider $data): void
{
if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
}
}
/**
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
* connu. Base du 422 d'archivage (RG-3.13).
*
* @return list<string>
*/
private function writablePayloadKeys(): array
{
$writable = array_merge(
self::MAIN_FIELDS,
self::ACCOUNTING_FIELDS,
[self::ARCHIVE_FIELD],
);
return array_values(array_intersect($this->payloadKeys(), $writable));
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
if ($content === $this->decodedContent) {
return $this->decodedPayloadKeys;
}
$this->decodedContent = $content;
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
return $this->decodedPayloadKeys;
}
/**
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function extractPayloadKeys(string $content): array
{
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
*
* @return never
*/
private function throwSitesViolation(Provider $root): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
null,
[],
$root,
'sites',
null,
));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderRib;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Processor d'ecriture de la sous-ressource RIB d'un prestataire (M3, spec-back
* § 4.5). Jumeau du SupplierRibProcessor (M2), recentre sur le perimetre ERP-135.
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent. Aucune normalisation
* specifique ; la validite de l'IBAN et du BIC est garantie par Assert\Iban /
* Assert\Bic sur l'entite (jouees en amont par API Platform). Aucun
* #[AuditIgnore] sur iban/bic : la tracabilite comptable est volontaire
* (decision M1/M2 reportee, spec § 2.7).
* - DELETE : RG-3.08 — si le prestataire est en reglement LCR, la suppression de
* son DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
*
* La security de l'operation (technique.providers.accounting.manage) est appliquee
* par API Platform en amont : un utilisateur sans cette permission recoit 403 sur
* POST/PATCH/DELETE avant d'atteindre ce processor — c'est le niveau de gating
* renforce des donnees bancaires (distinct de manage, spec § 4.5).
*
* @implements ProcessorInterface<ProviderRib, null|ProviderRib>
*/
final class ProviderRibProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderRib) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
$this->guardLastRibDeletionUnderLcr($data);
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le RIB au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/ribs) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ProviderRib $rib, array $uriVariables): void
{
if (null !== $rib->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
$rib->setProvider($provider);
}
/**
* RG-3.08 : un prestataire dont le type de reglement est LCR doit conserver au
* moins un RIB. La collection inclut le RIB en cours de suppression : un
* effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
* type de reglement, les RIBs sont optionnels (suppression libre).
*/
private function guardLastRibDeletionUnderLcr(ProviderRib $rib): void
{
$provider = $rib->getProvider();
if (null === $provider) {
return;
}
if ('LCR' === $provider->getPaymentType()?->getCode() && $provider->getRibs()->count() <= 1) {
throw new ConflictHttpException(
'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
);
}
}
}
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider du repertoire prestataires (M3). Cf. spec-back M3 § 4.1 / § 4.2 +
* RG-3.16 / RG-3.17. Jumeau du SupplierProvider (M2), augmente du cloisonnement
* par site pilote par l'utilisateur (§ 2.13).
*
* Collection (GET /api/providers) :
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
* (deleted_at IS NOT NULL) — RG-3.16 ;
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
* exclus au M3) — RG-3.16 ;
* - tri par defaut companyName ASC — RG-3.16 ;
* - filtres ?search=... (fuzzy companyName + contacts lies : firstName /
* lastName / email), ?categoryCode=<code> (prestataires ayant >= 1 categorie
* de ce code, repetable) et ?siteId=<id> (prestataires rattaches a ce site
* via la relation DIRECTE provider.sites, repetable) ;
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
* ?pagination=false pour alimenter un <select> sans pagination.
*
* Cloisonnement par site (RG-3.17, § 2.13) — applique ICI (le QueryBuilder du
* repository ne connait pas l'user courant) :
* - si l'user N'A PAS `sites.bypass_scope` ET que CurrentSiteProvider::get()
* retourne un site -> la liste est restreinte aux prestataires dont
* provider.sites contient le currentSite (repository::applySiteScope), AVANT
* pagination : totalItems reflete le perimetre de l'user ;
* - le DETAIL (Get / provider de PATCH) d'un prestataire hors perimetre renvoie
* 404 (null) — ne pas reveler l'existence d'une ligne hors site ;
* - user `bypass_scope` (Admin auto, profils consolidation) -> aucun filtre ;
* - currentSite = null (module Sites off / user sans site) -> no-op lecture
* (aligne site-aware.md § 5).
*
* Item (GET /api/providers/{id} + provider de PATCH) :
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
* M3) ; les archives restent consultables/restaurables en detail ;
* - 404 si hors perimetre site (cloisonnement, cf. ci-dessus).
*
* Le filtrage des champs comptables en lecture (groupe provider:read:accounting)
* n'est PAS fait ici mais dans ProviderReadGroupContextBuilder : un Provider
* retourne des donnees mais ne peut pas influencer les groupes de serialisation.
*
* @implements ProviderInterface<Provider>
*/
final class ProviderProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly Pagination $pagination,
private readonly Security $security,
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
// CurrentSiteProvider dans le service et ajouter la clause WHERE
// manuellement » pour les cas multi-site non couverts par
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Provider>|Paginator<Provider>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
$search = $filters['search'] ?? null;
// categoryCode accepte un code unique (?categoryCode=NETTOYAGE, selects)
// OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
$siteIds = $this->readIntList($filters['siteId'] ?? []);
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
$categoryCodes,
$siteIds,
$archivedOnly,
);
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
// Echappatoire ?pagination=false : collection complete sans Paginator
// (regle n°13 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Provider> $providers */
$providers = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (§ 2.12) : evite le
// N+1 si la serialisation touche categories/sites, sans cartesien.
$this->repository->hydrateListCollections($providers);
return $providers;
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
// puis on hydrate ses collections en lot (memes entites managees).
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
$this->repository->hydrateListCollections(iterator_to_array($paginator));
return $paginator;
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Provider
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$provider = $this->repository->findById((int) $id);
if (null === $provider) {
return null;
}
// Soft-delete : jamais expose au M3 (HP-M4) — 404 via retour null.
// Les archives restent visibles en detail (consultation + restauration).
if (null !== $provider->getDeletedAt()) {
return null;
}
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
// currentSite null.
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) {
return null;
}
return $provider;
}
/**
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
* / user sans currentSite, aligne site-aware.md § 5).
*/
private function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Vrai si le prestataire est rattache (relation directe provider.sites) au
* site d'id donne. Comparaison en memoire sur l'entite deja chargee (detail).
*/
private function providerHasSite(Provider $provider, int $siteId): bool
{
foreach ($provider->getSites() as $site) {
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
return true;
}
}
return false;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/
private function readBool(mixed $raw): bool
{
if (is_bool($raw)) {
return $raw;
}
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines. Tolere un code unique (string)
* ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
* valeur unique ou une liste (?key[]=1&key[]=2).
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -89,6 +89,26 @@ class DoctrineProviderRepository extends ServiceEntityRepository implements Prov
;
}
public function applySiteScope(QueryBuilder $qb, int $siteId): void
{
// Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires
// dont provider.sites contient le site donne. Sous-requete IN (alias p5
// distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du
// QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds.
// Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour
// que les deux clauses puissent coexister (intersection) sans collision.
$sub = $this->getEntityManager()->createQueryBuilder()
->select('p5.id')
->from(Provider::class, 'p5')
->join('p5.sites', 'site5')
->where('site5.id = :scopeSiteId')
;
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
->setParameter('scopeSiteId', $siteId)
;
}
public function hydrateContacts(array $providers): void
{
$ids = $this->collectIds($providers);
@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
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;
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;
/**
* Base des tests fonctionnels du repertoire prestataires (M3 — module Technique).
* Jumelle de la base fournisseurs (M2), recentree sur le perimetre ERP-134
* (Provider + Processor + cloisonnement site).
*
* Donnees (RETEX M1/M2 — pas de fixtures globales pour les tests) : chaque test
* seede ses prestataires en base via les helpers ci-dessous, puis le tearDown les
* purge. Les 3 sites (Chatellerault 86 / Saint-Jean 17 / Pommevic 82) sont seedes
* par SitesFixtures (make test-db-setup) ; on les recupere par code postal.
*
* Categories : `providerCategory('NETTOYAGE')` fetch-or-create une categorie de
* type PRESTATAIRE (requis par RG-3.09). Pour fabriquer une categorie d'un AUTRE
* type (test de rejet RG-3.09), utiliser `foreignCategory()`.
*
* Cleanup : tearDown purge prestataires AVANT categories/users (provider_category
* et provider_site sont ON DELETE CASCADE cote provider — le DELETE DQL sur
* Provider libere categories et sites pour les purges suivantes).
*
* @internal
*/
abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
protected const string TEST_CATEGORY_PREFIX = 'test_prov_cat_';
/** Codes postaux des 3 sites fixtures (cf. SitesFixtures). */
protected const string SITE_86 = '86100'; // Chatellerault
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();
$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<string> $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<string> $sitePostalCodes
*
* @return array<string, mixed>
*/
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<string> $permissionCodes
* @param list<string> $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];
}
/**
* 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;
}
/**
* 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).
*
* @param array<string, mixed> $body corps decode (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
* de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
* propertyPath de la violation (consommable par extractApiViolations cote front,
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires
* comptables (spec M3 § 3.1).
*
* @internal
*/
final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
{
// === RG-3.07 : Virement impose une banque ===
public function testVirementWithoutBankReturns422OnBankPath(): void
{
$client = $this->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.
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests fonctionnels du formulaire principal prestataire (POST + PATCH) — ERP-134.
* Couvre : creation (RG-3.03 sites obligatoires, RG-3.09 type categorie),
* normalisation companyName (RG-3.11), 409 doublon (RG-3.10).
*
* @internal
*/
final class ProviderApiTest extends AbstractProviderApiTestCase
{
public function testPostMainCreatesProvider(): void
{
$client = $this->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());
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests de la liste paginee /api/providers (ProviderProvider) — ERP-134.
* Couvre : envelope Hydra, tri companyName ASC, exclusion des archives,
* ?includeArchived (RG-3.16). Joue en admin (bypass_scope -> 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']);
}
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests du gating comptabilite + mode strict par groupe (ProviderProcessor /
* ProviderReadGroupContextBuilder) — ERP-134.
*
* Couvre : gating accounting PAR OMISSION (siren/ribs absents sans accounting.view,
* bug #4 M1), mode strict RG-3.15 (403 sur tout le payload), gating archive (RG-3.13).
*
* Les users sont crees via createUserWithPermissions() (parent) : rattaches a TOUS
* les sites SANS currentSite -> 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());
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests du cloisonnement par site pilote par l'utilisateur (RG-3.17 / § 2.13) —
* ERP-134. Couvre la LECTURE (liste filtree avant pagination + totalItems, detail
* 404 hors perimetre, bypass voit tout) et l'ECRITURE (sites hors user_site -> 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)));
}
}
@@ -0,0 +1,392 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
* (au moins un champ parmi prenom/nom/telephone/email), RG-3.05 (>= 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);
}
}