Compare commits

..

4 Commits

Author SHA1 Message Date
Matthieu bfed6ddca9 feat(technique) : câbler le RBAC technique.providers.* (3 sources + matrice rôles + bypass_scope) (ERP-138)
Câble les permissions du module Technique dans toutes les sources RBAC (règle
ABSOLUE n°8, dans le même commit) :

- RbacSeeder::MATRIX : bureau/compta/commerciale reçoivent technique.providers.*
  selon la matrice § 2.9 + sites.bypass_scope (visibilité multi-site, § 2.13) ;
  usine = technique.providers.view seul, SANS bypass (cloisonnée à son site).
- config/sidebar.php : nouvelle section Technique + item Répertoire prestataires
  (/providers, module technique, permission technique.providers.view).
- personas.ts + SeedE2ECommand.php : 5 perms technique.providers.* sur le persona
  user-full (porte déjà sites.bypass_scope) — pas de nouveau persona (règle n°7).
- i18n fr.json : clés sidebar.technique.section / sidebar.technique.providers.

Test : ProviderRBACMatrixTest (miroir SupplierRBACMatrixTest) valide la matrice
rôle×verbe via app:seed-rbac, dont le cloisonnement par site de l'Usine
(détail hors site → 404). 8 tests, 65 assertions.
2026-06-12 14:51:07 +02:00
Matthieu 11eeb13bff feat(technique) : export XLSX du repertoire prestataires (ProviderExportController, priority:1) (ERP-137) 2026-06-12 14:22:40 +02:00
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
19 changed files with 2235 additions and 22 deletions
+17
View File
@@ -61,6 +61,23 @@ return [
],
],
],
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
// la section disparait automatiquement (SidebarProvider) si le module
// `technique` est desactive ou si l'user n'a pas la permission.
[
'label' => 'sidebar.technique.section',
'icon' => 'mdi:wrench-outline',
'items' => [
[
'label' => 'sidebar.technique.providers',
'to' => '/providers',
'icon' => 'mdi:account-wrench-outline',
'module' => 'technique',
'permission' => 'technique.providers.view',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log).
//
+4
View File
@@ -30,6 +30,10 @@
"clients": "Répertoire clients",
"suppliers": "Répertoire fournisseurs"
},
"technique": {
"section": "Technique",
"providers": "Répertoire prestataires"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
+11
View File
@@ -84,6 +84,17 @@ export const personas: Record<PersonaKey, Persona> = {
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
// clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
// persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
// donc il voit les prestataires de tous les sites (M3 § 2.13).
// technique.providers.view n'ajoute pas de lien dans la section
// Administration, donc expectedAdminLinks reste inchange.
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
@@ -50,11 +50,19 @@ final class RbacSeeder
/**
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
* bypass tout via isAdmin ; `commercial.clients.archive` et
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
* `commercial.clients.archive`, `commercial.suppliers.archive` et
* `technique.providers.archive` ne sont attaches a aucun role metier —
* admin seul).
*
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
* Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
* reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
* C'est un cloisonnement pilote par user/permission, pas par code de role :
* pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
* ici, aucun autre code a changer.
*
* @var array<string, array{label: string, permissions: list<string>}>
*/
private const array MATRIX = [
@@ -66,6 +74,11 @@ final class RbacSeeder
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
'technique.providers.view',
'technique.providers.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -82,6 +95,13 @@ final class RbacSeeder
'commercial.suppliers.view',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
// (pas de manage global -> ne peut pas creer un prestataire).
'technique.providers.view',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -96,14 +116,25 @@ final class RbacSeeder
// (onglet Comptabilite masque/filtre pour la Commerciale).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
// (onglet Comptabilite masque/filtre pour la Commerciale).
'technique.providers.view',
'technique.providers.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
],
],
self::ROLE_USINE => [
'label' => 'Usine',
'permissions' => [],
'label' => 'Usine',
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
// courant. Aucun autre acces metier.
'permissions' => [
'technique.providers.view',
],
],
];
@@ -203,6 +203,15 @@ final class SeedE2ECommand extends Command
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// Technique — Repertoire prestataires (M3, ERP-138). Meme
// logique : mappe sur le persona "tout". user-full porte deja
// sites.bypass_scope -> voit les prestataires de tous les
// sites (M3 § 2.13). Miroir de personas.ts.
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
],
],
[
@@ -140,6 +140,12 @@ class Provider implements TimestampableInterface, BlamableInterface
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
private const string PAYMENT_TYPE_LCR = 'LCR';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -291,6 +297,44 @@ class Provider implements TimestampableInterface, BlamableInterface
}
}
/**
* RG-3.07 / RG-3.08 : coherence du type de reglement comptable. Comme au M2
* (decision figee ERP-89, jumeau Supplier::validatePaymentTypeConsistency),
* ces RG inter-champs passent par une contrainte d'entite (Assert\Callback +
* ->atPath()) et NON par le ProviderProcessor, afin que chaque 422 porte un
* propertyPath exploitable par extractApiViolations (mapping inline sous le
* champ, pas un toast — convention ERP-101).
* - RG-3.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
* - RG-3.08 : paymentType = LCR impose au moins un RIB -> violation sur
* `paymentType` (les RIB n'ont pas de champ de formulaire ou s'ancrer quand
* la liste est vide ; l'erreur s'affiche donc sous le select « Type de
* règlement », binde cote front). Le 409 sur DELETE du dernier RIB en LCR est
* porte par le ProviderRibProcessor (ERP-135).
*
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
* n'expose que provider:write:main), la contrainte ne mord en pratique que sur
* le PATCH de l'onglet Comptabilite.
*/
#[Assert\Callback]
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
{
$paymentCode = $this->paymentType?->getCode();
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
->atPath('bank')
->addViolation()
;
}
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('paymentType')
->addViolation()
;
}
}
public function getId(): ?int
{
return $this->id;
@@ -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'])]
@@ -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);
}
}
}
@@ -52,12 +52,13 @@ use Symfony\Component\Validator\ConstraintViolationList;
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
* restauration).
*
* La RG-3.09 (categorie de type PRESTATAIRE) est portee par un Assert\Callback +
* ->atPath() sur l'entite Provider (joue par API Platform AVANT ce processor),
* pour que la 422 porte un propertyPath consommable par extractApiViolations
* (mapping inline, pas un toast — convention ERP-101). Les RG-3.07 (Virement ->
* banque) et RG-3.08 (LCR -> RIB) relevent de l'onglet Comptabilite / sous-ressource
* RIB (ticket dedie) et ne sont pas portees ici.
* Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
* RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
* ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
* AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
* extractApiViolations (mapping inline, pas un toast — convention ERP-101). Le 409
* sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
* ProviderRibProcessor (ERP-135).
*
* @implements ProcessorInterface<Provider, Provider>
*/
@@ -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,328 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Controller;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du repertoire prestataires (M3, spec-back § 4.6). Jumeau du
* `SupplierExportController` (M2, module Commercial), augmente du cloisonnement
* par site pilote par l'utilisateur (§ 2.13).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est
* OBLIGATOIRE sur la route : sans cela API Platform capterait
* `/api/providers/export.xlsx` comme l'item `GET /api/providers/{id}.{_format}`
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des prestataires (memes filtres que
* `GET /api/providers`, via {@see ProviderRepositoryInterface::createListQueryBuilder()}),
* cloisonnement par site, et mapping metier des colonnes.
*
* Cloisonnement par site (RG-3.17, § 2.13) : replique la logique du
* {@see ProviderProvider}
* — un user sans `sites.bypass_scope` et possedant un currentSite n'exporte que
* les prestataires rattaches a ce site (relation DIRECTE provider.sites). Le
* QueryBuilder ne connait pas l'user : la decision est prise ICI, le DQL dans le
* repository (applySiteScope).
*
* Colonnes de contact : alimentees par le CONTACT PRINCIPAL du prestataire — le
* ProviderContact de plus petit `position` (decision D2, spec § 4.6).
*
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
* `technique.providers.accounting.view` (gating identique a la lecture, § 2.9).
*/
#[AsController]
final class ProviderExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly Security $security,
// Outillage site-aware (cf. ProviderProvider) : resout le site courant pour
// appliquer le cloisonnement RG-3.17 a l'export comme a la liste.
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
#[Route('/api/providers/export.xlsx', name: 'technique_providers_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('technique.providers.view')]
public function __invoke(Request $request): Response
{
// Memes filtres d'archivage que la vue liste (ProviderProvider) pour que
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
// - includeArchived : inclut les archives en plus des actifs ;
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
// createListQueryBuilder).
$includeArchived = $this->readBool($request->query->get('includeArchived'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null;
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
// ne pas lever d'exception sur une valeur scalaire.
$query = $request->query->all();
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
$siteIds = $this->readIntList($query['siteId'] ?? []);
$qb = $this->repository
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
;
// Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint
// au currentSite pour un user non-bypass (s'intersecte avec un eventuel
// ?siteId du client). No-op pour bypass_scope ou currentSite null.
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
/** @var list<Provider> $providers */
$providers = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
// selection ne fetch-join pas les to-many. On remplit categories + sites en
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
// contact principal) — chacune en requetes IN bornees, anti N+1.
$this->repository->hydrateListCollections($providers);
$this->repository->hydrateContacts($providers);
$withSiren = $this->security->isGranted('technique.providers.accounting.view');
$binary = $this->exporter->export(
'Répertoire prestataires',
$this->buildHeaders($withSiren),
$this->buildRows($providers, $withSiren),
);
return $this->buildResponse($binary);
}
/**
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
* / user sans currentSite). Miroir de ProviderProvider::siteScopeOrNull().
*/
private function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
* uniquement si l'utilisateur a accounting.view.
*
* @return list<string>
*/
private function buildHeaders(bool $withSiren): array
{
$headers = [
'Nom prestataire',
'Contact principal',
'Téléphone principal',
'Téléphone secondaire',
'Email',
'Catégories',
'Sites',
];
if ($withSiren) {
$headers[] = 'SIREN';
}
$headers[] = 'Date de création';
return $headers;
}
/**
* @param list<Provider> $providers
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $providers, bool $withSiren): iterable
{
foreach ($providers as $provider) {
$contact = $this->principalContact($provider);
$row = [
$provider->getCompanyName(),
null !== $contact ? $this->formatContactName($contact) : '',
$contact?->getPhonePrimary() ?? '',
$contact?->getPhoneSecondary() ?? '',
$contact?->getEmail() ?? '',
$this->formatCategories($provider),
$this->formatSites($provider),
];
if ($withSiren) {
$row[] = $provider->getSiren();
}
$row[] = $provider->getCreatedAt()?->format('d/m/Y');
yield $row;
}
}
/**
* Contact principal du prestataire : le ProviderContact de plus petit
* `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun
* contact (les colonnes contact restent vides).
*/
private function principalContact(Provider $provider): ?ProviderContact
{
$contacts = $provider->getContacts()->toArray();
if ([] === $contacts) {
return null;
}
usort(
$contacts,
static fn (ProviderContact $a, ProviderContact $b): int => $a->getPosition() <=> $b->getPosition(),
);
return $contacts[0];
}
/**
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
* sont optionnelles (RG-3.04 : au moins l'une des deux), d'ou le trim final.
*/
private function formatContactName(ProviderContact $contact): string
{
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
}
/**
* Libelles des categories du prestataire, dedupliques, tries, joints par
* virgule.
*/
private function formatCategories(Provider $provider): string
{
$names = [];
foreach ($provider->getCategories() as $category) {
// @var CategoryInterface $category
$name = $category->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 — contrairement
* au fournisseur M2 dont les sites sont portes par les adresses). La colonne
* « Sites » agrege l'union distincte des sites rattaches.
*/
private function formatSites(Provider $provider): string
{
$names = [];
foreach ($provider->getSites() as $site) {
// @var SiteInterface $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* @param array<string, true> $names ensemble de libelles (cles)
*/
private function joinSorted(array $names): string
{
$list = array_keys($names);
sort($list);
return implode(', ', $list);
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('repertoire-prestataires-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
* Aligne sur ProviderProvider pour un comportement identique a la liste.
*/
private function readBool(mixed $raw): bool
{
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines (valeur unique ou liste).
* Aligne sur ProviderProvider pour un comportement identique a la liste.
*
* @return list<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 (valeur unique
* ou liste). Aligne sur ProviderProvider.
*
* @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;
}
}
@@ -7,11 +7,15 @@ 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;
@@ -48,6 +52,12 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
protected const string SITE_17 = '17400'; // Saint-Jean
protected const string SITE_82 = '82400'; // Pommevic
/** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
protected const string VALID_BIC = 'BNPAFRPPXXX';
/** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
protected function tearDown(): void
{
$em = $this->getEm();
@@ -268,6 +278,80 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
return ['username' => $username, 'password' => $password];
}
/**
* Ajoute un contact a un prestataire deja persiste (seed direct).
*/
protected function addContact(
Provider $provider,
?string $firstName = 'Marie',
?string $lastName = 'Martin',
?string $phonePrimary = null,
?string $email = null,
int $position = 0,
): ProviderContact {
$contact = new ProviderContact();
$contact->setProvider($provider);
$contact->setFirstName($firstName);
$contact->setLastName($lastName);
$contact->setPhonePrimary($phonePrimary);
$contact->setEmail($email);
$contact->setPosition($position);
$provider->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
return $contact;
}
/**
* Ajoute un RIB a un prestataire deja persiste (seed direct).
*/
protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib
{
$rib = new ProviderRib();
$rib->setProvider($provider);
$rib->setLabel($label);
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$provider->addRib($rib);
$this->getEm()->persist($rib);
$this->getEm()->flush();
return $rib;
}
/**
* Recupere un type de reglement seede (CommercialReferentialFixtures) par code
* (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees).
*/
protected function paymentType(string $code): PaymentType
{
$paymentType = $this->getEm()->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$paymentType,
sprintf('Type de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $paymentType;
}
/**
* 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).
*
@@ -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,319 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du repertoire prestataires (M3, § 4.6).
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}
* (M2), augmente du cloisonnement par site (§ 2.13, propre au M3).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes contact
* principal / categories / sites (relation directe provider.sites), gating de la
* colonne SIREN selon technique.providers.accounting.view (admin ET user minimal a
* permission explicite), dedup (prestataire multi-categories rendu sur une seule
* ligne), cloisonnement par site (un user cloisonne n'exporte que son site), 403
* sans technique.providers.view, 401 anonyme.
*
* @internal
*/
final class ProviderExportControllerTest extends AbstractProviderApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/providers/export.xlsx';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Export Alpha');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="repertoire-prestataires-', $disposition);
self::assertMatchesRegularExpression(
'/filename="repertoire-prestataires-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$grid = $this->gridFromResponse($response->getContent());
$headers = $grid[0];
self::assertSame('Nom prestataire', $headers[0]);
self::assertContains('Contact principal', $headers);
self::assertContains('Téléphone principal', $headers);
self::assertContains('Téléphone secondaire', $headers);
self::assertContains('Email', $headers);
self::assertContains('Catégories', $headers);
self::assertContains('Sites', $headers);
self::assertContains('Date de création', $headers);
}
public function testExportExcludesArchivedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Active One');
$this->seedProvider('Archived One', [self::SITE_86], true);
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('ACTIVE ONE', $names);
self::assertNotContains('ARCHIVED ONE', $names);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Searchable Alpha');
$this->seedProvider('Other Beta');
$names = $this->companyNames(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('SEARCHABLE ALPHA', $names);
self::assertNotContains('OTHER BETA', $names);
}
/**
* Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
* de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
* ordre de position inverse pour garantir que c'est bien le principal (et non
* le premier insere) qui alimente la ligne.
*/
public function testExportUsesPrincipalContactColumns(): void
{
$client = $this->createAdminClient();
$provider = $this->seedProvider('Contact Co');
// position 1 (secondaire) insere en premier...
$this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$principal = $this->addContact($provider, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
// directement sur le contact principal pour alimenter la colonne dediee.
$principal->setPhoneSecondary('0698765432');
$this->getEm()->flush();
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
self::assertSame('Principal Alice', $row[1]);
self::assertSame('0612345678', $row[2]);
self::assertSame('0698765432', $row[3]);
self::assertSame('alice@contact.co', $row[4]);
}
/**
* Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
* vides sans erreur (cf. ERP-100 cote client). Le site est porte EN DIRECT par
* le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse).
*/
public function testExportPopulatesCategoryAndSiteColumns(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE');
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()).
// Derive du helper de base (idempotent) plutot que de hardcoder le prefixe.
self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat);
// Colonne « Sites » : site rattache en direct au prestataire (RG-3.03).
self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat);
}
public function testSirenColumnPresentWithAccountingView(): void
{
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
$client = $this->createAdminClient();
$this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789');
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('123456789', $this->flatten($grid));
}
public function testSirenColumnAbsentWithoutAccountingView(): void
{
// Seed via admin, puis relecture par un user qui n'a QUE providers.view.
$this->createAdminClient();
$this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321');
$creds = $this->createUserWithPermission('technique.providers.view');
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertNotContains('SIREN', $grid[0]);
self::assertStringNotContainsString('987654321', $this->flatten($grid));
}
/**
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
* un user minimal portant uniquement technique.providers.view +
* technique.providers.accounting.view voit bien la colonne SIREN et sa valeur.
* Complement de testSirenColumnPresentWithAccountingView (admin), qui ne prouve
* pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). Le pendant
* negatif est couvert par testSirenColumnAbsentWithoutAccountingView.
*/
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
{
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
$this->createAdminClient();
$this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123');
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.accounting.view',
]);
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('456789123', $this->flatten($grid));
}
/**
* Dedup : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par
* la jointure (selection/hydratation des collections) ; l'export doit le rendre
* sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il
* n'apparait qu'une fois dans la colonne « Nom prestataire ».
*/
public function testExportDeduplicatesProviderWithMultipleCategories(): void
{
$client = $this->createAdminClient();
$provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE');
// 2e categorie PRESTATAIRE sur le meme prestataire.
$provider->addCategory($this->providerCategory('SECURITE'));
$this->getEm()->flush();
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
$occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
self::assertSame(
1,
$occurrences,
'Un prestataire multi-categories doit apparaitre sur une seule ligne (dedup).',
);
}
/**
* Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur
* le site 86 n'exporte QUE les prestataires rattaches au site 86 — les
* prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant
* export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser.
*/
public function testExportIsScopedToCurrentSiteForNonBypassUser(): void
{
// Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement
// no-op et ce test perd son sens).
$this->skipIfSitesModuleDisabled();
$this->createAdminClient();
$this->seedProvider('Presta Site 86', [self::SITE_86]);
$this->seedProvider('Presta Site 17', [self::SITE_17]);
$this->seedProvider('Presta Site 82', [self::SITE_82]);
$creds = $this->createScopedUser(
['technique.providers.view'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('PRESTA SITE 86', $names);
self::assertNotContains('PRESTA SITE 17', $names);
self::assertNotContains('PRESTA SITE 82', $names);
}
public function testForbiddenWithoutProvidersViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Nom prestataire » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function companyNames(string $binary): array
{
$grid = $this->gridFromResponse($binary);
$rows = array_slice($grid, 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
/**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
*
* @return null|array<int, mixed>
*/
private function rowFor(string $binary, string $companyName): ?array
{
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
if ((string) ($row[0] ?? '') === $companyName) {
return $row;
}
}
return null;
}
/**
* Aplatit toute la grille en une chaine, pour les assertions de presence.
*
* @param array<int, array<int, mixed>> $grid
*/
private function flatten(array $grid): string
{
return implode('|', array_map(
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
$grid,
));
}
}
@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire prestataires par role metier (spec-back M3
* § 2.9 + § 2.13, ERP-138). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, le gating des champs comptables en
* lecture (omission de cle) et le cloisonnement par site de l'Usine.
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente —
* pas de mock de role. Jumeau de SupplierRBACMatrixTest (M2), avec la difference
* structurante du M3 : l'Usine n'est plus « 403 partout » mais possede
* `technique.providers.view` en lecture seule, CLOISONNEE a son site courant
* (pas de `sites.bypass_scope`).
*
* Matrice § 2.9 (ERP-138) — rappel :
* - bureau : providers.view + manage (ni accounting, ni archive) + bypass_scope
* - compta : providers.view + accounting.view + accounting.manage (PAS manage) + bypass_scope
* - commerciale : providers.view + manage (PAS accounting) + bypass_scope
* - usine : providers.view seul, SANS bypass_scope (cloisonne a son site)
* - archive : admin seul (aucun role metier)
*
* @internal
*/
final class ProviderRBACMatrixTest extends AbstractProviderApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 2.9 +
// comptes demo). Exerce aussi le chemin de code prod.
self::bootKernel();
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$exit = $application->run(
new ArrayInput([
'command' => 'app:seed-rbac',
'--with-demo-users' => true,
'--password' => self::PWD,
]),
new NullOutput(),
);
self::assertSame(
0,
$exit,
'app:seed-rbac a echoue : les permissions technique.providers.* sont-elles synchronisees (app:sync-permissions) ?',
);
self::ensureKernelShutdown();
}
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedProvider('Bureau Cible');
$client = $this->authAs('bureau');
// view
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK (bypass_scope -> peut attacher le site 86)
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Bureau Cree'),
]);
self::assertResponseStatusCodeSame(201);
// manage : edition onglet principal OK
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Bureau Renomme'],
]);
self::assertResponseStatusCodeSame(200);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauDetailHasNoAccountingFields(): void
{
// Bureau a view mais PAS accounting.view : les champs comptables sont
// ABSENTS du JSON (gating par omission, pas null).
$provider = $this->seedProvider('Bureau Gating Co', [self::SITE_86], siren: '123456789');
$client = $this->authAs('bureau');
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testComptaCanEditAccountingOnly(): void
{
$seed = $this->seedProvider('Compta Cible');
$client = $this->authAs('compta');
// view
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Compta Post'),
]);
self::assertResponseStatusCodeSame(403);
// accounting.manage : edition onglet Comptabilite OK
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(200);
// PAS manage : edition onglet principal refusee (mode strict RG-3.15)
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Compta Renomme'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testComptaDetailHasAccountingFields(): void
{
// Compta a accounting.view : siren + ribs presents dans le JSON.
$provider = $this->seedProvider('Compta View Co', [self::SITE_86], siren: '987654321');
$this->addRib($provider);
$client = $this->authAs('compta');
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $data);
self::assertSame('987654321', $data['siren']);
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
}
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedProvider('Commerciale Cible');
$client = $this->authAs('commerciale');
// view
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Cree'),
]);
self::assertResponseStatusCodeSame(201);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testCommercialeDetailHasNoAccountingFields(): void
{
$provider = $this->seedProvider('Commerciale Gating Co', [self::SITE_86], siren: '123456789');
$client = $this->authAs('commerciale');
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testUsineHasReadOnlyAccessScopedToItsSite(): void
{
// Usine a view (lecture seule), SANS manage / accounting / archive, et
// SANS bypass_scope -> cloisonnee a son site courant (Chatellerault,
// site 86, pose par ensureDemoUsers).
$inScope = $this->seedProvider('Usine InScope', [self::SITE_86]);
$client = $this->authAs('usine');
// view : liste OK (pas un 403 comme au M2)
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// view : detail d'un prestataire de SON site OK
$client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Usine Post'),
]);
self::assertResponseStatusCodeSame(403);
// PAS manage : edition onglet principal refusee
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Renomme Par Usine'],
]);
self::assertResponseStatusCodeSame(403);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testUsineCannotSeeProviderOutOfItsSite(): void
{
// Cloisonnement § 2.13 : un prestataire hors du site courant de l'Usine
// (site 17, l'Usine est sur le site 86) -> 404 (ne pas reveler la ligne).
$outOfScope = $this->seedProvider('Usine OutOfScope', [self::SITE_17]);
$client = $this->authAs('usine');
$client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(404);
}
private function authAs(string $role): Client
{
return $this->authenticatedClient($role, self::PWD);
}
}
@@ -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);
}
}