Compare commits

..

5 Commits

Author SHA1 Message Date
Matthieu 380b0c9ef1 test(commercial) : SupplierExportControllerTest sur base fournisseurs (catégories FOURNISSEUR, dédup F3) (ERP-113)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m6s
Fait étendre SupplierExportControllerTest à AbstractSupplierApiTestCase au
lieu d'AbstractCommercialApiTestCase. Supprime le seedSupplier() privé (qui
seedait des catégories de type CLIENT via createCategory(), violant RG-2.10)
et le tearDown() redondant, désormais portés par la base sur des catégories
FOURNISSEUR.

Le contact principal utilise le helper addContact() de la base ; le téléphone
secondaire, non porté par ce helper, est posé via le setter sur le contact
retourné. L'assertion de la colonne Catégories dérive le libellé attendu de
supplierCategory('NEGOCIANT') au lieu de hardcoder le préfixe de nom de test.
2026-06-08 09:49:33 +02:00
Matthieu 1343c1b797 test(commercial) : fix CI anti-N+1 (profiling test) + durcissement 422/gating M2 fournisseurs (ERP-92)
- config/packages/test/doctrine.yaml : force dbal profiling en test pour que
  doctrine.debug_data_holder existe sous APP_DEBUG=0 (CI). Le test anti-N+1
  SupplierListTest passait en local (debug=1) mais cassait en CI.
- RBACMatrix/SupplierApi : les 422 RG-2.03 et RG-2.14 assertent desormais le
  propertyPath / message (plus seulement le code) — un 422 orthogonal ne peut
  plus faire passer le test.
- RBACMatrix : gating bureau/commerciale verifie l'ensemble des champs
  comptables (accountNumber/nTva/tvaMode/paymentType), plus seulement siren/ribs.
- violationsByPath() mutualise dans AbstractSupplierApiTestCase (dedup).
2026-06-08 09:49:33 +02:00
Matthieu 2a17e9c45c test(commercial) : tests PHPUnit M2 fournisseurs (matrice RG + contrat sérialisation + DoD JSON réel) (ERP-92)
Suite fonctionnelle M2 assertant sur le CORPS JSON (jamais les annotations),
jumelle de la suite clients M1 :
- contrat de sérialisation : 4 régressions M1 re-testées (RIB gaté absent pour
  Commerciale, booléens triageProvider/isArchived présents, embed
  categories[].code/name, embed sites[].name/postalCode objet) + enveloppe AP4
  (member/totalItems/view, archivés exclus) + suppression du contact inline ;
- matrice RBAC réelle (app:seed-rbac) bureau/compta/commerciale/usine 200/403,
  gating accounting par omission de clé, mode strict PATCH (RG-2.16) ;
- RG-2.03/2.04/2.05/2.06/2.07/2.08/2.09/2.10/2.11/2.12/2.14/2.15/2.17 ;
- sous-ressources contacts/adresses/ribs (CRUD, sécurité, normalisation) ;
- anti N+1 liste (compte de requêtes constant), audit Supplier + RIB iban/bic.

Fix de contrat découvert et corrigé (sinon DoD figée sur un contrat faux) :
les référentiels comptables (TvaMode/PaymentType/PaymentDelay/Bank) ne portaient
que le groupe client:read:accounting (M1) → sur un fournisseur ils sortaient en
IRI nu. Ajout de supplier:read:accounting → objet {id, code, label} embarqué.

makefile : test-db-setup recrée l'index partiel uq_supplier_company_name_active
(droppé par schema:update comme pour le client) — oubli M2.

DoD § 4.0.bis : réponses JSON RÉELLES (liste + détail admin/commerciale) collées,
capturées via SupplierSerializationContractTest.
2026-06-08 09:49:33 +02: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
22 changed files with 2695 additions and 131 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
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.91'
app.version: '0.1.89'
+65 -48
View File
@@ -711,91 +711,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 +1063,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)
+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
+5 -4
View File
@@ -20,7 +20,8 @@ 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: [
@@ -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])]
@@ -20,7 +20,8 @@ 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: [
@@ -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,7 +23,8 @@ 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: [
@@ -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])]
@@ -24,7 +24,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
* 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: [
@@ -54,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])]
@@ -0,0 +1,510 @@
<?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
{
/** 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];
}
$category = $manager->getRepository(CategoryInterface::class)->findOneBy([
'name' => $name,
'deletedAt' => null,
]);
if (!$category instanceof CategoryInterface) {
throw new RuntimeException(sprintf(
'Categorie "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
$name,
));
}
return $this->categoryCache[$name] = $category;
}
/**
* 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;
}
}
@@ -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],
);
}
}
@@ -4,11 +4,8 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
@@ -23,24 +20,11 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
*
* @internal
*/
final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/suppliers/export.xlsx';
/**
* Les fournisseurs doivent etre purges AVANT les categories de test (le parent
* supprime les categories `test_cli_cat_*`) : la jointure supplier_category est
* en ON DELETE CASCADE cote supplier mais RESTRICT cote category. Le DELETE DQL
* sur Supplier declenche le cascade BDD sur supplier_category / _contact /
* _address (et leurs sous-jointures), liberant les categories pour le parent.
*/
protected function tearDown(): void
{
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
@@ -110,9 +94,13 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
$supplier = $this->seedSupplier('Contact Co');
// position 1 (secondaire) insere en premier...
$this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co');
$this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co');
$principal = $this->addContact($supplier, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
// directement sur le contact principal pour alimenter la colonne dediee.
$principal->setPhoneSecondary('0698765432');
$this->getEm()->flush();
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
@@ -149,8 +137,10 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie du fournisseur (getName()).
self::assertStringContainsString('test_cli_cat_negociant', $flat);
// 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);
}
@@ -206,50 +196,6 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(401);
}
/**
* Seede directement un Supplier en base (sans passer par l'API), pour les
* tests de liste / archivage. Stocke le nom en MAJUSCULES pour refleter l'etat
* normalise (RG-2.12) qu'aurait produit le SupplierProcessor via l'API.
*/
private function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): Supplier
{
$em = $this->getEm();
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$supplier->addCategory($this->createCategory($categoryCode));
$supplier->setIsArchived($isArchived);
if ($isArchived) {
$supplier->setArchivedAt(new DateTimeImmutable());
}
$em->persist($supplier);
$em->flush();
return $supplier;
}
private function addContact(
Supplier $supplier,
string $lastName,
string $firstName,
int $position,
?string $phonePrimary = null,
?string $phoneSecondary = null,
?string $email = null,
): void {
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setLastName($lastName);
$contact->setFirstName($firstName);
$contact->setPosition($position);
$contact->setPhonePrimary($phonePrimary);
$contact->setPhoneSecondary($phoneSecondary);
$contact->setEmail($email);
$supplier->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
@@ -284,7 +230,7 @@ final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
/**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
*
* @return array<int, mixed>|null
* @return null|array<int, mixed>
*/
private function rowFor(string $binary, string $companyName): ?array
{
@@ -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,357 @@
<?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
{
$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());
}
}