Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43b2251ef1 | |||
| 9cda225bdf | |||
| f031c70393 | |||
| e050a7b910 | |||
| b35deed8fe |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.89'
|
app.version: '0.1.95'
|
||||||
|
|||||||
@@ -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, 2 niveaux : `security` API Platform + `SupplierProvider`).
|
- **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`).
|
||||||
- **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,9 +159,11 @@ 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 + fetch-joins (cohérence M1/ERP-62)
|
### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (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. 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.
|
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 — **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é.
|
||||||
|
|
||||||
@@ -213,6 +215,8 @@ 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)
|
||||||
@@ -422,8 +426,10 @@ 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).
|
||||||
// ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
|
// ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le
|
||||||
// pour éviter le N+1 sur la liste (cf. § 2.12).
|
// SupplierRepository hydrate categories/sites/contacts via des requêtes
|
||||||
|
// 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',
|
||||||
@@ -442,13 +448,14 @@ 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', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
|
// ⚠ supplier:read:accounting est volontairement ABSENT ici : il est
|
||||||
|
// 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(
|
||||||
@@ -458,10 +465,13 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
processor: SupplierProcessor::class,
|
processor: SupplierProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
security: "is_granted('commercial.suppliers.manage')",
|
// Security élargie : `manage` OU `accounting.manage` — le rôle Compta
|
||||||
// Le SupplierProcessor inspecte les groupes envoyés pour autoriser
|
// n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un
|
||||||
// onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
|
// fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite
|
||||||
// exige is_granted('commercial.suppliers.accounting.manage') ;
|
// onglet par onglet (mode strict RG-2.16) :
|
||||||
|
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' => [
|
||||||
|
|||||||
@@ -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 supplier_address_category');
|
$this->addSql('DROP TABLE IF EXISTS supplier_address_category');
|
||||||
$this->addSql('DROP TABLE supplier_address_contact');
|
$this->addSql('DROP TABLE IF EXISTS supplier_address_contact');
|
||||||
$this->addSql('DROP TABLE supplier_address_site');
|
$this->addSql('DROP TABLE IF EXISTS supplier_address_site');
|
||||||
$this->addSql('DROP TABLE supplier_rib');
|
$this->addSql('DROP TABLE IF EXISTS supplier_rib');
|
||||||
$this->addSql('DROP TABLE supplier_address');
|
$this->addSql('DROP TABLE IF EXISTS supplier_address');
|
||||||
$this->addSql('DROP TABLE supplier_contact');
|
$this->addSql('DROP TABLE IF EXISTS supplier_contact');
|
||||||
$this->addSql('DROP TABLE supplier_category');
|
$this->addSql('DROP TABLE IF EXISTS supplier_category');
|
||||||
$this->addSql('DROP TABLE supplier');
|
$this->addSql('DROP TABLE IF EXISTS supplier');
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|||||||
+4
-1
@@ -43,7 +43,10 @@ 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(
|
||||||
sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
|
// Pas de nom de champ technique dans le message : la violation est
|
||||||
|
// 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,
|
||||||
|
|||||||
+4
-1
@@ -47,7 +47,10 @@ 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(
|
||||||
sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property),
|
// Pas de nom de champ technique dans le message : la violation est
|
||||||
|
// 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: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents 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: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents 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,6 +121,12 @@ 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,6 +114,12 @@ 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,6 +57,11 @@ 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;
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Commercial\Infrastructure\DataFixtures;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
|
||||||
|
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
|
||||||
|
use App\Module\Commercial\Domain\Entity\Bank;
|
||||||
|
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||||
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||||
|
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||||
|
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
||||||
|
use App\Module\Commercial\Domain\Entity\SupplierContact;
|
||||||
|
use App\Module\Commercial\Domain\Entity\SupplierRib;
|
||||||
|
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||||
|
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||||
|
use App\Shared\Domain\Contract\CategoryInterface;
|
||||||
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
|
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixtures dev/test du module Commercial : ~13 fournisseurs de demonstration
|
||||||
|
* couvrant l'ensemble des cas metier RG-2.xx du repertoire fournisseurs (M2),
|
||||||
|
* jumelles des fixtures Client (ERP-68). Theme metier : negoce / recyclage de
|
||||||
|
* metaux (d'ou les champs `bennes` et `triageProvider` sur les adresses).
|
||||||
|
*
|
||||||
|
* Cas pivots couverts (criteres d'acceptation ERP-112) :
|
||||||
|
* - reglement VIREMENT avec banque renseignee (RG-2.07) ;
|
||||||
|
* - reglement LCR avec 1 puis 2 RIB (RG-2.08) ; CHEQUE et NON_SOUMISE sans RIB ;
|
||||||
|
* - adresses multi-types PROSPECT / DEPART / RENDU (RG-2.09) et multi-sites
|
||||||
|
* (86 / 17 / 82, RG-2.06) ; bennes + prestataire de triage ;
|
||||||
|
* - 1 a 3 contacts dont un avec telephone secondaire et un nomme par le seul
|
||||||
|
* nom (RG-2.04) ;
|
||||||
|
* - 2 fournisseurs archives (isArchived + archivedAt) pour l'exclusion de la
|
||||||
|
* liste (RG-2.17) ;
|
||||||
|
* - mono et multi-categories de type FOURNISSEUR (RG-2.10) ;
|
||||||
|
* - onglet Information complet (dont volumeForecast, specifique fournisseur).
|
||||||
|
*
|
||||||
|
* Resolution inter-modules conforme a la regle n°1 (pas d'import direct) :
|
||||||
|
* - categories resolues via le contrat Shared CategoryInterface
|
||||||
|
* (resolve_target_entities -> Category) ;
|
||||||
|
* - sites resolus via le contrat Shared SiteProviderInterface.
|
||||||
|
*
|
||||||
|
* Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones
|
||||||
|
* formates) et normalisees par SupplierFieldNormalizer avant persist, exactement
|
||||||
|
* comme le ferait le SupplierProcessor via l'API (companyName UPPERCASE,
|
||||||
|
* first/last Capitalize, telephones chiffres seuls, emails lowercase).
|
||||||
|
*
|
||||||
|
* Coherence gating comptable (RG-2.16) : les scalaires comptables (siren,
|
||||||
|
* tvaMode, paymentType, bank...) et les RIB ne sont visibles qu'avec
|
||||||
|
* accounting.view. Les donnees sont posees pour que les roles SANS cette
|
||||||
|
* permission (ex. Commerciale) ne voient pas de compta — support des tests
|
||||||
|
* ERP-92 et du golden path front.
|
||||||
|
*
|
||||||
|
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
|
||||||
|
* partiel uq_supplier_company_name_active). Un fournisseur deja present n'est pas
|
||||||
|
* reconstruit (ses sous-collections ne sont pas redupliquees). Rejouable sans
|
||||||
|
* doublon meme si le purger Doctrine est desactive.
|
||||||
|
*
|
||||||
|
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
|
||||||
|
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
|
||||||
|
* les CHECK BDD (chk_supplier_contact_name : firstName OU lastName ;
|
||||||
|
* chk_supplier_address_type : PROSPECT | DEPART | RENDU) ET la coherence des
|
||||||
|
* validators d'entite (RG-2.07/2.08 : VIREMENT => banque, LCR => >= 1 RIB).
|
||||||
|
*
|
||||||
|
* Depend de CategoryFixtures (categories FOURNISSEUR), SitesFixtures (sites) et
|
||||||
|
* CommercialReferentialFixtures (referentiels comptables — REUTILISES de M1,
|
||||||
|
* aucune nouvelle table).
|
||||||
|
*
|
||||||
|
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
|
||||||
|
* la fixture ne charge rien : les tests seedent et nettoient leurs propres
|
||||||
|
* fournisseurs et comptent sur une table `supplier` vierge — y injecter 13
|
||||||
|
* fournisseurs de demo casserait les comptages de liste et les cleanups. Meme
|
||||||
|
* garde-fou que ClientFixtures / CategoryFixtures.
|
||||||
|
*/
|
||||||
|
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). */
|
||||||
|
private array $categoryCache = [];
|
||||||
|
|
||||||
|
/** Cache des sites resolus par nom. */
|
||||||
|
private array $siteCache = [];
|
||||||
|
|
||||||
|
/** ObjectManager courant, capture en debut de load (resolution categories). */
|
||||||
|
private ObjectManager $manager;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SupplierFieldNormalizer $normalizer,
|
||||||
|
private readonly SiteProviderInterface $siteProvider,
|
||||||
|
#[Autowire('%kernel.environment%')]
|
||||||
|
private readonly string $environment,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, class-string>
|
||||||
|
*/
|
||||||
|
public function getDependencies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CategoryFixtures::class,
|
||||||
|
SitesFixtures::class,
|
||||||
|
CommercialReferentialFixtures::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
|
||||||
|
if ('test' === $this->environment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->manager = $manager;
|
||||||
|
|
||||||
|
// === Fournisseur basique — VIREMENT + banque (RG-2.07), compta complete ===
|
||||||
|
[$negoce, $isNew] = $this->ensureSupplier($manager, 'Négoce Métaux Atlantique', ['Négociant']);
|
||||||
|
if ($isNew) {
|
||||||
|
$negoce->setSiren('841611054');
|
||||||
|
$negoce->setAccountNumber('F0001');
|
||||||
|
$negoce->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
||||||
|
$negoce->setNTva('FR12841611054');
|
||||||
|
$negoce->setPaymentDelay($this->paymentDelay($manager, 'J30'));
|
||||||
|
$negoce->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
||||||
|
$negoce->setBank($this->bank($manager, 'SG'));
|
||||||
|
$this->addContact($negoce, 'Jean', 'Dubois', 'Responsable achats', '05 49 00 00 01', null, 'jean.dubois@negoce-metaux.fr');
|
||||||
|
$this->addAddress($negoce, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '12 rue de la Ferraille', bennes: 4, triageProvider: true, categoryNames: ['Négociant']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === LCR avec 1 RIB (RG-2.08) + 2 contacts ===
|
||||||
|
[$coop, $isNew] = $this->ensureSupplier($manager, 'Coopérative Agricole du Sud-Ouest', ['Coopérative']);
|
||||||
|
if ($isNew) {
|
||||||
|
$coop->setSiren('775680459');
|
||||||
|
$coop->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
||||||
|
$coop->setPaymentDelay($this->paymentDelay($manager, 'J15'));
|
||||||
|
$coop->setPaymentType($this->paymentType($manager, 'LCR'));
|
||||||
|
$this->addContact($coop, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@coop-so.fr', 0);
|
||||||
|
$this->addContact($coop, 'Marc', 'Girard', 'Acheteur', '05 56 10 20 31', null, 'marc.girard@coop-so.fr', 1);
|
||||||
|
$this->addAddress($coop, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '8 route des Cooperateurs', bennes: 2);
|
||||||
|
$this->addRib($coop, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Prospect seul (adresse PROSPECT), compta minimale ===
|
||||||
|
[$producteur, $isNew] = $this->ensureSupplier($manager, 'Producteur Bio Charente', ['Producteur']);
|
||||||
|
if ($isNew) {
|
||||||
|
$this->addContact($producteur, 'Claire', 'Moreau', 'Gérante', '05 49 21 22 23', null, 'claire.moreau@bio-charente.fr');
|
||||||
|
$this->addAddress($producteur, 'PROSPECT', ['Saint-Jean'], '17400', 'Fontenet', '1 chemin des Producteurs');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Multi-categories M2M + LCR avec 2 RIB + 3 contacts ===
|
||||||
|
[$grossiste, $isNew] = $this->ensureSupplier($manager, 'Grossiste Multi-Métaux', ['Grossiste', 'Négociant']);
|
||||||
|
if ($isNew) {
|
||||||
|
$grossiste->setSiren('552081317');
|
||||||
|
$grossiste->setAccountNumber('F0004');
|
||||||
|
$grossiste->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
||||||
|
$grossiste->setNTva('FR45552081317');
|
||||||
|
$grossiste->setPaymentDelay($this->paymentDelay($manager, 'J30'));
|
||||||
|
$grossiste->setPaymentType($this->paymentType($manager, 'LCR'));
|
||||||
|
$this->addContact($grossiste, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@grossiste-mm.fr', 0);
|
||||||
|
$this->addContact($grossiste, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@grossiste-mm.fr', 1);
|
||||||
|
$this->addContact($grossiste, 'Hélène', 'Faure', 'Logistique', '05 56 31 32 35', null, 'helene.faure@grossiste-mm.fr', 2);
|
||||||
|
$this->addAddress($grossiste, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '20 zone des Activités', streetComplement: 'Bâtiment C', bennes: 6, triageProvider: true, categoryNames: ['Grossiste', 'Négociant']);
|
||||||
|
$this->addRib($grossiste, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
|
||||||
|
$this->addRib($grossiste, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630001007941234567890185', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === VIREMENT + banque, TVA intracom (importateur), multi-sites sur l'adresse ===
|
||||||
|
[$import, $isNew] = $this->ensureSupplier($manager, 'Import Recyclage International', ['Importateur']);
|
||||||
|
if ($isNew) {
|
||||||
|
$import->setSiren('409512012');
|
||||||
|
$import->setTvaMode($this->tvaMode($manager, 'INTRACOM_VENTES'));
|
||||||
|
$import->setNTva('FR90409512012');
|
||||||
|
$import->setPaymentDelay($this->paymentDelay($manager, 'J30'));
|
||||||
|
$import->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
||||||
|
$import->setBank($this->bank($manager, 'CIC'));
|
||||||
|
$this->addContact($import, 'Paul', 'Garnier', 'Import manager', '05 56 44 55 66', null, 'paul.garnier@import-recyclage.fr', 0);
|
||||||
|
$this->addContact($import, null, 'Bernard', 'Douanes', '05 56 44 55 67', null, 'douanes@import-recyclage.fr', 1);
|
||||||
|
$this->addAddress($import, 'RENDU', ['Pommevic', 'Saint-Jean'], '82400', 'Pommevic', '3 quai des Importateurs', bennes: 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Multi-adresses PROSPECT / DEPART / RENDU (RG-2.09) + VIREMENT/banque ===
|
||||||
|
[$ferrailleur, $isNew] = $this->ensureSupplier($manager, 'Ferrailleur Grand Ouest', ['Négociant']);
|
||||||
|
if ($isNew) {
|
||||||
|
$ferrailleur->setSiren('732829320');
|
||||||
|
$ferrailleur->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
||||||
|
$ferrailleur->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
||||||
|
$ferrailleur->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
||||||
|
$ferrailleur->setBank($this->bank($manager, 'CA'));
|
||||||
|
$this->addContact($ferrailleur, 'Olivier', 'Renard', 'Responsable site', '05 49 61 62 63', null, 'olivier.renard@ferrailleur-go.fr', 0);
|
||||||
|
$this->addContact($ferrailleur, 'Nadia', 'Benali', 'Pesée', '05 49 61 62 64', null, 'nadia.benali@ferrailleur-go.fr', 1);
|
||||||
|
// Prospect (site en cours de demarchage).
|
||||||
|
$this->addAddress($ferrailleur, 'PROSPECT', ['Chatellerault'], '86100', 'Châtellerault', '5 avenue de la Prospection', position: 0);
|
||||||
|
// Depart (collecte) multi-sites avec bennes + triage.
|
||||||
|
$this->addAddress($ferrailleur, 'DEPART', ['Saint-Jean', 'Pommevic'], '17400', 'Fontenet', '4 rue de la Collecte', bennes: 5, triageProvider: true, categoryNames: ['Négociant'], position: 1);
|
||||||
|
// Rendu (livraison).
|
||||||
|
$this->addAddress($ferrailleur, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '7 boulevard du Rendu', bennes: 3, position: 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Onglet Information complet (dont volumeForecast) + VIREMENT/banque ===
|
||||||
|
[$holding, $isNew] = $this->ensureSupplier($manager, 'Holding Recyclage Premium', ['Importateur']);
|
||||||
|
if ($isNew) {
|
||||||
|
$holding->setDescription('Holding de recyclage diversifiée, présente sur le Grand Sud-Ouest.');
|
||||||
|
$holding->setCompetitors('Groupe Atlantique Recyclage, Sud Métaux');
|
||||||
|
$holding->setFoundedAt(new DateTimeImmutable('2008-09-01'));
|
||||||
|
$holding->setEmployeesCount(180);
|
||||||
|
$holding->setRevenueAmount('24500000.00');
|
||||||
|
$holding->setDirectorName('Antoine Lefèvre');
|
||||||
|
$holding->setProfitAmount('1850000.00');
|
||||||
|
$holding->setVolumeForecast(120000);
|
||||||
|
$holding->setSiren('318471925');
|
||||||
|
$holding->setAccountNumber('F0007');
|
||||||
|
$holding->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
||||||
|
$holding->setNTva('FR33318471925');
|
||||||
|
$holding->setPaymentDelay($this->paymentDelay($manager, 'J30'));
|
||||||
|
$holding->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
||||||
|
$holding->setBank($this->bank($manager, 'SG'));
|
||||||
|
$this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-recyclage.fr');
|
||||||
|
$this->addAddress($holding, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '1 allée des Investisseurs', bennes: 5, triageProvider: true, categoryNames: ['Importateur']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Coop minimale — contact par le seul nom (RG-2.04), sans compta ===
|
||||||
|
[$coopMin, $isNew] = $this->ensureSupplier($manager, 'Coop Métaux Réunis', ['Coopérative']);
|
||||||
|
if ($isNew) {
|
||||||
|
$this->addContact($coopMin, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@coop-metaux-reunis.fr');
|
||||||
|
$this->addAddress($coopMin, 'DEPART', ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Reglement CHEQUE (sans banque ni RIB requis) ===
|
||||||
|
[$petit, $isNew] = $this->ensureSupplier($manager, 'Petit Négoce Local', ['Négociant']);
|
||||||
|
if ($isNew) {
|
||||||
|
$petit->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
||||||
|
$petit->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
||||||
|
$petit->setPaymentType($this->paymentType($manager, 'CHEQUE'));
|
||||||
|
$this->addContact($petit, 'Luc', 'Martin', 'Gérant', '05 56 71 72 73', null, 'luc.martin@petit-negoce.fr');
|
||||||
|
$this->addAddress($petit, 'RENDU', ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Commerce');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Reglement NON_SOUMISE + adresse multi-sites avec triage ===
|
||||||
|
[$recup, $isNew] = $this->ensureSupplier($manager, 'Récupération Métaux Express', ['Grossiste']);
|
||||||
|
if ($isNew) {
|
||||||
|
$recup->setSiren('490212019');
|
||||||
|
$recup->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
||||||
|
$recup->setPaymentDelay($this->paymentDelay($manager, 'J15'));
|
||||||
|
$recup->setPaymentType($this->paymentType($manager, 'NON_SOUMISE'));
|
||||||
|
$this->addContact($recup, 'Marie', 'Lemoine', 'Responsable', '05 49 77 88 99', null, 'marie.lemoine@recup-express.fr', 0);
|
||||||
|
$this->addContact($recup, 'Pierre', 'Durand', 'Chauffeur', '05 49 77 88 98', null, 'pierre.durand@recup-express.fr', 1);
|
||||||
|
$this->addAddress($recup, 'DEPART', ['Saint-Jean', 'Chatellerault'], '17400', 'Fontenet', '10 zone industrielle', bennes: 7, triageProvider: true, categoryNames: ['Grossiste']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Centre de tri — focus bennes/triage + multi-categories ===
|
||||||
|
[$centre, $isNew] = $this->ensureSupplier($manager, 'Centre de Tri Sud', ['Producteur', 'Coopérative']);
|
||||||
|
if ($isNew) {
|
||||||
|
$centre->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
||||||
|
$this->addContact($centre, 'Camille', 'Faure', 'Chef de centre', '05 56 91 92 93', null, 'camille.faure@centre-tri-sud.fr');
|
||||||
|
$this->addAddress($centre, 'DEPART', ['Pommevic'], '82400', 'Pommevic', '2 route du Tri', bennes: 12, triageProvider: true, categoryNames: ['Producteur']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Fournisseur archive #1 (RG-2.17) ===
|
||||||
|
[$ancien, $isNew] = $this->ensureSupplier($manager, 'Ancien Fournisseur Fermé', ['Producteur'], isArchived: true);
|
||||||
|
if ($isNew) {
|
||||||
|
$this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-fournisseur.fr');
|
||||||
|
$this->addAddress($ancien, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Fournisseur archive #2 (RG-2.17) ===
|
||||||
|
[$disparu, $isNew] = $this->ensureSupplier($manager, 'Négoce Disparu', ['Grossiste'], isArchived: true);
|
||||||
|
if ($isNew) {
|
||||||
|
$this->addContact($disparu, 'Gérard', 'Blanc', 'Ex-gérant', '05 56 00 00 00', null, 'gerard.blanc@negoce-disparu.fr');
|
||||||
|
$this->addAddress($disparu, 'RENDU', ['Saint-Jean'], '17400', 'Fontenet', '0 impasse Oubliée');
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cree un fournisseur (base normalisee + categories de type FOURNISSEUR)
|
||||||
|
* s'il n'existe pas encore, sinon retourne l'existant. Retourne
|
||||||
|
* [Supplier, isNew] : isNew=false bloque la reconstruction des
|
||||||
|
* sous-collections (idempotence sans doublon).
|
||||||
|
*
|
||||||
|
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
|
||||||
|
*
|
||||||
|
* @return array{0: Supplier, 1: bool}
|
||||||
|
*/
|
||||||
|
private function ensureSupplier(
|
||||||
|
ObjectManager $manager,
|
||||||
|
string $companyName,
|
||||||
|
array $categoryNames,
|
||||||
|
bool $isArchived = false,
|
||||||
|
): array {
|
||||||
|
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
|
||||||
|
|
||||||
|
$existing = $manager->getRepository(Supplier::class)->findOneBy(['companyName' => $normalizedName]);
|
||||||
|
if ($existing instanceof Supplier) {
|
||||||
|
return [$existing, false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$supplier = new Supplier();
|
||||||
|
$supplier->setCompanyName($normalizedName);
|
||||||
|
|
||||||
|
foreach ($categoryNames as $categoryName) {
|
||||||
|
$supplier->addCategory($this->category($manager, $categoryName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isArchived) {
|
||||||
|
$supplier->setIsArchived(true);
|
||||||
|
$supplier->setArchivedAt(new DateTimeImmutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager->persist($supplier);
|
||||||
|
|
||||||
|
return [$supplier, true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un contact normalise au fournisseur (cascade persist via
|
||||||
|
* Supplier.contacts). Au moins firstName OU lastName est toujours fourni
|
||||||
|
* (RG-2.04, chk_supplier_contact_name).
|
||||||
|
*/
|
||||||
|
private function addContact(
|
||||||
|
Supplier $supplier,
|
||||||
|
?string $firstName,
|
||||||
|
?string $lastName,
|
||||||
|
?string $jobTitle,
|
||||||
|
?string $phonePrimary,
|
||||||
|
?string $phoneSecondary,
|
||||||
|
?string $email,
|
||||||
|
int $position = 0,
|
||||||
|
): void {
|
||||||
|
$contact = new SupplierContact();
|
||||||
|
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
|
||||||
|
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
|
||||||
|
$contact->setJobTitle($jobTitle);
|
||||||
|
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
|
||||||
|
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
|
||||||
|
$contact->setEmail($this->normalizer->normalizeEmail($email));
|
||||||
|
$contact->setPosition($position);
|
||||||
|
|
||||||
|
$supplier->addContact($contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute une adresse au fournisseur (cascade persist via Supplier.addresses).
|
||||||
|
* Le type d'adresse est exclusif (PROSPECT | DEPART | RENDU — RG-2.09,
|
||||||
|
* chk_supplier_address_type) ; au moins un site est rattache (RG-2.06) ; les
|
||||||
|
* categories d'adresse sont de type FOURNISSEUR (RG-2.10).
|
||||||
|
*
|
||||||
|
* @param list<string> $siteNames au moins un site (RG-2.06)
|
||||||
|
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
|
||||||
|
*/
|
||||||
|
private function addAddress(
|
||||||
|
Supplier $supplier,
|
||||||
|
string $addressType,
|
||||||
|
array $siteNames,
|
||||||
|
string $postalCode,
|
||||||
|
string $city,
|
||||||
|
string $street,
|
||||||
|
?string $streetComplement = null,
|
||||||
|
?int $bennes = null,
|
||||||
|
bool $triageProvider = false,
|
||||||
|
array $categoryNames = [],
|
||||||
|
int $position = 0,
|
||||||
|
): void {
|
||||||
|
$address = new SupplierAddress();
|
||||||
|
$address->setAddressType($addressType);
|
||||||
|
$address->setPostalCode($postalCode);
|
||||||
|
$address->setCity($city);
|
||||||
|
$address->setStreet($street);
|
||||||
|
$address->setStreetComplement($streetComplement);
|
||||||
|
$address->setBennes($bennes);
|
||||||
|
$address->setTriageProvider($triageProvider);
|
||||||
|
$address->setPosition($position);
|
||||||
|
|
||||||
|
foreach ($siteNames as $siteName) {
|
||||||
|
$address->addSite($this->site($siteName));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($categoryNames as $categoryName) {
|
||||||
|
$address->addCategory($this->category($this->manager, $categoryName));
|
||||||
|
}
|
||||||
|
|
||||||
|
$supplier->addAddress($address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un RIB au fournisseur (cascade persist via Supplier.ribs). IBAN/BIC
|
||||||
|
* valides (Assert\Iban/Bic non rejouee sur persist direct mais donnees
|
||||||
|
* coherentes pour le golden path / les tests).
|
||||||
|
*/
|
||||||
|
private function addRib(Supplier $supplier, string $label, string $bic, string $iban, int $position = 0): void
|
||||||
|
{
|
||||||
|
$rib = new SupplierRib();
|
||||||
|
$rib->setLabel($label);
|
||||||
|
$rib->setBic($bic);
|
||||||
|
$rib->setIban($iban);
|
||||||
|
$rib->setPosition($position);
|
||||||
|
|
||||||
|
$supplier->addRib($rib);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout une categorie par son nom via le contrat Shared CategoryInterface
|
||||||
|
* (resolve_target_entities -> Category), sans importer le module Catalog
|
||||||
|
* (regle n°1). Mise en cache par nom.
|
||||||
|
*/
|
||||||
|
private function category(ObjectManager $manager, string $name): CategoryInterface
|
||||||
|
{
|
||||||
|
if (isset($this->categoryCache[$name])) {
|
||||||
|
return $this->categoryCache[$name];
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-2.10 : on filtre explicitement sur le type FOURNISSEUR. Un lookup par
|
||||||
|
// 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,
|
||||||
|
'deletedAt' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if ($candidate instanceof CategoryInterface
|
||||||
|
&& self::SUPPLIER_CATEGORY_TYPE_CODE === $candidate->getCategoryTypeCode()) {
|
||||||
|
return $this->categoryCache[$name] = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
|
||||||
|
$name,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resout un site par son nom via le contrat Shared SiteProviderInterface,
|
||||||
|
* sans importer le module Sites (regle n°1). Mise en cache par nom.
|
||||||
|
*/
|
||||||
|
private function site(string $name): SiteInterface
|
||||||
|
{
|
||||||
|
if (isset($this->siteCache[$name])) {
|
||||||
|
return $this->siteCache[$name];
|
||||||
|
}
|
||||||
|
|
||||||
|
$site = $this->siteProvider->findByName($name);
|
||||||
|
|
||||||
|
if (!$site instanceof SiteInterface) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Site "%s" introuvable : SitesFixtures doit tourner avant SupplierFixtures.',
|
||||||
|
$name,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->siteCache[$name] = $site;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tvaMode(ObjectManager $manager, string $code): TvaMode
|
||||||
|
{
|
||||||
|
$mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
|
||||||
|
|
||||||
|
if (!$mode instanceof TvaMode) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
|
||||||
|
$code,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay
|
||||||
|
{
|
||||||
|
$delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
|
||||||
|
|
||||||
|
if (!$delay instanceof PaymentDelay) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
|
||||||
|
$code,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paymentType(ObjectManager $manager, string $code): PaymentType
|
||||||
|
{
|
||||||
|
$type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
|
||||||
|
|
||||||
|
if (!$type instanceof PaymentType) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
|
||||||
|
$code,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bank(ObjectManager $manager, string $code): Bank
|
||||||
|
{
|
||||||
|
$bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
|
||||||
|
|
||||||
|
if (!$bank instanceof Bank) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
|
||||||
|
$code,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bank;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,8 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,29 +15,17 @@ 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, 403 sans commercial.suppliers.view,
|
* commercial.suppliers.accounting.view (admin ET user minimal a permission
|
||||||
* 401 anonyme.
|
* explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne),
|
||||||
|
* 403 sans commercial.suppliers.view, 401 anonyme.
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
|
final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
|
||||||
{
|
{
|
||||||
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();
|
||||||
@@ -110,9 +95,13 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
|
|||||||
$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, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co');
|
$this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
|
||||||
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
|
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
|
||||||
$this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co');
|
$principal = $this->addContact($supplier, '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');
|
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
|
||||||
|
|
||||||
@@ -149,8 +138,10 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
|
|||||||
|
|
||||||
$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 du fournisseur (getName()).
|
// Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur
|
||||||
self::assertStringContainsString('test_cli_cat_negociant', $flat);
|
// (getName()). On le derive du helper de base (idempotent) plutot que de
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
@@ -188,6 +179,60 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
|
|||||||
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');
|
||||||
@@ -206,50 +251,6 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
|
|||||||
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.
|
||||||
*
|
*
|
||||||
@@ -284,7 +285,7 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
|
|||||||
/**
|
/**
|
||||||
* 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 array<int, mixed>|null
|
* @return null|array<int, mixed>
|
||||||
*/
|
*/
|
||||||
private function rowFor(string $binary, string $companyName): ?array
|
private function rowFor(string $binary, string $companyName): ?array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ 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,6 +101,24 @@ 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
|
||||||
@@ -175,6 +193,33 @@ 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 {
|
||||||
|
|||||||
@@ -90,6 +90,26 @@ 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();
|
||||||
@@ -97,17 +117,6 @@ 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';
|
||||||
@@ -116,7 +125,22 @@ 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();
|
||||||
|
|||||||
Reference in New Issue
Block a user