Compare commits

..

1 Commits

Author SHA1 Message Date
gitea-actions 2e206b0362 chore: bump version to v0.1.93
Build & Push Docker Image / build (push) Has been cancelled
2026-06-08 08:00:37 +00:00
15 changed files with 112 additions and 233 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.95' app.version: '0.1.93'
+12 -22
View File
@@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `
Notes (miroir M1) : Notes (miroir M1) :
- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global). - **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global).
- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back). Mécanisme réel (le code fait foi) : le groupe de lecture `supplier:read:accounting` n'est **pas** dans le contexte de sérialisation par défaut ; le `SupplierReadGroupContextBuilder` ne l'**ajoute** dynamiquement que si l'utilisateur porte `commercial.suppliers.accounting.view` (gating **par ajout** de groupe, jamais par retrait). Sans la permission, les champs comptables (et les RIB) ne sont donc jamais sérialisés. La colonne SIREN de l'export XLSX suit la même règle (`accounting.view`). - **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`).
- **Bureau** : `view` + `manage` (tout sauf Comptabilité). - **Bureau** : `view` + `manage` (tout sauf Comptabilité).
- **Usine** : aucune permission → item sidebar invisible, accès direct 403. - **Usine** : aucune permission → item sidebar invisible, accès direct 403.
@@ -159,11 +159,9 @@ final class SupplierFieldNormalizer
Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls). Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls).
### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/ERP-62) ### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62)
Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
Conséquence performance — **implémentation réelle (le code fait foi)** : le `DoctrineSupplierRepository` **ne fetch-joine PAS** les to-many dans la requête de sélection (`createListQueryBuilder` ne fait que filtres + tri). L'anti-N+1 passe par `hydrateListCollections()` (puis `hydrateContacts()`) : une fois le jeu de fournisseurs borné (page ou export), des requêtes **`IN` bornées séparées** remplissent `categories`, puis `addresses.sites`, puis `contacts` sur les **mêmes** instances `Supplier` (identity map). Ce découpage évite le **produit cartésien** qu'un fetch-join combiné `categories × addresses.sites` imposerait aux chemins non paginés (export, `?pagination=false`). Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
> Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé. > Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé.
@@ -215,8 +213,6 @@ Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrati
> **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '<table>')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis). > **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '<table>')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis).
> **Types réels de la migration (le code fait foi)** : le SQL ci-dessous est *illustratif*. La migration mergée (`Version20260605130000`) utilise le **style aligné M1** : clés primaires en `INT GENERATED BY DEFAULT AS IDENTITY` (et **non** `SERIAL`) et horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (et **non** `TIMESTAMPTZ`, car le `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que `schema:update` reste un no-op une fois les entités mappées.
```sql ```sql
-- ===================================================================== -- =====================================================================
-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés) -- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés)
@@ -426,10 +422,8 @@ use Symfony\Component\Validator\Constraints as Assert;
// Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de // Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
// champ dérivé aplati). Maillon (c) : category:read + site:read dans // champ dérivé aplati). Maillon (c) : category:read + site:read dans
// le contexte pour exposer Category(code/name) + Site(name/postalCode). // le contexte pour exposer Category(code/name) + Site(name/postalCode).
// ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le // ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
// SupplierRepository hydrate categories/sites/contacts via des requêtes // pour éviter le N+1 sur la liste (cf. § 2.12).
// IN bornées séparées (hydrateListCollections), pour éviter le produit
// cartésien sur les chemins non paginés (export) — cf. § 2.12.
normalizationContext: ['groups' => [ normalizationContext: ['groups' => [
'supplier:read', 'supplier:read',
'category:read', 'category:read',
@@ -448,14 +442,13 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => [ normalizationContext: ['groups' => [
'supplier:read', 'supplier:read',
'supplier:item:read', // embed contacts / addresses 'supplier:item:read', // embed contacts / addresses
// ⚠ supplier:read:accounting est volontairement ABSENT ici : il est 'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
// AJOUTÉ dynamiquement par le SupplierReadGroupContextBuilder quand
// l'user porte accounting.view (gating par ajout, pas par retrait —
// parade bug #4 M1). Il porte les scalaires compta + l'embed ribs.
'category:read', // embed des Category (id/code/name) — relation imbriquée 'category:read', // embed des Category (id/code/name) — relation imbriquée
'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée 'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
'default:read', 'default:read',
]], ]],
// Le Provider RETIRE supplier:read:accounting du contexte si l'user
// n'a pas is_granted('commercial.suppliers.accounting.view').
provider: SupplierProvider::class, provider: SupplierProvider::class,
), ),
new Post( new Post(
@@ -465,13 +458,10 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: SupplierProcessor::class, processor: SupplierProcessor::class,
), ),
new Patch( new Patch(
// Security élargie : `manage` OU `accounting.manage` — le rôle Compta security: "is_granted('commercial.suppliers.manage')",
// n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un // Le SupplierProcessor inspecte les groupes envoyés pour autoriser
// fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite // onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
// onglet par onglet (mode strict RG-2.16) : // exige is_granted('commercial.suppliers.accounting.manage') ;
security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')",
// Patch des champs comptables exige accounting.manage (guardAccounting) ;
// champs main/information exigent manage (guardManage) ;
// patch isArchived exige is_granted('commercial.suppliers.archive'). // patch isArchived exige is_granted('commercial.suppliers.archive').
normalizationContext: ['groups' => ['supplier:read', 'default:read']], normalizationContext: ['groups' => ['supplier:read', 'default:read']],
denormalizationContext: ['groups' => [ denormalizationContext: ['groups' => [
+8 -8
View File
@@ -82,14 +82,14 @@ final class Version20260605130000 extends AbstractMigration
// Ordre inverse des dependances FK : jointures et sous-collections // Ordre inverse des dependances FK : jointures et sous-collections
// d'abord, puis supplier. Les referentiels comptables et le // d'abord, puis supplier. Les referentiels comptables et le
// CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs). // CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs).
$this->addSql('DROP TABLE IF EXISTS supplier_address_category'); $this->addSql('DROP TABLE supplier_address_category');
$this->addSql('DROP TABLE IF EXISTS supplier_address_contact'); $this->addSql('DROP TABLE supplier_address_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_address_site'); $this->addSql('DROP TABLE supplier_address_site');
$this->addSql('DROP TABLE IF EXISTS supplier_rib'); $this->addSql('DROP TABLE supplier_rib');
$this->addSql('DROP TABLE IF EXISTS supplier_address'); $this->addSql('DROP TABLE supplier_address');
$this->addSql('DROP TABLE IF EXISTS supplier_contact'); $this->addSql('DROP TABLE supplier_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_category'); $this->addSql('DROP TABLE supplier_category');
$this->addSql('DROP TABLE IF EXISTS supplier'); $this->addSql('DROP TABLE supplier');
} }
// ================================================================= // =================================================================
@@ -43,10 +43,7 @@ final class ClientInformationCompletenessValidator
foreach ($fields as $property => $value) { foreach ($fields as $property => $value) {
if ($this->isMissing($value)) { if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation( $violations->add(new ConstraintViolation(
// Pas de nom de champ technique dans le message : la violation est sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
null, null,
[], [],
$client, $client,
@@ -47,10 +47,7 @@ final class SupplierInformationCompletenessValidator
foreach ($fields as $property => $value) { foreach ($fields as $property => $value) {
if ($this->isMissing($value)) { if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation( $violations->add(new ConstraintViolation(
// Pas de nom de champ technique dans le message : la violation est sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property),
// deja rattachee au bon champ via son propertyPath (mappe inline
// cote front par useFormErrors).
'Ce champ est obligatoire pour le rôle Commerciale.',
null, null,
[], [],
$supplier, $supplier,
@@ -188,7 +188,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $description = null; private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
@@ -181,7 +181,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
private ?string $description = null; private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
@@ -121,12 +121,6 @@ final class ClientProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys(); $writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys); $isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -114,12 +114,6 @@ final class SupplierProcessor implements ProcessorInterface
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
// Reinitialisation de la memoisation du payload en debut de traitement :
// le service est partage (stateful), on repart du corps de LA requete
// courante et on n'herite jamais des cles decodees d'une requete passee.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys(); $writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys); $isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -57,11 +57,6 @@ final class SupplierExportController
#[IsGranted('commercial.suppliers.view')] #[IsGranted('commercial.suppliers.view')]
public function __invoke(Request $request): Response public function __invoke(Request $request): Response
{ {
// Memes filtres d'archivage que la vue liste (SupplierProvider) 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')); $includeArchived = $this->readBool($request->query->get('includeArchived'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly')); $archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null; $search = $request->query->getString('search') ?: null;
@@ -82,12 +82,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
*/ */
class SupplierFixtures extends Fixture implements DependentFixtureInterface class SupplierFixtures extends Fixture implements DependentFixtureInterface
{ {
/**
* Type de categorie exige pour un fournisseur et ses adresses (RG-2.10).
* Miroir de Supplier::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
*/
private const string SUPPLIER_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
/** Cache des categories resolues par nom (evite des requetes repetees). */ /** Cache des categories resolues par nom (evite des requetes repetees). */
private array $categoryCache = []; private array $categoryCache = [];
@@ -421,27 +415,19 @@ class SupplierFixtures extends Fixture implements DependentFixtureInterface
return $this->categoryCache[$name]; return $this->categoryCache[$name];
} }
// RG-2.10 : on filtre explicitement sur le type FOURNISSEUR. Un lookup par $category = $manager->getRepository(CategoryInterface::class)->findOneBy([
// le seul `name` rattacherait une categorie homonyme d'un autre type (ex.
// futur PRESTA) — donc du MAUVAIS type — ce qui violerait « au moins une
// categorie de type FOURNISSEUR ». Le filtre type est porte cote PHP
// (findBy ne sait pas filtrer une propriete imbriquee categoryType.code).
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
'name' => $name, 'name' => $name,
'deletedAt' => null, 'deletedAt' => null,
]); ]);
foreach ($candidates as $candidate) { if (!$category instanceof CategoryInterface) {
if ($candidate instanceof CategoryInterface throw new RuntimeException(sprintf(
&& self::SUPPLIER_CATEGORY_TYPE_CODE === $candidate->getCategoryTypeCode()) { 'Categorie "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
return $this->categoryCache[$name] = $candidate; $name,
} ));
} }
throw new RuntimeException(sprintf( return $this->categoryCache[$name] = $category;
'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
$name,
));
} }
/** /**
@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api; namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress; use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Sites\Domain\Entity\Site; use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
/** /**
@@ -15,17 +18,29 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des * Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes * archives par defaut, respect du filtre ?search, peuplement des colonnes
* contact principal / categories / sites, gating de la colonne SIREN selon * contact principal / categories / sites, gating de la colonne SIREN selon
* commercial.suppliers.accounting.view (admin ET user minimal a permission * commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view,
* explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne), * 401 anonyme.
* 403 sans commercial.suppliers.view, 401 anonyme.
* *
* @internal * @internal
*/ */
final class SupplierExportControllerTest extends AbstractSupplierApiTestCase final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
{ {
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/suppliers/export.xlsx'; private const string EXPORT_URL = '/api/suppliers/export.xlsx';
/**
* Les fournisseurs doivent etre purges AVANT les categories de test (le parent
* supprime les categories `test_cli_cat_*`) : la jointure supplier_category est
* en ON DELETE CASCADE cote supplier mais RESTRICT cote category. Le DELETE DQL
* sur Supplier declenche le cascade BDD sur supplier_category / _contact /
* _address (et leurs sous-jointures), liberant les categories pour le parent.
*/
protected function tearDown(): void
{
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -95,13 +110,9 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
$supplier = $this->seedSupplier('Contact Co'); $supplier = $this->seedSupplier('Contact Co');
// position 1 (secondaire) insere en premier... // position 1 (secondaire) insere en premier...
$this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1); $this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co');
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner. // ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$principal = $this->addContact($supplier, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0); $this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co');
// 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'); $row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
@@ -138,10 +149,8 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent())); $flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur // Colonne « Catégories » : libelle de la categorie du fournisseur (getName()).
// (getName()). On le derive du helper de base (idempotent) plutot que de self::assertStringContainsString('test_cli_cat_negociant', $flat);
// hardcoder le prefixe de nom de test.
self::assertStringContainsString((string) $this->supplierCategory('NEGOCIANT')->getName(), $flat);
// Colonne « Sites » : site agrege depuis l'adresse (RG-2.06). // Colonne « Sites » : site agrege depuis l'adresse (RG-2.06).
self::assertStringContainsString((string) $site->getName(), $flat); self::assertStringContainsString((string) $site->getName(), $flat);
} }
@@ -179,60 +188,6 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
self::assertStringNotContainsString('987654321', $this->flatten($grid)); self::assertStringNotContainsString('987654321', $this->flatten($grid));
} }
/**
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
* un user minimal portant uniquement commercial.suppliers.view +
* commercial.suppliers.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 (sans accounting.view -> colonne absente) est couvert par
* testSirenColumnAbsentWithoutAccountingView.
*/
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
{
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
$this->createAdminClient();
$supplier = $this->seedSupplier('Gated Siren Co');
$em = $this->getEm();
$supplier->setSiren('456789123');
$em->flush();
$creds = $this->createUserWithPermissions([
'commercial.suppliers.view',
'commercial.suppliers.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 F3 : un fournisseur portant >= 2 categories FOURNISSEUR est multiplie
* par la jointure (selection/hydratation des collections) ; l'export doit le
* rendre sur UNE SEULE ligne. On seede un fournisseur a 2 categories et on
* assert qu'il n'apparait qu'une fois dans la colonne « Nom fournisseur ».
*/
public function testExportDeduplicatesSupplierWithMultipleCategories(): void
{
$client = $this->createAdminClient();
$supplier = $this->seedSupplier('Multi Cat Co', false, 'NEGOCIANT');
// 2e categorie FOURNISSEUR sur le meme fournisseur (RG-2.10).
$supplier->addCategory($this->supplierCategory('GROSSISTE'));
$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 fournisseur multi-categories doit apparaitre sur une seule ligne (dedup F3).',
);
}
public function testForbiddenWithoutSuppliersViewPermission(): void public function testForbiddenWithoutSuppliersViewPermission(): void
{ {
$creds = $this->createUserWithPermission('core.users.view'); $creds = $this->createUserWithPermission('core.users.view');
@@ -251,6 +206,50 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
self::assertResponseStatusCodeSame(401); self::assertResponseStatusCodeSame(401);
} }
/**
* Seede directement un Supplier en base (sans passer par l'API), pour les
* tests de liste / archivage. Stocke le nom en MAJUSCULES pour refleter l'etat
* normalise (RG-2.12) qu'aurait produit le SupplierProcessor via l'API.
*/
private function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): Supplier
{
$em = $this->getEm();
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$supplier->addCategory($this->createCategory($categoryCode));
$supplier->setIsArchived($isArchived);
if ($isArchived) {
$supplier->setArchivedAt(new DateTimeImmutable());
}
$em->persist($supplier);
$em->flush();
return $supplier;
}
private function addContact(
Supplier $supplier,
string $lastName,
string $firstName,
int $position,
?string $phonePrimary = null,
?string $phoneSecondary = null,
?string $email = null,
): void {
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setLastName($lastName);
$contact->setFirstName($firstName);
$contact->setPosition($position);
$contact->setPhonePrimary($phonePrimary);
$contact->setPhoneSecondary($phoneSecondary);
$contact->setEmail($email);
$supplier->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
}
/** /**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
* *
@@ -285,7 +284,7 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
/** /**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName. * Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
* *
* @return null|array<int, mixed> * @return array<int, mixed>|null
*/ */
private function rowFor(string $binary, string $companyName): ?array private function rowFor(string $binary, string $companyName): ?array
{ {
@@ -126,10 +126,6 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
public function testPostAddressWithoutSiteReturns422(): void public function testPostAddressWithoutSiteReturns422(): void
{ {
// Sans cette garde, un module Sites desactive renverrait 404 (route
// /addresses indisponible) et le test passerait pour la MAUVAISE raison
// au lieu de prouver RG-2.06 (Assert\Count min 1 sur sites).
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedSupplier('Address No Site'); $seed = $this->seedSupplier('Address No Site');
@@ -101,24 +101,6 @@ final class SupplierProcessorTest extends TestCase
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation())); self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
} }
public function testAdminIncompleteInformationPasses(): void
{
// Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale
// (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role
// metier) n'est pas soumis a la completude Information -> 200 malgre un
// onglet Information incomplet. Prouve que le gate porte bien sur le ROLE
// metier Commerciale, et pas sur « il y a un utilisateur connecte ».
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description');
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: $this->adminUser(),
);
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
/** /**
* @param list<string> $granted Permissions accordees a l'utilisateur courant * @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete * @param array<string, mixed> $payload Corps JSON simule de la requete
@@ -193,33 +175,6 @@ final class SupplierProcessorTest extends TestCase
return $this->createStub(Operation::class); return $this->createStub(Operation::class);
} }
/**
* Utilisateur authentifie non-Commerciale (profil admin) : porte
* BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a
* distinguer « pas de role Commerciale » de « pas d'utilisateur » (null).
*/
private function adminUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return false;
}
public function getRoles(): array
{
return ['ROLE_ADMIN'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'admin-test';
}
};
}
private function commercialeUser(): UserInterface private function commercialeUser(): UserInterface
{ {
return new class implements UserInterface, BusinessRoleAwareInterface { return new class implements UserInterface, BusinessRoleAwareInterface {
+12 -36
View File
@@ -90,26 +90,6 @@ abstract class AbstractApiTestCase extends ApiTestCase
* @return array{username: string, password: string} Les identifiants pour authenticatedClient() * @return array{username: string, password: string} Les identifiants pour authenticatedClient()
*/ */
protected function createUserWithPermission(string $permissionCode): array protected function createUserWithPermission(string $permissionCode): array
{
return $this->createUserWithPermissions([$permissionCode]);
}
/**
* Variante multi-permissions de {@see createUserWithPermission()} : cree un
* utilisateur non-admin portant PLUSIEURS permissions via un unique role
* jetable. Utile pour prouver qu'une combinaison precise de permissions
* (sans le bypass admin) suffit a debloquer un comportement — ex. la colonne
* SIREN de l'export, gatee par accounting.view EN PLUS de suppliers.view.
*
* Memes garanties que le singulier : suffixe aleatoire, password "testpass",
* rattachement a tous les sites, echec explicite si une permission est
* introuvable en base.
*
* @param list<string> $permissionCodes codes des permissions a accorder
*
* @return array{username: string, password: string} identifiants pour authenticatedClient()
*/
protected function createUserWithPermissions(array $permissionCodes): array
{ {
if (!self::$kernel) { if (!self::$kernel) {
self::bootKernel(); self::bootKernel();
@@ -117,6 +97,17 @@ abstract class AbstractApiTestCase extends ApiTestCase
$em = $this->getEm(); $em = $this->getEm();
/** @var null|Permission $permission */
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
self::assertNotNull(
$permission,
sprintf(
'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
$permissionCode,
),
);
$suffix = substr(bin2hex(random_bytes(4)), 0, 8); $suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'testuser_'.$suffix; $username = 'testuser_'.$suffix;
$password = 'testpass'; $password = 'testpass';
@@ -125,22 +116,7 @@ abstract class AbstractApiTestCase extends ApiTestCase
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false); $role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
$role->addPermission($permission);
foreach ($permissionCodes as $permissionCode) {
/** @var null|Permission $permission */
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
self::assertNotNull(
$permission,
sprintf(
'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
$permissionCode,
),
);
$role->addPermission($permission);
}
$em->persist($role); $em->persist($role);
$user = new User(); $user = new User();