Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08281613b9 |
@@ -1,12 +0,0 @@
|
|||||||
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
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.95'
|
app.version: '0.1.91'
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `
|
|||||||
|
|
||||||
Notes (miroir M1) :
|
Notes (miroir M1) :
|
||||||
- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global).
|
- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global).
|
||||||
- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back). Mécanisme réel (le code fait foi) : le groupe de lecture `supplier:read:accounting` n'est **pas** dans le contexte de sérialisation par défaut ; le `SupplierReadGroupContextBuilder` ne l'**ajoute** dynamiquement que si l'utilisateur porte `commercial.suppliers.accounting.view` (gating **par ajout** de groupe, jamais par retrait). Sans la permission, les champs comptables (et les RIB) ne sont donc jamais sérialisés. La colonne SIREN de l'export XLSX suit la même règle (`accounting.view`).
|
- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`).
|
||||||
- **Bureau** : `view` + `manage` (tout sauf Comptabilité).
|
- **Bureau** : `view` + `manage` (tout sauf Comptabilité).
|
||||||
- **Usine** : aucune permission → item sidebar invisible, accès direct 403.
|
- **Usine** : aucune permission → item sidebar invisible, accès direct 403.
|
||||||
|
|
||||||
@@ -159,11 +159,9 @@ final class SupplierFieldNormalizer
|
|||||||
|
|
||||||
Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls).
|
Le formatage `XX XX XX XX XX` est fait à l'affichage côté front. Le back stocke `0612345678` (chiffres seuls).
|
||||||
|
|
||||||
### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/ERP-62)
|
### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62)
|
||||||
|
|
||||||
Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis.
|
Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
|
||||||
|
|
||||||
Conséquence performance — **implémentation réelle (le code fait foi)** : le `DoctrineSupplierRepository` **ne fetch-joine PAS** les to-many dans la requête de sélection (`createListQueryBuilder` ne fait que filtres + tri). L'anti-N+1 passe par `hydrateListCollections()` (puis `hydrateContacts()`) : une fois le jeu de fournisseurs borné (page ou export), des requêtes **`IN` bornées séparées** remplissent `categories`, puis `addresses.sites`, puis `contacts` sur les **mêmes** instances `Supplier` (identity map). Ce découpage évite le **produit cartésien** qu'un fetch-join combiné `categories × addresses.sites` imposerait aux chemins non paginés (export, `?pagination=false`). Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas.
|
|
||||||
|
|
||||||
> Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé.
|
> Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé.
|
||||||
|
|
||||||
@@ -215,8 +213,6 @@ Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrati
|
|||||||
|
|
||||||
> **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '<table>')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis).
|
> **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '<table>')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis).
|
||||||
|
|
||||||
> **Types réels de la migration (le code fait foi)** : le SQL ci-dessous est *illustratif*. La migration mergée (`Version20260605130000`) utilise le **style aligné M1** : clés primaires en `INT GENERATED BY DEFAULT AS IDENTITY` (et **non** `SERIAL`) et horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (et **non** `TIMESTAMPTZ`, car le `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que `schema:update` reste un no-op une fois les entités mappées.
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés)
|
-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés)
|
||||||
@@ -426,10 +422,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
// Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
|
// Cohérence M1/ERP-62 : la LISTE embarque catégories + sites (pas de
|
||||||
// champ dérivé aplati). Maillon (c) : category:read + site:read dans
|
// champ dérivé aplati). Maillon (c) : category:read + site:read dans
|
||||||
// le contexte pour exposer Category(code/name) + Site(name/postalCode).
|
// le contexte pour exposer Category(code/name) + Site(name/postalCode).
|
||||||
// ⚠ Anti-N+1 : pas de fetch-join dans la requête de liste — le
|
// ⚠ Le SupplierRepository DOIT fetch-join categories + addresses.sites
|
||||||
// SupplierRepository hydrate categories/sites/contacts via des requêtes
|
// pour éviter le N+1 sur la liste (cf. § 2.12).
|
||||||
// IN bornées séparées (hydrateListCollections), pour éviter le produit
|
|
||||||
// cartésien sur les chemins non paginés (export) — cf. § 2.12.
|
|
||||||
normalizationContext: ['groups' => [
|
normalizationContext: ['groups' => [
|
||||||
'supplier:read',
|
'supplier:read',
|
||||||
'category:read',
|
'category:read',
|
||||||
@@ -448,14 +442,13 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
normalizationContext: ['groups' => [
|
normalizationContext: ['groups' => [
|
||||||
'supplier:read',
|
'supplier:read',
|
||||||
'supplier:item:read', // embed contacts / addresses
|
'supplier:item:read', // embed contacts / addresses
|
||||||
// ⚠ supplier:read:accounting est volontairement ABSENT ici : il est
|
'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view)
|
||||||
// AJOUTÉ dynamiquement par le SupplierReadGroupContextBuilder quand
|
|
||||||
// l'user porte accounting.view (gating par ajout, pas par retrait —
|
|
||||||
// parade bug #4 M1). Il porte les scalaires compta + l'embed ribs.
|
|
||||||
'category:read', // embed des Category (id/code/name) — relation imbriquée
|
'category:read', // embed des Category (id/code/name) — relation imbriquée
|
||||||
'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
|
'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée
|
||||||
'default:read',
|
'default:read',
|
||||||
]],
|
]],
|
||||||
|
// Le Provider RETIRE supplier:read:accounting du contexte si l'user
|
||||||
|
// n'a pas is_granted('commercial.suppliers.accounting.view').
|
||||||
provider: SupplierProvider::class,
|
provider: SupplierProvider::class,
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
@@ -465,13 +458,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
processor: SupplierProcessor::class,
|
processor: SupplierProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
// Security élargie : `manage` OU `accounting.manage` — le rôle Compta
|
security: "is_granted('commercial.suppliers.manage')",
|
||||||
// n'a pas `manage` mais doit pouvoir éditer l'onglet Comptabilité d'un
|
// Le SupplierProcessor inspecte les groupes envoyés pour autoriser
|
||||||
// fournisseur existant (§ 2.9). Le SupplierProcessor re-gate ensuite
|
// onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables
|
||||||
// onglet par onglet (mode strict RG-2.16) :
|
// exige is_granted('commercial.suppliers.accounting.manage') ;
|
||||||
security: "is_granted('commercial.suppliers.manage') or is_granted('commercial.suppliers.accounting.manage')",
|
|
||||||
// Patch des champs comptables exige accounting.manage (guardAccounting) ;
|
|
||||||
// champs main/information exigent manage (guardManage) ;
|
|
||||||
// patch isArchived exige is_granted('commercial.suppliers.archive').
|
// patch isArchived exige is_granted('commercial.suppliers.archive').
|
||||||
normalizationContext: ['groups' => ['supplier:read', 'default:read']],
|
normalizationContext: ['groups' => ['supplier:read', 'default:read']],
|
||||||
denormalizationContext: ['groups' => [
|
denormalizationContext: ['groups' => [
|
||||||
@@ -721,108 +711,91 @@ Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamable
|
|||||||
| Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` |
|
| Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` |
|
||||||
| `ribs[]` (label/bic/iban) | `ribs` ∈ `supplier:read:accounting` | ✅ (gated) | — |
|
| `ribs[]` (label/bic/iban) | `ribs` ∈ `supplier:read:accounting` | ✅ (gated) | — |
|
||||||
|
|
||||||
### 4.0.bis Réponses JSON de référence (DoD — RÉELLES, capturées ERP-92)
|
### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle)
|
||||||
|
|
||||||
> **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.
|
> **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.
|
||||||
>
|
|
||||||
> **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`.
|
> **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?search=…` (liste, ADMIN — un membre) :
|
`GET /api/suppliers` (liste, ADMIN) :
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@context": "/api/contexts/Supplier",
|
"@context": "/api/contexts/Supplier",
|
||||||
"@id": "/api/suppliers",
|
"@id": "/api/suppliers",
|
||||||
"@type": "Collection",
|
"@type": "Collection",
|
||||||
"totalItems": 1,
|
"totalItems": 13,
|
||||||
"member": [
|
"member": [
|
||||||
{
|
{
|
||||||
"@id": "/api/suppliers/85",
|
"@id": "/api/suppliers/1",
|
||||||
"@type": "Supplier",
|
"@type": "Supplier",
|
||||||
"id": 85,
|
"id": 1,
|
||||||
"companyName": "DOD59393F 862875",
|
"companyName": "RECYCLA SAS",
|
||||||
"categories": [
|
"categories": [
|
||||||
{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT",
|
{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
|
||||||
"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": [
|
"sites": [
|
||||||
{"@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"},
|
{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
|
||||||
{"@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"}
|
{"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#…"}
|
||||||
],
|
],
|
||||||
|
"updatedAt": "2026-02-17T09:30:00+00:00",
|
||||||
"isArchived": false
|
"isArchived": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"view": {"@id": "/api/suppliers?search=…", "@type": "PartialCollectionView"}
|
"view": {
|
||||||
|
"@id": "/api/suppliers?page=1",
|
||||||
|
"@type": "PartialCollectionView",
|
||||||
|
"first": "/api/suppliers?page=1",
|
||||||
|
"last": "/api/suppliers?page=2",
|
||||||
|
"next": "/api/suppliers?page=2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 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.
|
> 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).
|
||||||
|
|
||||||
`GET /api/suppliers/85` (détail — user avec `accounting.view`) :
|
`GET /api/suppliers/1` (détail — user avec `accounting.view`) :
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@context": "/api/contexts/Supplier",
|
"@id": "/api/suppliers/1",
|
||||||
"@id": "/api/suppliers/85",
|
|
||||||
"@type": "Supplier",
|
"@type": "Supplier",
|
||||||
"id": 85,
|
"id": 1,
|
||||||
"companyName": "DOD59393F 862875",
|
"companyName": "RECYCLA SAS",
|
||||||
"categories": [
|
"categories": [
|
||||||
{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT",
|
{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
|
||||||
"categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"}}
|
|
||||||
],
|
],
|
||||||
"description": "Fournisseur de test complet.", "competitors": "Concurrent A, Concurrent B",
|
"description": "…", "competitors": "…", "foundedAt": "2008-04-01",
|
||||||
"foundedAt": "2008-04-01T00:00:00+02:00", "employeesCount": 42, "revenueAmount": "1500000.00",
|
"employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…",
|
||||||
"directorName": "Jean Dupont", "profitAmount": "120000.00", "volumeForecast": 8000,
|
"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": [
|
"contacts": [
|
||||||
{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin",
|
{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin",
|
||||||
"jobTitle": "Responsable achats", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
|
"jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null,
|
||||||
|
"email": "marie.martin@recycla.fr"}
|
||||||
],
|
],
|
||||||
"addresses": [
|
"addresses": [
|
||||||
{"@id": "/api/supplier_addresses/33", "@type": "SupplierAddress", "id": 33, "addressType": "DEPART",
|
{"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART",
|
||||||
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
"country": "France", "postalCode": "86000", "city": "Poitiers",
|
||||||
|
"street": "12 rue des Acacias", "streetComplement": null,
|
||||||
"bennes": 3, "triageProvider": true,
|
"bennes": 3, "triageProvider": true,
|
||||||
"sites": [
|
"sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
|
||||||
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"},
|
"categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}],
|
||||||
{"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00"}
|
"contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]}
|
||||||
],
|
|
||||||
"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": [
|
"ribs": [
|
||||||
{"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
|
{"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal",
|
||||||
|
"bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"}
|
||||||
],
|
],
|
||||||
"isArchived": false
|
"isArchived": false, "archivedAt": null,
|
||||||
|
"updatedAt": "2026-02-17T09:30:00+00:00"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 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).
|
> 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.
|
||||||
|
|
||||||
### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE)
|
### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE)
|
||||||
|
|
||||||
@@ -1073,7 +1046,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] 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**
|
- [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**
|
||||||
- [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
|
- [ ] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)*
|
||||||
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16)
|
- [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] 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)
|
- [x] Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, `usePaginatedList`, blocs, archive, normalisation)
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ 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_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_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_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:
|
fixtures:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||||
|
|||||||
@@ -82,14 +82,14 @@ final class Version20260605130000 extends AbstractMigration
|
|||||||
// Ordre inverse des dependances FK : jointures et sous-collections
|
// Ordre inverse des dependances FK : jointures et sous-collections
|
||||||
// d'abord, puis supplier. Les referentiels comptables et le
|
// d'abord, puis supplier. Les referentiels comptables et le
|
||||||
// CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs).
|
// CategoryType FOURNISSEUR ne sont pas touches (crees ailleurs).
|
||||||
$this->addSql('DROP TABLE IF EXISTS supplier_address_category');
|
$this->addSql('DROP TABLE supplier_address_category');
|
||||||
$this->addSql('DROP TABLE IF EXISTS supplier_address_contact');
|
$this->addSql('DROP TABLE supplier_address_contact');
|
||||||
$this->addSql('DROP TABLE IF EXISTS supplier_address_site');
|
$this->addSql('DROP TABLE supplier_address_site');
|
||||||
$this->addSql('DROP TABLE IF EXISTS supplier_rib');
|
$this->addSql('DROP TABLE supplier_rib');
|
||||||
$this->addSql('DROP TABLE IF EXISTS supplier_address');
|
$this->addSql('DROP TABLE supplier_address');
|
||||||
$this->addSql('DROP TABLE IF EXISTS supplier_contact');
|
$this->addSql('DROP TABLE supplier_contact');
|
||||||
$this->addSql('DROP TABLE IF EXISTS supplier_category');
|
$this->addSql('DROP TABLE supplier_category');
|
||||||
$this->addSql('DROP TABLE IF EXISTS supplier');
|
$this->addSql('DROP TABLE supplier');
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|||||||
+1
-4
@@ -43,10 +43,7 @@ final class ClientInformationCompletenessValidator
|
|||||||
foreach ($fields as $property => $value) {
|
foreach ($fields as $property => $value) {
|
||||||
if ($this->isMissing($value)) {
|
if ($this->isMissing($value)) {
|
||||||
$violations->add(new ConstraintViolation(
|
$violations->add(new ConstraintViolation(
|
||||||
// Pas de nom de champ technique dans le message : la violation est
|
sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
|
||||||
// deja rattachee au bon champ via son propertyPath (mappe inline
|
|
||||||
// cote front par useFormErrors).
|
|
||||||
'Ce champ est obligatoire pour le rôle Commerciale.',
|
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
$client,
|
$client,
|
||||||
|
|||||||
+1
-4
@@ -47,10 +47,7 @@ final class SupplierInformationCompletenessValidator
|
|||||||
foreach ($fields as $property => $value) {
|
foreach ($fields as $property => $value) {
|
||||||
if ($this->isMissing($value)) {
|
if ($this->isMissing($value)) {
|
||||||
$violations->add(new ConstraintViolation(
|
$violations->add(new ConstraintViolation(
|
||||||
// Pas de nom de champ technique dans le message : la violation est
|
sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property),
|
||||||
// deja rattachee au bon champ via son propertyPath (mappe inline
|
|
||||||
// cote front par useFormErrors).
|
|
||||||
'Ce champ est obligatoire pour le rôle Commerciale.',
|
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
$supplier,
|
$supplier,
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
|
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
|
||||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
* 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(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -48,15 +47,15 @@ class Bank
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
|||||||
private ?string $description = null;
|
private ?string $description = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
#[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||||
#[Groups(['client:read', 'client:write:information'])]
|
#[Groups(['client:read', 'client:write:information'])]
|
||||||
private ?string $competitors = null;
|
private ?string $competitors = null;
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
|
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
|
||||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
* 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(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -48,15 +47,15 @@ class PaymentDelay
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
|
* permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de
|
||||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
* 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(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -51,15 +50,15 @@ class PaymentType
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
|||||||
private ?string $description = null;
|
private ?string $description = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
#[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||||
private ?string $competitors = null;
|
private ?string $competitors = null;
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
|
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
||||||
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
||||||
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
|
* d'un Client (onglet Comptabilite) au lieu d'un IRI.
|
||||||
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0).
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -55,15 +54,15 @@ class TvaMode
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -121,12 +121,6 @@ final class ClientProcessor implements ProcessorInterface
|
|||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reinitialisation de la memoisation du payload en debut de traitement :
|
|
||||||
// le service est partage (stateful), on repart du corps de LA requete
|
|
||||||
// courante et on n'herite jamais des cles decodees d'une requete passee.
|
|
||||||
$this->decodedContent = null;
|
|
||||||
$this->decodedPayloadKeys = [];
|
|
||||||
|
|
||||||
$writableKeys = $this->writablePayloadKeys();
|
$writableKeys = $this->writablePayloadKeys();
|
||||||
|
|
||||||
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
|
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
|
||||||
|
|||||||
@@ -114,12 +114,6 @@ final class SupplierProcessor implements ProcessorInterface
|
|||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reinitialisation de la memoisation du payload en debut de traitement :
|
|
||||||
// le service est partage (stateful), on repart du corps de LA requete
|
|
||||||
// courante et on n'herite jamais des cles decodees d'une requete passee.
|
|
||||||
$this->decodedContent = null;
|
|
||||||
$this->decodedPayloadKeys = [];
|
|
||||||
|
|
||||||
$writableKeys = $this->writablePayloadKeys();
|
$writableKeys = $this->writablePayloadKeys();
|
||||||
|
|
||||||
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
|
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
|
||||||
|
|||||||
@@ -57,11 +57,6 @@ final class SupplierExportController
|
|||||||
#[IsGranted('commercial.suppliers.view')]
|
#[IsGranted('commercial.suppliers.view')]
|
||||||
public function __invoke(Request $request): Response
|
public function __invoke(Request $request): Response
|
||||||
{
|
{
|
||||||
// Memes filtres d'archivage que la vue liste (SupplierProvider) pour que
|
|
||||||
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
|
|
||||||
// - includeArchived : inclut les archives en plus des actifs ;
|
|
||||||
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
|
|
||||||
// createListQueryBuilder).
|
|
||||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||||
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||||
$search = $request->query->getString('search') ?: null;
|
$search = $request->query->getString('search') ?: null;
|
||||||
|
|||||||
@@ -1,524 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Commercial\Infrastructure\DataFixtures;
|
|
||||||
|
|
||||||
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
|
|
||||||
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
|
|
||||||
use App\Module\Commercial\Domain\Entity\Bank;
|
|
||||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
|
||||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
|
||||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
|
||||||
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
|
||||||
use App\Module\Commercial\Domain\Entity\SupplierContact;
|
|
||||||
use App\Module\Commercial\Domain\Entity\SupplierRib;
|
|
||||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
|
||||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
|
||||||
use App\Shared\Domain\Contract\CategoryInterface;
|
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
|
||||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
|
||||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
|
||||||
use Doctrine\Persistence\ObjectManager;
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixtures dev/test du module Commercial : ~13 fournisseurs de demonstration
|
|
||||||
* couvrant l'ensemble des cas metier RG-2.xx du repertoire fournisseurs (M2),
|
|
||||||
* jumelles des fixtures Client (ERP-68). Theme metier : negoce / recyclage de
|
|
||||||
* metaux (d'ou les champs `bennes` et `triageProvider` sur les adresses).
|
|
||||||
*
|
|
||||||
* Cas pivots couverts (criteres d'acceptation ERP-112) :
|
|
||||||
* - reglement VIREMENT avec banque renseignee (RG-2.07) ;
|
|
||||||
* - reglement LCR avec 1 puis 2 RIB (RG-2.08) ; CHEQUE et NON_SOUMISE sans RIB ;
|
|
||||||
* - adresses multi-types PROSPECT / DEPART / RENDU (RG-2.09) et multi-sites
|
|
||||||
* (86 / 17 / 82, RG-2.06) ; bennes + prestataire de triage ;
|
|
||||||
* - 1 a 3 contacts dont un avec telephone secondaire et un nomme par le seul
|
|
||||||
* nom (RG-2.04) ;
|
|
||||||
* - 2 fournisseurs archives (isArchived + archivedAt) pour l'exclusion de la
|
|
||||||
* liste (RG-2.17) ;
|
|
||||||
* - mono et multi-categories de type FOURNISSEUR (RG-2.10) ;
|
|
||||||
* - onglet Information complet (dont volumeForecast, specifique fournisseur).
|
|
||||||
*
|
|
||||||
* Resolution inter-modules conforme a la regle n°1 (pas d'import direct) :
|
|
||||||
* - categories resolues via le contrat Shared CategoryInterface
|
|
||||||
* (resolve_target_entities -> Category) ;
|
|
||||||
* - sites resolus via le contrat Shared SiteProviderInterface.
|
|
||||||
*
|
|
||||||
* Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones
|
|
||||||
* formates) et normalisees par SupplierFieldNormalizer avant persist, exactement
|
|
||||||
* comme le ferait le SupplierProcessor via l'API (companyName UPPERCASE,
|
|
||||||
* first/last Capitalize, telephones chiffres seuls, emails lowercase).
|
|
||||||
*
|
|
||||||
* Coherence gating comptable (RG-2.16) : les scalaires comptables (siren,
|
|
||||||
* tvaMode, paymentType, bank...) et les RIB ne sont visibles qu'avec
|
|
||||||
* accounting.view. Les donnees sont posees pour que les roles SANS cette
|
|
||||||
* permission (ex. Commerciale) ne voient pas de compta — support des tests
|
|
||||||
* ERP-92 et du golden path front.
|
|
||||||
*
|
|
||||||
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
|
|
||||||
* partiel uq_supplier_company_name_active). Un fournisseur deja present n'est pas
|
|
||||||
* reconstruit (ses sous-collections ne sont pas redupliquees). Rejouable sans
|
|
||||||
* doublon meme si le purger Doctrine est desactive.
|
|
||||||
*
|
|
||||||
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
|
|
||||||
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
|
|
||||||
* les CHECK BDD (chk_supplier_contact_name : firstName OU lastName ;
|
|
||||||
* chk_supplier_address_type : PROSPECT | DEPART | RENDU) ET la coherence des
|
|
||||||
* validators d'entite (RG-2.07/2.08 : VIREMENT => banque, LCR => >= 1 RIB).
|
|
||||||
*
|
|
||||||
* Depend de CategoryFixtures (categories FOURNISSEUR), SitesFixtures (sites) et
|
|
||||||
* CommercialReferentialFixtures (referentiels comptables — REUTILISES de M1,
|
|
||||||
* aucune nouvelle table).
|
|
||||||
*
|
|
||||||
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
|
|
||||||
* la fixture ne charge rien : les tests seedent et nettoient leurs propres
|
|
||||||
* fournisseurs et comptent sur une table `supplier` vierge — y injecter 13
|
|
||||||
* fournisseurs de demo casserait les comptages de liste et les cleanups. Meme
|
|
||||||
* garde-fou que ClientFixtures / CategoryFixtures.
|
|
||||||
*/
|
|
||||||
class SupplierFixtures extends Fixture implements DependentFixtureInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Type de categorie exige pour un fournisseur et ses adresses (RG-2.10).
|
|
||||||
* Miroir de Supplier::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
|
|
||||||
*/
|
|
||||||
private const string SUPPLIER_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
|
||||||
|
|
||||||
/** Cache des categories resolues par nom (evite des requetes repetees). */
|
|
||||||
private array $categoryCache = [];
|
|
||||||
|
|
||||||
/** Cache des sites resolus par nom. */
|
|
||||||
private array $siteCache = [];
|
|
||||||
|
|
||||||
/** ObjectManager courant, capture en debut de load (resolution categories). */
|
|
||||||
private ObjectManager $manager;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly SupplierFieldNormalizer $normalizer,
|
|
||||||
private readonly SiteProviderInterface $siteProvider,
|
|
||||||
#[Autowire('%kernel.environment%')]
|
|
||||||
private readonly string $environment,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, class-string>
|
|
||||||
*/
|
|
||||||
public function getDependencies(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
CategoryFixtures::class,
|
|
||||||
SitesFixtures::class,
|
|
||||||
CommercialReferentialFixtures::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function load(ObjectManager $manager): void
|
|
||||||
{
|
|
||||||
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
|
|
||||||
if ('test' === $this->environment) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->manager = $manager;
|
|
||||||
|
|
||||||
// === Fournisseur basique — VIREMENT + banque (RG-2.07), compta complete ===
|
|
||||||
[$negoce, $isNew] = $this->ensureSupplier($manager, 'Négoce Métaux Atlantique', ['Négociant']);
|
|
||||||
if ($isNew) {
|
|
||||||
$negoce->setSiren('841611054');
|
|
||||||
$negoce->setAccountNumber('F0001');
|
|
||||||
$negoce->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
|
||||||
$negoce->setNTva('FR12841611054');
|
|
||||||
$negoce->setPaymentDelay($this->paymentDelay($manager, 'J30'));
|
|
||||||
$negoce->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
|
||||||
$negoce->setBank($this->bank($manager, 'SG'));
|
|
||||||
$this->addContact($negoce, 'Jean', 'Dubois', 'Responsable achats', '05 49 00 00 01', null, 'jean.dubois@negoce-metaux.fr');
|
|
||||||
$this->addAddress($negoce, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '12 rue de la Ferraille', bennes: 4, triageProvider: true, categoryNames: ['Négociant']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === LCR avec 1 RIB (RG-2.08) + 2 contacts ===
|
|
||||||
[$coop, $isNew] = $this->ensureSupplier($manager, 'Coopérative Agricole du Sud-Ouest', ['Coopérative']);
|
|
||||||
if ($isNew) {
|
|
||||||
$coop->setSiren('775680459');
|
|
||||||
$coop->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
|
||||||
$coop->setPaymentDelay($this->paymentDelay($manager, 'J15'));
|
|
||||||
$coop->setPaymentType($this->paymentType($manager, 'LCR'));
|
|
||||||
$this->addContact($coop, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@coop-so.fr', 0);
|
|
||||||
$this->addContact($coop, 'Marc', 'Girard', 'Acheteur', '05 56 10 20 31', null, 'marc.girard@coop-so.fr', 1);
|
|
||||||
$this->addAddress($coop, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '8 route des Cooperateurs', bennes: 2);
|
|
||||||
$this->addRib($coop, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Prospect seul (adresse PROSPECT), compta minimale ===
|
|
||||||
[$producteur, $isNew] = $this->ensureSupplier($manager, 'Producteur Bio Charente', ['Producteur']);
|
|
||||||
if ($isNew) {
|
|
||||||
$this->addContact($producteur, 'Claire', 'Moreau', 'Gérante', '05 49 21 22 23', null, 'claire.moreau@bio-charente.fr');
|
|
||||||
$this->addAddress($producteur, 'PROSPECT', ['Saint-Jean'], '17400', 'Fontenet', '1 chemin des Producteurs');
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Multi-categories M2M + LCR avec 2 RIB + 3 contacts ===
|
|
||||||
[$grossiste, $isNew] = $this->ensureSupplier($manager, 'Grossiste Multi-Métaux', ['Grossiste', 'Négociant']);
|
|
||||||
if ($isNew) {
|
|
||||||
$grossiste->setSiren('552081317');
|
|
||||||
$grossiste->setAccountNumber('F0004');
|
|
||||||
$grossiste->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
|
||||||
$grossiste->setNTva('FR45552081317');
|
|
||||||
$grossiste->setPaymentDelay($this->paymentDelay($manager, 'J30'));
|
|
||||||
$grossiste->setPaymentType($this->paymentType($manager, 'LCR'));
|
|
||||||
$this->addContact($grossiste, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@grossiste-mm.fr', 0);
|
|
||||||
$this->addContact($grossiste, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@grossiste-mm.fr', 1);
|
|
||||||
$this->addContact($grossiste, 'Hélène', 'Faure', 'Logistique', '05 56 31 32 35', null, 'helene.faure@grossiste-mm.fr', 2);
|
|
||||||
$this->addAddress($grossiste, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '20 zone des Activités', streetComplement: 'Bâtiment C', bennes: 6, triageProvider: true, categoryNames: ['Grossiste', 'Négociant']);
|
|
||||||
$this->addRib($grossiste, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
|
|
||||||
$this->addRib($grossiste, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630001007941234567890185', 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === VIREMENT + banque, TVA intracom (importateur), multi-sites sur l'adresse ===
|
|
||||||
[$import, $isNew] = $this->ensureSupplier($manager, 'Import Recyclage International', ['Importateur']);
|
|
||||||
if ($isNew) {
|
|
||||||
$import->setSiren('409512012');
|
|
||||||
$import->setTvaMode($this->tvaMode($manager, 'INTRACOM_VENTES'));
|
|
||||||
$import->setNTva('FR90409512012');
|
|
||||||
$import->setPaymentDelay($this->paymentDelay($manager, 'J30'));
|
|
||||||
$import->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
|
||||||
$import->setBank($this->bank($manager, 'CIC'));
|
|
||||||
$this->addContact($import, 'Paul', 'Garnier', 'Import manager', '05 56 44 55 66', null, 'paul.garnier@import-recyclage.fr', 0);
|
|
||||||
$this->addContact($import, null, 'Bernard', 'Douanes', '05 56 44 55 67', null, 'douanes@import-recyclage.fr', 1);
|
|
||||||
$this->addAddress($import, 'RENDU', ['Pommevic', 'Saint-Jean'], '82400', 'Pommevic', '3 quai des Importateurs', bennes: 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Multi-adresses PROSPECT / DEPART / RENDU (RG-2.09) + VIREMENT/banque ===
|
|
||||||
[$ferrailleur, $isNew] = $this->ensureSupplier($manager, 'Ferrailleur Grand Ouest', ['Négociant']);
|
|
||||||
if ($isNew) {
|
|
||||||
$ferrailleur->setSiren('732829320');
|
|
||||||
$ferrailleur->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
|
||||||
$ferrailleur->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
|
||||||
$ferrailleur->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
|
||||||
$ferrailleur->setBank($this->bank($manager, 'CA'));
|
|
||||||
$this->addContact($ferrailleur, 'Olivier', 'Renard', 'Responsable site', '05 49 61 62 63', null, 'olivier.renard@ferrailleur-go.fr', 0);
|
|
||||||
$this->addContact($ferrailleur, 'Nadia', 'Benali', 'Pesée', '05 49 61 62 64', null, 'nadia.benali@ferrailleur-go.fr', 1);
|
|
||||||
// Prospect (site en cours de demarchage).
|
|
||||||
$this->addAddress($ferrailleur, 'PROSPECT', ['Chatellerault'], '86100', 'Châtellerault', '5 avenue de la Prospection', position: 0);
|
|
||||||
// Depart (collecte) multi-sites avec bennes + triage.
|
|
||||||
$this->addAddress($ferrailleur, 'DEPART', ['Saint-Jean', 'Pommevic'], '17400', 'Fontenet', '4 rue de la Collecte', bennes: 5, triageProvider: true, categoryNames: ['Négociant'], position: 1);
|
|
||||||
// Rendu (livraison).
|
|
||||||
$this->addAddress($ferrailleur, 'RENDU', ['Pommevic'], '82400', 'Pommevic', '7 boulevard du Rendu', bennes: 3, position: 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Onglet Information complet (dont volumeForecast) + VIREMENT/banque ===
|
|
||||||
[$holding, $isNew] = $this->ensureSupplier($manager, 'Holding Recyclage Premium', ['Importateur']);
|
|
||||||
if ($isNew) {
|
|
||||||
$holding->setDescription('Holding de recyclage diversifiée, présente sur le Grand Sud-Ouest.');
|
|
||||||
$holding->setCompetitors('Groupe Atlantique Recyclage, Sud Métaux');
|
|
||||||
$holding->setFoundedAt(new DateTimeImmutable('2008-09-01'));
|
|
||||||
$holding->setEmployeesCount(180);
|
|
||||||
$holding->setRevenueAmount('24500000.00');
|
|
||||||
$holding->setDirectorName('Antoine Lefèvre');
|
|
||||||
$holding->setProfitAmount('1850000.00');
|
|
||||||
$holding->setVolumeForecast(120000);
|
|
||||||
$holding->setSiren('318471925');
|
|
||||||
$holding->setAccountNumber('F0007');
|
|
||||||
$holding->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
|
||||||
$holding->setNTva('FR33318471925');
|
|
||||||
$holding->setPaymentDelay($this->paymentDelay($manager, 'J30'));
|
|
||||||
$holding->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
|
||||||
$holding->setBank($this->bank($manager, 'SG'));
|
|
||||||
$this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-recyclage.fr');
|
|
||||||
$this->addAddress($holding, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '1 allée des Investisseurs', bennes: 5, triageProvider: true, categoryNames: ['Importateur']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Coop minimale — contact par le seul nom (RG-2.04), sans compta ===
|
|
||||||
[$coopMin, $isNew] = $this->ensureSupplier($manager, 'Coop Métaux Réunis', ['Coopérative']);
|
|
||||||
if ($isNew) {
|
|
||||||
$this->addContact($coopMin, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@coop-metaux-reunis.fr');
|
|
||||||
$this->addAddress($coopMin, 'DEPART', ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village');
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Reglement CHEQUE (sans banque ni RIB requis) ===
|
|
||||||
[$petit, $isNew] = $this->ensureSupplier($manager, 'Petit Négoce Local', ['Négociant']);
|
|
||||||
if ($isNew) {
|
|
||||||
$petit->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
|
||||||
$petit->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
|
||||||
$petit->setPaymentType($this->paymentType($manager, 'CHEQUE'));
|
|
||||||
$this->addContact($petit, 'Luc', 'Martin', 'Gérant', '05 56 71 72 73', null, 'luc.martin@petit-negoce.fr');
|
|
||||||
$this->addAddress($petit, 'RENDU', ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Commerce');
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Reglement NON_SOUMISE + adresse multi-sites avec triage ===
|
|
||||||
[$recup, $isNew] = $this->ensureSupplier($manager, 'Récupération Métaux Express', ['Grossiste']);
|
|
||||||
if ($isNew) {
|
|
||||||
$recup->setSiren('490212019');
|
|
||||||
$recup->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
|
||||||
$recup->setPaymentDelay($this->paymentDelay($manager, 'J15'));
|
|
||||||
$recup->setPaymentType($this->paymentType($manager, 'NON_SOUMISE'));
|
|
||||||
$this->addContact($recup, 'Marie', 'Lemoine', 'Responsable', '05 49 77 88 99', null, 'marie.lemoine@recup-express.fr', 0);
|
|
||||||
$this->addContact($recup, 'Pierre', 'Durand', 'Chauffeur', '05 49 77 88 98', null, 'pierre.durand@recup-express.fr', 1);
|
|
||||||
$this->addAddress($recup, 'DEPART', ['Saint-Jean', 'Chatellerault'], '17400', 'Fontenet', '10 zone industrielle', bennes: 7, triageProvider: true, categoryNames: ['Grossiste']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Centre de tri — focus bennes/triage + multi-categories ===
|
|
||||||
[$centre, $isNew] = $this->ensureSupplier($manager, 'Centre de Tri Sud', ['Producteur', 'Coopérative']);
|
|
||||||
if ($isNew) {
|
|
||||||
$centre->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
|
||||||
$this->addContact($centre, 'Camille', 'Faure', 'Chef de centre', '05 56 91 92 93', null, 'camille.faure@centre-tri-sud.fr');
|
|
||||||
$this->addAddress($centre, 'DEPART', ['Pommevic'], '82400', 'Pommevic', '2 route du Tri', bennes: 12, triageProvider: true, categoryNames: ['Producteur']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Fournisseur archive #1 (RG-2.17) ===
|
|
||||||
[$ancien, $isNew] = $this->ensureSupplier($manager, 'Ancien Fournisseur Fermé', ['Producteur'], isArchived: true);
|
|
||||||
if ($isNew) {
|
|
||||||
$this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-fournisseur.fr');
|
|
||||||
$this->addAddress($ancien, 'DEPART', ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Fournisseur archive #2 (RG-2.17) ===
|
|
||||||
[$disparu, $isNew] = $this->ensureSupplier($manager, 'Négoce Disparu', ['Grossiste'], isArchived: true);
|
|
||||||
if ($isNew) {
|
|
||||||
$this->addContact($disparu, 'Gérard', 'Blanc', 'Ex-gérant', '05 56 00 00 00', null, 'gerard.blanc@negoce-disparu.fr');
|
|
||||||
$this->addAddress($disparu, 'RENDU', ['Saint-Jean'], '17400', 'Fontenet', '0 impasse Oubliée');
|
|
||||||
}
|
|
||||||
|
|
||||||
$manager->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cree un fournisseur (base normalisee + categories de type FOURNISSEUR)
|
|
||||||
* s'il n'existe pas encore, sinon retourne l'existant. Retourne
|
|
||||||
* [Supplier, isNew] : isNew=false bloque la reconstruction des
|
|
||||||
* sous-collections (idempotence sans doublon).
|
|
||||||
*
|
|
||||||
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
|
|
||||||
*
|
|
||||||
* @return array{0: Supplier, 1: bool}
|
|
||||||
*/
|
|
||||||
private function ensureSupplier(
|
|
||||||
ObjectManager $manager,
|
|
||||||
string $companyName,
|
|
||||||
array $categoryNames,
|
|
||||||
bool $isArchived = false,
|
|
||||||
): array {
|
|
||||||
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
|
|
||||||
|
|
||||||
$existing = $manager->getRepository(Supplier::class)->findOneBy(['companyName' => $normalizedName]);
|
|
||||||
if ($existing instanceof Supplier) {
|
|
||||||
return [$existing, false];
|
|
||||||
}
|
|
||||||
|
|
||||||
$supplier = new Supplier();
|
|
||||||
$supplier->setCompanyName($normalizedName);
|
|
||||||
|
|
||||||
foreach ($categoryNames as $categoryName) {
|
|
||||||
$supplier->addCategory($this->category($manager, $categoryName));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($isArchived) {
|
|
||||||
$supplier->setIsArchived(true);
|
|
||||||
$supplier->setArchivedAt(new DateTimeImmutable());
|
|
||||||
}
|
|
||||||
|
|
||||||
$manager->persist($supplier);
|
|
||||||
|
|
||||||
return [$supplier, true];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ajoute un contact normalise au fournisseur (cascade persist via
|
|
||||||
* Supplier.contacts). Au moins firstName OU lastName est toujours fourni
|
|
||||||
* (RG-2.04, chk_supplier_contact_name).
|
|
||||||
*/
|
|
||||||
private function addContact(
|
|
||||||
Supplier $supplier,
|
|
||||||
?string $firstName,
|
|
||||||
?string $lastName,
|
|
||||||
?string $jobTitle,
|
|
||||||
?string $phonePrimary,
|
|
||||||
?string $phoneSecondary,
|
|
||||||
?string $email,
|
|
||||||
int $position = 0,
|
|
||||||
): void {
|
|
||||||
$contact = new SupplierContact();
|
|
||||||
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
|
|
||||||
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
|
|
||||||
$contact->setJobTitle($jobTitle);
|
|
||||||
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
|
|
||||||
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
|
|
||||||
$contact->setEmail($this->normalizer->normalizeEmail($email));
|
|
||||||
$contact->setPosition($position);
|
|
||||||
|
|
||||||
$supplier->addContact($contact);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ajoute une adresse au fournisseur (cascade persist via Supplier.addresses).
|
|
||||||
* Le type d'adresse est exclusif (PROSPECT | DEPART | RENDU — RG-2.09,
|
|
||||||
* chk_supplier_address_type) ; au moins un site est rattache (RG-2.06) ; les
|
|
||||||
* categories d'adresse sont de type FOURNISSEUR (RG-2.10).
|
|
||||||
*
|
|
||||||
* @param list<string> $siteNames au moins un site (RG-2.06)
|
|
||||||
* @param list<string> $categoryNames categories de type FOURNISSEUR (RG-2.10)
|
|
||||||
*/
|
|
||||||
private function addAddress(
|
|
||||||
Supplier $supplier,
|
|
||||||
string $addressType,
|
|
||||||
array $siteNames,
|
|
||||||
string $postalCode,
|
|
||||||
string $city,
|
|
||||||
string $street,
|
|
||||||
?string $streetComplement = null,
|
|
||||||
?int $bennes = null,
|
|
||||||
bool $triageProvider = false,
|
|
||||||
array $categoryNames = [],
|
|
||||||
int $position = 0,
|
|
||||||
): void {
|
|
||||||
$address = new SupplierAddress();
|
|
||||||
$address->setAddressType($addressType);
|
|
||||||
$address->setPostalCode($postalCode);
|
|
||||||
$address->setCity($city);
|
|
||||||
$address->setStreet($street);
|
|
||||||
$address->setStreetComplement($streetComplement);
|
|
||||||
$address->setBennes($bennes);
|
|
||||||
$address->setTriageProvider($triageProvider);
|
|
||||||
$address->setPosition($position);
|
|
||||||
|
|
||||||
foreach ($siteNames as $siteName) {
|
|
||||||
$address->addSite($this->site($siteName));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($categoryNames as $categoryName) {
|
|
||||||
$address->addCategory($this->category($this->manager, $categoryName));
|
|
||||||
}
|
|
||||||
|
|
||||||
$supplier->addAddress($address);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ajoute un RIB au fournisseur (cascade persist via Supplier.ribs). IBAN/BIC
|
|
||||||
* valides (Assert\Iban/Bic non rejouee sur persist direct mais donnees
|
|
||||||
* coherentes pour le golden path / les tests).
|
|
||||||
*/
|
|
||||||
private function addRib(Supplier $supplier, string $label, string $bic, string $iban, int $position = 0): void
|
|
||||||
{
|
|
||||||
$rib = new SupplierRib();
|
|
||||||
$rib->setLabel($label);
|
|
||||||
$rib->setBic($bic);
|
|
||||||
$rib->setIban($iban);
|
|
||||||
$rib->setPosition($position);
|
|
||||||
|
|
||||||
$supplier->addRib($rib);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resout une categorie par son nom via le contrat Shared CategoryInterface
|
|
||||||
* (resolve_target_entities -> Category), sans importer le module Catalog
|
|
||||||
* (regle n°1). Mise en cache par nom.
|
|
||||||
*/
|
|
||||||
private function category(ObjectManager $manager, string $name): CategoryInterface
|
|
||||||
{
|
|
||||||
if (isset($this->categoryCache[$name])) {
|
|
||||||
return $this->categoryCache[$name];
|
|
||||||
}
|
|
||||||
|
|
||||||
// RG-2.10 : on filtre explicitement sur le type FOURNISSEUR. Un lookup par
|
|
||||||
// le seul `name` rattacherait une categorie homonyme d'un autre type (ex.
|
|
||||||
// futur PRESTA) — donc du MAUVAIS type — ce qui violerait « au moins une
|
|
||||||
// categorie de type FOURNISSEUR ». Le filtre type est porte cote PHP
|
|
||||||
// (findBy ne sait pas filtrer une propriete imbriquee categoryType.code).
|
|
||||||
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
|
|
||||||
'name' => $name,
|
|
||||||
'deletedAt' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
|
||||||
if ($candidate instanceof CategoryInterface
|
|
||||||
&& self::SUPPLIER_CATEGORY_TYPE_CODE === $candidate->getCategoryTypeCode()) {
|
|
||||||
return $this->categoryCache[$name] = $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'Categorie FOURNISSEUR "%s" introuvable : CategoryFixtures doit tourner avant SupplierFixtures.',
|
|
||||||
$name,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resout un site par son nom via le contrat Shared SiteProviderInterface,
|
|
||||||
* sans importer le module Sites (regle n°1). Mise en cache par nom.
|
|
||||||
*/
|
|
||||||
private function site(string $name): SiteInterface
|
|
||||||
{
|
|
||||||
if (isset($this->siteCache[$name])) {
|
|
||||||
return $this->siteCache[$name];
|
|
||||||
}
|
|
||||||
|
|
||||||
$site = $this->siteProvider->findByName($name);
|
|
||||||
|
|
||||||
if (!$site instanceof SiteInterface) {
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'Site "%s" introuvable : SitesFixtures doit tourner avant SupplierFixtures.',
|
|
||||||
$name,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->siteCache[$name] = $site;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tvaMode(ObjectManager $manager, string $code): TvaMode
|
|
||||||
{
|
|
||||||
$mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
|
|
||||||
|
|
||||||
if (!$mode instanceof TvaMode) {
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
|
|
||||||
$code,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay
|
|
||||||
{
|
|
||||||
$delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
|
|
||||||
|
|
||||||
if (!$delay instanceof PaymentDelay) {
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
|
|
||||||
$code,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $delay;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function paymentType(ObjectManager $manager, string $code): PaymentType
|
|
||||||
{
|
|
||||||
$type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
|
|
||||||
|
|
||||||
if (!$type instanceof PaymentType) {
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
|
|
||||||
$code,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $type;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function bank(ObjectManager $manager, string $code): Bank
|
|
||||||
{
|
|
||||||
$bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
|
|
||||||
|
|
||||||
if (!$bank instanceof Bank) {
|
|
||||||
throw new RuntimeException(sprintf(
|
|
||||||
'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant SupplierFixtures.',
|
|
||||||
$code,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $bank;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<?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.
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
<?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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<?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,8 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||||
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
||||||
|
use App\Module\Commercial\Domain\Entity\SupplierContact;
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use DateTimeImmutable;
|
||||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,17 +18,29 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
|
|||||||
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
|
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
|
||||||
* archives par defaut, respect du filtre ?search, peuplement des colonnes
|
* archives par defaut, respect du filtre ?search, peuplement des colonnes
|
||||||
* contact principal / categories / sites, gating de la colonne SIREN selon
|
* contact principal / categories / sites, gating de la colonne SIREN selon
|
||||||
* commercial.suppliers.accounting.view (admin ET user minimal a permission
|
* commercial.suppliers.accounting.view, 403 sans commercial.suppliers.view,
|
||||||
* explicite), dedup F3 (fournisseur multi-categories rendu sur une seule ligne),
|
* 401 anonyme.
|
||||||
* 403 sans commercial.suppliers.view, 401 anonyme.
|
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
|
final class SupplierExportControllerTest extends AbstractCommercialApiTestCase
|
||||||
{
|
{
|
||||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
private const string EXPORT_URL = '/api/suppliers/export.xlsx';
|
private const string EXPORT_URL = '/api/suppliers/export.xlsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les fournisseurs doivent etre purges AVANT les categories de test (le parent
|
||||||
|
* supprime les categories `test_cli_cat_*`) : la jointure supplier_category est
|
||||||
|
* en ON DELETE CASCADE cote supplier mais RESTRICT cote category. Le DELETE DQL
|
||||||
|
* sur Supplier declenche le cascade BDD sur supplier_category / _contact /
|
||||||
|
* _address (et leurs sous-jointures), liberant les categories pour le parent.
|
||||||
|
*/
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
@@ -95,13 +110,9 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
|
|||||||
$supplier = $this->seedSupplier('Contact Co');
|
$supplier = $this->seedSupplier('Contact Co');
|
||||||
|
|
||||||
// position 1 (secondaire) insere en premier...
|
// position 1 (secondaire) insere en premier...
|
||||||
$this->addContact($supplier, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
|
$this->addContact($supplier, 'Secondaire', 'Bob', 1, '0600000001', '0600000002', 'bob@contact.co');
|
||||||
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
|
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
|
||||||
$principal = $this->addContact($supplier, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
|
$this->addContact($supplier, 'Principal', 'Alice', 0, '0612345678', '0698765432', 'alice@contact.co');
|
||||||
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
|
|
||||||
// directement sur le contact principal pour alimenter la colonne dediee.
|
|
||||||
$principal->setPhoneSecondary('0698765432');
|
|
||||||
$this->getEm()->flush();
|
|
||||||
|
|
||||||
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
|
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
|
||||||
|
|
||||||
@@ -138,10 +149,8 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
|
|||||||
|
|
||||||
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||||
|
|
||||||
// Colonne « Catégories » : libelle de la categorie FOURNISSEUR du fournisseur
|
// Colonne « Catégories » : libelle de la categorie du fournisseur (getName()).
|
||||||
// (getName()). On le derive du helper de base (idempotent) plutot que de
|
self::assertStringContainsString('test_cli_cat_negociant', $flat);
|
||||||
// hardcoder le prefixe de nom de test.
|
|
||||||
self::assertStringContainsString((string) $this->supplierCategory('NEGOCIANT')->getName(), $flat);
|
|
||||||
// Colonne « Sites » : site agrege depuis l'adresse (RG-2.06).
|
// Colonne « Sites » : site agrege depuis l'adresse (RG-2.06).
|
||||||
self::assertStringContainsString((string) $site->getName(), $flat);
|
self::assertStringContainsString((string) $site->getName(), $flat);
|
||||||
}
|
}
|
||||||
@@ -179,60 +188,6 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
|
|||||||
self::assertStringNotContainsString('987654321', $this->flatten($grid));
|
self::assertStringNotContainsString('987654321', $this->flatten($grid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
|
|
||||||
* un user minimal portant uniquement commercial.suppliers.view +
|
|
||||||
* commercial.suppliers.accounting.view voit bien la colonne SIREN et sa
|
|
||||||
* valeur. Complement de testSirenColumnPresentWithAccountingView (admin), qui
|
|
||||||
* ne prouve pas que accounting.view SEULE suffit (l'admin bypasse le RBAC).
|
|
||||||
* Le pendant negatif (sans accounting.view -> colonne absente) est couvert par
|
|
||||||
* testSirenColumnAbsentWithoutAccountingView.
|
|
||||||
*/
|
|
||||||
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
|
|
||||||
{
|
|
||||||
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
|
|
||||||
$this->createAdminClient();
|
|
||||||
$supplier = $this->seedSupplier('Gated Siren Co');
|
|
||||||
$em = $this->getEm();
|
|
||||||
$supplier->setSiren('456789123');
|
|
||||||
$em->flush();
|
|
||||||
|
|
||||||
$creds = $this->createUserWithPermissions([
|
|
||||||
'commercial.suppliers.view',
|
|
||||||
'commercial.suppliers.accounting.view',
|
|
||||||
]);
|
|
||||||
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
|
|
||||||
|
|
||||||
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
|
|
||||||
|
|
||||||
self::assertContains('SIREN', $grid[0]);
|
|
||||||
self::assertStringContainsString('456789123', $this->flatten($grid));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dedup F3 : un fournisseur portant >= 2 categories FOURNISSEUR est multiplie
|
|
||||||
* par la jointure (selection/hydratation des collections) ; l'export doit le
|
|
||||||
* rendre sur UNE SEULE ligne. On seede un fournisseur a 2 categories et on
|
|
||||||
* assert qu'il n'apparait qu'une fois dans la colonne « Nom fournisseur ».
|
|
||||||
*/
|
|
||||||
public function testExportDeduplicatesSupplierWithMultipleCategories(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$supplier = $this->seedSupplier('Multi Cat Co', false, 'NEGOCIANT');
|
|
||||||
// 2e categorie FOURNISSEUR sur le meme fournisseur (RG-2.10).
|
|
||||||
$supplier->addCategory($this->supplierCategory('GROSSISTE'));
|
|
||||||
$this->getEm()->flush();
|
|
||||||
|
|
||||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
|
||||||
|
|
||||||
$occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
|
|
||||||
self::assertSame(
|
|
||||||
1,
|
|
||||||
$occurrences,
|
|
||||||
'Un fournisseur multi-categories doit apparaitre sur une seule ligne (dedup F3).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testForbiddenWithoutSuppliersViewPermission(): void
|
public function testForbiddenWithoutSuppliersViewPermission(): void
|
||||||
{
|
{
|
||||||
$creds = $this->createUserWithPermission('core.users.view');
|
$creds = $this->createUserWithPermission('core.users.view');
|
||||||
@@ -251,6 +206,50 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(401);
|
self::assertResponseStatusCodeSame(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seede directement un Supplier en base (sans passer par l'API), pour les
|
||||||
|
* tests de liste / archivage. Stocke le nom en MAJUSCULES pour refleter l'etat
|
||||||
|
* normalise (RG-2.12) qu'aurait produit le SupplierProcessor via l'API.
|
||||||
|
*/
|
||||||
|
private function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'SECTEUR'): Supplier
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
$supplier = new Supplier();
|
||||||
|
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
|
||||||
|
$supplier->addCategory($this->createCategory($categoryCode));
|
||||||
|
$supplier->setIsArchived($isArchived);
|
||||||
|
if ($isArchived) {
|
||||||
|
$supplier->setArchivedAt(new DateTimeImmutable());
|
||||||
|
}
|
||||||
|
$em->persist($supplier);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $supplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addContact(
|
||||||
|
Supplier $supplier,
|
||||||
|
string $lastName,
|
||||||
|
string $firstName,
|
||||||
|
int $position,
|
||||||
|
?string $phonePrimary = null,
|
||||||
|
?string $phoneSecondary = null,
|
||||||
|
?string $email = null,
|
||||||
|
): void {
|
||||||
|
$contact = new SupplierContact();
|
||||||
|
$contact->setSupplier($supplier);
|
||||||
|
$contact->setLastName($lastName);
|
||||||
|
$contact->setFirstName($firstName);
|
||||||
|
$contact->setPosition($position);
|
||||||
|
$contact->setPhonePrimary($phonePrimary);
|
||||||
|
$contact->setPhoneSecondary($phoneSecondary);
|
||||||
|
$contact->setEmail($email);
|
||||||
|
|
||||||
|
$supplier->addContact($contact);
|
||||||
|
$this->getEm()->persist($contact);
|
||||||
|
$this->getEm()->flush();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||||
*
|
*
|
||||||
@@ -285,7 +284,7 @@ final class SupplierExportControllerTest extends AbstractSupplierApiTestCase
|
|||||||
/**
|
/**
|
||||||
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
|
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
|
||||||
*
|
*
|
||||||
* @return null|array<int, mixed>
|
* @return array<int, mixed>|null
|
||||||
*/
|
*/
|
||||||
private function rowFor(string $binary, string $companyName): ?array
|
private function rowFor(string $binary, string $companyName): ?array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?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'",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du fournisseur
|
|
||||||
* (M2, spec § 4.5). Couvrent : normalisation contact (RG-2.12), RG-2.04 (prenom
|
|
||||||
* OU nom), RG-2.05 (code postal), RG-2.06 (>= 1 site), RG-2.09 (enum addressType),
|
|
||||||
* RG-2.10 (categorie FOURNISSEUR sur adresse), RG-2.08 (DELETE dernier RIB sous
|
|
||||||
* LCR -> 409), DELETE contact libre au M2 (pas de garde « dernier contact ») et le
|
|
||||||
* gating comptable des RIB (manage seul -> 403). Jumeau de ClientSubResourceApiTest.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
|
||||||
{
|
|
||||||
// === Contacts ===
|
|
||||||
|
|
||||||
public function testPostContactNormalizesFields(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Contact Host');
|
|
||||||
|
|
||||||
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'firstName' => 'JEAN',
|
|
||||||
'lastName' => 'dupont',
|
|
||||||
'phonePrimary' => '06.12.34.56.78',
|
|
||||||
'email' => 'Jean.DUPONT@ACME.FR',
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
// RG-2.12 : prenom/nom Title Case, telephone chiffres seuls, email lowercase.
|
|
||||||
self::assertSame('Jean', $data['firstName']);
|
|
||||||
self::assertSame('Dupont', $data['lastName']);
|
|
||||||
self::assertSame('0612345678', $data['phonePrimary']);
|
|
||||||
self::assertSame('jean.dupont@acme.fr', $data['email']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostContactWithoutNameReturns422OnFirstNamePath(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Contact No Name');
|
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
|
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
|
||||||
'json' => ['jobTitle' => 'Directeur'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// RG-2.04 (prenom OU nom obligatoire) -> 422 rattachee a firstName.
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
$byPath = $this->violationsByPath($response->toArray(false));
|
|
||||||
self::assertArrayHasKey('firstName', $byPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostContactOnMissingSupplierReturns404(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
|
|
||||||
$client->request('POST', '/api/suppliers/999999/contacts', [
|
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
|
||||||
'json' => ['firstName' => 'Orphan'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteLastContactReturns204(): void
|
|
||||||
{
|
|
||||||
// M2 : pas de garde « dernier contact » (RG-2.13 front-driven) — la
|
|
||||||
// suppression du dernier contact est libre (204), contrairement au M1.
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Contact Solo');
|
|
||||||
$contact = $this->addContact($seed, 'Unique', 'Contact');
|
|
||||||
|
|
||||||
$client->request('DELETE', '/api/supplier_contacts/'.$contact->getId());
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(204);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testContactWriteWithoutManageReturns403(): void
|
|
||||||
{
|
|
||||||
// Un user sans aucune permission suppliers -> 403 sur la sous-ressource.
|
|
||||||
$seed = $this->seedSupplier('Contact Forbidden');
|
|
||||||
$creds = $this->createUserWithPermission('core.users.view');
|
|
||||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
|
||||||
|
|
||||||
$http->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => ['firstName' => 'Nope'],
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Adresses ===
|
|
||||||
|
|
||||||
public function testPostAddressWithValidPayloadReturns201(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Address Host');
|
|
||||||
$category = $this->supplierCategory('NEGOCIANT');
|
|
||||||
|
|
||||||
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'addressType' => 'DEPART',
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$this->firstSiteIri()],
|
|
||||||
'categories' => ['/api/categories/'.$category->getId()],
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
self::assertSame('DEPART', $data['addressType']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostAddressWithoutSiteReturns422(): void
|
|
||||||
{
|
|
||||||
// Sans cette garde, un module Sites desactive renverrait 404 (route
|
|
||||||
// /addresses indisponible) et le test passerait pour la MAUVAISE raison
|
|
||||||
// au lieu de prouver RG-2.06 (Assert\Count min 1 sur sites).
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Address No Site');
|
|
||||||
|
|
||||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'addressType' => 'DEPART',
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// RG-2.06 (Assert\Count min 1 sur sites).
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostAddressWithInvalidPostalCodeReturns422(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Address Bad CP');
|
|
||||||
|
|
||||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'addressType' => 'DEPART',
|
|
||||||
'postalCode' => '123',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$this->firstSiteIri()],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// RG-2.05 (Assert\Regex ^[0-9]{4,5}$).
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Address Incoherent');
|
|
||||||
|
|
||||||
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
|
|
||||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'addressType' => 'DEPART',
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Marseille',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$this->firstSiteIri()],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostAddressWithInvalidTypeReturns422(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Address Bad Type');
|
|
||||||
|
|
||||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'addressType' => 'INVALID',
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$this->firstSiteIri()],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// RG-2.09 (Assert\Choice PROSPECT|DEPART|RENDU).
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-2.09 : les 3 valeurs valides de addressType sont acceptees.
|
|
||||||
*/
|
|
||||||
public function testPostAddressWithEachValidTypeReturns201(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Address Types');
|
|
||||||
$siteIri = $this->firstSiteIri();
|
|
||||||
|
|
||||||
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
|
|
||||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'addressType' => $type,
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$siteIri],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostAddressWithNonFournisseurCategoryReturns422(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Address Bad Cat');
|
|
||||||
// categorie de type CLIENT -> interdite sur une adresse fournisseur.
|
|
||||||
$clientTypedCategory = $this->createCategory('SECTEUR');
|
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'addressType' => 'DEPART',
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$this->firstSiteIri()],
|
|
||||||
'categories' => ['/api/categories/'.$clientTypedCategory->getId()],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// RG-2.10 -> 422 rattachee a categories.
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// === RIBs ===
|
|
||||||
|
|
||||||
public function testPostRibByAdminReturns201(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Rib Host');
|
|
||||||
|
|
||||||
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'label' => 'Compte principal',
|
|
||||||
'bic' => self::VALID_BIC,
|
|
||||||
'iban' => self::VALID_IBAN,
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
self::assertSame('Compte principal', $data['label']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostRibWithInvalidIbanReturns422(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Rib Bad Iban');
|
|
||||||
|
|
||||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteRibNonLcrReturns204(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Rib Non LCR');
|
|
||||||
$rib = $this->addRib($seed);
|
|
||||||
|
|
||||||
$client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(204);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteLastRibUnderLcrReturns409(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedSupplier('Rib LCR Solo');
|
|
||||||
$rib = $this->addRib($seed);
|
|
||||||
// Passe le fournisseur en LCR (seed direct).
|
|
||||||
$em = $this->getEm();
|
|
||||||
$managed = $em->getRepository(Supplier::class)->find($seed->getId());
|
|
||||||
$managed->setPaymentType($this->paymentType('LCR'));
|
|
||||||
$em->flush();
|
|
||||||
|
|
||||||
$client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
|
|
||||||
|
|
||||||
// RG-2.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
|
|
||||||
self::assertResponseStatusCodeSame(409);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRibWriteWithoutAccountingManageReturns403(): void
|
|
||||||
{
|
|
||||||
// Un user portant seulement suppliers.manage (sans accounting.manage) ne
|
|
||||||
// peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
|
|
||||||
$seed = $this->seedSupplier('Rib Forbidden');
|
|
||||||
$rib = $this->addRib($seed);
|
|
||||||
$creds = $this->createUserWithPermission('commercial.suppliers.manage');
|
|
||||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
|
||||||
|
|
||||||
$http->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
|
|
||||||
$http->request('PATCH', '/api/supplier_ribs/'.$rib->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['label' => 'Y'],
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
|
|
||||||
$http->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Helpers ===
|
|
||||||
|
|
||||||
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
|
|
||||||
|
|
||||||
private function firstSiteIri(): string
|
|
||||||
{
|
|
||||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
|
||||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
|
|
||||||
|
|
||||||
return '/api/sites/'.$site->getId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -101,24 +101,6 @@ final class SupplierProcessorTest extends TestCase
|
|||||||
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
|
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAdminIncompleteInformationPasses(): void
|
|
||||||
{
|
|
||||||
// Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale
|
|
||||||
// (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role
|
|
||||||
// metier) n'est pas soumis a la completude Information -> 200 malgre un
|
|
||||||
// onglet Information incomplet. Prouve que le gate porte bien sur le ROLE
|
|
||||||
// metier Commerciale, et pas sur « il y a un utilisateur connecte ».
|
|
||||||
$supplier = $this->minimalSupplier();
|
|
||||||
$supplier->setDescription('Une description');
|
|
||||||
|
|
||||||
$processor = $this->makeProcessor(
|
|
||||||
payload: ['description' => 'Une description'],
|
|
||||||
user: $this->adminUser(),
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $granted Permissions accordees a l'utilisateur courant
|
* @param list<string> $granted Permissions accordees a l'utilisateur courant
|
||||||
* @param array<string, mixed> $payload Corps JSON simule de la requete
|
* @param array<string, mixed> $payload Corps JSON simule de la requete
|
||||||
@@ -193,33 +175,6 @@ final class SupplierProcessorTest extends TestCase
|
|||||||
return $this->createStub(Operation::class);
|
return $this->createStub(Operation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Utilisateur authentifie non-Commerciale (profil admin) : porte
|
|
||||||
* BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a
|
|
||||||
* distinguer « pas de role Commerciale » de « pas d'utilisateur » (null).
|
|
||||||
*/
|
|
||||||
private function adminUser(): UserInterface
|
|
||||||
{
|
|
||||||
return new class implements UserInterface, BusinessRoleAwareInterface {
|
|
||||||
public function hasBusinessRole(string $roleCode): bool
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoles(): array
|
|
||||||
{
|
|
||||||
return ['ROLE_ADMIN'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function eraseCredentials(): void {}
|
|
||||||
|
|
||||||
public function getUserIdentifier(): string
|
|
||||||
{
|
|
||||||
return 'admin-test';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function commercialeUser(): UserInterface
|
private function commercialeUser(): UserInterface
|
||||||
{
|
{
|
||||||
return new class implements UserInterface, BusinessRoleAwareInterface {
|
return new class implements UserInterface, BusinessRoleAwareInterface {
|
||||||
|
|||||||
@@ -90,26 +90,6 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
|||||||
* @return array{username: string, password: string} Les identifiants pour authenticatedClient()
|
* @return array{username: string, password: string} Les identifiants pour authenticatedClient()
|
||||||
*/
|
*/
|
||||||
protected function createUserWithPermission(string $permissionCode): array
|
protected function createUserWithPermission(string $permissionCode): array
|
||||||
{
|
|
||||||
return $this->createUserWithPermissions([$permissionCode]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variante multi-permissions de {@see createUserWithPermission()} : cree un
|
|
||||||
* utilisateur non-admin portant PLUSIEURS permissions via un unique role
|
|
||||||
* jetable. Utile pour prouver qu'une combinaison precise de permissions
|
|
||||||
* (sans le bypass admin) suffit a debloquer un comportement — ex. la colonne
|
|
||||||
* SIREN de l'export, gatee par accounting.view EN PLUS de suppliers.view.
|
|
||||||
*
|
|
||||||
* Memes garanties que le singulier : suffixe aleatoire, password "testpass",
|
|
||||||
* rattachement a tous les sites, echec explicite si une permission est
|
|
||||||
* introuvable en base.
|
|
||||||
*
|
|
||||||
* @param list<string> $permissionCodes codes des permissions a accorder
|
|
||||||
*
|
|
||||||
* @return array{username: string, password: string} identifiants pour authenticatedClient()
|
|
||||||
*/
|
|
||||||
protected function createUserWithPermissions(array $permissionCodes): array
|
|
||||||
{
|
{
|
||||||
if (!self::$kernel) {
|
if (!self::$kernel) {
|
||||||
self::bootKernel();
|
self::bootKernel();
|
||||||
@@ -117,6 +97,17 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
|||||||
|
|
||||||
$em = $this->getEm();
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
/** @var null|Permission $permission */
|
||||||
|
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
|
||||||
|
|
||||||
|
self::assertNotNull(
|
||||||
|
$permission,
|
||||||
|
sprintf(
|
||||||
|
'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
|
||||||
|
$permissionCode,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||||
$username = 'testuser_'.$suffix;
|
$username = 'testuser_'.$suffix;
|
||||||
$password = 'testpass';
|
$password = 'testpass';
|
||||||
@@ -125,22 +116,7 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
|||||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
|
||||||
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
|
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
|
||||||
|
$role->addPermission($permission);
|
||||||
foreach ($permissionCodes as $permissionCode) {
|
|
||||||
/** @var null|Permission $permission */
|
|
||||||
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $permissionCode]);
|
|
||||||
|
|
||||||
self::assertNotNull(
|
|
||||||
$permission,
|
|
||||||
sprintf(
|
|
||||||
'Permission "%s" introuvable en base. Assurez-vous que `app:sync-permissions` a ete execute.',
|
|
||||||
$permissionCode,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$role->addPermission($permission);
|
|
||||||
}
|
|
||||||
|
|
||||||
$em->persist($role);
|
$em->persist($role);
|
||||||
|
|
||||||
$user = new User();
|
$user = new User();
|
||||||
|
|||||||
Reference in New Issue
Block a user