Compare commits

...

10 Commits

Author SHA1 Message Date
gitea-actions 43b2251ef1 chore: bump version to v0.1.95
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 21s
2026-06-08 08:50:41 +00:00
matthieu 9cda225bdf Correctifs post-review M2 fournisseurs (P1 + P2/P3 + alignement M1) (#74)
Auto Tag Develop / tag (push) Successful in 8s
Correctifs issus de la review lead du stack M2 fournisseurs (ERP-84→113), répartis en priorités. Base : `develop`. Suite verte : `make test` 577 tests / 2475 assertions, `php-cs-fixer` 0 correction.

## P1 — défauts bloquants
- **ERP-89** — Le message de complétude Information ne fuit plus le nom de champ technique (`(champ "%s")` retiré). Correction miroir appliquée aux deux validators (Supplier + Client), accent uniformisé. Le `propertyPath` est conservé pour le mapping inline front.
- **ERP-112** — La fixture fournisseurs résout désormais la catégorie en filtrant sur le type `FOURNISSEUR` (via `CategoryInterface::getCategoryTypeCode()`), évitant de rattacher une catégorie homonyme d'un autre type (RG-2.10).
- **ERP-113** — Tests d'export complétés : dédup F3 (fournisseur multi-catégories rendu sur une seule ligne) ; gating SIREN prouvé via un utilisateur minimal non-admin portant `suppliers.view` + `suppliers.accounting.view` (nouveau helper `createUserWithPermissions`).

## P2 / P3
- **ERP-86** — `maxMessage` explicite sur `competitors` (Supplier).
- **ERP-92** — Garde `skipIfSitesModuleDisabled()` sur le test POST adresse sans site (évite un faux positif si le module Sites est désactivé).
- **ERP-89 bis** — Nouveau test : Admin authentifié non-Commerciale + Information incomplète → 200 (distinct du cas `user=null`).
- **ERP-85** — `down()` de la migration fournisseurs en `DROP TABLE IF EXISTS`.
- **ERP-87** — Reset de la mémoïsation payload en début de `process()` du SupplierProcessor + documentation du filtre `?archivedOnly` de l'export (parité avec le provider liste).
- **spec-back.md (M2)** — Alignée sur le code (le code fait foi) : security PATCH `manage or accounting.manage`, gating accounting par ajout de groupe (`SupplierReadGroupContextBuilder`), anti-N+1 via `hydrateListCollections` (pas de fetch-join), types de colonnes réels (`IDENTITY` / `TIMESTAMP(0)`).

## Alignement M1 ↔ M2
- **ERP-86/87 (Client)** — Mêmes corrections appliquées aux jumeaux M1 : message `competitors` explicite + reset mémoïsation `ClientProcessor`.

## Décision actée
- **RG-2.10 (catégorie)** : court-circuit conservé (une seule violation sur `categories`). Les violations partageant path + message sont fusionnées côté front ; ERP-101 (toutes les erreurs en un aller-retour) est déjà respecté car le Callback n'interrompt pas la validation des autres champs.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #74
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 08:47:43 +00:00
gitea-actions f031c70393 chore: bump version to v0.1.94
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 19s
2026-06-08 08:04:20 +00:00
matthieu e050a7b910 test(commercial) : SupplierExportControllerTest sur base fournisseurs (catégories FOURNISSEUR, dédup F3) (ERP-113) (#73)
Auto Tag Develop / tag (push) Successful in 7s
Suivi du finding F3 de la review ERP-92. **Test uniquement** — aucune modif de code applicatif (le controller d'export ERP-91 est correct).

### Problème (F3)
`SupplierExportControllerTest` étendait `AbstractCommercialApiTestCase` et redéfinissait un `seedSupplier()` privé appelant `createCategory()` du parent → catégorie de **type CLIENT**, ce qui viole RG-2.10 dans les données de test (latent : l'export ne filtre pas par type de catégorie, mais le contrat de test était faux).

### Changements
- Bascule de base : `extends AbstractSupplierApiTestCase` (helpers `seedSupplier`/`addContact`/`supplierCategory` sur type **FOURNISSEUR**).
- Suppression du `seedSupplier()` privé (type CLIENT) et du `tearDown()` redondant — dédup F3.
- `testExportUsesPrincipalContactColumns` : utilise `addContact()` de la base ; le téléphone secondaire (non porté par ce helper) est posé via le setter sur le contact retourné.
- `testExportPopulatesCategoryAndSiteColumns` : l'assertion de la colonne « Catégories » dérive le libellé de `supplierCategory('NEGOCIANT')->getName()` au lieu de hardcoder le préfixe de nom de test (la base nomme `test_cli_cat_fr_negociant`).
- Imports `Supplier` / `SupplierContact` / `DateTimeImmutable` retirés (inutilisés).

### Vérifications
- `SupplierExportControllerTest` : 9 tests, 48 assertions — vert sous APP_DEBUG=0.
- Suite complète `make test` : 574 tests, 2448 assertions — OK sous APP_DEBUG=0.
- `make php-cs-fixer-allow-risky` : 0 correction.

> MR stackée sur `feature/ERP-112-fixtures-fournisseurs`.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #73
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:49:48 +00:00
matthieu b35deed8fe feat(commercial) : fixtures Doctrine fournisseurs (≈13 suppliers complets + sous-collections) (ERP-112) (#72)
Auto Tag Develop / tag (push) Successful in 6s
## ERP-112 — Fixtures Doctrine fournisseurs (M2)

`SupplierFixtures` (calquée sur `ClientFixtures` / ERP-68) : ~13 fournisseurs de démonstration couvrant les cas pivots du répertoire fournisseurs (M2), chargés par `make db-reset`.

### Contenu
- **13 fournisseurs** (dont **2 archivés** — RG-2.17), `companyName` variés (UPPERCASE serveur), mono et multi-catégories de type FOURNISSEUR (RG-2.10).
- **19 contacts** (1 à 3 par fournisseur, dont un avec téléphone secondaire et un nommé par le seul nom — RG-2.04).
- **15 adresses** multi-types PROSPECT / DEPART / RENDU (RG-2.09) et multi-sites 86/17/82 (RG-2.06), avec `bennes` et `triageProvider`.
- **3 RIB**, compta complète sur une partie (siren, tvaMode, paymentDelay, paymentType).

### Cas pivots
- VIREMENT → banque renseignée (RG-2.07) ; LCR → 1 puis 2 RIB (RG-2.08) ; CHEQUE et NON_SOUMISE sans RIB.
- Onglet Information complet (dont `volumeForecast`, spécifique fournisseur).
- Cohérence gating comptable (un rôle sans `accounting.view` ne voit pas la compta) — support des tests ERP-92 et du golden path front.

### Notes
- **Idempotent** (lookup par companyName normalisé, aligné sur `uq_supplier_company_name_active`) ; rejouable sans doublon même purger désactivé.
- Référentiels comptables **réutilisés de M1** (tva_modes / payment_delays / payment_types / banks) — aucune nouvelle table.
- Données de démonstration **dev uniquement** : early return en env `test` (les tests seedent leurs propres données).

### Vérifications
- `make db-reset` : 13 fournisseurs (2 archivés), 19 contacts, 15 adresses, 3 RIB chargés sans erreur.
- Idempotence `--append` : compteurs inchangés.
- `make php-cs-fixer-allow-risky` : 0 fichier à corriger.
- `make test` : 574 tests OK.

Base : `feature/ERP-92-tests-phpunit-m2` (sommet de la pile M2).
---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #72
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:49:28 +00:00
matthieu 6f9bb68170 test(commercial) : tests PHPUnit M2 fournisseurs (matrice RG + contrat sérialisation + DoD JSON réel) (ERP-92) (#71)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-92 — Tests PHPUnit M2 fournisseurs (#521)

Suite fonctionnelle M2 assertant sur le **corps JSON** (jamais les annotations), jumelle de la suite clients M1.

### Couverture
- **Contrat de sérialisation** (`SupplierSerializationContractTest`) : 4 régressions M1 re-testées — RIB gaté **absent** pour la Commerciale, booléens `triageProvider`/`isArchived` présents, embed `categories[].code/name`, embed `sites[].name/postalCode` (objet, pas IRI) — + enveloppe AP4 (`member`/`totalItems`/`view`, archivés exclus) + suppression du contact inline.
- **Matrice RBAC réelle** (`app:seed-rbac`, pas de mock) : bureau/compta/commerciale/usine 200/403, gating `accounting` par **omission de clé**, mode strict PATCH (RG-2.16).
- **Matrice RG-2.03 → RG-2.17** (création, normalisation RG-2.12, catégorie FOURNISSEUR RG-2.10, unicité RG-2.11, archivage RG-2.14/2.15, RG-2.07/2.08 compta, sous-ressources RG-2.04/2.05/2.06/2.09).
- **Anti N+1 liste** : nombre de requêtes constant entre 2 et 4 fournisseurs. **Audit** Supplier + RIB (`iban`/`bic` dans le diff).

### Fix de contrat (découvert par la DoD)
Les référentiels comptables (`TvaMode`/`PaymentType`/`PaymentDelay`/`Bank`) ne portaient que `client:read:accounting` (M1) → sur un fournisseur ils sortaient en **IRI nu**. Ajout de `supplier:read:accounting` → objet `{id, code, label}` embarqué (additif, zéro impact M1). Sans ce fix, #95/#96 auraient été développés contre un contrat faux.

### Infra
`makefile` : `test-db-setup` recrée l'index partiel `uq_supplier_company_name_active` (droppé par `schema:update` comme celui du client — oubli M2).

### DoD 
§ 4.0.bis : réponses JSON **réelles** (liste + détail admin/commerciale) collées. Front #93→#96 peuvent démarrer.

### Vérifs
- `make test` : **574 tests OK** (suite complète verte)
- `make php-cs-fixer-allow-risky` : 0 correction

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #71
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:49:17 +00:00
matthieu 97459e798f feat(commercial) : export XLSX fournisseurs (ERP-91) (#70)
Auto Tag Develop / tag (push) Successful in 7s
Export XLSX du répertoire fournisseurs (spec-back M2 § 4.6), jumeau de l'export client M1. **Stack : cible `feature/ERP-90-rbac-fournisseurs`** (ERP-84→91 non encore mergés dans develop).

## Périmètre
- **`SupplierExportController`** avec `#[Route(priority: 1)]` (anti-conflit API Platform `{id}`) + `is_granted('commercial.suppliers.view')`.
- Mêmes filtres que la liste (`includeArchived`/`archivedOnly`/`search`/`categoryCode`/`siteId`) via `createListQueryBuilder()` partagé avec le `SupplierProvider` ; non archivés par défaut.
- Colonnes : Nom fournisseur, **Contact principal** (Nom + Prénom du `SupplierContact` de plus petit `position`, ERP-106), Tél principal, Tél secondaire, Email, Catégories (CSV), Sites (CSV), **SIREN omis sans `accounting.view`**, Date de création.
- Fichier `repertoire-fournisseurs-{YYYYMMDD}.xlsx`.
- **`hydrateContacts()`** ajouté au repository : chargement batché des contacts en une requête `IN` (anti-N+1). Méthode dédiée à l'export — la liste paginée n'embarque pas les contacts, on ne lui impose pas ce coût.

## Correctif hors-périmètre (signalé)
Tables `supplier*` ajoutées à `ColumnCommentsCatalog` : leurs `COMMENT ON COLUMN` (posés par la migration ERP-85) étaient dropés par le `schema:update --force` du `test-db-setup` et non restaurés (catalogue = source rejouée par `app:apply-column-comments`), cassant `ColumnsHaveSqlCommentTest` dès un re-setup de la base de test. Trou laissé par ERP-85/86, vert tant que personne ne re-setup la base.

## Tests
- `SupplierExportControllerTest` (9 cas) : réponse/filename, exclusion archives, filtre search, contact principal, colonnes catégories/sites, gating SIREN avec/sans `accounting.view`, 403, 401.
- `make test` : 508 tests / 2035 assertions, 0 échec. `php-cs-fixer` clean.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #70
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:48:59 +00:00
matthieu 58cbfe4437 feat(commercial) : RBAC fournisseurs (permissions + 3 sources + seed par rôle + sécurité référentiels) (ERP-90) (#69)
Auto Tag Develop / tag (push) Successful in 6s
ERP-90 — Étape 3/7 M2 fournisseurs (stack sur ERP-89).

## Périmètre
- **5 permissions** `commercial.suppliers.*` (view / manage / accounting.view / accounting.manage / archive) dans `CommercialModule::permissions()`.
- **3 sources RBAC synchronisées** (règle ABSOLUE n°8, même commit) :
  - `config/sidebar.php` — item `/suppliers` + `commercial.suppliers.view`
  - `frontend/tests/e2e/_fixtures/personas.ts` — persona `user-full`
  - `SeedE2ECommand.php` — miroir back
- **Assignation par rôle** dans `RbacSeeder::MATRIX` (§ 2.9, idempotent) :
  - Bureau : view + manage
  - Compta : view + accounting.view + accounting.manage
  - Commerciale : view + manage
  - Usine : aucune
  - archive : Admin seul
- **Sécurité des référentiels** (`tva_modes` / `payment_delays` / `payment_types` / `banks`) élargie : `view client OR view fournisseur` (§ 4.7).

## Vérifications
- `app:sync-permissions` (+5) et `app:seed-rbac --with-demo-users` (idempotent) OK
- `make test` : 499 tests verts
- `make php-cs-fixer-allow-risky` : 0 fix
- `make nuxt-test` : 234 tests verts

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #69
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:48:43 +00:00
gitea-actions 54091be60e chore: bump version to v0.1.89
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-06-08 07:36:48 +00:00
matthieu e265a008bc feat(commercial) : validators M2 fournisseurs (RG-2.03/2.07/2.08/2.10) (ERP-89) (#68)
Auto Tag Develop / tag (push) Successful in 7s
Étape 4/7 du M2 fournisseurs — stackée sur #67 (ERP-88).

## Périmètre (RG-2.03 / 2.07 / 2.08 / 2.10)

Décision figée ERP-89 : les RG inter-champs passent par `Assert\Callback` + `->atPath()` sur l'entité Supplier (et non dans le Processor), pour que chaque 422 porte un `propertyPath` consommable par `extractApiViolations` (mapping inline, pas un toast — ERP-101).

- **RG-2.10** — `Supplier::validateCategoryType()` → `atPath('categories')` : catégories de type FOURNISSEUR uniquement sur `supplier.categories` (miroir d'ERP-88 côté adresse).
- **RG-2.07** — `Supplier::validatePaymentTypeConsistency()` → `atPath('bank')` : VIREMENT impose une banque.
- **RG-2.08** — même Callback → `atPath('ribs')` : LCR impose ≥ 1 RIB (le 409 sur DELETE du dernier RIB en LCR reste porté par ERP-88).
- **RG-2.03** — `SupplierInformationCompletenessValidator` (8 champs Information dont `volumeForecast`), invoqué par le `SupplierProcessor` après détection back du rôle Commerciale via `BusinessRoleAwareInterface`. Le Processor ne porte que rôle / mode strict / gating.

## Tests (16, verts)

- `SupplierValidationTest` — Callbacks RG-2.07/2.08/2.10, assertion par propertyPath.
- `SupplierInformationCompletenessValidatorTest` — complétude / champs manquants / zéros valides.
- `SupplierProcessorTest` — détection rôle RG-2.03 (POST + PATCH main-only + non-Commerciale).

`make test` : 499 tests OK. `php-cs-fixer` : clean.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #68
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-08 07:33:38 +00:00
42 changed files with 4327 additions and 126 deletions
+12
View File
@@ -0,0 +1,12 @@
doctrine:
dbal:
connections:
# Force le profiling DBAL en environnement de test independamment de
# APP_DEBUG. Sans cela, la CI tourne en APP_DEBUG=0 (prod-like) et le
# service `doctrine.debug_data_holder` n'est pas enregistre : le test
# anti-N+1 (SupplierListTest::testListQueryCountDoesNotGrowWithRowCount)
# qui compte les requetes via ce holder echoue alors en CI alors qu'il
# passe en local (APP_DEBUG=1). Activer le profiling ici garde le test
# actif precisement la ou il compte (CI), sans impacter la prod.
default:
profiling: true
+5 -4
View File
@@ -53,10 +53,11 @@ return [
'permission' => 'commercial.clients.view',
],
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
'permission' => 'commercial.suppliers.view',
],
],
],
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.87'
app.version: '0.1.95'
+87 -60
View File
@@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `
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).
- **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é).
- **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).
### 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é.
@@ -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).
> **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
-- =====================================================================
-- 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
// champ dérivé aplati). Maillon (c) : category:read + site:read dans
// le contexte pour exposer Category(code/name) + Site(name/postalCode).
// ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
// pour éviter le N+1 sur la liste (cf. § 2.12).
// ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le
// 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' => [
'supplier:read',
'category:read',
@@ -442,13 +448,14 @@ use Symfony\Component\Validator\Constraints as Assert;
normalizationContext: ['groups' => [
'supplier:read',
'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
'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
'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,
),
new Post(
@@ -458,10 +465,13 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: SupplierProcessor::class,
),
new Patch(
security: "is_granted('commercial.suppliers.manage')",
// Le SupplierProcessor inspecte les groupes envoyés pour autoriser
// onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
// exige is_granted('commercial.suppliers.accounting.manage') ;
// Security élargie : `manage` OU `accounting.manage` — le rôle Compta
// n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un
// fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite
// 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').
normalizationContext: ['groups' => ['supplier:read', 'default:read']],
denormalizationContext: ['groups' => [
@@ -711,91 +721,108 @@ Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamable
| Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` |
| `ribs[]` (label/bic/iban) | `ribs``supplier:read:accounting` | ✅ (gated) | — |
### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle)
### 4.0.bis Réponses JSON de référence (DoD — RÉELLES, capturées ERP-92)
> **Definition of Done de cette spec back (RETEX M1 §3)** : avant d'écrire les tickets front, créer un fournisseur de test et **coller ici les réponses RÉELLES** de `GET /api/suppliers` et `GET /api/suppliers/{id}`. Les containers n'étant pas lancés au moment de la rédaction, le JSON ci-dessous est le **contrat CIBLE** — à valider/remplacer par la réponse réelle (`make start` puis `curl`). Toute donnée affichée par le front DOIT apparaître dans ce JSON.
> **Definition of Done CLÔTURÉE (ERP-92, 2026-06-05)** : les réponses ci-dessous sont **réelles**, capturées sur l'API de test via PHPUnit (`SupplierSerializationContractTest`, fournisseur complet seedé). Les `id`/timestamps sont illustratifs (run de test). Toute donnée affichée par le front DOIT apparaître dans ce JSON. Front #93→#96 peuvent démarrer.
>
> **2 constats validés à la capture** (cf. § 4.0.ter) :
> 1. 🔧 **Fix ERP-92** : les réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) sortaient en **IRI nu** (les entités partagées ne portaient que `client:read:accounting`, pas `supplier:read:accounting`). Corrigé → objet `{id, code, label}` embarqué (le front consultation/édition affiche le libellé sans fetch).
> 2. ️ **Liste « riche »** : le groupe `supplier:read` étant partagé liste+détail, la **collection embarque tout le bloc Information** (et, pour un user `accounting.view`, les scalaires compta + `ribs[]`). Comportement identique au M1 (groupe `client:read` partagé) — la datatable n'affiche que Nom/Catégories/Site(s)/MAJ, mais le payload est complet. Le gating `accounting` reste effectif (Commerciale ne voit ni compta ni `ribs` en liste comme en détail).
> **Forme d'enveloppe confirmée sur le M1 réel** (API Platform 4.2) : JSON-LD **sans préfixe `hydra:`** → clés `member` / `totalItems` / `view`, avec `@type: "Collection"` et `view.@type: "PartialCollectionView"`. `Content-Type: application/ld+json; charset=utf-8`. Pagination défaut 10 confirmée. Login réel = `POST /api/login_check` (nginx réécrit vers `/login_check`), réponse `204` + cookie HttpOnly `BEARER`.
`GET /api/suppliers` (liste, ADMIN) :
`GET /api/suppliers?search=…` (liste, ADMIN — un membre) :
```json
{
"@context": "/api/contexts/Supplier",
"@id": "/api/suppliers",
"@type": "Collection",
"totalItems": 13,
"totalItems": 1,
"member": [
{
"@id": "/api/suppliers/1",
"@id": "/api/suppliers/85",
"@type": "Supplier",
"id": 1,
"companyName": "RECYCLA SAS",
"id": 85,
"companyName": "DOD59393F 862875",
"categories": [
{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT",
"categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"},
"createdAt": "…", "updatedAt": "…"}
],
"description": "Fournisseur de test complet.",
"competitors": "Concurrent A, Concurrent B",
"foundedAt": "2008-04-01T00:00:00+02:00",
"employeesCount": 42,
"revenueAmount": "1500000.00",
"directorName": "Jean Dupont",
"profitAmount": "120000.00",
"volumeForecast": 8000,
"siren": "123456789",
"accountNumber": "F0001",
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00123456789",
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
"ribs": [
{"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "…", "updatedAt": "…"}
],
"createdAt": "…", "updatedAt": "…",
"sites": [
{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
{"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#"}
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "fullAddress": "Z i\n17400 Fontenet"}
],
"updatedAt": "2026-02-17T09:30:00+00:00",
"isArchived": false
}
],
"view": {
"@id": "/api/suppliers?page=1",
"@type": "PartialCollectionView",
"first": "/api/suppliers?page=1",
"last": "/api/suppliers?page=2",
"next": "/api/suppliers?page=2"
}
"view": {"@id": "/api/suppliers?search=…", "@type": "PartialCollectionView"}
}
```
> Les fournisseurs archivés sont **exclus** du `totalItems` (sur le M1, 14 clients en base → `totalItems: 13` car 1 archivé filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository obligatoires (anti N+1).
> Les fournisseurs archivés sont **exclus** du `totalItems` (RG-2.17 — filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository (anti N+1) **vérifiés par test** (`SupplierListTest::testListQueryCountDoesNotGrowWithRowCount` : nombre de requêtes constant entre 2 et 4 fournisseurs). ⚠️ Le membre embarque aussi l'**Information complète** et — pour un user `accounting.view` (ici admin) — les **scalaires compta + `ribs[]`** (groupe `supplier:read` partagé liste/détail). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre.
`GET /api/suppliers/1` (détail — user avec `accounting.view`) :
`GET /api/suppliers/85` (détail — user avec `accounting.view`) :
```json
{
"@id": "/api/suppliers/1",
"@context": "/api/contexts/Supplier",
"@id": "/api/suppliers/85",
"@type": "Supplier",
"id": 1,
"companyName": "RECYCLA SAS",
"id": 85,
"companyName": "DOD59393F 862875",
"categories": [
{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT",
"categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"}}
],
"description": "…", "competitors": "…", "foundedAt": "2008-04-01",
"employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…",
"profitAmount": "120000.00", "volumeForecast": 8000,
"description": "Fournisseur de test complet.", "competitors": "Concurrent A, Concurrent B",
"foundedAt": "2008-04-01T00:00:00+02:00", "employeesCount": 42, "revenueAmount": "1500000.00",
"directorName": "Jean Dupont", "profitAmount": "120000.00", "volumeForecast": 8000,
"siren": "123456789", "accountNumber": "F0001",
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00123456789",
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
"contacts": [
{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin",
"jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null,
"email": "marie.martin@recycla.fr"}
{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin",
"jobTitle": "Responsable achats", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
],
"addresses": [
{"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART",
"country": "France", "postalCode": "86000", "city": "Poitiers",
"street": "12 rue des Acacias", "streetComplement": null,
{"@id": "/api/supplier_addresses/33", "@type": "SupplierAddress", "id": 33, "addressType": "DEPART",
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"bennes": 3, "triageProvider": true,
"sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
"categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}],
"contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]}
"sites": [
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
{"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00"}
],
"contacts": [{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin"}],
"categories": [{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT"}]}
],
"siren": "123456789", "accountNumber": "F0001",
"tvaMode": {"@id": "/api/tva_modes/1", "id": 1, "label": "France (ventes)"},
"nTva": "FR00123456789",
"paymentDelay": {"@id": "/api/payment_delays/2", "id": 2, "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/2", "id": 2, "code": "LCR", "label": "LCR"},
"bank": null,
"ribs": [
{"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal",
"bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"}
{"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
],
"isArchived": false, "archivedAt": null,
"updatedAt": "2026-02-17T09:30:00+00:00"
"isArchived": false
}
```
> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées car le Provider retire le groupe). Le gating par **omission de clé** est confirmé confortable côté front. Le blame `updatedBy` est sérialisé en **IRI** (`"/api/me"` quand c'est l'user courant) — en tenir compte côté front.
> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées : le `SupplierReadGroupContextBuilder` n'ajoute pas le groupe). Gating par **omission de clé** confirmé sur le JSON réel (`SupplierSerializationContractTest::testRibsAbsentForCommercialeWithoutAccountingView` + `testAccountingScalarsGatedByOmission`). `bennes`/`triageProvider`/`addressType`/`addresses[].contacts` restent visibles (onglet Adresse non gaté). NB : ici `bank` est absent (paymentType=LCR sans banque) ; avec un VIREMENT, `bank` est embarqué `{id, code, label}` (fix ERP-92).
### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE)
@@ -1046,7 +1073,7 @@ Le M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour le M2, p
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only**
- [ ] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)*
- [x] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — capturées via PHPUnit (ERP-92, 2026-06-05) ; fix réfs compta IRI→{id,label} inclus
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16)
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit, routes à plat : rappelés
- [x] Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, `usePaginatedList`, blocs, archive, normalisation)
+9
View File
@@ -75,6 +75,15 @@ export const personas: Record<PersonaKey, Persona> = {
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
'commercial.clients.archive',
// Commercial — Repertoire fournisseurs (M2, ERP-90). Meme logique que
// les clients : mappe sur le persona "tout", pas de nouveau persona
// (regle ABSOLUE n°7). commercial.suppliers.view n'ajoute pas de lien
// dans la section Administration, donc expectedAdminLinks reste inchange.
'commercial.suppliers.view',
'commercial.suppliers.manage',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
+1
View File
@@ -229,6 +229,7 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
+8 -8
View File
@@ -82,14 +82,14 @@ final class Version20260605130000 extends AbstractMigration
// Ordre inverse des dependances FK : jointures et sous-collections
// d'abord, puis supplier. Les referentiels comptables et le
// CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs).
$this->addSql('DROP TABLE supplier_address_category');
$this->addSql('DROP TABLE supplier_address_contact');
$this->addSql('DROP TABLE supplier_address_site');
$this->addSql('DROP TABLE supplier_rib');
$this->addSql('DROP TABLE supplier_address');
$this->addSql('DROP TABLE supplier_contact');
$this->addSql('DROP TABLE supplier_category');
$this->addSql('DROP TABLE supplier');
$this->addSql('DROP TABLE IF EXISTS supplier_address_category');
$this->addSql('DROP TABLE IF EXISTS supplier_address_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_address_site');
$this->addSql('DROP TABLE IF EXISTS supplier_rib');
$this->addSql('DROP TABLE IF EXISTS supplier_address');
$this->addSql('DROP TABLE IF EXISTS supplier_contact');
$this->addSql('DROP TABLE IF EXISTS supplier_category');
$this->addSql('DROP TABLE IF EXISTS supplier');
}
// =================================================================
@@ -43,7 +43,10 @@ final class ClientInformationCompletenessValidator
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$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,
[],
$client,
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Application\Validator;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Domain\Entity\Supplier;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Validator metier RG-2.03 (jumeau du ClientInformationCompletenessValidator M1) :
* pour un utilisateur portant le role metier Commerciale, TOUS les champs de
* l'onglet Information sont obligatoires sur POST comme sur tout PATCH,
* independamment des champs reellement envoyes.
*
* Invoque par le SupplierProcessor des que l'utilisateur courant porte le role
* Commerciale (detection du role cote back). Pour les autres roles, ces champs
* restent optionnels — le validator n'est pas appele.
*
* NEW vs Client : ajoute le champ `volumeForecast` (volume previsionnel),
* specifique fournisseur.
*
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, chaque
* violation portant son propertyPath (consommable par extractApiViolations,
* ERP-101), par coherence avec les violations Symfony rendues par API Platform.
*/
final class SupplierInformationCompletenessValidator
{
public function validate(Supplier $supplier): void
{
// Map champ -> valeur courante de l'onglet Information.
$fields = [
'description' => $supplier->getDescription(),
'competitors' => $supplier->getCompetitors(),
'foundedAt' => $supplier->getFoundedAt(),
'employeesCount' => $supplier->getEmployeesCount(),
'revenueAmount' => $supplier->getRevenueAmount(),
'directorName' => $supplier->getDirectorName(),
'profitAmount' => $supplier->getProfitAmount(),
'volumeForecast' => $supplier->getVolumeForecast(),
];
$violations = new ConstraintViolationList();
foreach ($fields as $property => $value) {
if ($this->isMissing($value)) {
$violations->add(new ConstraintViolation(
// 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,
[],
$supplier,
$property,
$value,
));
}
}
if (count($violations) > 0) {
throw new ValidationException($violations);
}
}
/**
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
* zeros numeriques (employeesCount = 0, profitAmount = "0.00",
* volumeForecast = 0) sont des valeurs valides : on ne les considere pas
* manquants.
*/
private function isMissing(mixed $value): bool
{
if (null === $value) {
return true;
}
return is_string($value) && '' === trim($value);
}
}
@@ -39,6 +39,11 @@ final class CommercialModule
['code' => 'commercial.clients.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un client'],
['code' => 'commercial.clients.archive', 'label' => 'Archiver / restaurer un client'],
['code' => 'commercial.suppliers.view', 'label' => 'Voir les fournisseurs'],
['code' => 'commercial.suppliers.manage', 'label' => 'Créer / modifier les fournisseurs (hors onglet Comptabilité)'],
['code' => 'commercial.suppliers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un fournisseur'],
['code' => 'commercial.suppliers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un fournisseur'],
['code' => 'commercial.suppliers.archive', 'label' => 'Archiver / restaurer un fournisseur'],
];
}
}
+8 -7
View File
@@ -20,12 +20,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client.
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['bank:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
@@ -33,11 +34,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['bank:read']],
),
],
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
#[ORM\Table(name: 'bank')]
@@ -47,15 +48,15 @@ class Bank
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bank:read', 'client:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['bank:read', 'client:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['bank:read', 'client:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -188,7 +188,7 @@ class Client implements TimestampableInterface, BlamableInterface
private ?string $description = null;
#[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'])]
private ?string $competitors = null;
@@ -20,12 +20,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client.
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['payment_delay:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
@@ -33,11 +34,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['payment_delay:read']],
),
],
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
#[ORM\Table(name: 'payment_delay')]
@@ -47,15 +48,15 @@ class PaymentDelay
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_delay:read', 'client:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_delay:read', 'client:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_delay:read', 'client:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -23,12 +23,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client.
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['payment_type:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC.
order: ['position' => 'ASC', 'label' => 'ASC'],
@@ -36,11 +37,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['payment_type:read']],
),
],
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
#[ORM\Table(name: 'payment_type')]
@@ -50,15 +51,15 @@ class PaymentType
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_type:read', 'client:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_type:read', 'client:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_type:read', 'client:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -25,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Fournisseur (M2 Commercial) — entite racine du repertoire fournisseurs,
@@ -133,6 +134,20 @@ class Supplier implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
/**
* RG-2.10 : seules les categories de ce type sont autorisees sur le
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
* S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du
* module Catalog — regle ABSOLUE n°1).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
/** RG-2.07 : code du type de reglement imposant une banque. */
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
/** RG-2.08 : code du type de reglement imposant au moins un RIB. */
private const string PAYMENT_TYPE_LCR = 'LCR';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -166,7 +181,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
private ?string $description = null;
#[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'])]
private ?string $competitors = null;
@@ -280,6 +295,65 @@ class Supplier implements TimestampableInterface, BlamableInterface
$this->ribs = new ArrayCollection();
}
/**
* RG-2.10 : toute categorie posee sur le fournisseur doit etre de type
* FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
* POST (categories ∈ supplier:write:main) comme sur PATCH.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
->atPath('categories')
->addViolation()
;
return;
}
}
}
/**
* RG-2.07 / RG-2.08 : coherence du type de reglement comptable. Decision
* figee ERP-89 : ces RG inter-champs passent par une contrainte d'entite
* (Assert\Callback + ->atPath()) et NON par le SupplierProcessor, afin que
* chaque 422 porte un propertyPath exploitable par extractApiViolations
* (mapping inline sous le champ, pas un toast — convention ERP-101).
* - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs`
* (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88).
*
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
* n'expose que supplier:write:main), la contrainte ne mord en pratique que
* sur le PATCH de l'onglet Comptabilite.
*/
#[Assert\Callback]
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
{
$paymentCode = $this->paymentType?->getCode();
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
->atPath('bank')
->addViolation()
;
}
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('ribs')
->addViolation()
;
}
}
public function getId(): ?int
{
return $this->id;
@@ -17,18 +17,20 @@ use Symfony\Component\Serializer\Attribute\Groups;
* re-seede en dev/test par CommercialReferentialFixtures.
*
* Lecture seule au M1 (HP-M2-2) : seules GetCollection et Get sont exposees
* (ERP-56), sous la permission commercial.clients.view ; aucune ecriture
* (ERP-56), sous la permission commercial.clients.view (elargie aux roles
* fournisseurs au M2 via commercial.suppliers.view, ERP-90) ; aucune ecriture
* declaree -> POST/PATCH/DELETE renvoient 405.
*
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
* d'un Client (onglet Comptabilite) au lieu d'un IRI.
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['tva_mode:read']],
// Tri par defaut spec M1 § 4.7 : position ASC puis label ASC
// (ordre des selecteurs comptables) — provider Doctrine par defaut.
@@ -39,11 +41,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['tva_mode:read']],
),
],
security: "is_granted('commercial.clients.view')",
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
#[ORM\Table(name: 'tva_mode')]
@@ -53,15 +55,15 @@ class TvaMode
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tva_mode:read', 'client:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['tva_mode:read', 'client:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['tva_mode:read', 'client:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -65,4 +65,16 @@ interface SupplierRepositoryInterface
* @param list<Supplier> $suppliers
*/
public function hydrateListCollections(array $suppliers): void;
/**
* Hydrate en lot la collection `contacts` sur un jeu de fournisseurs DEJA
* charges (memes instances via l'identity map). Reservee a l'export XLSX
* (§ 4.6) qui a besoin du contact principal : la LISTE paginee n'embarque
* pas les contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe
* supplementaire dans {@see self::hydrateListCollections()} — on n'impose pas
* le cout du chargement des contacts au chemin liste.
*
* @param list<Supplier> $suppliers
*/
public function hydrateContacts(array $suppliers): void;
}
@@ -121,6 +121,12 @@ final class ClientProcessor implements ProcessorInterface
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();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -7,7 +7,10 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
@@ -40,14 +43,19 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
* collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
* restauration).
*
* Hors perimetre ERP-87 (ticket #5 « Validators ») : RG-2.03 (completude
* Information pour la Commerciale), RG-2.07 (Virement -> banque), RG-2.08 (LCR ->
* RIB), RG-2.10 (categorie de type FOURNISSEUR). Ces regles metier seront
* branchees ici via des validators dedies au ticket suivant.
* Validators metier (ERP-89). Decision figee : ce processor ne porte QUE
* RG-2.03 (completude Information exigee pour le role Commerciale — detection du
* role cote back, non exprimable en contrainte d'entite). Les RG inter-champs
* RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie
* de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur
* l'entite Supplier (jouees par API Platform AVANT ce processor), pour que
* chaque 422 porte un propertyPath consommable par extractApiViolations
* (mapping inline, pas un toast — convention ERP-101).
*
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories...)
* est jouee par API Platform AVANT ce processor ; on n'y traite donc que les
* regles non exprimables en simples contraintes d'attribut.
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories,
* les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce
* processor ; on n'y traite donc que les regles non exprimables en simples
* contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant).
*
* @implements ProcessorInterface<Supplier, Supplier>
*/
@@ -94,6 +102,7 @@ final class SupplierProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly SupplierFieldNormalizer $normalizer,
private readonly SupplierInformationCompletenessValidator $informationValidator,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
@@ -105,6 +114,12 @@ final class SupplierProcessor implements ProcessorInterface
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();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
@@ -117,6 +132,8 @@ final class SupplierProcessor implements ProcessorInterface
// normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
$this->validateInformationCompleteness($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
@@ -244,6 +261,38 @@ final class SupplierProcessor implements ProcessorInterface
}
}
/**
* RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les
* champs de l'onglet Information sont obligatoires sur POST comme sur TOUT
* PATCH — independamment des champs reellement envoyes. Garantit qu'un
* fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet
* Information incomplet. Pour les autres roles, ces champs restent optionnels.
*
* Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que
* supplier:write:main, une Commerciale obtient 422 sur tout POST tant que
* l'Information n'est pas complete -> la completude se fait via les PATCH
* supplier:write:information.
*/
private function validateInformationCompleteness(Supplier $data): void
{
if ($this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data);
}
}
/**
* Detection du role metier Commerciale cote back (jamais front), via le
* contrat BusinessRoleAwareInterface (pas d'import de User — regle ABSOLUE
* n°1). Identique au ClientProcessor (M1).
*/
private function currentUserIsCommerciale(): bool
{
$user = $this->security->getUser();
return $user instanceof BusinessRoleAwareInterface
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
}
/**
* Champs « metier » (onglets principal + Information, hors comptabilite et
* archivage) dont la valeur courante differe de l'etat persiste. Memes
@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Controller;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Commercial\Domain\Repository\SupplierRepositoryInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du repertoire fournisseurs (M2, spec-back § 4.6). Jumeau du
* {@see ClientExportController} (M1).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est
* OBLIGATOIRE sur la route : sans cela API Platform capterait
* `/api/suppliers/export.xlsx` comme l'item `GET /api/suppliers/{id}.{_format}`
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des fournisseurs (memes filtres que
* `GET /api/suppliers`, via {@see SupplierRepositoryInterface::createListQueryBuilder()})
* et mapping metier des colonnes.
*
* Colonnes de contact : depuis la suppression du contact inline (ERP-106), elles
* sont alimentees par le CONTACT PRINCIPAL du fournisseur — le SupplierContact de
* plus petit `position` (decision D2, spec § 4.6).
*
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
* `commercial.suppliers.accounting.view` (gating identique a la lecture).
*/
#[AsController]
final class SupplierExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineSupplierRepository')]
private readonly SupplierRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly Security $security,
) {}
#[Route('/api/suppliers/export.xlsx', name: 'commercial_suppliers_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('commercial.suppliers.view')]
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'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null;
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
// ne pas lever d'exception sur une valeur scalaire.
$query = $request->query->all();
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
$siteIds = $this->readIntList($query['siteId'] ?? []);
/** @var list<Supplier> $suppliers */
$suppliers = $this->repository
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
->getQuery()
->getResult()
;
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
// selection ne fetch-join pas les to-many. On remplit categories + sites en
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
// contact principal) — chacune en requetes IN bornees, anti N+1.
$this->repository->hydrateListCollections($suppliers);
$this->repository->hydrateContacts($suppliers);
$withSiren = $this->security->isGranted('commercial.suppliers.accounting.view');
$binary = $this->exporter->export(
'Répertoire fournisseurs',
$this->buildHeaders($withSiren),
$this->buildRows($suppliers, $withSiren),
);
return $this->buildResponse($binary);
}
/**
* Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
* uniquement si l'utilisateur a accounting.view.
*
* @return list<string>
*/
private function buildHeaders(bool $withSiren): array
{
$headers = [
'Nom fournisseur',
'Contact principal',
'Téléphone principal',
'Téléphone secondaire',
'Email',
'Catégories',
'Sites',
];
if ($withSiren) {
$headers[] = 'SIREN';
}
$headers[] = 'Date de création';
return $headers;
}
/**
* @param list<Supplier> $suppliers
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $suppliers, bool $withSiren): iterable
{
foreach ($suppliers as $supplier) {
$contact = $this->principalContact($supplier);
$row = [
$supplier->getCompanyName(),
null !== $contact ? $this->formatContactName($contact) : '',
$contact?->getPhonePrimary() ?? '',
$contact?->getPhoneSecondary() ?? '',
$contact?->getEmail() ?? '',
$this->formatCategories($supplier),
$this->formatSites($supplier),
];
if ($withSiren) {
$row[] = $supplier->getSiren();
}
$row[] = $supplier->getCreatedAt()?->format('d/m/Y');
yield $row;
}
}
/**
* Contact principal du fournisseur : le SupplierContact de plus petit
* `position` (decision D2, spec § 4.6). Null si le fournisseur n'a aucun
* contact (les colonnes contact restent vides).
*/
private function principalContact(Supplier $supplier): ?SupplierContact
{
$contacts = $supplier->getContacts()->toArray();
if ([] === $contacts) {
return null;
}
usort(
$contacts,
static fn (SupplierContact $a, SupplierContact $b): int => $a->getPosition() <=> $b->getPosition(),
);
return $contacts[0];
}
/**
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
* sont optionnelles (RG-2.04 : au moins l'une des deux), d'ou le trim final.
*/
private function formatContactName(SupplierContact $contact): string
{
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
}
/**
* Libelles des categories du fournisseur, dedupliques, tries, joints par
* virgule.
*/
private function formatCategories(Supplier $supplier): string
{
$names = [];
foreach ($supplier->getCategories() as $category) {
// @var CategoryInterface $category
$name = $category->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Le fournisseur ne porte pas de sites en propre : ils sont rattaches aux
* adresses (RG-2.06). La colonne « Sites » agrege donc l'union distincte des
* sites de toutes les adresses du fournisseur.
*/
private function formatSites(Supplier $supplier): string
{
$names = [];
foreach ($supplier->getAddresses() as $address) {
foreach ($address->getSites() as $site) {
// @var SiteInterface $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
}
return $this->joinSorted($names);
}
/**
* @param array<string, true> $names ensemble de libelles (cles)
*/
private function joinSorted(array $names): string
{
$list = array_keys($names);
sort($list);
return implode(', ', $list);
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('repertoire-fournisseurs-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
* Aligne sur SupplierProvider pour un comportement identique a la liste.
*/
private function readBool(mixed $raw): bool
{
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines (valeur unique ou liste).
* Aligne sur SupplierProvider pour un comportement identique a la liste.
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
* ou liste). Aligne sur SupplierProvider.
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -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;
}
}
@@ -102,6 +102,31 @@ class DoctrineSupplierRepository extends ServiceEntityRepository implements Supp
;
}
public function hydrateContacts(array $suppliers): void
{
$ids = [];
foreach ($suppliers as $supplier) {
$id = $supplier->getId();
if (null !== $id) {
$ids[] = $id;
}
}
if ([] === $ids) {
return;
}
// Une seule requete IN bornee : remplit la collection `contacts` des MEMES
// instances Supplier (identity map). Tri par position pour que le « contact
// principal » (plus petit position) soit deterministe a l'export (§ 4.6).
$this->createQueryBuilder('s')
->leftJoin('s.contacts', 'sc')->addSelect('sc')
->where('s.id IN (:ids)')->setParameter('ids', $ids)
->orderBy('sc.position', 'ASC')
->getQuery()
->getResult()
;
}
/**
* Recherche fuzzy insensible a la casse sur companyName ET sur les contacts
* lies (firstName / lastName / email) — decision D1, refonte-contact (§ 4.1).
@@ -51,8 +51,9 @@ final class RbacSeeder
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
* bypass tout via isAdmin ; `commercial.clients.archive` n'est attache a
* aucun role metier — admin seul).
* bypass tout via isAdmin ; `commercial.clients.archive` et
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
* admin seul).
*
* @var array<string, array{label: string, permissions: list<string>}>
*/
@@ -62,6 +63,9 @@ final class RbacSeeder
'permissions' => [
'commercial.clients.view',
'commercial.clients.manage',
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -73,6 +77,11 @@ final class RbacSeeder
'commercial.clients.view',
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
// Fournisseurs (M2 § 2.9, ERP-90) : view + onglet Comptabilite uniquement
// (pas de manage global -> ne peut pas creer un fournisseur).
'commercial.suppliers.view',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -83,6 +92,10 @@ final class RbacSeeder
'permissions' => [
'commercial.clients.view',
'commercial.clients.manage',
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage, sans accounting
// (onglet Comptabilite masque/filtre pour la Commerciale).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -195,6 +195,14 @@ final class SeedE2ECommand extends Command
'commercial.clients.accounting.view',
'commercial.clients.accounting.manage',
'commercial.clients.archive',
// Commercial — Repertoire fournisseurs (M2, ERP-90). Meme
// logique que les clients : mappe sur le persona "tout".
// Miroir de frontend/tests/e2e/_fixtures/personas.ts.
'commercial.suppliers.view',
'commercial.suppliers.manage',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
],
],
[
@@ -256,6 +256,95 @@ final class ColumnCommentsCatalog
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).',
] + self::timestampableBlamableComments(),
// === M2 Commercial (ERP-85) — miroir des COMMENT de la migration
// Version20260605130000 pour le chemin schema:update (dev/test). ===
'supplier' => [
'_table' => 'Repertoire fournisseurs (M2 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M3).',
'id' => 'Identifiant interne auto-incremente.',
'company_name' => 'Raison sociale du fournisseur (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_supplier_company_name_active, § 2.6).',
'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-2.03), optionnel sinon.',
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-2.03).',
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-2.03).',
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-2.03).',
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).',
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-2.03).',
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-2.03).',
'volume_forecast' => 'Onglet Information : volume previsionnel (entier >= 0) — specifique fournisseur. Obligatoire role Commerciale (RG-2.03).',
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (§ 2.6).',
'account_number' => 'Onglet Comptabilite : numero de compte comptable du fournisseur.',
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.',
'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.',
'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque si VIREMENT) et RG-2.08 (RIB).',
'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-2.07), null sinon.',
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.suppliers.archive.',
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
'deleted_at' => 'Horodatage du soft-delete technique (HP M3) — non expose par l API au M2. Null = ligne active.',
] + self::timestampableBlamableComments(),
'supplier_category' => [
'_table' => 'Jointure M2M supplier <-> category (Catalog) — categories de type FOURNISSEUR du fournisseur, au moins une obligatoire (RG-2.10).',
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur porteur de la categorie.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie de type FOURNISSEUR rattachee au fournisseur (RG-2.10).',
],
'supplier_contact' => [
'_table' => 'Contacts d un fournisseur (1:n) — au moins firstName OU lastName par contact (RG-2.04).',
'id' => 'Identifiant interne auto-incremente.',
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-2.04, chk_supplier_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
'email' => 'Email du contact (lowercase serveur).',
'position' => 'Ordre d affichage du contact dans la liste du fournisseur (croissant).',
] + self::timestampableBlamableComments(),
'supplier_address' => [
'_table' => 'Adresses d un fournisseur (1:n) — type PROSPECT/DEPART/RENDU exclusif (RG-2.09), >= 1 site rattache (RG-2.06).',
'id' => 'Identifiant interne auto-incremente.',
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire de l adresse.',
'address_type' => 'Type d adresse : PROSPECT | DEPART | RENDU (radio exclusif par construction — RG-2.09, chk_supplier_address_type).',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (4-5 chiffres attendus).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'bennes' => 'Nombre de bennes sur le site fournisseur (entier nullable) — specifique fournisseur.',
'triage_provider' => 'Le fournisseur est prestataire de triage sur cette adresse. Faux par defaut.',
'position' => 'Ordre d affichage de l adresse dans la liste du fournisseur (croissant).',
] + self::timestampableBlamableComments(),
'supplier_address_site' => [
'_table' => 'Jointure M2M supplier_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-2.06).',
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
],
'supplier_address_contact' => [
'_table' => 'Jointure M2M supplier_address <-> supplier_contact — contacts associes a une adresse.',
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
'supplier_contact_id' => 'FK -> supplier_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
],
'supplier_address_category' => [
'_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).',
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).',
],
'supplier_rib' => [
'_table' => 'Coordonnees bancaires d un fournisseur (1:n) — >= 1 RIB attendu selon le type de reglement (RG-2.08). Tous les champs audites (pas d AuditIgnore).',
'id' => 'Identifiant interne auto-incremente.',
'supplier_id' => 'FK -> supplier.id, ON DELETE CASCADE — fournisseur proprietaire du RIB.',
'label' => 'Libelle du RIB (ex: compte principal).',
'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).',
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
] + self::timestampableBlamableComments(),
];
}
@@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\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\Domain\Entity\Site;
use DateTimeImmutable;
/**
* Base des tests fonctionnels du repertoire fournisseurs (M2). Jumelle de la base
* clients (M1), elle ajoute les factories specifiques fournisseur au-dessus de
* {@see AbstractCommercialApiTestCase} (qui apporte deja createCategory sous le
* type CLIENT, createUserWithPermission, authenticatedClient...).
*
* Donnees (RETEX M1 — pas de fixtures globales pour les tests) : chaque test seede
* ses fournisseurs en base via les helpers ci-dessous, puis le tearDown les purge.
* Les referentiels comptables (tva_mode / payment_delay / payment_type / bank) et
* les categories FOURNISSEUR (Negociant, Cooperative...) sont seedes par les
* fixtures applicatives (make test-db-setup) ; on les recupere par code.
*
* Categories : `supplierCategory('NEGOCIANT')` fetch-or-create une categorie de
* type FOURNISSEUR (requis par RG-2.10) — fetch-or-create par code pour rester
* idempotent et auto-suffisant (ne depend pas du seed, que d'autres tests de la
* suite peuvent purger). Pour fabriquer une categorie d'un AUTRE type (test de
* rejet RG-2.10), utiliser `createCategory()` du parent, qui cree sous CLIENT.
*
* Cleanup : le tearDown purge les fournisseurs AVANT le parent (qui supprime les
* categories `test_cli_cat_*`) : la jointure supplier_category est ON DELETE
* CASCADE cote supplier mais RESTRICT cote category — le DELETE DQL sur Supplier
* declenche le cascade BDD sur supplier_category / _contact / _address, liberant
* les categories pour la purge du parent.
*
* @internal
*/
abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
/** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
protected const string VALID_BIC = 'BNPAFRPPXXX';
protected function tearDown(): void
{
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
parent::tearDown();
}
/**
* Fetch-or-create une categorie de type FOURNISSEUR par code (defaut
* Negociant). Type FOURNISSEUR exige par RG-2.10 : un POST fournisseur portant
* cette categorie passe la validation. Idempotent (lookup par code, aligne sur
* l'index unique partiel uq_category_code) et auto-suffisant : ne depend pas du
* seed CategoryFixtures (que d'autres tests de la suite peuvent purger). Une
* categorie creee ici porte le prefixe de nom de test -> purgee par le parent.
*/
protected function supplierCategory(string $code = 'NEGOCIANT'): Category
{
$em = $this->getEm();
$existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
if (null !== $existing) {
return $existing;
}
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code));
$category->setCode($code);
$category->setCategoryType($this->supplierCategoryType());
$em->persist($category);
$em->flush();
return $category;
}
/**
* Recupere (ou cree) le type FOURNISSEUR. Idempotent : la contrainte d'unicite
* sur category_type.code interdit les doublons.
*/
protected function supplierCategoryType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'FOURNISSEUR']);
if (null !== $existing) {
return $existing;
}
$type = new CategoryType();
$type->setCode('FOURNISSEUR');
$type->setLabel('Fournisseur');
$em->persist($type);
$em->flush();
return $type;
}
/**
* Seede directement un Supplier minimal (sans passer par l'API), pour les
* tests de liste / archivage / serialisation. Nom stocke en MAJUSCULES pour
* refleter l'etat normalise (RG-2.12) qu'aurait produit le SupplierProcessor.
* Porte une categorie FOURNISSEUR (defaut Negociant).
*/
protected function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'NEGOCIANT'): Supplier
{
$em = $this->getEm();
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$supplier->addCategory($this->supplierCategory($categoryCode));
$supplier->setIsArchived($isArchived);
if ($isArchived) {
$supplier->setArchivedAt(new DateTimeImmutable());
}
$em->persist($supplier);
$em->flush();
return $supplier;
}
/**
* Seede un fournisseur COMPLET (sans passer par l'API — validations
* applicatives non rejouees mais CHECK BDD respectes) : onglet Information
* rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie
* FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle
* au contrat de serialisation et a la DoD (§ 4.0.bis).
*
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
* coherent avec le RIB seede ; RG-2.08)
*/
protected function seedCompleteSupplier(string $companyName, string $paymentTypeCode = 'LCR'): Supplier
{
$em = $this->getEm();
// Nom unique parmi les actifs (index partiel uq_supplier_company_name_active).
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$supplier->addCategory($this->supplierCategory('NEGOCIANT'));
// Onglet Information complet (RG-2.03 : exige pour la Commerciale).
$supplier->setDescription('Fournisseur de test complet.');
$supplier->setCompetitors('Concurrent A, Concurrent B');
$supplier->setFoundedAt(new DateTimeImmutable('2008-04-01'));
$supplier->setEmployeesCount(42);
$supplier->setRevenueAmount('1500000.00');
$supplier->setDirectorName('Jean Dupont');
$supplier->setProfitAmount('120000.00');
$supplier->setVolumeForecast(8000);
// Bloc comptable non nul (gating par omission cote Commerciale).
$supplier->setSiren('123456789');
$supplier->setAccountNumber('F0001');
$supplier->setNTva('FR00123456789');
$supplier->setTvaMode($this->tvaMode('FRANCE_VENTES'));
$supplier->setPaymentDelay($this->paymentDelay('J30'));
$supplier->setPaymentType($this->paymentType($paymentTypeCode));
if ('VIREMENT' === $paymentTypeCode) {
$supplier->setBank($this->bank('SG'));
}
$em->persist($supplier);
// >= 2 sites fixtures pour une adresse multi-sites (RG-2.06).
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setFirstName('Marie');
$contact->setLastName('Martin');
$contact->setJobTitle('Responsable achats');
$contact->setPhonePrimary('0612345678');
$contact->setEmail('marie.martin@seed.test');
$supplier->addContact($contact);
$em->persist($contact);
$address = new SupplierAddress();
$address->setSupplier($supplier);
$address->setAddressType('DEPART');
$address->setPostalCode('86000');
$address->setCity('Poitiers');
$address->setStreet('12 rue des Acacias');
$address->setBennes(3);
// triageProvider=true : prouve qu'un booleen `true` est bien serialise
// (piege n°3 du M1 — la cle etait droppee).
$address->setTriageProvider(true);
foreach ($sites as $site) {
$address->addSite($site);
}
$address->addCategory($this->supplierCategory('NEGOCIANT'));
$address->addContact($contact);
$supplier->addAddress($address);
$em->persist($address);
$rib = new SupplierRib();
$rib->setSupplier($supplier);
$rib->setLabel('Compte principal');
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$supplier->addRib($rib);
$em->persist($rib);
$em->flush();
return $supplier;
}
/**
* Ajoute un contact a un fournisseur deja persiste (seed direct).
*/
protected function addContact(
Supplier $supplier,
?string $firstName = 'Marie',
?string $lastName = 'Martin',
?string $phonePrimary = null,
?string $email = null,
int $position = 0,
): SupplierContact {
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setFirstName($firstName);
$contact->setLastName($lastName);
$contact->setPhonePrimary($phonePrimary);
$contact->setEmail($email);
$contact->setPosition($position);
$supplier->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
return $contact;
}
/**
* Ajoute un RIB a un fournisseur deja persiste (seed direct).
*/
protected function addRib(Supplier $supplier, string $label = 'Compte principal'): SupplierRib
{
$rib = new SupplierRib();
$rib->setSupplier($supplier);
$rib->setLabel($label);
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$supplier->addRib($rib);
$this->getEm()->persist($rib);
$this->getEm()->flush();
return $rib;
}
/**
* Payload minimal valide de l'onglet principal (companyName + 1 categorie
* FOURNISSEUR). Si $categoryId est null, la categorie Negociant seedee est
* utilisee.
*
* @return array<string, mixed>
*/
protected function validMainPayload(string $companyName, ?int $categoryId = null): array
{
$categoryId ??= $this->supplierCategory('NEGOCIANT')->getId();
return [
'companyName' => $companyName,
'categories' => ['/api/categories/'.$categoryId],
];
}
protected function paymentType(string $code): PaymentType
{
return $this->referential(PaymentType::class, $code);
}
protected function paymentDelay(string $code): PaymentDelay
{
return $this->referential(PaymentDelay::class, $code);
}
protected function tvaMode(string $code): TvaMode
{
return $this->referential(TvaMode::class, $code);
}
protected function bank(string $code): Bank
{
return $this->referential(Bank::class, $code);
}
/**
* Recupere un referentiel comptable seede (CommercialReferentialFixtures) par
* code. Echoue explicitement si absent (fixtures non chargees).
*
* @template T of object
*
* @param class-string<T> $entityClass
*
* @return T
*/
private function referential(string $entityClass, string $code): object
{
$entity = $this->getEm()->getRepository($entityClass)->findOneBy(['code' => $code]);
self::assertNotNull(
$entity,
sprintf('Referentiel %s "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $entityClass, $code),
);
return $entity;
}
/**
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
* visee etait cassee pour une autre raison.
*
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
* de l'entite Supplier (M2, RG-2.07 / RG-2.08), via le PATCH de l'onglet
* Comptabilite (groupe supplier:write:accounting). On asserte le code HTTP et le
* propertyPath de la violation (consommable par extractApiViolations cote front,
* ERP-101). Complete les tests unitaires SupplierValidationTest par la preuve HTTP.
*
* @internal
*/
final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
{
// === RG-2.07 : Virement impose une banque ===
public function testVirementWithoutBankReturns422OnBankPath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Virement No Bank');
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId()],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('bank', $this->violationsByPath($response->toArray(false)));
}
public function testVirementWithBankReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Virement With Bank');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId(),
'bank' => '/api/banks/'.$this->bank('SG')->getId(),
],
]);
self::assertResponseStatusCodeSame(200);
}
// === RG-2.08 : LCR impose au moins un RIB ===
public function testLcrWithoutRibReturns422OnRibsPath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Lcr No Rib');
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('ribs', $this->violationsByPath($response->toArray(false)));
}
public function testLcrWithRibReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Lcr With Rib');
$this->addRib($seed);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(200);
}
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
/**
* Tests fonctionnels du formulaire principal fournisseur (M2, spec § 4.3 / § 4.4)
* sur le CORPS JSON : creation (companyName + categories), normalisation serveur
* (RG-2.12 UPPERCASE), categorie de type FOURNISSEUR (RG-2.10), unicite du nom
* (RG-2.11) et archivage nominal (RG-2.14). Jumeau de ClientApiTest (M1).
*
* @internal
*/
final class SupplierApiTest extends AbstractSupplierApiTestCase
{
// === POST formulaire principal ===
public function testPostMainFormUppercasesCompanyName(): void
{
$client = $this->createAdminClient();
$cat = $this->supplierCategory('NEGOCIANT');
$data = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'recycla sas',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// RG-2.12 : companyName normalise en MAJUSCULES sur la valeur RENVOYEE.
self::assertSame('RECYCLA SAS', $data['companyName']);
// Embed categorie : code/name presents (category:read dans le contexte).
self::assertSame('NEGOCIANT', $data['categories'][0]['code']);
}
public function testPostMainFormHasNoInlineContactFields(): void
{
// refonte-contact V0.2 : plus aucun champ de contact inline au POST.
$client = $this->createAdminClient();
$cat = $this->supplierCategory('NEGOCIANT');
$data = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Inline Co',
// Champs historiques : ignores par le denormaliseur.
'firstName' => 'Ignored',
'lastName' => 'Ignored',
'phonePrimary' => '0612345678',
'email' => 'ignored@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
self::assertArrayNotHasKey($key, $data);
}
}
// === RG-2.10 : categorie de type FOURNISSEUR ===
public function testPostWithNonFournisseurCategoryReturns422OnCategoriesPath(): void
{
$client = $this->createAdminClient();
// createCategory() (parent) cree une categorie de type CLIENT -> interdite.
$clientTypedCategory = $this->createCategory('SECTEUR');
$response = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'companyName' => 'Wrong Cat Type',
'categories' => ['/api/categories/'.$clientTypedCategory->getId()],
],
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
// ERP-101 : la violation porte propertyPath=categories (mapping inline front).
self::assertArrayHasKey('categories', $byPath);
self::assertSame('Type de catégorie non autorisé (FOURNISSEUR attendu).', $byPath['categories']);
}
// === RG-2.11 : unicite du nom de societe ===
public function testPostDuplicateCompanyNameReturns409(): void
{
$client = $this->createAdminClient();
$this->seedSupplier('Dup Name Co');
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Dup Name Co'),
]);
// RG-2.11 : doublon parmi les actifs -> 409 (index uq_supplier_company_name_active).
self::assertResponseStatusCodeSame(409);
}
public function testPostSameNameAfterArchivingPreviousReturns201(): void
{
$client = $this->createAdminClient();
// L'homonyme est archive -> hors index partiel : le nom redevient disponible.
$this->seedSupplier('Reuse After Archive', true);
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Reuse After Archive'),
]);
self::assertResponseStatusCodeSame(201);
}
// === RG-2.14 : archivage (admin) ===
public function testAdminArchiveSetsArchivedAt(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Archive Me');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
self::assertNotNull($reloaded);
self::assertTrue($reloaded->isArchived());
self::assertNotNull($reloaded->getArchivedAt(), 'RG-2.14 : archivedAt doit etre rempli a l\'archivage.');
}
public function testArchiveWithOtherFieldReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Archive Plus Field');
// RG-2.14 : une requete d'archivage ne modifie aucun autre champ.
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true, 'companyName' => 'Renamed While Archiving'],
]);
self::assertResponseStatusCodeSame(422);
// Le 422 doit etre celui de RG-2.14 (archivage exclusif) et non un 422
// orthogonal : on verifie le message porte par l'exception.
self::assertStringContainsString('archivage', $response->getContent(false));
}
public function testRestoreSetsArchivedAtNull(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Restore Me', true);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
self::assertResponseStatusCodeSame(200);
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
self::assertNotNull($reloaded);
self::assertFalse($reloaded->isArchived());
self::assertNull($reloaded->getArchivedAt(), 'RG-2.15 : archivedAt repasse a null a la restauration.');
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests d'archivage / restauration fournisseur — trou 409 de restauration en
* conflit d'unicite (M2, RG-2.15). Le nominal RG-2.14 (archive pose archivedAt)
* et le 422 « archive + autre champ » sont couverts par SupplierApiTest. Jumeau
* de ClientArchiveTest (M1).
*
* @internal
*/
final class SupplierArchiveTest extends AbstractSupplierApiTestCase
{
/**
* RG-2.15 : restaurer un fournisseur archive dont le nom a ete repris par un
* fournisseur actif entre-temps doit echouer en 409 (index partiel
* uq_supplier_company_name_active : un seul actif portant ce nom).
*/
public function testRestoreConflictReturns409(): void
{
$client = $this->createAdminClient();
$archived = $this->seedSupplier('Acme Conflict', true);
$this->seedSupplier('Acme Conflict', false);
$client->request('PATCH', '/api/suppliers/'.$archived->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
self::assertResponseStatusCodeSame(409);
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use Doctrine\DBAL\Connection;
/**
* Tests Audit du repertoire fournisseurs (M2, spec § 6). Couvre :
* - POST / PATCH / archivage -> ligne audit_log entity_type='commercial.Supplier'
* avec l'action et le diff attendus ;
* - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles
* DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1).
*
* @internal
*/
final class SupplierAuditTest extends AbstractSupplierApiTestCase
{
private const string SUPPLIER_TYPE = 'commercial.Supplier';
private const string RIB_TYPE = 'commercial.SupplierRib';
private ?Connection $auditConnection = null;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
}
protected function tearDown(): void
{
if (null !== $this->auditConnection) {
$this->auditConnection->close();
}
parent::tearDown();
}
public function testPostSupplierIsAudited(): void
{
$admin = $this->createAdminClient();
$cat = $this->supplierCategory('NEGOCIANT');
$created = $admin->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Audit Created Co',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::SUPPLIER_TYPE, (string) $created['id'], 'create'),
'Un audit_log "create" doit etre genere pour le fournisseur.',
);
}
public function testPatchSupplierIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedSupplier('Audit Patch Co');
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Audit Patch Renamed'],
]);
self::assertResponseStatusCodeSame(200);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::SUPPLIER_TYPE, (string) $seed->getId(), 'update'),
'Un audit_log "update" doit etre genere pour le PATCH.',
);
}
public function testArchiveSupplierIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedSupplier('Audit Archive Co');
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
['type' => self::SUPPLIER_TYPE, 'id' => (string) $seed->getId(), 'action' => 'update'],
);
self::assertGreaterThanOrEqual(1, count($rows));
/** @var array<string, mixed> $changes */
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.');
}
public function testRibCreateAuditIncludesIbanAndBic(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Audit Host');
$rib = $admin->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte audite',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
['type' => self::RIB_TYPE, 'id' => (string) $rib['id'], 'action' => 'create'],
);
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "create" doit etre genere pour le RIB.');
/** @var array<string, mixed> $changes */
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertSame(self::VALID_IBAN, $changes['iban']);
self::assertSame(self::VALID_BIC, $changes['bic']);
}
private function countAudit(string $type, string $id, string $action): int
{
return (int) $this->auditConnection->fetchOne(
'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action',
['type' => $type, 'id' => $id, 'action' => $action],
);
}
}
@@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Sites\Domain\Entity\Site;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du repertoire fournisseurs (M2, § 4.6).
* Jumeau du {@see ClientExportControllerTest} (M1).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes
* contact principal / categories / sites, gating de la colonne SIREN selon
* commercial.suppliers.accounting.view (admin ET user minimal a permission
* explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne),
* 403 sans commercial.suppliers.view, 401 anonyme.
*
* @internal
*/
final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/suppliers/export.xlsx';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
$this->seedSupplier('Export Alpha');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="repertoire-fournisseurs-', $disposition);
self::assertMatchesRegularExpression(
'/filename="repertoire-fournisseurs-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$grid = $this->gridFromResponse($response->getContent());
$headers = $grid[0];
self::assertSame('Nom fournisseur', $headers[0]);
self::assertContains('Contact principal', $headers);
self::assertContains('Téléphone principal', $headers);
self::assertContains('Téléphone secondaire', $headers);
self::assertContains('Email', $headers);
self::assertContains('Catégories', $headers);
self::assertContains('Sites', $headers);
self::assertContains('Date de création', $headers);
}
public function testExportExcludesArchivedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedSupplier('Active One');
$this->seedSupplier('Archived One', true);
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('ACTIVE ONE', $names);
self::assertNotContains('ARCHIVED ONE', $names);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedSupplier('Searchable Alpha');
$this->seedSupplier('Other Beta');
$names = $this->companyNames(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('SEARCHABLE ALPHA', $names);
self::assertNotContains('OTHER BETA', $names);
}
/**
* Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
* de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
* ordre de position inverse pour garantir que c'est bien le principal (et non
* le premier insere) qui alimente la ligne.
*/
public function testExportUsesPrincipalContactColumns(): void
{
$client = $this->createAdminClient();
$supplier = $this->seedSupplier('Contact Co');
// position 1 (secondaire) insere en premier...
$this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$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');
self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
self::assertSame('Principal Alice', $row[1]);
self::assertSame('0612345678', $row[2]);
self::assertSame('0698765432', $row[3]);
self::assertSame('alice@contact.co', $row[4]);
}
/**
* Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
* vides sans erreur (cf. ERP-100 cote client). Le site est porte par l'adresse
* (RG-2.06).
*/
public function testExportPopulatesCategoryAndSiteColumns(): void
{
$client = $this->createAdminClient();
$supplier = $this->seedSupplier('Hydrate Co', false, 'NEGOCIANT');
$em = $this->getEm();
$site = $em->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Sites.');
$address = new SupplierAddress();
$address->setSupplier($supplier);
$address->setAddressType('DEPART');
$address->setPostalCode('86100');
$address->setCity('Châtellerault');
$address->setStreet('1 rue du Test');
$address->addSite($site);
$em->persist($address);
$em->flush();
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur
// (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).
self::assertStringContainsString((string) $site->getName(), $flat);
}
public function testSirenColumnPresentWithAccountingView(): void
{
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
$client = $this->createAdminClient();
$supplier = $this->seedSupplier('Siren Co');
$em = $this->getEm();
$supplier->setSiren('123456789');
$em->flush();
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('123456789', $this->flatten($grid));
}
public function testSirenColumnAbsentWithoutAccountingView(): void
{
// Seed via admin, puis relecture par un user qui n'a QUE suppliers.view.
$admin = $this->createAdminClient();
$supplier = $this->seedSupplier('No Siren Co');
$em = $this->getEm();
$supplier->setSiren('987654321');
$em->flush();
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertNotContains('SIREN', $grid[0]);
self::assertStringNotContainsString('987654321', $this->flatten($grid));
}
/**
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
* un user minimal portant uniquement 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
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Nom fournisseur » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function companyNames(string $binary): array
{
$grid = $this->gridFromResponse($binary);
$rows = array_slice($grid, 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
/**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
*
* @return null|array<int, mixed>
*/
private function rowFor(string $binary, string $companyName): ?array
{
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
if ((string) ($row[0] ?? '') === $companyName) {
return $row;
}
}
return null;
}
/**
* Aplatit toute la grille en une chaine, pour les assertions de presence.
*
* @param array<int, array<int, mixed>> $grid
*/
private function flatten(array $grid): string
{
return implode('|', array_map(
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
$grid,
));
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests fonctionnels de la liste fournisseurs (M2, spec § 4.1 + RG-2.17 + règle
* ABSOLUE n°13) : exclusion des archives par défaut, ?includeArchived, tri
* companyName ASC, enveloppe Hydra (member/totalItems/view), échappatoire
* ?pagination=false, et ANTI N+1 (le nombre de requêtes SQL de la liste ne croît
* pas avec le nombre de lignes — fetch-joins/hydratation batchée § 2.12).
*
* @internal
*/
final class SupplierListTest extends AbstractSupplierApiTestCase
{
public function testListExcludesArchivedByDefaultAndIncludesWithFlag(): void
{
$http = $this->createAdminClient();
$token = $this->token();
$this->seedSupplier($token.' Active');
$this->seedSupplier($token.' Archived', true);
$default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(1, $default['totalItems'], 'RG-2.17 : archives exclus par defaut.');
$all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems'], 'RG-2.17 : ?includeArchived reintegre les archives.');
}
public function testListIsSortedByCompanyNameAsc(): void
{
$http = $this->createAdminClient();
$token = $this->token();
// Inseres dans le desordre ; le tri par defaut doit remonter ALPHA avant ZETA.
$this->seedSupplier($token.' Zeta');
$this->seedSupplier($token.' Alpha');
$names = array_map(
static fn (array $m): string => (string) $m['companyName'],
$http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray()['member'],
);
self::assertCount(2, $names);
self::assertStringContainsString('ALPHA', $names[0], 'RG-2.17 : tri companyName ASC.');
self::assertStringContainsString('ZETA', $names[1]);
}
public function testPaginationDisabledReturnsFullCollection(): void
{
$http = $this->createAdminClient();
$token = $this->token();
for ($i = 0; $i < 3; ++$i) {
$this->seedSupplier($token.' Item'.$i);
}
// ?pagination=false : echappatoire pour alimenter un <select> (regle n°13).
$data = $http->request('GET', '/api/suppliers?search='.$token.'&pagination=false', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $data);
self::assertCount(3, $data['member']);
}
/**
* Anti N+1 (§ 2.12) : le nombre de requetes SQL de la liste ne doit PAS croitre
* avec le nombre de fournisseurs. On mesure pour N=2 puis N=4 (memes relations
* embarquees : categories + addresses.sites) et on exige un compte IDENTIQUE —
* preuve que l'hydratation est batchee (WHERE IN) et non par ligne.
*/
public function testListQueryCountDoesNotGrowWithRowCount(): void
{
$this->skipIfSitesModuleDisabled();
$token = $this->token();
// Premiere mesure : 2 fournisseurs complets (avec adresses/sites/categories).
$this->seedCompleteSupplier($token.' A');
$this->seedCompleteSupplier($token.' B');
$countFor2 = $this->countListQueries($token);
// Seconde mesure : 2 de plus (4 au total, tous sur la meme page).
$this->seedCompleteSupplier($token.' C');
$this->seedCompleteSupplier($token.' D');
$countFor4 = $this->countListQueries($token);
self::assertSame(
$countFor2,
$countFor4,
sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
);
}
/**
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
* debug Doctrine (actif car kernel.debug=true en test). Le holder est remis a
* zero juste avant la requete pour isoler ses requetes (hors login).
*/
private function countListQueries(string $token): int
{
$http = $this->createAdminClient();
$holder = self::getContainer()->get('doctrine.debug_data_holder');
$holder->reset();
$http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]]);
$data = $holder->getData();
return count($data['default'] ?? []);
}
private function token(): string
{
return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests de structure / migration M2 (§ 8.1). Vérifie au niveau du schéma Postgres :
* - l'unique index partiel fonctionnel uq_supplier_company_name_active existe
* (LOWER(company_name), partiel sur actifs non archivés / non supprimés —
* RG-2.11), seule unicité de nom conservée ; pas d'index unique siren/email ;
* - le type de catégorie FOURNISSEUR est présent (seedé migration + fixture).
*
* @internal
*/
final class SupplierMigrationTest extends AbstractSupplierApiTestCase
{
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
{
$rows = $this->supplierIndexes();
$companyNameIndexes = array_filter(
$rows,
static fn (array $r): bool => 'uq_supplier_company_name_active' === $r['indexname'],
);
self::assertCount(1, $companyNameIndexes, 'Il doit exister exactement UN index uq_supplier_company_name_active.');
$def = strtolower((string) array_values($companyNameIndexes)[0]['indexdef']);
self::assertStringContainsString('unique', $def);
self::assertStringContainsString('lower', $def);
self::assertStringContainsString('company_name', $def);
self::assertStringContainsString('where', $def, 'L\'index doit etre partiel (clause WHERE sur les actifs).');
}
public function testNoSirenOrEmailUniqueIndexOnSupplier(): void
{
$names = array_map(static fn (array $r): string => $r['indexname'], $this->supplierIndexes());
// § 2.6 : SIREN et email NON uniques sur le fournisseur.
self::assertNotContains('uq_supplier_siren_active', $names);
self::assertNotContains('uq_supplier_email_active', $names);
}
public function testFournisseurCategoryTypeExists(): void
{
self::bootKernel();
$count = (int) $this->getEm()->getConnection()->fetchOne(
"SELECT COUNT(*) FROM category_type WHERE code = 'FOURNISSEUR'",
);
self::assertSame(1, $count, 'Le type de categorie FOURNISSEUR doit etre present (migration + fixture).');
}
/**
* @return list<array{indexname: string, indexdef: string}>
*/
private function supplierIndexes(): array
{
self::bootKernel();
/** @var list<array{indexname: string, indexdef: string}> $rows */
return $this->getEm()->getConnection()->fetchAllAssociative(
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'supplier'",
);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
/**
* Mode strict PATCH multi-groupes fournisseur (M2, RG-2.16) — preuve fonctionnelle
* HTTP, SANS dependre d'un role metier : un user portant
* `commercial.suppliers.manage` mais PAS `commercial.suppliers.accounting.manage`
* qui envoie un PATCH melant un champ principal (companyName) et un champ
* comptable (siren) recoit 403 sur TOUT le payload — aucun champ applique (pas de
* filtrage silencieux). Jumeau de ClientPatchStrictTest (M1).
*
* @internal
*/
final class SupplierPatchStrictTest extends AbstractSupplierApiTestCase
{
public function testMixedGroupsPatchWithoutAccountingPermissionIsForbidden(): void
{
$seed = $this->seedSupplier('Strict Mix');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'companyName' => 'Renamed Strict',
'siren' => '123456789',
],
]);
// RG-2.16 : 403 strict (le champ comptable siren exige accounting.manage).
self::assertResponseStatusCodeSame(403);
// Aucun champ applique : le companyName d'origine est intact.
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
self::assertNotNull($reloaded);
self::assertSame('STRICT MIX', $reloaded->getCompanyName());
}
}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire fournisseurs par role metier (spec-back M2
* § 2.9 + ERP-90). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, le gating des champs comptables en
* lecture (omission de cle) et le durcissement RG-2.03 (Commerciale) au POST/PATCH.
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente —
* pas de mock de role. Jumeau de ClientRBACMatrixTest (M1).
*
* Matrice § 2.9 (ERP-90) — rappel :
* - bureau : suppliers.view + manage (ni accounting, ni archive)
* - compta : suppliers.view + accounting.view + accounting.manage (PAS manage)
* - commerciale : suppliers.view + manage (PAS accounting), durcie RG-2.03
* - usine : aucune permission (403 partout)
* - archive : admin seul (aucun role metier)
*
* @internal
*/
final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 2.9 +
// comptes demo). Exerce aussi le chemin de code prod.
self::bootKernel();
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$exit = $application->run(
new ArrayInput([
'command' => 'app:seed-rbac',
'--with-demo-users' => true,
'--password' => self::PWD,
]),
new NullOutput(),
);
self::assertSame(
0,
$exit,
'app:seed-rbac a echoue : les permissions commercial.suppliers.* sont-elles synchronisees (app:sync-permissions) ?',
);
self::ensureKernelShutdown();
}
public function testUsineIsForbiddenEverywhere(): void
{
$seed = $this->seedSupplier('Usine Target');
$client = $this->authAs('usine');
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
$client->request('GET', '/api/suppliers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Usine Post'),
]);
self::assertResponseStatusCodeSame(403);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Renamed By Usine'],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedSupplier('Bureau Target');
$cat = $this->supplierCategory('NEGOCIANT');
$client = $this->authAs('bureau');
// view
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK (bureau n'est pas gate par RG-2.03)
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Bureau Created', $cat->getId()),
]);
self::assertResponseStatusCodeSame(201);
// manage : edition onglet principal OK
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Bureau Renamed'],
]);
self::assertResponseStatusCodeSame(200);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauDetailHasNoAccountingFields(): void
{
// Bureau a view mais PAS accounting.view : les champs comptables sont
// ABSENTS du JSON (gating par omission, pas null).
$supplier = $this->seedCompleteSupplier('Bureau Gating Co');
$client = $this->authAs('bureau');
$data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Gating par omission sur l'ensemble des champs comptables (pas seulement
// siren/ribs) : une regression reintroduisant accountNumber/nTva/tvaMode/
// paymentType dans le groupe bureau serait sinon invisible.
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testComptaCanEditAccountingOnly(): void
{
$seed = $this->seedSupplier('Compta Target');
$client = $this->authAs('compta');
// view
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Compta Post'),
]);
self::assertResponseStatusCodeSame(403);
// accounting.manage : edition onglet Comptabilite OK
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(200);
// PAS manage : edition onglet principal refusee (guardManage)
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Compta Renamed'],
]);
self::assertResponseStatusCodeSame(403);
// PAS manage : edition onglet Information refusee (guardManage)
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['description' => 'Une description'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testComptaDetailHasAccountingFields(): void
{
// Compta a accounting.view : siren + ribs presents dans le JSON.
$supplier = $this->seedCompleteSupplier('Compta View Co');
$client = $this->authAs('compta');
$data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $data);
self::assertSame('123456789', $data['siren']);
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
}
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedSupplier('Commerciale Target');
$client = $this->authAs('commerciale');
// view
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : la creation passe la security d'operation (pas un 403 comme
// Compta) mais bute sur RG-2.03 (onglet Information incomplet) -> 422.
$response = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Post'),
]);
self::assertResponseStatusCodeSame(422);
// Le 422 doit bien etre celui de RG-2.03 (onglet Information) et non un
// 422 orthogonal : on exige une violation sur un champ de completude.
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testCommercialeDetailHasNoAccountingFields(): void
{
$supplier = $this->seedCompleteSupplier('Commerciale Gating Co');
$client = $this->authAs('commerciale');
$data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testRG203CommercialePostIncompleteIs422AdminIs201(): void
{
$cat = $this->supplierCategory('NEGOCIANT');
// RG-2.03 : Commerciale POST sans onglet Information complet -> 422.
$commerciale = $this->authAs('commerciale');
$response = $commerciale->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG203 Commerciale', $cat->getId()),
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
// Meme payload par un Admin (non gate par RG-2.03) -> 201.
$admin = $this->createAdminClient();
$admin->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG203 Admin', $cat->getId()),
]);
self::assertResponseStatusCodeSame(201);
}
public function testRG203CommercialePatchIncompleteIs422(): void
{
// RG-2.03 : tout PATCH par une Commerciale exige l'Information complete.
// Le fournisseur seede a une Information vide -> meme un PATCH du nom -> 422.
$seed = $this->seedSupplier('Commerciale Patch Incomplete');
$commerciale = $this->authAs('commerciale');
$response = $commerciale->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Commerciale Renamed'],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
// Le meme PATCH par un Admin passe (non gate par RG-2.03) -> 200.
$admin = $this->createAdminClient();
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Admin Renamed'],
]);
self::assertResponseStatusCodeSame(200);
}
private function authAs(string $role): Client
{
return $this->authenticatedClient($role, self::PWD);
}
}
@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire fournisseurs
* (M2, spec-back § 4.0 / § 4.0.bis / § 4.0.ter). Jumeau du
* {@see ClientSerializationContractTest} (M1), il reverifie sur le JSON reel les
* 4 pieges silencieux constates en prod sur le M1 :
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> clé `ribs`
* ABSENTE pour la Commerciale.
* - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
* -> triageProvider (adresse) et isArchived (fournisseur) presents.
* - #1 : categories embarquees sans code/name -> code + name presents en LISTE
* ET DETAIL.
* - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
* (via getSites()) ET DETAIL (addresses[].sites[]).
* Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives
* exclus) et la suppression du contact inline (refonte-contact V0.2).
*
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
* annotations. Toute regression de groupe de serialisation casse ici.
*
* @internal
*/
final class SupplierSerializationContractTest extends AbstractSupplierApiTestCase
{
// === #4 — Gating des RIB par accounting.view ===
public function testRibsPresentForAdminWithAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Rib Admin Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
self::assertSame('Compte principal', $data['ribs'][0]['label']);
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
}
public function testRibsAbsentForCommercialeWithoutAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Rib Commerciale Co');
// Commerciale : commercial.suppliers.view SANS accounting.view.
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// La clé `ribs` est ABSENTE (pas null) : le groupe supplier:read:accounting
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
// fuite IBAN/BIC (piege n°4 du M1).
self::assertArrayNotHasKey('ribs', $data);
}
// === #4.bis — Gating par OMISSION des scalaires comptables ===
public function testAccountingScalarsGatedByOmission(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Compta Gating Co');
$id = $supplier->getId();
// Admin : scalaires comptables presents.
$admin = $this->createAdminClient();
$adminData = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $adminData);
self::assertSame('123456789', $adminData['siren']);
self::assertArrayHasKey('accountNumber', $adminData);
self::assertArrayHasKey('paymentType', $adminData);
// Commerciale : scalaires comptables ABSENTS (omission, pas null).
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
// === Refs comptables embarquees {id,label} et non IRI nu (ERP-92) ===
public function testAccountingReferentialsEmbedIdAndLabel(): void
{
$this->skipIfSitesModuleDisabled();
// Reglement Virement -> banque renseignee : on couvre les 4 referentiels.
$supplier = $this->seedCompleteSupplier('Refs Embed Co', 'VIREMENT');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Avant fix ERP-92 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
// car les entites partagees ne portaient que `client:read:accounting` (M1),
// pas `supplier:read:accounting`. Apres fix : objet {id, label} embarque
// (le front consultation/edition affiche le libelle sans fetch — § 4.0).
foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
self::assertArrayHasKey('id', $data[$ref]);
self::assertArrayHasKey('label', $data[$ref]);
self::assertNotSame('', (string) $data[$ref]['label']);
}
// paymentType embarque aussi son code (logique front VIREMENT/LCR).
self::assertArrayHasKey('code', $data['paymentType']);
self::assertSame('VIREMENT', $data['paymentType']['code']);
}
// === #3 — Booleens presents dans le JSON (triageProvider + isArchived) ===
public function testAddressTriageProviderBooleanIsPresentInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Bool Addr Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('addresses', $data);
self::assertNotEmpty($data['addresses']);
$address = $data['addresses'][0];
// Le bug M1 droppait TOTALEMENT la cle (Groups sur la propriete `triageProvider`,
// getter derivant `triage`). Apres parade (Groups + SerializedName sur le
// getter isTriageProvider), la cle est presente ET typee bool `true`.
self::assertArrayHasKey('triageProvider', $address);
self::assertTrue($address['triageProvider']);
}
public function testSupplierIsArchivedBooleanIsPresentInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Bool Archived Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// isArchived expose via Groups + SerializedName('isArchived') sur le getter :
// sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
self::assertArrayHasKey('isArchived', $data);
self::assertFalse($data['isArchived']);
}
// === #1 — Embed code/name des Category (liste ET detail) ===
public function testCategoriesEmbedCodeAndNameInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Embed Cat Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['categories']);
$category = $data['categories'][0];
// Avant correctif M1 : seuls @id/@type (category:read absent du contexte).
// Apres : code + name embarques.
self::assertArrayHasKey('code', $category);
self::assertArrayHasKey('name', $category);
self::assertSame('NEGOCIANT', $category['code']);
// Categories d'adresse aussi (category:read dans le contexte du detail).
self::assertArrayHasKey('categories', $data['addresses'][0]);
self::assertNotEmpty($data['addresses'][0]['categories']);
self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
}
public function testCategoriesEmbedCodeAndNameInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = $this->seedCompleteSupplier($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $supplier->getId());
self::assertNotNull($row, 'Le fournisseur seede doit apparaitre dans la liste filtree.');
self::assertNotEmpty($row['categories']);
self::assertArrayHasKey('code', $row['categories'][0]);
self::assertArrayHasKey('name', $row['categories'][0]);
self::assertSame('NEGOCIANT', $row['categories'][0]['code']);
}
// === #2 — Embed name/postalCode des Site (liste via getSites + detail) ===
public function testSitesEmbedNameAndPostalCodeInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = $this->seedCompleteSupplier($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $supplier->getId());
self::assertNotNull($row);
// sites agreges depuis les adresses via getSites() : objet Site entier
// (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
self::assertArrayHasKey('sites', $row);
self::assertGreaterThanOrEqual(2, count($row['sites']));
self::assertArrayHasKey('name', $row['sites'][0]);
self::assertArrayHasKey('postalCode', $row['sites'][0]);
self::assertNotSame('', (string) $row['sites'][0]['name']);
}
public function testSitesEmbedNameAndPostalCodeInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Site Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
$address = $data['addresses'][0];
self::assertArrayHasKey('sites', $address);
self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
self::assertArrayHasKey('name', $address['sites'][0]);
self::assertArrayHasKey('postalCode', $address['sites'][0]);
self::assertNotSame('', (string) $address['sites'][0]['name']);
}
// === Detail : sous-collections embarquees ===
public function testDetailEmbedsContactsAddressesRibs(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Embed Subres Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['contacts']);
self::assertSame('Marie', $data['contacts'][0]['firstName']);
self::assertSame('Martin', $data['contacts'][0]['lastName']);
self::assertArrayHasKey('email', $data['contacts'][0]);
self::assertNotEmpty($data['addresses']);
self::assertSame('DEPART', $data['addresses'][0]['addressType']);
self::assertNotEmpty($data['ribs']);
}
// === refonte-contact V0.2 : plus de contact inline sur le fournisseur ===
public function testSupplierHasNoInlineContactFields(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('No Inline Contact Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du fournisseur.', $key));
}
}
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
{
$this->skipIfSitesModuleDisabled();
$http = $this->createAdminClient();
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
$this->seedSupplier($token.' Active');
$this->seedSupplier($token.' Archived', true);
// Liste par defaut filtree sur le token : enveloppe member/totalItems sans
// prefixe hydra:, archive EXCLU du totalItems (RG-2.17).
$default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $default);
self::assertArrayHasKey('totalItems', $default);
self::assertArrayNotHasKey('hydra:member', $default);
self::assertArrayNotHasKey('hydra:totalItems', $default);
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
// includeArchived : l'archive reintegre le total.
$all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems']);
// `view` (PartialCollectionView) sans prefixe hydra:.
$paged = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('view', $paged);
self::assertArrayNotHasKey('hydra:view', $paged);
}
/**
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
* detail commerciale) pour les coller dans la spec avant de lancer les tickets
* front. Le test asserte la forme ; si la variable d'env SUPPLIER_DOD_DUMP est
* positionnee, il ecrit aussi les 3 corps formates sous /tmp pour copie.
*/
public function testDodReferenceJsonShape(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = $this->seedCompleteSupplier($token);
$id = (int) $supplier->getId();
$admin = $this->createAdminClient();
$list = $admin->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$detailAdmin = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$commerciale = $this->authenticatedClient($creds['username'], $creds['password']);
$detailCommerciale = $commerciale->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
// Forme minimale attendue (la DoD valide que tout champ front est present).
self::assertArrayHasKey('member', $list);
self::assertArrayHasKey('siren', $detailAdmin);
self::assertArrayHasKey('ribs', $detailAdmin);
self::assertArrayNotHasKey('siren', $detailCommerciale);
self::assertArrayNotHasKey('ribs', $detailCommerciale);
if (false !== getenv('SUPPLIER_DOD_DUMP')) {
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/supplier-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/supplier-dod-detail-admin.json', json_encode($detailAdmin, $flags));
file_put_contents('/tmp/supplier-dod-detail-commerciale.json', json_encode($detailCommerciale, $flags));
}
}
/**
* Retrouve un membre de la collection par son id (liste filtree).
*
* @param array<string, mixed> $collection
*
* @return array<string, mixed>|null
*/
private function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -0,0 +1,361 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Sites\Domain\Entity\Site;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du fournisseur
* (M2, spec § 4.5). Couvrent : normalisation contact (RG-2.12), RG-2.04 (prenom
* OU nom), RG-2.05 (code postal), RG-2.06 (>= 1 site), RG-2.09 (enum addressType),
* RG-2.10 (categorie FOURNISSEUR sur adresse), RG-2.08 (DELETE dernier RIB sous
* LCR -> 409), DELETE contact libre au M2 (pas de garde « dernier contact ») et le
* gating comptable des RIB (manage seul -> 403). Jumeau de ClientSubResourceApiTest.
*
* @internal
*/
final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
{
// === Contacts ===
public function testPostContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Contact Host');
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// RG-2.12 : prenom/nom Title Case, telephone chiffres seuls, email lowercase.
self::assertSame('Jean', $data['firstName']);
self::assertSame('Dupont', $data['lastName']);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
}
public function testPostContactWithoutNameReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Contact No Name');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'],
]);
// RG-2.04 (prenom OU nom obligatoire) -> 422 rattachee a firstName.
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('firstName', $byPath);
}
public function testPostContactOnMissingSupplierReturns404(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/suppliers/999999/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Orphan'],
]);
self::assertResponseStatusCodeSame(404);
}
public function testDeleteLastContactReturns204(): void
{
// M2 : pas de garde « dernier contact » (RG-2.13 front-driven) — la
// suppression du dernier contact est libre (204), contrairement au M1.
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Contact Solo');
$contact = $this->addContact($seed, 'Unique', 'Contact');
$client->request('DELETE', '/api/supplier_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(204);
}
public function testContactWriteWithoutManageReturns403(): void
{
// Un user sans aucune permission suppliers -> 403 sur la sous-ressource.
$seed = $this->seedSupplier('Contact Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['firstName' => 'Nope'],
]);
self::assertResponseStatusCodeSame(403);
}
// === Adresses ===
public function testPostAddressWithValidPayloadReturns201(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Host');
$category = $this->supplierCategory('NEGOCIANT');
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('DEPART', $data['addressType']);
}
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();
$seed = $this->seedSupplier('Address No Site');
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [],
],
]);
// RG-2.06 (Assert\Count min 1 sur sites).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithInvalidPostalCodeReturns422(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Bad CP');
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '123',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
// RG-2.05 (Assert\Regex ^[0-9]{4,5}$).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Incoherent');
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '86100',
'city' => 'Marseille',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testPostAddressWithInvalidTypeReturns422(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Bad Type');
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'INVALID',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
// RG-2.09 (Assert\Choice PROSPECT|DEPART|RENDU).
self::assertResponseStatusCodeSame(422);
}
/**
* RG-2.09 : les 3 valeurs valides de addressType sont acceptees.
*/
public function testPostAddressWithEachValidTypeReturns201(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Types');
$siteIri = $this->firstSiteIri();
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => $type,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$siteIri],
],
]);
self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type));
}
}
public function testPostAddressWithNonFournisseurCategoryReturns422(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Bad Cat');
// categorie de type CLIENT -> interdite sur une adresse fournisseur.
$clientTypedCategory = $this->createCategory('SECTEUR');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$clientTypedCategory->getId()],
],
]);
// RG-2.10 -> 422 rattachee a categories.
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
// === RIBs ===
public function testPostRibByAdminReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Host');
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte principal',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Compte principal', $data['label']);
}
public function testPostRibWithInvalidIbanReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Bad Iban');
$client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
]);
self::assertResponseStatusCodeSame(422);
}
public function testDeleteRibNonLcrReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Non LCR');
$rib = $this->addRib($seed);
$client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(204);
}
public function testDeleteLastRibUnderLcrReturns409(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib LCR Solo');
$rib = $this->addRib($seed);
// Passe le fournisseur en LCR (seed direct).
$em = $this->getEm();
$managed = $em->getRepository(Supplier::class)->find($seed->getId());
$managed->setPaymentType($this->paymentType('LCR'));
$em->flush();
$client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
// RG-2.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
self::assertResponseStatusCodeSame(409);
}
public function testRibWriteWithoutAccountingManageReturns403(): void
{
// Un user portant seulement suppliers.manage (sans accounting.manage) ne
// peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
$seed = $this->seedSupplier('Rib Forbidden');
$rib = $this->addRib($seed);
$creds = $this->createUserWithPermission('commercial.suppliers.manage');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(403);
$http->request('PATCH', '/api/supplier_ribs/'.$rib->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['label' => 'Y'],
]);
self::assertResponseStatusCodeSame(403);
$http->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(403);
}
// === Helpers ===
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
private function firstSiteIri(): string
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
return '/api/sites/'.$site->getId();
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
/**
* Tests d'unicite fournisseur (M2, RG-2.11). Le doublon de companyName (409) est
* couvert par {@see SupplierApiTest::testPostDuplicateCompanyNameReturns409}. Ce
* fichier prouve l'envers de la decision § 2.6 : SIREN NON unique (etablissements
* multiples). Jumeau de ClientUniquenessTest (M1).
*
* @internal
*/
final class SupplierUniquenessTest extends AbstractSupplierApiTestCase
{
public function testDuplicateSirenIsAllowed(): void
{
self::bootKernel();
$em = $this->getEm();
$one = $this->seedSupplier('Siren Share One');
$two = $this->seedSupplier('Siren Share Two');
// Le SIREN n'est pas ecrivable au POST (groupe accounting) : seed direct.
$one->setSiren('123456789');
$two->setSiren('123456789');
$em->flush();
// Aucune exception : pas d'index unique sur siren (§ 2.6).
self::assertSame('123456789', $em->getRepository(Supplier::class)->find($one->getId())?->getSiren());
self::assertSame('123456789', $em->getRepository(Supplier::class)->find($two->getId())?->getSiren());
}
}
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Domain\Entity;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierRib;
use App\Shared\Domain\Contract\CategoryInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Tests des contraintes inter-champs de l'entite Supplier portees par
* Assert\Callback (decision figee ERP-89) : RG-2.10 (categorie de type
* FOURNISSEUR), RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB).
*
* On valide l'entite avec le validator Symfony (mapping par attributs) et on
* assert le propertyPath exact de chaque violation (contrat ERP-101 :
* exploitable par extractApiViolations). Pas de base : les Callback ne touchent
* que des champs en memoire (categories via un double CategoryInterface).
*
* @internal
*/
final class SupplierValidationTest extends TestCase
{
private ValidatorInterface $validator;
protected function setUp(): void
{
$this->validator = Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
;
}
// === RG-2.10 : categories de type FOURNISSEUR ===
public function testFournisseurCategoryIsAccepted(): void
{
$supplier = $this->validSupplier();
self::assertSame([], $this->violationPaths($supplier));
}
public function testNonFournisseurCategoryIsRejectedOnCategoriesPath(): void
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS');
$supplier->addCategory($this->category('CLIENT'));
self::assertContains('categories', $this->violationPaths($supplier));
}
// === RG-2.07 : Virement impose une banque ===
public function testVirementWithoutBankIsRejectedOnBankPath(): void
{
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('VIREMENT'));
self::assertContains('bank', $this->violationPaths($supplier));
}
public function testVirementWithBankPasses(): void
{
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('VIREMENT'));
$supplier->setBank(new Bank());
self::assertNotContains('bank', $this->violationPaths($supplier));
}
// === RG-2.08 : LCR impose au moins un RIB ===
public function testLcrWithoutRibIsRejectedOnRibsPath(): void
{
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('LCR'));
self::assertContains('ribs', $this->violationPaths($supplier));
}
public function testLcrWithRibPasses(): void
{
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('LCR'));
$supplier->addRib(new SupplierRib());
self::assertNotContains('ribs', $this->violationPaths($supplier));
}
public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void
{
// Un type de reglement neutre (ni VIREMENT ni LCR) n'exige ni banque ni RIB.
$supplier = $this->validSupplier();
$supplier->setPaymentType($this->paymentType('CHEQUE'));
$paths = $this->violationPaths($supplier);
self::assertNotContains('bank', $paths);
self::assertNotContains('ribs', $paths);
}
/**
* Fournisseur valide (nom + 1 categorie FOURNISSEUR), sans onglet
* Comptabilite renseigne : sert de base aux tests RG-2.07/2.08.
*/
private function validSupplier(): Supplier
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS');
$supplier->addCategory($this->category('FOURNISSEUR'));
return $supplier;
}
/**
* @return list<string> propertyPaths des violations levees par le validator
*/
private function violationPaths(Supplier $supplier): array
{
$paths = [];
foreach ($this->validator->validate($supplier) as $violation) {
$paths[] = $violation->getPropertyPath();
}
return $paths;
}
/**
* Double minimal de CategoryInterface (pas d'acces base) renvoyant le code de
* type de categorie voulu — seul element regarde par validateCategoryType.
*/
private function category(string $typeCode): CategoryInterface
{
return new class($typeCode) implements CategoryInterface {
public function __construct(private readonly string $typeCode) {}
public function getId(): ?int
{
return 1;
}
public function getName(): ?string
{
return 'Categorie test';
}
public function getCode(): ?string
{
return 'TEST';
}
public function getCategoryTypeCode(): ?string
{
return $this->typeCode;
}
};
}
private function paymentType(string $code): PaymentType
{
$type = new PaymentType();
$type->setCode($code);
$type->setLabel($code);
return $type;
}
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
/**
* Tests unitaires du SupplierInformationCompletenessValidator (RG-2.03) : pour le
* role Commerciale, TOUS les champs de l'onglet Information sont obligatoires.
* Chaque champ manquant produit une violation portant son propertyPath (ERP-101).
*
* @internal
*/
final class SupplierInformationCompletenessValidatorTest extends TestCase
{
public function testCompleteInformationPasses(): void
{
$supplier = $this->completeSupplier();
$this->validator()->validate($supplier);
// Aucune exception levee : la completude est satisfaite.
$this->addToAssertionCount(1);
}
public function testEmptyInformationListsEveryMissingField(): void
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS'); // onglet principal, hors Information
try {
$this->validator()->validate($supplier);
self::fail('Une ValidationException etait attendue (onglet Information vide).');
} catch (ValidationException $e) {
$paths = [];
foreach ($e->getConstraintViolationList() as $violation) {
$paths[] = $violation->getPropertyPath();
}
// Les 8 champs Information (dont volumeForecast, NEW vs Client) sont
// tous signales d'un coup, chacun sous son propre propertyPath.
sort($paths);
self::assertSame([
'competitors',
'description',
'directorName',
'employeesCount',
'foundedAt',
'profitAmount',
'revenueAmount',
'volumeForecast',
], $paths);
}
}
public function testPartialInformationReportsOnlyMissingFields(): void
{
$supplier = $this->completeSupplier();
$supplier->setDirectorName(null);
$supplier->setVolumeForecast(null);
try {
$this->validator()->validate($supplier);
self::fail('Une ValidationException etait attendue (2 champs manquants).');
} catch (ValidationException $e) {
$paths = [];
foreach ($e->getConstraintViolationList() as $violation) {
$paths[] = $violation->getPropertyPath();
}
sort($paths);
self::assertSame(['directorName', 'volumeForecast'], $paths);
}
}
public function testZeroNumericValuesAreNotMissing(): void
{
// employeesCount = 0, profitAmount = "0.00", volumeForecast = 0 sont des
// valeurs valides (un zero n'est pas une absence) -> pas de violation.
$supplier = $this->completeSupplier();
$supplier->setEmployeesCount(0);
$supplier->setProfitAmount('0.00');
$supplier->setVolumeForecast(0);
$this->validator()->validate($supplier);
$this->addToAssertionCount(1);
}
public function testBlankStringIsMissing(): void
{
// Une chaine vide apres trim compte comme manquante.
$supplier = $this->completeSupplier();
$supplier->setDescription(' ');
$this->expectException(ValidationException::class);
$this->validator()->validate($supplier);
}
/**
* Fournisseur dont l'onglet Information est entierement renseigne.
*/
private function completeSupplier(): Supplier
{
$supplier = new Supplier();
$supplier->setCompanyName('Recycla SAS');
$supplier->setDescription('Specialiste du recyclage');
$supplier->setCompetitors('Concurrent A, Concurrent B');
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
$supplier->setEmployeesCount(42);
$supplier->setRevenueAmount('1000000.00');
$supplier->setDirectorName('Marie Durand');
$supplier->setProfitAmount('150000.00');
$supplier->setVolumeForecast(5000);
return $supplier;
}
private function validator(): SupplierInformationCompletenessValidator
{
return new SupplierInformationCompletenessValidator();
}
}
@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierProcessor;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Tests unitaires du SupplierProcessor — perimetre ERP-89 : detection du role
* Commerciale cote back (RG-2.03). Les autres responsabilites du processor
* (gating accounting / archive / mode strict) sont heritees d'ERP-87 et testees
* a leur niveau ; les RG inter-champs (RG-2.07/2.08/2.10) sont des contraintes
* d'entite (cf. SupplierValidationTest), non portees ici.
*
* @internal
*/
final class SupplierProcessorTest extends TestCase
{
public function testCommercialeIncompleteInformationIsUnprocessable(): void
{
// RG-2.03 : role Commerciale + onglet Information incomplet -> 422, meme
// sur un POST (les champs Information n'y sont pas renseignables).
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description'); // les autres champs Information restent null
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: $this->commercialeUser(),
);
$this->expectException(ValidationException::class);
$processor->process($supplier, $this->operation());
}
public function testCommercialeIncompleteInformationOnMainOnlyPatchIsUnprocessable(): void
{
// RG-2.03 : pour une Commerciale, la completude Information est exigee
// meme quand le payload ne touche PAS l'onglet Information (ici
// companyName seul) -> 422.
$supplier = $this->minimalSupplier();
$supplier->setCompanyName('Renamed Co');
$processor = $this->makeProcessor(
granted: ['commercial.suppliers.manage'],
payload: ['companyName' => 'Renamed Co'],
user: $this->commercialeUser(),
managed: true,
originalData: [
'companyName' => 'TEST CO',
'isArchived' => false,
],
);
$this->expectException(ValidationException::class);
$processor->process($supplier, $this->operation());
}
public function testCommercialeCompleteInformationPasses(): void
{
// RG-2.03 satisfaite : tous les champs Information renseignes -> 200.
$supplier = $this->completeInformationSupplier();
$processor = $this->makeProcessor(
granted: ['commercial.suppliers.manage'],
payload: ['description' => 'desc'],
user: $this->commercialeUser(),
);
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
public function testNonCommercialeSkipsInformationCompleteness(): void
{
// Meme onglet Information incomplet, mais user non-Commerciale -> aucun
// blocage (la completude est specifique a la Commerciale).
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description');
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: null,
);
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 array<string, mixed> $payload Corps JSON simule de la requete
* @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
* @param array<string, mixed> $originalData Etat persiste simule (getOriginalEntityData)
*/
private function makeProcessor(
array $granted = [],
array $payload = [],
?UserInterface $user = null,
bool $managed = false,
array $originalData = [],
): SupplierProcessor {
$persist = new class implements ProcessorInterface {
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
return $data;
}
};
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
);
$security->method('getUser')->willReturn($user);
$requestStack = new RequestStack();
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
$uow = $this->createMock(UnitOfWork::class);
$uow->method('getOriginalEntityData')->willReturn($originalData);
$em = $this->createMock(EntityManagerInterface::class);
$em->method('contains')->willReturn($managed);
$em->method('getUnitOfWork')->willReturn($uow);
return new SupplierProcessor(
$persist,
new SupplierFieldNormalizer(),
new SupplierInformationCompletenessValidator(),
$security,
$requestStack,
$em,
);
}
private function minimalSupplier(): Supplier
{
$supplier = new Supplier();
$supplier->setCompanyName('Test Co');
return $supplier;
}
private function completeInformationSupplier(): Supplier
{
$supplier = $this->minimalSupplier();
$supplier->setDescription('desc');
$supplier->setCompetitors('concurrents');
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
$supplier->setEmployeesCount(10);
$supplier->setRevenueAmount('1000.00');
$supplier->setDirectorName('Marie Durand');
$supplier->setProfitAmount('100.00');
$supplier->setVolumeForecast(500);
return $supplier;
}
private function operation(): Operation
{
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
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return BusinessRoles::COMMERCIALE === $roleCode;
}
public function getRoles(): array
{
return ['ROLE_USER'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'commerciale-test';
}
};
}
}
+36 -12
View File
@@ -90,6 +90,26 @@ abstract class AbstractApiTestCase extends ApiTestCase
* @return array{username: string, password: string} Les identifiants pour authenticatedClient()
*/
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) {
self::bootKernel();
@@ -97,17 +117,6 @@ abstract class AbstractApiTestCase extends ApiTestCase
$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);
$username = 'testuser_'.$suffix;
$password = 'testpass';
@@ -116,7 +125,22 @@ abstract class AbstractApiTestCase extends ApiTestCase
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$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);
$user = new User();