Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8be14a299 |
@@ -61,23 +61,6 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
|
||||
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
|
||||
// la section disparait automatiquement (SidebarProvider) si le module
|
||||
// `technique` est desactive ou si l'user n'a pas la permission.
|
||||
[
|
||||
'label' => 'sidebar.technique.section',
|
||||
'icon' => 'mdi:account-convert-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.technique.providers',
|
||||
'to' => '/providers',
|
||||
'icon' => 'mdi:account-wrench-outline',
|
||||
'module' => 'technique',
|
||||
'permission' => 'technique.providers.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Administration" : regroupe toutes les pages de configuration
|
||||
// applicative (RBAC, users, sites, audit log).
|
||||
//
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.119'
|
||||
app.version: '0.1.110'
|
||||
|
||||
@@ -176,7 +176,7 @@ Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS
|
||||
- **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter).
|
||||
- **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre).
|
||||
- **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses).
|
||||
- **Cohérence sous-ressources** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site.
|
||||
- **Cohérence sous-ressources** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`).
|
||||
|
||||
> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle).
|
||||
|
||||
@@ -624,153 +624,67 @@ Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamab
|
||||
> 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`).
|
||||
> 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`).
|
||||
|
||||
> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front.
|
||||
|
||||
`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) :
|
||||
`GET /api/providers` (liste, ADMIN — un membre, forme attendue) :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/Provider",
|
||||
"@id": "/api/providers",
|
||||
"@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/providers/572",
|
||||
"@type": "Provider",
|
||||
"id": 572,
|
||||
"companyName": "DOD21AADC 0E3CCE",
|
||||
"categories": [
|
||||
{
|
||||
"@type": "Category",
|
||||
"@id": "/api/categories/3006",
|
||||
"id": 3006,
|
||||
"name": "test_prov_cat_nettoyage",
|
||||
"code": "NETTOYAGE",
|
||||
"categoryTypes": [
|
||||
{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}
|
||||
],
|
||||
"createdAt": "2026-06-12T15:17:29+02:00",
|
||||
"updatedAt": "2026-06-12T15:17:29+02:00"
|
||||
}
|
||||
],
|
||||
"sites": [
|
||||
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||
],
|
||||
"siren": "987654321",
|
||||
"accountNumber": "P0001",
|
||||
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||
"nTva": "FR00987654321",
|
||||
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
|
||||
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
|
||||
"ribs": [
|
||||
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"createdAt": "2026-06-12T15:17:29+02:00",
|
||||
"updatedAt": "2026-06-12T15:17:29+02:00",
|
||||
"isArchived": false
|
||||
}
|
||||
],
|
||||
"view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"}
|
||||
"@context": "/api/contexts/Provider",
|
||||
"@id": "/api/providers",
|
||||
"@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
|
||||
"companyName": "MAINTENANCE PRO SAS",
|
||||
"categories": [
|
||||
{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE",
|
||||
"categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}}
|
||||
],
|
||||
"sites": [
|
||||
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}
|
||||
],
|
||||
"siren": "987654321", "accountNumber": "P0001",
|
||||
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
|
||||
"ribs": [
|
||||
{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
|
||||
],
|
||||
"updatedAt": "2026-06-11T10:00:00+02:00",
|
||||
"isArchived": false
|
||||
}
|
||||
],
|
||||
"view": {"@id": "/api/providers", "@type": "PartialCollectionView"}
|
||||
}
|
||||
```
|
||||
|
||||
> Les `sites[]` de la liste sont la **relation directe** `provider.sites` (formulaire principal — RG-3.03), objet `Site` entier (pas un IRI nu). Les catégories embarquent `code` + `name`. Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour un profil **sans `accounting.view`** (ex. Commerciale), `siren`/`accountNumber`/`tvaMode`/`nTva`/`paymentDelay`/`paymentType`/`bank`/`ribs` **disparaissent** de chaque membre (gating par omission — cf. détail restreint ci-dessous).
|
||||
> Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre.
|
||||
|
||||
`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) :
|
||||
`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/Provider",
|
||||
"@id": "/api/providers/572",
|
||||
"@type": "Provider",
|
||||
"id": 572,
|
||||
"companyName": "DOD21AADC 0E3CCE",
|
||||
"categories": [
|
||||
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"sites": [
|
||||
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||
],
|
||||
"siren": "987654321",
|
||||
"accountNumber": "P0001",
|
||||
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||
"nTva": "FR00987654321",
|
||||
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
|
||||
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
|
||||
"contacts": [
|
||||
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
|
||||
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
||||
"sites": [
|
||||
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||
],
|
||||
"contacts": [
|
||||
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"categories": [
|
||||
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
|
||||
}
|
||||
],
|
||||
"ribs": [
|
||||
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"createdAt": "2026-06-12T15:17:29+02:00",
|
||||
"updatedAt": "2026-06-12T15:17:29+02:00",
|
||||
"isArchived": false
|
||||
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
|
||||
"companyName": "MAINTENANCE PRO SAS",
|
||||
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}],
|
||||
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
|
||||
"siren": "987654321", "accountNumber": "P0001",
|
||||
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||
"nTva": "FR00987654321",
|
||||
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
|
||||
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
|
||||
"contacts": [
|
||||
{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
|
||||
],
|
||||
"addresses": [
|
||||
{"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
||||
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
|
||||
"contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}],
|
||||
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]}
|
||||
],
|
||||
"ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}],
|
||||
"isArchived": false
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/Provider",
|
||||
"@id": "/api/providers/572",
|
||||
"@type": "Provider",
|
||||
"id": 572,
|
||||
"companyName": "DOD21AADC 0E3CCE",
|
||||
"categories": [
|
||||
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"sites": [
|
||||
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||
],
|
||||
"contacts": [
|
||||
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"addresses": [
|
||||
{
|
||||
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
|
||||
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
||||
"sites": [
|
||||
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||
],
|
||||
"contacts": [
|
||||
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"categories": [
|
||||
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||
],
|
||||
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
|
||||
}
|
||||
],
|
||||
"createdAt": "2026-06-12T15:17:29+02:00",
|
||||
"updatedAt": "2026-06-12T15:17:29+02:00",
|
||||
"isArchived": false
|
||||
}
|
||||
```
|
||||
|
||||
> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`.
|
||||
>
|
||||
> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`.
|
||||
> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (gating par omission — à confirmer par test).
|
||||
|
||||
### 4.1 `GET /api/providers` — Liste
|
||||
|
||||
@@ -1009,7 +923,7 @@ Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur `
|
||||
|
||||
- [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] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139)
|
||||
- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front)
|
||||
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15)
|
||||
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
|
||||
- [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
"clients": "Répertoire clients",
|
||||
"suppliers": "Répertoire fournisseurs"
|
||||
},
|
||||
"technique": {
|
||||
"section": "Technique",
|
||||
"providers": "Répertoire prestataires"
|
||||
},
|
||||
"core": {
|
||||
"roles": "Gestion des rôles",
|
||||
"users": "Utilisateurs",
|
||||
@@ -366,103 +362,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"technique": {
|
||||
"providers": {
|
||||
"title": "Répertoire prestataires",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun prestataire pour l'instant.",
|
||||
"column": {
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"includeArchived": "Inclure les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"tab": {
|
||||
"contact": "Contact",
|
||||
"address": "Adresse",
|
||||
"accounting": "Comptabilité"
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un prestataire",
|
||||
"back": "Précédent",
|
||||
"submit": "Valider",
|
||||
"duplicateCompany": "Un prestataire portant ce nom de société existe déjà.",
|
||||
"main": {
|
||||
"companyName": "Nom du prestataire (Entreprise)",
|
||||
"categories": "Catégorie",
|
||||
"sites": "Site"
|
||||
},
|
||||
"errors": {
|
||||
"siteRequired": "Sélectionnez au moins un site.",
|
||||
"categoryRequired": "Sélectionnez au moins une catégorie."
|
||||
},
|
||||
"contact": {
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
"email": "Email",
|
||||
"phonePrimary": "Téléphone",
|
||||
"phoneSecondary": "Téléphone (2)",
|
||||
"addPhone": "Ajouter un numéro",
|
||||
"remove": "Supprimer le contact",
|
||||
"add": "Nouveau contact"
|
||||
},
|
||||
"address": {
|
||||
"sites": "Sites",
|
||||
"categories": "Catégorie",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"street": "Adresse",
|
||||
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"remove": "Supprimer l'adresse",
|
||||
"add": "Nouvelle adresse",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
"nTva": "N° de TVA",
|
||||
"paymentDelay": "Délai de règlement",
|
||||
"paymentType": "Type de règlement",
|
||||
"bank": "Banque",
|
||||
"ribLabel": "Libellé",
|
||||
"ribBic": "BIC",
|
||||
"ribIban": "IBAN",
|
||||
"addRib": "Ajouter un RIB",
|
||||
"removeRib": "Supprimer le RIB"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Confirmer la suppression",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer",
|
||||
"contact": "Supprimer ce contact ?",
|
||||
"address": "Supprimer cette adresse ?",
|
||||
"rib": "Supprimer ce RIB ?"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
|
||||
"createSuccess": "Prestataire créé avec succès",
|
||||
"updateSuccess": "Prestataire mis à jour avec succès"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"logout": "Deconnexion",
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('technique.providers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('technique.providers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('technique.providers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('technique.providers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('technique.providers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('technique.providers.form.address.streetComplement')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: ProviderAddressFormDraft
|
||||
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
||||
categoryOptions: RefOption[]
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
contactOptions: RefOption[]
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ProviderAddressFormDraft]
|
||||
'remove': []
|
||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||
'degraded': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
|
||||
const degraded = ref(false)
|
||||
let unavailableNotified = false
|
||||
const banCityOptions = ref<RefOption[]>([])
|
||||
const banAddressOptions = ref<RefOption[]>([])
|
||||
|
||||
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
|
||||
const cityOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.city
|
||||
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banCityOptions.value]
|
||||
}
|
||||
return banCityOptions.value
|
||||
})
|
||||
|
||||
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
|
||||
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
|
||||
const addressOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.street
|
||||
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banAddressOptions.value]
|
||||
}
|
||||
return banAddressOptions.value
|
||||
})
|
||||
const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
unavailableNotified = true
|
||||
emit('degraded')
|
||||
}
|
||||
}
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const suggestions = await autocomplete.searchCity(digits)
|
||||
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||
degraded.value = false
|
||||
}
|
||||
catch {
|
||||
degraded.value = true
|
||||
notifyUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||
async function onAddressSearch(query: string): Promise<void> {
|
||||
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
|
||||
if (query.trim().length < 3) {
|
||||
banAddressOptions.value = []
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||
lastAddressSuggestions = suggestions
|
||||
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
}
|
||||
catch {
|
||||
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
|
||||
banAddressOptions.value = []
|
||||
notifyUnavailable()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
|
||||
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
||||
if (option === null) {
|
||||
return
|
||||
}
|
||||
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||
if (!suggestion) {
|
||||
update('street', String(option.value))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
street: suggestion.street,
|
||||
city: suggestion.city,
|
||||
postalCode: suggestion.postalCode,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.lastName"
|
||||
:label="t('technique.providers.form.contact.lastName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.firstName"
|
||||
:label="t('technique.providers.form.contact.firstName')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('technique.providers.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('technique.providers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('technique.providers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('technique.providers.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('technique.providers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon du contact (v-model). */
|
||||
modelValue: ProviderContactFormDraft
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable). */
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ProviderContactFormDraft]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Revele le 2e numero (max 1 secondaire, le « + » disparait). */
|
||||
function revealSecondaryPhone(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||
}
|
||||
</script>
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
import ProviderAddressBlock from '../ProviderAddressBlock.vue'
|
||||
|
||||
// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2.
|
||||
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||
searchCityMock: vi.fn(),
|
||||
searchAddressMock: vi.fn(),
|
||||
}))
|
||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||
useAddressAutocomplete: () => ({
|
||||
searchCity: searchCityMock,
|
||||
searchAddress: searchAddressMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
|
||||
const MalioInputAutocompleteStub = defineComponent({
|
||||
name: 'MalioInputAutocomplete',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
minSearchLength: { type: Number, default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue', 'search', 'select'],
|
||||
setup(props) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'addr-autocomplete',
|
||||
'data-options': JSON.stringify(props.options.map(o => o.value)),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
|
||||
return mount(ProviderAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => {
|
||||
it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => {
|
||||
const wrapper = mountBlock()
|
||||
// Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3.
|
||||
expect(wrapper.find('malio-input-number-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false)
|
||||
// Aucun select ne porte le label « type d'adresse ».
|
||||
const hasAddressType = wrapper.findAll('malio-select-stub').some(
|
||||
el => el.attributes('label') === 'technique.providers.form.address.addressType',
|
||||
)
|
||||
expect(hasAddressType).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
|
||||
const wrapper = mountBlock({}, {
|
||||
sites: 'Au moins un site est obligatoire.',
|
||||
categories: 'Au moins une catégorie est obligatoire.',
|
||||
})
|
||||
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
|
||||
|
||||
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||
})
|
||||
|
||||
it('affiche l\'erreur serveur sur le code postal', () => {
|
||||
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
|
||||
const field = wrapper.findAll('malio-input-text-stub').find(
|
||||
el => el.attributes('label') === 'technique.providers.form.address.postalCode',
|
||||
)
|
||||
expect(field?.attributes('error')).toBe('Code postal invalide.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchAddressMock.mockReset()
|
||||
})
|
||||
|
||||
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
|
||||
const wrapper = mountBlock()
|
||||
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
|
||||
await flushPromises()
|
||||
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
|
||||
searchAddressMock
|
||||
.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||
.mockResolvedValueOnce([
|
||||
{ label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' },
|
||||
])
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue du test')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue du teste')
|
||||
await flushPromises()
|
||||
|
||||
expect(searchAddressMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => {
|
||||
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue du test')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue du teste')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
||||
})
|
||||
|
||||
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
|
||||
const wrapper = mountBlock({ street: '1 rue du Test' })
|
||||
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
|
||||
expect(values).toContain('1 rue du Test')
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
import ProviderContactBlock from '../ProviderContactBlock.vue'
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */
|
||||
function errorProbe(testid: string) {
|
||||
return defineComponent({
|
||||
name: `Probe-${testid}`,
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
error: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mountBlock(errors?: Record<string, string>) {
|
||||
return mount(ProviderContactBlock, {
|
||||
props: {
|
||||
modelValue: emptyProviderContact(),
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioInputPhone: true,
|
||||
MalioInputText: errorProbe('contact-text'),
|
||||
MalioInputEmail: errorProbe('contact-email'),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ProviderContactBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
|
||||
const wrapper = mountBlock({ email: 'L\'adresse email n\'est pas valide.' })
|
||||
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('L\'adresse email n\'est pas valide.')
|
||||
})
|
||||
|
||||
it('laisse les champs sans erreur quand errors est absent', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,587 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141).
|
||||
*
|
||||
* `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et
|
||||
* l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la
|
||||
* creation :
|
||||
* - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie
|
||||
* -> POST bloque, erreurs inline, aucun appel reseau.
|
||||
* - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json
|
||||
* + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact +
|
||||
* reaffichage du nom normalise.
|
||||
* - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName.
|
||||
* - 422 -> mapping inline par champ (propertyPath).
|
||||
* - Onglets : « Comptabilite » present uniquement avec accounting.view ;
|
||||
* completeTab deverrouille/avance et signale le dernier onglet.
|
||||
*/
|
||||
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite).
|
||||
const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false }))
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: vi.fn(),
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('usePermissions', () => ({
|
||||
can: (perm: string) => {
|
||||
if (perm === 'technique.providers.accounting.view') return permState.accountingView
|
||||
if (perm === 'technique.providers.accounting.manage') return permState.accountingManage
|
||||
return true
|
||||
},
|
||||
}))
|
||||
|
||||
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
|
||||
const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm')
|
||||
type ProviderForm = ReturnType<typeof useProviderForm>
|
||||
|
||||
const SITE_86 = '/api/sites/1'
|
||||
const CAT_MAINT = '/api/categories/7'
|
||||
|
||||
/** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */
|
||||
function contactAt(form: ProviderForm, index = 0) {
|
||||
return form.contacts.value[index] ?? emptyProviderContact()
|
||||
}
|
||||
|
||||
/** Accede a un bloc adresse (idem). */
|
||||
function addressAt(form: ProviderForm, index = 0) {
|
||||
return form.addresses.value[index] ?? emptyProviderAddress()
|
||||
}
|
||||
|
||||
describe('useProviderForm', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
|
||||
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => {
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
await form.submitMain()
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.mainErrors.errors.sites).toBeUndefined()
|
||||
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||
})
|
||||
|
||||
it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' })
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Maintenance Pro'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers')
|
||||
expect(body).toEqual({
|
||||
companyName: 'Maintenance Pro',
|
||||
categories: [CAT_MAINT],
|
||||
sites: [SITE_86],
|
||||
})
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
|
||||
expect(form.providerId.value).toBe(42)
|
||||
// RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur.
|
||||
expect(form.main.companyName).toBe('MAINTENANCE PRO')
|
||||
expect(form.mainLocked.value).toBe(true)
|
||||
expect(form.activeTab.value).toBe('contact')
|
||||
expect(form.unlockedIndex.value).toBe(0)
|
||||
})
|
||||
|
||||
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = ' '
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
await form.submitMain()
|
||||
|
||||
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
|
||||
expect(body).not.toHaveProperty('companyName')
|
||||
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
|
||||
})
|
||||
|
||||
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
|
||||
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'Doublon'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
|
||||
expect(form.mainLocked.value).toBe(false)
|
||||
})
|
||||
|
||||
it('422 : mappe les violations serveur inline par champ', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] },
|
||||
},
|
||||
})
|
||||
const form = useProviderForm()
|
||||
form.main.companyName = 'X'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
|
||||
const created = await form.submitMain()
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.')
|
||||
})
|
||||
|
||||
it('onglet Comptabilite : absent sans accounting.view, present avec', () => {
|
||||
expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address'])
|
||||
expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting'])
|
||||
|
||||
permState.accountingView = true
|
||||
const form = useProviderForm()
|
||||
expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting'])
|
||||
})
|
||||
|
||||
it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => {
|
||||
const form = useProviderForm()
|
||||
|
||||
// Contact -> Adresse (pas le dernier).
|
||||
expect(form.completeTab('contact')).toBe(false)
|
||||
expect(form.isValidated('contact')).toBe(true)
|
||||
expect(form.activeTab.value).toBe('address')
|
||||
expect(form.unlockedIndex.value).toBe(1)
|
||||
|
||||
// Adresse = dernier onglet remplissable (sans accounting.view) -> true.
|
||||
expect(form.completeTab('address')).toBe(true)
|
||||
expect(form.isValidated('address')).toBe(true)
|
||||
})
|
||||
|
||||
it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => {
|
||||
const form = useProviderForm()
|
||||
|
||||
await form.patchProvider({ siren: '123456789' })
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
|
||||
mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' })
|
||||
form.main.companyName = 'Acme'
|
||||
form.main.categoryIris = [CAT_MAINT]
|
||||
form.main.siteIris = [SITE_86]
|
||||
await form.submitMain()
|
||||
|
||||
await form.patchProvider({ siren: '123456789' })
|
||||
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — onglet Contact (ERP-142)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
/** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
|
||||
function createdForm() {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier bloc est vide', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
|
||||
// addContact est un no-op tant que le bloc est vide.
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
contactAt(form).lastName = 'Doe'
|
||||
expect(form.canAddContact.value).toBe(true)
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removeContact retire le bloc et son erreur de ligne', () => {
|
||||
const form = createdForm()
|
||||
contactAt(form).lastName = 'Doe'
|
||||
form.addContact()
|
||||
form.contactErrors.value = [{}, { lastName: 'x' }]
|
||||
|
||||
form.removeContact(1)
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
expect(form.contactErrors.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => {
|
||||
mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 })
|
||||
const form = createdForm()
|
||||
contactAt(form).lastName = 'Doe'
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers/7/contacts')
|
||||
expect(body).toMatchObject({ lastName: 'Doe' })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(contactAt(form).id).toBe(55)
|
||||
expect(contactAt(form).iri).toBe('/api/provider_contacts/55')
|
||||
expect(form.isValidated('contact')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
contactAt(form).id = 55
|
||||
contactAt(form).lastName = 'Doe'
|
||||
|
||||
await form.submitContacts(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
|
||||
})
|
||||
|
||||
it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName inline', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
|
||||
expect(form.isValidated('contact')).toBe(false)
|
||||
})
|
||||
|
||||
it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => {
|
||||
mockPost
|
||||
.mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 })
|
||||
.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
contactAt(form).lastName = 'Doe'
|
||||
form.addContact()
|
||||
contactAt(form, 1).email = 'invalide'
|
||||
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.contactErrors.value[0]).toBeUndefined()
|
||||
expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = false
|
||||
permState.accountingManage = false
|
||||
})
|
||||
|
||||
/** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */
|
||||
function createdForm() {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
|
||||
function fillValidAddress(form: ProviderForm, index = 0): void {
|
||||
const a = addressAt(form, index)
|
||||
a.siteIris = [SITE_86]
|
||||
a.categoryIris = [CAT_MAINT]
|
||||
a.postalCode = '86100'
|
||||
a.city = 'Châtellerault'
|
||||
a.street = '1 rue du Test'
|
||||
}
|
||||
|
||||
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddAddress.value).toBe(false)
|
||||
|
||||
// no-op tant que l'adresse n'est pas valide.
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
|
||||
addressAt(form).siteIris = [SITE_86]
|
||||
expect(form.canAddAddress.value).toBe(false) // categorie manquante
|
||||
addressAt(form).categoryIris = [CAT_MAINT]
|
||||
expect(form.canAddAddress.value).toBe(true)
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('removeAddress retire le bloc et son erreur de ligne', () => {
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
form.addAddress()
|
||||
form.addressErrors.value = [{}, { city: 'x' }]
|
||||
|
||||
form.removeAddress(1)
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
expect(form.addressErrors.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 88 })
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers/7/addresses')
|
||||
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(addressAt(form).id).toBe(88)
|
||||
expect(form.isValidated('address')).toBe(true)
|
||||
})
|
||||
|
||||
it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
addressAt(form).id = 88
|
||||
|
||||
await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false })
|
||||
})
|
||||
|
||||
it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillValidAddress(form)
|
||||
|
||||
const ok = await form.submitAddresses(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.')
|
||||
expect(form.isValidated('address')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
|
||||
const TVA = '/api/tva_modes/1'
|
||||
const DELAY = '/api/payment_delays/1'
|
||||
const TYPE = '/api/payment_types/3'
|
||||
const BANK = '/api/banks/2'
|
||||
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
permState.accountingView = true
|
||||
permState.accountingManage = true
|
||||
})
|
||||
|
||||
/** Prestataire cree, onglet Comptabilite editable (view + manage). */
|
||||
function createdForm() {
|
||||
const form = useProviderForm()
|
||||
form.providerId.value = 7
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit les scalaires comptables communs. */
|
||||
function fillScalars(form: ProviderForm): void {
|
||||
form.accounting.siren = '123456789'
|
||||
form.accounting.accountNumber = '4010'
|
||||
form.accounting.tvaModeIri = TVA
|
||||
form.accounting.nTva = 'FR123'
|
||||
form.accounting.paymentDelayIri = DELAY
|
||||
form.accounting.paymentTypeIri = TYPE
|
||||
}
|
||||
|
||||
it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => {
|
||||
permState.accountingManage = false
|
||||
const form = createdForm()
|
||||
expect(form.accountingReadonly.value).toBe(true)
|
||||
|
||||
permState.accountingManage = true
|
||||
const form2 = createdForm()
|
||||
expect(form2.accountingReadonly.value).toBe(false)
|
||||
})
|
||||
|
||||
it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => {
|
||||
const form = createdForm()
|
||||
form.accounting.bankIri = BANK
|
||||
|
||||
// Type VIREMENT -> banque requise, conservee.
|
||||
form.setPaymentType(TYPE, true, false)
|
||||
expect(form.accounting.bankIri).toBe(BANK)
|
||||
|
||||
// Type non-VIREMENT -> banque videe (sans objet).
|
||||
form.setPaymentType(TYPE, false, false)
|
||||
expect(form.accounting.bankIri).toBeNull()
|
||||
})
|
||||
|
||||
it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => {
|
||||
const form = createdForm()
|
||||
expect(form.ribs.value).toHaveLength(0)
|
||||
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
expect(form.ribs.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => {
|
||||
const form = createdForm()
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
expect(form.canAddRib.value).toBe(false)
|
||||
|
||||
const rib = form.ribs.value[0]
|
||||
if (rib) {
|
||||
rib.label = 'Compte'
|
||||
rib.bic = 'BNPAFRPP'
|
||||
rib.iban = 'FR76...'
|
||||
}
|
||||
expect(form.canAddRib.value).toBe(true)
|
||||
})
|
||||
|
||||
it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.accounting.bankIri = BANK
|
||||
|
||||
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/providers/7',
|
||||
expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }),
|
||||
{ toast: false },
|
||||
)
|
||||
expect(form.isValidated('accounting')).toBe(true)
|
||||
})
|
||||
|
||||
it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => {
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false)
|
||||
|
||||
await form.submitAccounting(false, false, vi.fn())
|
||||
|
||||
const body = mockPatch.mock.calls[0]?.[1] as Record<string, unknown>
|
||||
expect(body.bank).toBeNull()
|
||||
})
|
||||
|
||||
it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => {
|
||||
mockPost.mockResolvedValueOnce({ id: 50 })
|
||||
mockPatch.mockResolvedValueOnce({})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
const rib = form.ribs.value[0]
|
||||
if (rib) {
|
||||
rib.label = 'Compte'
|
||||
rib.bic = 'BNPAFRPP'
|
||||
rib.iban = 'FR76...'
|
||||
}
|
||||
|
||||
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/providers/7/ribs',
|
||||
expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }),
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
expect(form.ribs.value[0]?.id).toBe(50)
|
||||
// Le PATCH des scalaires intervient APRES la creation du RIB.
|
||||
expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false })
|
||||
})
|
||||
|
||||
it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => {
|
||||
mockPatch.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
|
||||
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.')
|
||||
expect(form.isValidated('accounting')).toBe(false)
|
||||
})
|
||||
|
||||
it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
const form = createdForm()
|
||||
fillScalars(form)
|
||||
form.setPaymentType(TYPE, false, true)
|
||||
const rib = form.ribs.value[0]
|
||||
if (rib) {
|
||||
rib.label = 'Compte'
|
||||
rib.bic = 'BNPAFRPP'
|
||||
}
|
||||
|
||||
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.')
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useProvidersRepository, type Provider } from '../useProvidersRepository'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests du repertoire prestataires (ERP-140).
|
||||
*
|
||||
* `useProvidersRepository` est une fine enveloppe de `usePaginatedList<Provider>`
|
||||
* sur `/providers`. Les invariants generiques de pagination sont deja couverts
|
||||
* par `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
|
||||
* - la ressource ciblee est bien `/providers`
|
||||
* - l'enveloppe Hydra (member / totalItems) est consommee
|
||||
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
|
||||
* renvoie un tableau plat sans pagination)
|
||||
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
|
||||
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
|
||||
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
|
||||
*/
|
||||
describe('useProvidersRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
})
|
||||
|
||||
/** Une page de prestataires Hydra, avec categories[] et sites[] embarques. */
|
||||
const PAGE: Provider[] = [
|
||||
{
|
||||
id: 1,
|
||||
companyName: 'ACME MAINTENANCE',
|
||||
categories: [{ code: 'MAINTENANCE_INDUSTRIELLE', name: 'Maintenance industrielle' }],
|
||||
sites: [{ id: 4, name: 'Chatellerault', color: '#056CF2' }],
|
||||
updatedAt: '2026-06-15T08:12:01+02:00',
|
||||
isArchived: false,
|
||||
},
|
||||
]
|
||||
|
||||
it('cible /providers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/providers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
})
|
||||
expect(repo.items.value).toEqual(PAGE)
|
||||
expect(repo.totalItems.value).toBe(1)
|
||||
})
|
||||
|
||||
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
|
||||
await repo.fetch()
|
||||
|
||||
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(query.includeArchived).toBeUndefined()
|
||||
})
|
||||
|
||||
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
const repo = useProvidersRepository()
|
||||
await repo.fetch()
|
||||
|
||||
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
|
||||
await repo.setFilters({ includeArchived: true })
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(query.includeArchived).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,558 +0,0 @@
|
||||
import { computed, reactive, ref, type Ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||
import {
|
||||
emptyProviderAccounting,
|
||||
emptyProviderAddress,
|
||||
emptyProviderContact,
|
||||
emptyProviderMain,
|
||||
emptyProviderRib,
|
||||
type ProviderAccountingDraft,
|
||||
type ProviderAddressFormDraft,
|
||||
type ProviderAddressResponse,
|
||||
type ProviderContactFormDraft,
|
||||
type ProviderContactResponse,
|
||||
type ProviderMainDraft,
|
||||
type ProviderMainResponse,
|
||||
type ProviderRibFormDraft,
|
||||
type ProviderRibResponse,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
import {
|
||||
buildProviderContactPayload,
|
||||
isProviderContactBlank,
|
||||
} from '~/modules/technique/utils/forms/providerContact'
|
||||
import {
|
||||
buildProviderAddressPayload,
|
||||
isProviderAddressValid,
|
||||
} from '~/modules/technique/utils/forms/providerAddress'
|
||||
import {
|
||||
buildProviderAccountingPayload,
|
||||
buildProviderRibPayload,
|
||||
isRibBlank,
|
||||
isRibComplete,
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
|
||||
/**
|
||||
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
|
||||
* miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en
|
||||
* composable.
|
||||
*
|
||||
* Particularites M3 (cf. spec-front § « Ecran Ajouter ») :
|
||||
* - PAS d'onglet « Information » : le formulaire principal est minimal (Nom +
|
||||
* Categorie + Site).
|
||||
* - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe
|
||||
* `provider.sites`).
|
||||
* - Creation incrementale par onglets (Contact · Adresse · Comptabilite) :
|
||||
* POST principal puis PATCH partiels par groupe de serialisation
|
||||
* (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets
|
||||
* arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et
|
||||
* l'orchestration des onglets.
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ;
|
||||
* « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification.
|
||||
* L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir
|
||||
* (`technique.providers.accounting.view` — Admin, Compta).
|
||||
*/
|
||||
export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] {
|
||||
return canAccountingView
|
||||
? ['contact', 'address', 'accounting']
|
||||
: ['contact', 'address']
|
||||
}
|
||||
|
||||
export function useProviderForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||
const mainErrors = useFormErrors()
|
||||
|
||||
// ── Etat du prestataire cree ────────────────────────────────────────────
|
||||
const providerId = ref<number | null>(null)
|
||||
const mainLocked = ref(false)
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
|
||||
// ── Formulaire principal ──────────────────────────────────────────────────
|
||||
const main = reactive<ProviderMainDraft>(emptyProviderMain())
|
||||
|
||||
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
||||
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
||||
const canAccountingManage = computed(() => can('technique.providers.accounting.manage'))
|
||||
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
|
||||
|
||||
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
|
||||
const unlockedIndex = ref(-1)
|
||||
const activeTab = ref<string>('contact')
|
||||
// Onglets valides (passent en lecture seule).
|
||||
const validated = reactive<Record<string, boolean>>({})
|
||||
|
||||
function isValidated(key: string): boolean {
|
||||
return validated[key] === true
|
||||
}
|
||||
|
||||
function tabIndex(key: string): number {
|
||||
return tabKeys.value.indexOf(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09
|
||||
* (>= 1 categorie). Pose les erreurs inline et retourne false si invalide.
|
||||
* Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un
|
||||
* aller-retour inutile et porte la garantie RG-3.03 cote front.
|
||||
*/
|
||||
function validateMainFront(): boolean {
|
||||
let valid = true
|
||||
if (main.siteIris.length === 0) {
|
||||
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
|
||||
valid = false
|
||||
}
|
||||
if (main.categoryIris.length === 0) {
|
||||
mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired'))
|
||||
valid = false
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du POST principal (groupe `provider:write:main`). `companyName` est
|
||||
* omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur
|
||||
* le champ plutot qu'une erreur de type. Les relations M2M partent en IRI.
|
||||
*/
|
||||
function buildMainPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
categories: [...main.categoryIris],
|
||||
sites: [...main.siteIris],
|
||||
}
|
||||
if (main.companyName?.trim()) {
|
||||
payload.companyName = main.companyName
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09,
|
||||
* puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er
|
||||
* onglet et bascule sur « Contact ». Retourne true si cree, false sinon.
|
||||
*/
|
||||
async function submitMain(): Promise<boolean> {
|
||||
if (mainSubmitting.value) return false
|
||||
mainErrors.clearErrors()
|
||||
if (!validateMainFront()) return false
|
||||
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const created = await api.post<ProviderMainResponse>('/providers', buildMainPayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
|
||||
providerId.value = created.id
|
||||
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11).
|
||||
main.companyName = created.companyName ?? main.companyName
|
||||
|
||||
mainLocked.value = true
|
||||
unlockedIndex.value = 0
|
||||
activeTab.value = tabKeys.value[0] ?? 'contact'
|
||||
toast.success({ title: t('technique.providers.toast.createSuccess') })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
// 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ;
|
||||
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
const message = t('technique.providers.form.duplicateCompany')
|
||||
mainErrors.setError('companyName', message)
|
||||
toast.error({ title: t('technique.providers.toast.error'), message })
|
||||
}
|
||||
else {
|
||||
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
|
||||
}
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH partiel du prestataire (mode strict : un seul groupe de serialisation
|
||||
* par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires
|
||||
* (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources
|
||||
* (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas.
|
||||
*/
|
||||
async function patchProvider(payload: Record<string, unknown>): Promise<void> {
|
||||
if (providerId.value === null) return
|
||||
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un onglet valide (passe en lecture seule), deverrouille et avance a
|
||||
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
|
||||
* (creation terminee), false sinon.
|
||||
*/
|
||||
function completeTab(key: string): boolean {
|
||||
validated[key] = true
|
||||
const index = tabIndex(key)
|
||||
const next = tabKeys.value[index + 1]
|
||||
if (next === undefined) {
|
||||
return true
|
||||
}
|
||||
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
|
||||
activeTab.value = next
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
|
||||
* on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la
|
||||
* cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le
|
||||
* fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne
|
||||
* true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`.
|
||||
*/
|
||||
async function submitRows<T>(
|
||||
rows: T[],
|
||||
target: Ref<Record<string, string>[]>,
|
||||
saveRow: (row: T, index: number) => Promise<void>,
|
||||
onUnmappedError: (error: unknown, index: number) => void,
|
||||
shouldSkip?: (row: T, index: number) => boolean,
|
||||
): Promise<boolean> {
|
||||
target.value = []
|
||||
let hasError = false
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index] as T
|
||||
if (shouldSkip?.(row, index)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await saveRow(row, index)
|
||||
}
|
||||
catch (error) {
|
||||
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
|
||||
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
|
||||
if (Object.keys(mapped).length > 0) {
|
||||
target.value[index] = mapped
|
||||
}
|
||||
else {
|
||||
onUnmappedError(error, index)
|
||||
}
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
return hasError
|
||||
}
|
||||
|
||||
// ── Onglet Contact (ERP-142) ──────────────────────────────────────────────
|
||||
const contacts = ref<ProviderContactFormDraft[]>([emptyProviderContact()])
|
||||
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
|
||||
const contactErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouveau contact » desactive tant que le dernier bloc est vide (RG-3.04).
|
||||
const canAddContact = computed(() => {
|
||||
const last = contacts.value[contacts.value.length - 1]
|
||||
return last !== undefined && !isProviderContactBlank(last)
|
||||
})
|
||||
|
||||
function addContact(): void {
|
||||
if (canAddContact.value) {
|
||||
contacts.value.push(emptyProviderContact())
|
||||
}
|
||||
}
|
||||
|
||||
function removeContact(index: number): void {
|
||||
contacts.value.splice(index, 1)
|
||||
contactErrors.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contact : POST des nouveaux contacts sur
|
||||
* /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id}
|
||||
* (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc
|
||||
* valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour
|
||||
* declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un
|
||||
* onglet vide. Retourne true si l'onglet a ete valide (avance/termine).
|
||||
*/
|
||||
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (providerId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c))
|
||||
const hasError = await submitRows(
|
||||
contacts.value,
|
||||
contactErrors,
|
||||
async (contact) => {
|
||||
const body = buildProviderContactPayload(contact)
|
||||
if (contact.id === null) {
|
||||
const created = await api.post<ProviderContactResponse>(
|
||||
`/providers/${providerId.value}/contacts`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
contact.id = created.id
|
||||
contact.iri = created['@id'] ?? null
|
||||
}
|
||||
else {
|
||||
await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onError,
|
||||
contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact),
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
}
|
||||
completeTab('contact')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Adresse (ERP-143) ──────────────────────────────────────────────
|
||||
const addresses = ref<ProviderAddressFormDraft[]>([emptyProviderAddress()])
|
||||
// Erreurs 422 par ligne (alignees sur l'index du v-for).
|
||||
const addressErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas
|
||||
// au moins un site ET une categorie (RG-3.05 / RG-3.09).
|
||||
const canAddAddress = computed(() => {
|
||||
const last = addresses.value[addresses.value.length - 1]
|
||||
return last !== undefined && isProviderAddressValid(last)
|
||||
})
|
||||
|
||||
function addAddress(): void {
|
||||
if (canAddAddress.value) {
|
||||
addresses.value.push(emptyProviderAddress())
|
||||
}
|
||||
}
|
||||
|
||||
function removeAddress(index: number): void {
|
||||
addresses.value.splice(index, 1)
|
||||
addressErrors.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Adresse : POST des nouvelles adresses sur
|
||||
* /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id}
|
||||
* (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par
|
||||
* ligne. Retourne true si l'onglet a ete valide (avance/termine).
|
||||
*/
|
||||
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (providerId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasError = await submitRows(
|
||||
addresses.value,
|
||||
addressErrors,
|
||||
async (address) => {
|
||||
const body = buildProviderAddressPayload(address)
|
||||
if (address.id === null) {
|
||||
const created = await api.post<ProviderAddressResponse>(
|
||||
`/providers/${providerId.value}/addresses`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
address.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/provider_addresses/${address.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onError,
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
}
|
||||
completeTab('address')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Comptabilite (ERP-144) ─────────────────────────────────────────
|
||||
const accounting = reactive<ProviderAccountingDraft>(emptyProviderAccounting())
|
||||
const ribs = ref<ProviderRibFormDraft[]>([])
|
||||
const accountingErrors = useFormErrors()
|
||||
// Erreurs 422 par ligne de RIB (alignees sur l'index du v-for).
|
||||
const ribErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
|
||||
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
|
||||
|
||||
/**
|
||||
* Met a jour le type de reglement (IRI) en propageant ses RG inter-champs :
|
||||
* - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ;
|
||||
* - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on
|
||||
* purge les erreurs de RIB (les blocs sont conserves mais non persistes).
|
||||
* `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a
|
||||
* partir du code resolu via les referentiels.
|
||||
*/
|
||||
function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void {
|
||||
accounting.paymentTypeIri = iri
|
||||
if (!isBankRequired) {
|
||||
accounting.bankIri = null
|
||||
}
|
||||
if (isRibRequired) {
|
||||
if (ribs.value.length === 0) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
else {
|
||||
ribErrors.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08).
|
||||
const canAddRib = computed(() => {
|
||||
const last = ribs.value[ribs.value.length - 1]
|
||||
return last !== undefined && isRibComplete(last)
|
||||
})
|
||||
|
||||
function addRib(): void {
|
||||
if (canAddRib.value) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
|
||||
function removeRib(index: number): void {
|
||||
ribs.value.splice(index, 1)
|
||||
ribErrors.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (sous LCR).
|
||||
if (ribs.value.length === 0) {
|
||||
ribs.value.push(emptyProviderRib())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord
|
||||
* (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister
|
||||
* AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting,
|
||||
* banque envoyee seulement si VIREMENT — RG-3.07). Erreurs RIB par ligne ;
|
||||
* erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete
|
||||
* valide.
|
||||
*/
|
||||
async function submitAccounting(
|
||||
isBankRequired: boolean,
|
||||
isRibRequired: boolean,
|
||||
onRibError: (error: unknown) => void,
|
||||
): Promise<boolean> {
|
||||
if (providerId.value === null || tabSubmitting.value) {
|
||||
return false
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
accountingErrors.clearErrors()
|
||||
try {
|
||||
// 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee
|
||||
// s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli)
|
||||
// on la soumet pour declencher la 422 NotBlank inline.
|
||||
if (isRibRequired) {
|
||||
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||
const ribHasError = await submitRows(
|
||||
ribs.value,
|
||||
ribErrors,
|
||||
async (rib) => {
|
||||
const body = buildProviderRibPayload(rib)
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<ProviderRibResponse>(
|
||||
`/providers/${providerId.value}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
},
|
||||
onRibError,
|
||||
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||
)
|
||||
if (ribHasError) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||
try {
|
||||
await api.patch(
|
||||
`/providers/${providerId.value}`,
|
||||
buildProviderAccountingPayload(accounting, isBankRequired),
|
||||
{ toast: false },
|
||||
)
|
||||
}
|
||||
catch (error) {
|
||||
accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
|
||||
return false
|
||||
}
|
||||
|
||||
completeTab('accounting')
|
||||
return true
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// etat
|
||||
main,
|
||||
providerId,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
tabSubmitting,
|
||||
mainErrors,
|
||||
// onglets
|
||||
canAccountingView,
|
||||
canAccountingManage,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
validated,
|
||||
isValidated,
|
||||
// contacts
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
// adresses
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
// comptabilite
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
// actions
|
||||
validateMainFront,
|
||||
buildMainPayload,
|
||||
submitMain,
|
||||
patchProvider,
|
||||
completeTab,
|
||||
submitRows,
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Charge les referentiels (listes courtes) alimentant les selects du formulaire
|
||||
* principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) :
|
||||
* categories (type PRESTATAIRE) et sites (86 / 17 / 82).
|
||||
*
|
||||
* Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire
|
||||
* principal) seuls categories + sites sont necessaires. Les referentiels
|
||||
* comptables (modes de TVA, delais/types de reglement, banques) seront charges
|
||||
* par l'onglet Comptabilite (ERP-144).
|
||||
*
|
||||
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||
* `?pagination=false` (referentiels de quelques entrees), avec l'en-tete
|
||||
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
|
||||
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
|
||||
* quelle dans le payload POST (relations M2M).
|
||||
*
|
||||
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un
|
||||
* echec (permission manquante, reseau) laisse simplement la liste vide.
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
|
||||
export interface PaymentTypeOption extends RefOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
interface HydraMember {
|
||||
'@id': string
|
||||
}
|
||||
|
||||
interface ReferentialMember extends HydraMember {
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface CategoryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface SiteMember extends HydraMember {
|
||||
name: string
|
||||
postalCode: string
|
||||
}
|
||||
|
||||
interface CountryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
export function useProviderReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const categories = ref<RefOption[]>([])
|
||||
const sites = ref<RefOption[]>([])
|
||||
const countries = ref<RefOption[]>([])
|
||||
// Referentiels comptables (charges a la demande via loadAccounting).
|
||||
const tvaModes = ref<RefOption[]>([])
|
||||
const paymentDelays = ref<RefOption[]>([])
|
||||
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||
const banks = ref<RefOption[]>([])
|
||||
|
||||
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||
async function fetchAll<T extends HydraMember>(
|
||||
url: string,
|
||||
query: Record<string, string | string[]> = {},
|
||||
): Promise<T[]> {
|
||||
const res = await api.get<{ member?: T[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
/** Charge en parallele les referentiels du formulaire principal (categories + sites). */
|
||||
async function loadMain(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
// RG-3.09 : un prestataire ne porte que des categories de type
|
||||
// PRESTATAIRE -> filtre cote API. Libelle affiche = `name`.
|
||||
fetchAll<CategoryMember>('/categories', { typeCode: 'PRESTATAIRE' })
|
||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }),
|
||||
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
|
||||
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
|
||||
fetchAll<SiteMember>('/sites')
|
||||
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
|
||||
// Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
|
||||
// `country` en chaine libre, « France »...). value === label. Aligne sur
|
||||
// les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
|
||||
fetchAll<CountryMember>('/countries')
|
||||
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele
|
||||
* uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient
|
||||
* (allSettled) : un referentiel en echec reste vide.
|
||||
*/
|
||||
async function loadAccounting(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll<ReferentialMember>('/tva_modes')
|
||||
.then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||
fetchAll<ReferentialMember>('/payment_delays')
|
||||
.then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }),
|
||||
// Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR).
|
||||
fetchAll<ReferentialMember>('/payment_types')
|
||||
.then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }),
|
||||
fetchAll<ReferentialMember>('/banks')
|
||||
.then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
sites,
|
||||
countries,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
paymentTypes,
|
||||
banks,
|
||||
loadMain,
|
||||
loadAccounting,
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Site Starseed rattache DIRECTEMENT au prestataire (M2M `provider_site`,
|
||||
* RG-3.03), tel qu'embarque en LISTE (groupe site:read) pour la colonne « Site »
|
||||
* du Repertoire (badges colores).
|
||||
*
|
||||
* Difference M3 vs M2 : au M2 les sites venaient de l'agregat dedoublonne des
|
||||
* adresses (`Supplier::getSites()`) ; ici c'est une relation directe portee par
|
||||
* le formulaire principal (cf. spec-back M3 § 2.12).
|
||||
*/
|
||||
export interface ProviderSite {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie (type PRESTATAIRE) rattachee au prestataire, embarquee en LISTE
|
||||
* (groupe category:read). La colonne « Catégories » affiche le `name` (cohérence
|
||||
* M1/M2 — libellé = `name`, pas `code`).
|
||||
*/
|
||||
export interface ProviderCategory {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un prestataire pour le Repertoire (datatable). Volontairement
|
||||
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-140).
|
||||
*/
|
||||
export interface Provider {
|
||||
id: number
|
||||
companyName: string
|
||||
categories: ProviderCategory[]
|
||||
sites: ProviderSite[]
|
||||
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||
updatedAt: string | null
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Repertoire prestataires (ERP-140) — simple enveloppe de `usePaginatedList<Provider>`
|
||||
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
|
||||
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
|
||||
*
|
||||
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
|
||||
* par la page via `setFilters` du composable partage — la remise en page 1 est
|
||||
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
|
||||
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
|
||||
*
|
||||
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
|
||||
* fonction de l'utilisateur — rien a filtrer cote front.
|
||||
*
|
||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
export function useProvidersRepository() {
|
||||
return usePaginatedList<Provider>({ url: '/providers' })
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('technique.providers.title') }}
|
||||
<template #actions>
|
||||
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
|
||||
<div class="flex items-center gap-8">
|
||||
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
@click="openFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('technique.providers.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList via useProvidersRepository :
|
||||
pagination serveur, tri companyName ASC par defaut (cote back),
|
||||
archives masques par defaut. Cloisonnement par site cote back. -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
table-class="table-fixed providers-table"
|
||||
:empty-message="t('technique.providers.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : libelles (name) separes par une virgule. -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
|
||||
<!-- Sites : badges colores (name + color), relation directe du prestataire. -->
|
||||
<template #cell-sites="{ item }">
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="site in (item.sites as ProviderSite[])"
|
||||
:key="site.id"
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
|
||||
:style="{ backgroundColor: site.color }"
|
||||
>
|
||||
{{ site.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Derniere activite : date de derniere modification (updatedAt), format JJ-MM-AAAA. -->
|
||||
<template #cell-lastActivity="{ item }">
|
||||
{{ formatLastActivity(item) }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('technique.providers.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Voir les résultats ». Meme pattern que le repertoire fournisseurs.
|
||||
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('technique.providers.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche : nom entreprise + contact + email (param `search`). -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Categories (type PRESTATAIRE) : cases a cocher (multi). Valeur = code stable. -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.categories')" value="categories">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in categoryOptions"
|
||||
:id="`filter-category-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftCategoryCodes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.sites')" value="sites">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in siteOptions"
|
||||
:id="`filter-site-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftSiteIds.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
|
||||
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
|
||||
<MalioCheckbox
|
||||
id="filter-include-archived"
|
||||
:label="t('technique.providers.filters.includeArchived')"
|
||||
:model-value="draftIncludeArchived"
|
||||
@update:model-value="(val: boolean) => draftIncludeArchived = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('technique.providers.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Provider, ProviderSite } from '~/modules/technique/composables/useProvidersRepository'
|
||||
|
||||
interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('technique.providers.title') })
|
||||
|
||||
// Bouton « Ajouter » reserve a `manage` (POST /providers garde manage seul —
|
||||
// Compta cree pas). « Exporter » et « Filtrer » suivent `view`.
|
||||
const canManage = computed(() => can('technique.providers.manage'))
|
||||
const canView = computed(() => can('technique.providers.view'))
|
||||
|
||||
const {
|
||||
items: providers,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadProviders,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = useProvidersRepository()
|
||||
|
||||
// Mappe les prestataires en objets « plats » pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||
// implicite, contrairement a l'interface Provider. Meme pattern que fournisseurs.
|
||||
const rows = computed(() => providers.value.map(provider => ({
|
||||
id: provider.id,
|
||||
companyName: provider.companyName,
|
||||
categories: provider.categories,
|
||||
sites: provider.sites,
|
||||
updatedAt: provider.updatedAt,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'companyName', label: t('technique.providers.column.companyName') },
|
||||
{ key: 'categories', label: t('technique.providers.column.categories') },
|
||||
{ key: 'sites', label: t('technique.providers.column.sites') },
|
||||
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Libelles des categories du prestataire, separes par une virgule (name). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Provider['categories']) ?? []
|
||||
return categories.map(c => c.name).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Derniere activite : date de derniere modification de la fiche (updatedAt,
|
||||
* expose en liste via default:read). Format court francais JJ-MM-AAAA (tirets,
|
||||
* cf. spec-front M3 § Datatable).
|
||||
*/
|
||||
function formatLastActivity(item: Record<string, unknown>): string {
|
||||
const value = item.updatedAt as string | null | undefined
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const year = date.getFullYear()
|
||||
return `${day}-${month}-${year}`
|
||||
}
|
||||
|
||||
/** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/providers/${item.id}`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/providers/new')
|
||||
}
|
||||
|
||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern repertoire fournisseurs) :
|
||||
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryCodes = ref<string[]>([])
|
||||
const draftSiteIds = ref<string[]>([])
|
||||
const draftIncludeArchived = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryCodes = ref<string[]>([])
|
||||
const appliedSiteIds = ref<string[]>([])
|
||||
const appliedIncludeArchived = ref(false)
|
||||
|
||||
// Options des selects multi, chargees une fois (referentiels courts).
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
const siteOptions = ref<FilterOption[]>([])
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryCodes.value.length > 0) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
if (appliedIncludeArchived.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('technique.providers.filters.title')
|
||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||
})
|
||||
|
||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
|
||||
// reouverture reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
draftIncludeArchived.value = appliedIncludeArchived.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function toggleCategory(code: string, selected: boolean): void {
|
||||
draftCategoryCodes.value = selected
|
||||
? [...draftCategoryCodes.value, code]
|
||||
: draftCategoryCodes.value.filter(c => c !== code)
|
||||
}
|
||||
|
||||
function toggleSite(id: string, selected: boolean): void {
|
||||
draftSiteIds.value = selected
|
||||
? [...draftSiteIds.value, id]
|
||||
: draftSiteIds.value.filter(s => s !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
|
||||
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
|
||||
* Les filtres vides sont omis pour une query propre.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
const payload: Record<string, string | string[] | boolean> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||
if (appliedIncludeArchived.value) payload.includeArchived = true
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
||||
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
appliedIncludeArchived.value = draftIncludeArchived.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryCodes.value = []
|
||||
draftSiteIds.value = []
|
||||
draftIncludeArchived.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryCodes.value = []
|
||||
appliedSiteIds.value = []
|
||||
appliedIncludeArchived.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
/** Charge les referentiels du drawer (categories PRESTATAIRE + sites) via ?pagination=false. */
|
||||
async function loadFilterOptions(): Promise<void> {
|
||||
const [cats, sites] = await Promise.all([
|
||||
api.get<{ member?: Array<{ code: string, name: string }> }>(
|
||||
'/categories',
|
||||
// Taxonomie multi-types : le filtre du repertoire prestataires ne
|
||||
// propose que les categories de type PRESTATAIRE.
|
||||
{ pagination: 'false', typeCode: 'PRESTATAIRE' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
api.get<{ member?: Array<{ id: number, name: string }> }>(
|
||||
'/sites',
|
||||
{ pagination: 'false' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
])
|
||||
|
||||
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
|
||||
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
|
||||
}
|
||||
|
||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
|
||||
// l'utilisateur a accounting.view (gere cote back).
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportXlsx(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||
// contenu faute d'overload blob sur le client partage — meme approche que
|
||||
// l'export fournisseurs.
|
||||
const blob = await api.get<Blob>('/providers/export.xlsx', buildFilterPayload(), {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'repertoire-prestataires.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: t('technique.providers.toast.exportError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
|
||||
// l'utilisateur perd juste les options de filtre.
|
||||
loadFilterOptions().catch(() => {
|
||||
categoryOptions.value = []
|
||||
siteOptions.value = []
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
|
||||
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
|
||||
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
|
||||
* couleurs/tailles (qui restent sur les defauts Malio).
|
||||
*/
|
||||
:deep(.providers-table tbody td:nth-child(3)) {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,511 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers le repertoire + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('technique.providers.form.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
||||
Sans validation de ce bloc, les onglets restent inaccessibles. Au
|
||||
succes du POST, les champs passent en lecture seule et on bascule
|
||||
automatiquement sur l'onglet Contact (PAS d'onglet Information au M3).
|
||||
Selecteur de site present ici (RG-3.03, relation directe). -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="referentials.categories.value"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.siteIris"
|
||||
:options="referentials.sites.value"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="mainSubmitting"
|
||||
@click="submitMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets a validation incrementale ─────────────────────────────
|
||||
Onglet Contact actif (ERP-142) ; Adresse / Comptabilite arrivent aux
|
||||
tickets ERP-143 / 144 : placeholders « A venir » pour l'instant. -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Contact : saisie multi-contacts (blocs ajoutables). -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.contact.add')"
|
||||
:disabled="!canAddContact"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Onglet Adresse : saisie multi-adresses (blocs ajoutables). -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ProviderAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<div v-if="!isValidated('address')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.address.add')"
|
||||
:disabled="!canAddAddress"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('technique.providers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('technique.providers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@update:model-value="onPaymentTypeChange"
|
||||
/>
|
||||
<!-- Banque : visible et obligatoire seulement si VIREMENT (RG-3.07). -->
|
||||
<MalioSelect
|
||||
v-if="isBankRequired"
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('technique.providers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
||||
<div
|
||||
v-for="(rib, index) in visibleRibs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('technique.providers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('technique.providers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
v-if="isRibRequired"
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.form.accounting.addRib')"
|
||||
:disabled="!canAddRib"
|
||||
@click="addRib"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitAccounting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
|
||||
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
|
||||
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmModal.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||
@click="confirmModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="t('technique.providers.form.confirmDelete.confirm')"
|
||||
@click="runConfirm"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
||||
import {
|
||||
isBankRequiredForPaymentType,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('technique.providers.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage` (POST /providers garde
|
||||
// manage seul — Compta ne cree pas). Compta (accounting seul) et Usine sont
|
||||
// rediriges vers le repertoire.
|
||||
if (!can('technique.providers.manage')) {
|
||||
await navigateTo('/providers')
|
||||
}
|
||||
|
||||
const referentials = useProviderReferentials()
|
||||
|
||||
const {
|
||||
main,
|
||||
mainLocked,
|
||||
mainSubmitting,
|
||||
mainErrors,
|
||||
canAccountingView,
|
||||
tabKeys,
|
||||
activeTab,
|
||||
unlockedIndex,
|
||||
submitMain,
|
||||
tabSubmitting,
|
||||
isValidated,
|
||||
contacts,
|
||||
contactErrors,
|
||||
canAddContact,
|
||||
addContact,
|
||||
removeContact,
|
||||
submitContacts,
|
||||
addresses,
|
||||
addressErrors,
|
||||
canAddAddress,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
submitAddresses,
|
||||
accounting,
|
||||
ribs,
|
||||
accountingErrors,
|
||||
ribErrors,
|
||||
accountingReadonly,
|
||||
setPaymentType,
|
||||
canAddRib,
|
||||
addRib,
|
||||
removeRib,
|
||||
submitAccounting,
|
||||
} = useProviderForm()
|
||||
|
||||
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/providers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API. Retourne
|
||||
* TOUJOURS une chaine (le composant de toast plante sur `undefined`).
|
||||
*/
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const data = (error as { response?: { _data?: unknown } })?.response?._data
|
||||
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
|
||||
}
|
||||
|
||||
// ── Onglet Contact ──────────────────────────────────────────────────────────
|
||||
/** Valide l'onglet Contact ; toast de succes si l'onglet a ete finalise. */
|
||||
async function onSubmitContacts(): Promise<void> {
|
||||
const ok = await submitContacts(error => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
if (ok) {
|
||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
}
|
||||
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
|
||||
}
|
||||
|
||||
// ── Onglet Adresse ────────────────────────────────────────────────────────────
|
||||
// Contacts deja persistes (IRI non nul), rattachables a une adresse (M2M). Le
|
||||
// libelle reprend le nom complet, a defaut l'email.
|
||||
const contactOptions = computed<RefOption[]>(() =>
|
||||
contacts.value
|
||||
.filter(c => c.iri !== null)
|
||||
.map(c => ({
|
||||
value: c.iri as string,
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||
})),
|
||||
)
|
||||
|
||||
// Pays : France garantie en tete meme si /countries echoue (resilience ERP-102),
|
||||
// pour rester preselectionnable par defaut sur chaque adresse.
|
||||
const countryOptions = computed<RefOption[]>(() => {
|
||||
const list = referentials.countries.value
|
||||
return list.some(c => c.value === 'France')
|
||||
? list
|
||||
: [{ value: 'France', label: 'France' }, ...list]
|
||||
})
|
||||
|
||||
const addressDegradedNotified = ref(false)
|
||||
|
||||
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade (RG-3.06). */
|
||||
function onAddressDegraded(): void {
|
||||
if (addressDegradedNotified.value) {
|
||||
return
|
||||
}
|
||||
addressDegradedNotified.value = true
|
||||
toast.warning({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: t('technique.providers.form.address.degraded'),
|
||||
})
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresse ; toast de succes si l'onglet a ete finalise. */
|
||||
async function onSubmitAddresses(): Promise<void> {
|
||||
const ok = await submitAddresses(error => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}))
|
||||
if (ok) {
|
||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
|
||||
}
|
||||
|
||||
// ── Onglet Comptabilite ───────────────────────────────────────────────────────
|
||||
// Code stable du type de reglement selectionne (pour RG-3.07 / RG-3.08).
|
||||
const selectedPaymentTypeCode = computed(() =>
|
||||
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||
)
|
||||
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
|
||||
// Les blocs RIB ne sont affiches que pour une LCR (RG-3.08).
|
||||
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
||||
|
||||
/** Changement de type de reglement : propage les RG inter-champs (banque / RIB). */
|
||||
function onPaymentTypeChange(value: string | number | null): void {
|
||||
const iri = value === null ? null : String(value)
|
||||
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
|
||||
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
|
||||
}
|
||||
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
|
||||
}
|
||||
|
||||
/** Valide l'onglet Comptabilite ; toast de succes si l'onglet a ete finalise. */
|
||||
async function onSubmitAccounting(): Promise<void> {
|
||||
const ok = await submitAccounting(
|
||||
isBankRequired.value,
|
||||
isRibRequired.value,
|
||||
error => toast.error({
|
||||
title: t('technique.providers.toast.error'),
|
||||
message: apiErrorMessage(error),
|
||||
}),
|
||||
)
|
||||
if (ok) {
|
||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal de confirmation generique ─────────────────────────────────────────
|
||||
const confirmModal = reactive({
|
||||
open: false,
|
||||
message: '',
|
||||
action: null as null | (() => void),
|
||||
})
|
||||
|
||||
function askConfirm(message: string, action: () => void): void {
|
||||
confirmModal.message = message
|
||||
confirmModal.action = action
|
||||
confirmModal.open = true
|
||||
}
|
||||
|
||||
function runConfirm(): void {
|
||||
confirmModal.action?.()
|
||||
confirmModal.action = null
|
||||
confirmModal.open = false
|
||||
}
|
||||
|
||||
// Icone (Iconify) affichee dans l'onglet, par cle.
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
contact: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
}
|
||||
|
||||
// Onglets desactives tant que le formulaire principal n'est pas valide
|
||||
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
|
||||
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||
key,
|
||||
label: t(`technique.providers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
disabled: index > unlockedIndex.value,
|
||||
})))
|
||||
|
||||
onMounted(() => {
|
||||
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||
referentials.loadMain().catch(() => {})
|
||||
// Referentiels comptables charges uniquement si l'onglet est accessible.
|
||||
if (canAccountingView.value) {
|
||||
referentials.loadAccounting().catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique).
|
||||
*
|
||||
* Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet
|
||||
* Information, et porte en plus un selecteur de site SUR le formulaire principal
|
||||
* (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse).
|
||||
*
|
||||
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
|
||||
* DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet
|
||||
* Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent.
|
||||
*
|
||||
* Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`),
|
||||
* envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 :
|
||||
* `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`).
|
||||
*/
|
||||
|
||||
/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */
|
||||
export interface ProviderMainDraft {
|
||||
/** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */
|
||||
companyName: string | null
|
||||
/** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */
|
||||
siteIris: string[]
|
||||
}
|
||||
|
||||
/** Fabrique un formulaire principal vierge. */
|
||||
export function emptyProviderMain(): ProviderMainDraft {
|
||||
return {
|
||||
companyName: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */
|
||||
export interface ProviderMainResponse {
|
||||
id: number
|
||||
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
|
||||
companyName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Un contact du prestataire (onglet Contact, ERP-142). Miroir de
|
||||
* `SupplierContactFormDraft` (M2). Tous les champs sont nullable cote ORM ; la
|
||||
* validite (RG-3.04) tient a la presence d'AU MOINS un champ rempli parmi
|
||||
* prenom / nom / fonction / telephone principal / email (cf. back).
|
||||
*/
|
||||
export interface ProviderContactFormDraft {
|
||||
/** Id serveur une fois le contact cree (null tant que non persiste). */
|
||||
id: number | null
|
||||
/** IRI Hydra du contact cree — servira au rattachement M2M cote adresse (ERP-143). */
|
||||
iri: string | null
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
jobTitle: string | null
|
||||
phonePrimary: string | null
|
||||
phoneSecondary: string | null
|
||||
email: string | null
|
||||
/** UI : le 2e numero a ete revele via le bouton « + » (max 2 telephones). */
|
||||
hasSecondaryPhone: boolean
|
||||
}
|
||||
|
||||
/** Fabrique un contact vierge. */
|
||||
export function emptyProviderContact(): ProviderContactFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
iri: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
jobTitle: null,
|
||||
phonePrimary: null,
|
||||
phoneSecondary: null,
|
||||
email: null,
|
||||
hasSecondaryPhone: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse du POST /providers/{id}/contacts (groupe provider:item:read + IRI Hydra). */
|
||||
export interface ProviderContactResponse {
|
||||
'@id'?: string
|
||||
id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Une adresse du prestataire (onglet Adresse, ERP-143). Version SIMPLIFIEE de
|
||||
* `SupplierAddressFormDraft` (M2) : PAS de type d'adresse (Prospect/Depart/Rendu),
|
||||
* PAS de bennes, PAS de prestation de triage. Champs postaux + M2M sites /
|
||||
* categories / contacts (par IRI).
|
||||
*/
|
||||
export interface ProviderAddressFormDraft {
|
||||
/** Id serveur une fois l'adresse creee (null tant que non persistee). */
|
||||
id: number | null
|
||||
/** Pays (chaine libre, defaut « France »). */
|
||||
country: string
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
|
||||
siteIris: string[]
|
||||
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
|
||||
contactIris: string[]
|
||||
}
|
||||
|
||||
/** Fabrique une adresse vierge (France presaisi). */
|
||||
export function emptyProviderAddress(): ProviderAddressFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
country: 'France',
|
||||
postalCode: null,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
contactIris: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse du POST /providers/{id}/addresses (id suffisant pour le suivi cote front). */
|
||||
export interface ProviderAddressResponse {
|
||||
id: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`).
|
||||
* Relations (TVA / delai / type de reglement / banque) portees par leur IRI.
|
||||
*/
|
||||
export interface ProviderAccountingDraft {
|
||||
siren: string | null
|
||||
accountNumber: string | null
|
||||
tvaModeIri: string | null
|
||||
nTva: string | null
|
||||
paymentDelayIri: string | null
|
||||
paymentTypeIri: string | null
|
||||
/** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */
|
||||
bankIri: string | null
|
||||
}
|
||||
|
||||
/** Fabrique un onglet Comptabilite vierge. */
|
||||
export function emptyProviderAccounting(): ProviderAccountingDraft {
|
||||
return {
|
||||
siren: null,
|
||||
accountNumber: null,
|
||||
tvaModeIri: null,
|
||||
nTva: null,
|
||||
paymentDelayIri: null,
|
||||
paymentTypeIri: null,
|
||||
bankIri: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */
|
||||
export interface ProviderRibFormDraft {
|
||||
id: number | null
|
||||
label: string | null
|
||||
bic: string | null
|
||||
iban: string | null
|
||||
}
|
||||
|
||||
/** Fabrique un RIB vierge. */
|
||||
export function emptyProviderRib(): ProviderRibFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
label: null,
|
||||
bic: null,
|
||||
iban: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */
|
||||
export interface ProviderRibResponse {
|
||||
id: number
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProviderAccountingPayload,
|
||||
buildProviderRibPayload,
|
||||
isBankRequiredForPaymentType,
|
||||
isRibBlank,
|
||||
isRibComplete,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '../providerAccounting'
|
||||
import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs
|
||||
* RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads.
|
||||
*/
|
||||
describe('providerAccounting helpers', () => {
|
||||
describe('RG-3.07 / RG-3.08 — type de reglement', () => {
|
||||
it('banque requise uniquement pour VIREMENT', () => {
|
||||
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
||||
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
|
||||
expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false)
|
||||
expect(isBankRequiredForPaymentType(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('RIB requis uniquement pour LCR', () => {
|
||||
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
|
||||
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
|
||||
expect(isRibRequiredForPaymentType(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRibBlank / isRibComplete', () => {
|
||||
it('un RIB vierge est vide et incomplet', () => {
|
||||
expect(isRibBlank(emptyProviderRib())).toBe(true)
|
||||
expect(isRibComplete(emptyProviderRib())).toBe(false)
|
||||
})
|
||||
|
||||
it('un RIB partiel n\'est ni vide ni complet', () => {
|
||||
const rib = { ...emptyProviderRib(), iban: 'FR76...' }
|
||||
expect(isRibBlank(rib)).toBe(false)
|
||||
expect(isRibComplete(rib)).toBe(false)
|
||||
})
|
||||
|
||||
it('un RIB avec libelle + BIC + IBAN est complet', () => {
|
||||
const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }
|
||||
expect(isRibComplete(rib)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderAccountingPayload (RG-3.07)', () => {
|
||||
it('envoie la banque si requise (VIREMENT)', () => {
|
||||
const payload = buildProviderAccountingPayload({
|
||||
...emptyProviderAccounting(),
|
||||
paymentTypeIri: '/api/payment_types/3',
|
||||
bankIri: '/api/banks/2',
|
||||
}, true)
|
||||
expect(payload.bank).toBe('/api/banks/2')
|
||||
expect(payload.paymentType).toBe('/api/payment_types/3')
|
||||
})
|
||||
|
||||
it('force la banque a null si non requise (hors VIREMENT)', () => {
|
||||
const payload = buildProviderAccountingPayload({
|
||||
...emptyProviderAccounting(),
|
||||
bankIri: '/api/banks/2',
|
||||
}, false)
|
||||
expect(payload.bank).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderRibPayload', () => {
|
||||
it('omet les champs requis vides (NotBlank back joue sur le champ)', () => {
|
||||
const payload = buildProviderRibPayload(emptyProviderRib())
|
||||
expect(payload).not.toHaveProperty('label')
|
||||
expect(payload).not.toHaveProperty('bic')
|
||||
expect(payload).not.toHaveProperty('iban')
|
||||
})
|
||||
|
||||
it('conserve les champs remplis', () => {
|
||||
const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||
expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProviderAddressPayload,
|
||||
isProviderAddressValid,
|
||||
} from '../providerAddress'
|
||||
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Helpers purs de l'onglet Adresse prestataire (ERP-143). RG-3.05 (>= 1 site) et
|
||||
* construction du payload de sous-ressource (relations en IRI, requis vides omis,
|
||||
* pas de type d'adresse / bennes / triage — difference M2).
|
||||
*/
|
||||
describe('providerAddress helpers', () => {
|
||||
const SITE = '/api/sites/1'
|
||||
const CAT = '/api/categories/7'
|
||||
|
||||
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
|
||||
it('false sans site', () => {
|
||||
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('false sans categorie', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('true avec au moins un site ET une categorie', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
|
||||
expect(isProviderAddressValid(address)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderAddressPayload', () => {
|
||||
it('mappe les relations en IRI et n\'embarque PAS type/bennes/triage (difference M2)', () => {
|
||||
const payload = buildProviderAddressPayload({
|
||||
...emptyProviderAddress(),
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
contactIris: ['/api/provider_contacts/9'],
|
||||
})
|
||||
expect(payload).toEqual({
|
||||
country: 'France',
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
streetComplement: null,
|
||||
categories: [CAT],
|
||||
sites: [SITE],
|
||||
contacts: ['/api/provider_contacts/9'],
|
||||
})
|
||||
expect(payload).not.toHaveProperty('addressType')
|
||||
expect(payload).not.toHaveProperty('bennes')
|
||||
expect(payload).not.toHaveProperty('triageProvider')
|
||||
})
|
||||
|
||||
it('omet les scalaires requis vides (NotBlank back joue sur le champ)', () => {
|
||||
const payload = buildProviderAddressPayload({
|
||||
...emptyProviderAddress(),
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
})
|
||||
expect(payload).not.toHaveProperty('postalCode')
|
||||
expect(payload).not.toHaveProperty('city')
|
||||
expect(payload).not.toHaveProperty('street')
|
||||
// streetComplement n'est PAS requis -> reste present a null.
|
||||
expect(payload).toHaveProperty('streetComplement', null)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildProviderContactPayload,
|
||||
hasAtLeastOneFilledContact,
|
||||
isProviderContactBlank,
|
||||
} from '../providerContact'
|
||||
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Helpers purs de l'onglet Contact prestataire (ERP-142). On verifie la
|
||||
* definition de « bloc vide » (RG-3.04, alignee sur le back) et la construction
|
||||
* du payload de sous-ressource.
|
||||
*/
|
||||
describe('providerContact helpers', () => {
|
||||
describe('isProviderContactBlank (RG-3.04)', () => {
|
||||
it('un bloc vierge est vide', () => {
|
||||
expect(isProviderContactBlank(emptyProviderContact())).toBe(true)
|
||||
})
|
||||
|
||||
it('un seul champ rempli parmi nom/prenom/fonction/tel/email suffit a le rendre non vide', () => {
|
||||
for (const field of ['firstName', 'lastName', 'jobTitle', 'phonePrimary', 'email'] as const) {
|
||||
const contact = { ...emptyProviderContact(), [field]: 'x' }
|
||||
expect(isProviderContactBlank(contact)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('ignore les espaces (trim) — un champ blanc ne compte pas', () => {
|
||||
expect(isProviderContactBlank({ ...emptyProviderContact(), lastName: ' ' })).toBe(true)
|
||||
})
|
||||
|
||||
it('un 2e telephone seul NE suffit PAS (exclu, comme le back)', () => {
|
||||
const contact = { ...emptyProviderContact(), hasSecondaryPhone: true, phoneSecondary: '0102030405' }
|
||||
expect(isProviderContactBlank(contact)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAtLeastOneFilledContact (RG-3.12)', () => {
|
||||
it('false si tous les blocs sont vides', () => {
|
||||
expect(hasAtLeastOneFilledContact([emptyProviderContact(), emptyProviderContact()])).toBe(false)
|
||||
})
|
||||
|
||||
it('true des qu\'un bloc porte une donnee', () => {
|
||||
expect(hasAtLeastOneFilledContact([
|
||||
emptyProviderContact(),
|
||||
{ ...emptyProviderContact(), email: 'a@b.fr' },
|
||||
])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildProviderContactPayload', () => {
|
||||
it('mappe les champs et envoie null pour les vides', () => {
|
||||
const payload = buildProviderContactPayload({ ...emptyProviderContact(), lastName: 'Doe' })
|
||||
expect(payload).toEqual({
|
||||
firstName: null,
|
||||
lastName: 'Doe',
|
||||
jobTitle: null,
|
||||
phonePrimary: null,
|
||||
phoneSecondary: null,
|
||||
email: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('n\'envoie le 2e telephone que si revele (max 2)', () => {
|
||||
const masque = buildProviderContactPayload({
|
||||
...emptyProviderContact(),
|
||||
phoneSecondary: '0102030405',
|
||||
hasSecondaryPhone: false,
|
||||
})
|
||||
expect(masque.phoneSecondary).toBeNull()
|
||||
|
||||
const revele = buildProviderContactPayload({
|
||||
...emptyProviderContact(),
|
||||
phoneSecondary: '0102030405',
|
||||
hasSecondaryPhone: true,
|
||||
})
|
||||
expect(revele.phoneSecondary).toBe('0102030405')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144) —
|
||||
* miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle
|
||||
* ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07
|
||||
* (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProviderAccountingDraft,
|
||||
ProviderRibFormDraft,
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
|
||||
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
|
||||
const PAYMENT_TYPE_VIREMENT = 'VIREMENT'
|
||||
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
|
||||
const PAYMENT_TYPE_LCR = 'LCR'
|
||||
|
||||
/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */
|
||||
const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
|
||||
|
||||
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||
function isFilled(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== ''
|
||||
}
|
||||
|
||||
/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */
|
||||
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||
return code === PAYMENT_TYPE_VIREMENT
|
||||
}
|
||||
|
||||
/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */
|
||||
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||
return code === PAYMENT_TYPE_LCR
|
||||
}
|
||||
|
||||
/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */
|
||||
export function isRibBlank(rib: ProviderRibFormDraft): boolean {
|
||||
return ![rib.label, rib.bic, rib.iban].some(isFilled)
|
||||
}
|
||||
|
||||
/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */
|
||||
export function isRibComplete(rib: ProviderRibFormDraft): boolean {
|
||||
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations
|
||||
* sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon
|
||||
* `null` (le back vide la relation hors VIREMENT).
|
||||
*/
|
||||
export function buildProviderAccountingPayload(
|
||||
accounting: ProviderAccountingDraft,
|
||||
isBankRequired: boolean,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
siren: accounting.siren || null,
|
||||
accountNumber: accounting.accountNumber || null,
|
||||
tvaMode: accounting.tvaModeIri,
|
||||
nTva: accounting.nTva || null,
|
||||
paymentDelay: accounting.paymentDelayIri,
|
||||
paymentType: accounting.paymentTypeIri,
|
||||
bank: isBankRequired ? accounting.bankIri : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les
|
||||
* champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur
|
||||
* le champ.
|
||||
*/
|
||||
export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
label: rib.label,
|
||||
bic: rib.bic,
|
||||
iban: rib.iban,
|
||||
}
|
||||
|
||||
for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) {
|
||||
const value = payload[key]
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete payload[key]
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Adresse prestataire (M3 Technique, ERP-143) — miroir
|
||||
* SIMPLIFIE de `supplierFormRules`/`supplierEdit` (M2), reimplemente cote module
|
||||
* Technique (regle ABSOLUE n°1 : pas d'import inter-module). Testables sans Vue.
|
||||
*/
|
||||
|
||||
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/**
|
||||
* Champs scalaires obligatoires non nullable cote back (NotBlank). A la creation
|
||||
* (POST), on OMET du payload ceux qui sont vides pour que la 422 porte la
|
||||
* violation NotBlank propre (sur le champ) plutot qu'une erreur de type.
|
||||
*/
|
||||
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
||||
|
||||
/**
|
||||
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
|
||||
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
|
||||
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
|
||||
*/
|
||||
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
|
||||
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource addresses (groupe `provider:write:addresses`).
|
||||
* Relations M2M en IRI. Les scalaires requis vides sont omis a la creation (cf.
|
||||
* REQUIRED_NON_NULLABLE_KEYS).
|
||||
*/
|
||||
export function buildProviderAddressPayload(address: ProviderAddressFormDraft): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
country: address.country,
|
||||
postalCode: address.postalCode || null,
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
categories: [...address.categoryIris],
|
||||
sites: [...address.siteIris],
|
||||
contacts: [...address.contactIris],
|
||||
}
|
||||
|
||||
for (const key of REQUIRED_NON_NULLABLE_KEYS) {
|
||||
const value = payload[key]
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete payload[key]
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Helpers purs de l'onglet Contact prestataire (M3 Technique, ERP-142) — miroir
|
||||
* reduit de `supplierFormRules.ts` / `supplierEdit.ts` (M2). Testables sans Vue
|
||||
* ni API : detection de bloc vide (RG-3.04) et construction du payload de
|
||||
* sous-ressource contacts.
|
||||
*/
|
||||
|
||||
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||
function isFilled(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un bloc Contact est VIDE tant qu'aucun des champs comptant pour la
|
||||
* validite n'est rempli — prenom / nom / fonction / telephone principal / email.
|
||||
*
|
||||
* `phoneSecondary` est volontairement EXCLU : le back (CHECK
|
||||
* `chk_provider_contact_name` + `ProviderContactProcessor`) ne le compte pas non
|
||||
* plus, un bloc ne portant qu'un 2e numero reste invalide. Garder la meme
|
||||
* definition cote front evite tout drift (un bloc « vide » front == bloc rejete
|
||||
* back).
|
||||
*/
|
||||
export function isProviderContactBlank(contact: ProviderContactFormDraft): boolean {
|
||||
return ![
|
||||
contact.firstName,
|
||||
contact.lastName,
|
||||
contact.jobTitle,
|
||||
contact.phonePrimary,
|
||||
contact.email,
|
||||
].some(isFilled)
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
|
||||
* bloc non vide (au moins un contact valide).
|
||||
*/
|
||||
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
|
||||
return contacts.some(contact => !isProviderContactBlank(contact))
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de la sous-ressource contacts (groupe `provider:write:contacts`). Les
|
||||
* chaines vides sont envoyees a null (le serveur normalise/trim de toute facon).
|
||||
* `phoneSecondary` n'est envoye que si le 2e numero a ete revele (max 2 tel).
|
||||
*/
|
||||
export function buildProviderContactPayload(contact: ProviderContactFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
firstName: contact.firstName || null,
|
||||
lastName: contact.lastName || null,
|
||||
jobTitle: contact.jobTitle || null,
|
||||
phonePrimary: contact.phonePrimary || null,
|
||||
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||
email: contact.email || null,
|
||||
}
|
||||
}
|
||||
@@ -84,17 +84,6 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
// Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
|
||||
// clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
|
||||
// persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
|
||||
// donc il voit les prestataires de tous les sites (M3 § 2.13).
|
||||
// technique.providers.view n'ajoute pas de lien dans la section
|
||||
// Administration, donc expectedAdminLinks reste inchange.
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
|
||||
@@ -252,7 +252,7 @@ final class Version20260612100000 extends AbstractMigration
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_provider_contact_name
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
|
||||
CONSTRAINT fk_provider_contact_provider
|
||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_contact_created_by
|
||||
@@ -263,12 +263,12 @@ final class Version20260612100000 extends AbstractMigration
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
|
||||
|
||||
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.');
|
||||
$this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
|
||||
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||
$this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
|
||||
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
|
||||
|
||||
@@ -21,8 +21,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
|
||||
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -49,15 +48,15 @@ class Bank
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -21,8 +21,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
|
||||
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -49,15 +48,15 @@ class PaymentDelay
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -24,8 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
|
||||
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -52,15 +51,15 @@ class PaymentType
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -25,8 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
||||
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
||||
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
|
||||
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0) ;
|
||||
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -56,15 +55,15 @@ class TvaMode
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -50,19 +50,11 @@ final class RbacSeeder
|
||||
/**
|
||||
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
||||
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
||||
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
|
||||
* `commercial.clients.archive`, `commercial.suppliers.archive` et
|
||||
* `technique.providers.archive` ne sont attaches a aucun role metier —
|
||||
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
|
||||
* bypass tout via isAdmin ; `commercial.clients.archive` et
|
||||
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
|
||||
* admin seul).
|
||||
*
|
||||
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
|
||||
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
|
||||
* Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
|
||||
* reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
|
||||
* C'est un cloisonnement pilote par user/permission, pas par code de role :
|
||||
* pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
|
||||
* ici, aucun autre code a changer.
|
||||
*
|
||||
* @var array<string, array{label: string, permissions: list<string>}>
|
||||
*/
|
||||
private const array MATRIX = [
|
||||
@@ -74,11 +66,6 @@ final class RbacSeeder
|
||||
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
@@ -95,13 +82,6 @@ final class RbacSeeder
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
|
||||
// (pas de manage global -> ne peut pas creer un prestataire).
|
||||
'technique.providers.view',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
@@ -116,25 +96,14 @@ final class RbacSeeder
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'commercial.suppliers.view',
|
||||
'commercial.suppliers.manage',
|
||||
// Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
|
||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
],
|
||||
],
|
||||
self::ROLE_USINE => [
|
||||
'label' => 'Usine',
|
||||
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
|
||||
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
|
||||
// courant. Aucun autre acces metier.
|
||||
'permissions' => [
|
||||
'technique.providers.view',
|
||||
],
|
||||
'label' => 'Usine',
|
||||
'permissions' => [],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -203,15 +203,6 @@ final class SeedE2ECommand extends Command
|
||||
'commercial.suppliers.accounting.view',
|
||||
'commercial.suppliers.accounting.manage',
|
||||
'commercial.suppliers.archive',
|
||||
// Technique — Repertoire prestataires (M3, ERP-138). Meme
|
||||
// logique : mappe sur le persona "tout". user-full porte deja
|
||||
// sites.bypass_scope -> voit les prestataires de tous les
|
||||
// sites (M3 § 2.13). Miroir de personas.ts.
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
'technique.providers.archive',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Application\Service;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un Provider / ProviderContact,
|
||||
* appliquee par le ProviderProcessor (et les processors de sous-ressources,
|
||||
* ticket ulterieur M3) AVANT persistance. Cf. spec-back M3 § 2.11 + RG-3.11.
|
||||
* Jumeau de SupplierFieldNormalizer (M2) — duplique volontairement (isolation
|
||||
* Commercial / Technique, decision § 2.1).
|
||||
*
|
||||
* - companyName : UPPERCASE integral (RG-3.11)
|
||||
* - firstName / lastName (personnes, sur ProviderContact) : Title Case (RG-3.11)
|
||||
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-3.11).
|
||||
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
|
||||
* - email : lowercase integral (RG-3.11)
|
||||
*
|
||||
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
|
||||
* apres trim devient null (evite de persister "" dans des colonnes nullable).
|
||||
*/
|
||||
final class ProviderFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Nom de societe en majuscules (RG-3.11). Conserve null tel quel ; une
|
||||
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
|
||||
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
|
||||
*/
|
||||
public function normalizeCompanyName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_strtoupper(trim($value), 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom/prenom de personne en Title Case (RG-3.11) : "JEAN dupont" ->
|
||||
* "Jean Dupont". Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizePersonName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Email en minuscules (RG-3.11). Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizeEmail(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte libre simplement trim (ex : jobTitle / Fonction du contact). Pas de
|
||||
* changement de casse — on preserve la saisie. Une chaine vide apres trim
|
||||
* devient null (evite de persister "" et de faire passer a tort le garde-fou
|
||||
* RG-3.04 / le CHECK chk_provider_contact_name sur une Fonction vide).
|
||||
*/
|
||||
public function normalizeText(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
|
||||
* "0612345678". Une valeur sans aucun chiffre devient null.
|
||||
*/
|
||||
public function normalizePhone(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $value) ?? '';
|
||||
|
||||
return '' === $digits ? null : $digits;
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,6 @@ 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\TvaMode;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderProcessor;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
|
||||
use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
@@ -55,12 +53,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* reference de donnees de reference, pas de logique inter-module.
|
||||
*
|
||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups
|
||||
* sont poses ICI (source unique). L'#[ApiResource] cable (ERP-134) le ProviderProvider
|
||||
* (liste paginee anti-N+1, exclusion archives, cloisonnement site lecture + detail
|
||||
* 404) et le ProviderProcessor (normalisation, archivage, mode strict par groupe,
|
||||
* cloisonnement site ecriture, 409 doublon). Le groupe provider:read:accounting est
|
||||
* ajoute dynamiquement au contexte par le ProviderReadGroupContextBuilder selon la
|
||||
* permission accounting.view (ERP-134) — jamais pose en dur sur l'operation.
|
||||
* sont poses ICI (source unique). L'#[ApiResource] est ici un SQUELETTE (operations
|
||||
* + contextes + security) ; le ProviderProvider (liste paginee anti-N+1, exclusion
|
||||
* archives, cloisonnement site, gating accounting) et le ProviderProcessor
|
||||
* (normalisation, archivage, 409 doublon, RG-3.07 / RG-3.08) sont cables au ticket
|
||||
* suivant (ERP-134) — ils ne sont volontairement PAS references ici.
|
||||
*
|
||||
* Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) +
|
||||
* Timestampable / Blamable via le trait Shared.
|
||||
@@ -72,18 +69,17 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
// La liste embarque les categories (code/name, groupe category:read) et
|
||||
// les sites du prestataire (name/postalCode, groupe site:read — relation
|
||||
// DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read +
|
||||
// site:read presents dans le contexte. Hydratation anti-N+1 cablee par
|
||||
// le ProviderProvider (cf. DoctrineProviderRepository::hydrateListCollections).
|
||||
// site:read presents dans le contexte. L'hydratation anti-N+1 sera
|
||||
// cablee par le ProviderProvider (ERP-134, cf. DoctrineProviderRepository).
|
||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||
provider: ProviderProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// Detail : prestataire + sous-collections embarquees (contacts, adresses
|
||||
// + leurs sites/categories/contacts) + RIB (gates compta). Le groupe
|
||||
// provider:read:accounting est volontairement ABSENT : il est ajoute au
|
||||
// contexte par le ProviderReadGroupContextBuilder selon la permission
|
||||
// accounting.view (parade fuite IBAN/BIC — bug #4 M1).
|
||||
// provider:read:accounting est volontairement ABSENT : il sera ajoute au
|
||||
// contexte par le ProviderProvider / ReadGroupContextBuilder selon la
|
||||
// permission accounting.view (ERP-134, parade fuite IBAN/BIC — bug #4 M1).
|
||||
normalizationContext: ['groups' => [
|
||||
'provider:read',
|
||||
'provider:item:read',
|
||||
@@ -91,13 +87,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: ProviderProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:main']],
|
||||
processor: ProviderProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
// Security elargie : `manage` OU `accounting.manage` — le role Compta n'a
|
||||
@@ -111,8 +105,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'provider:write:accounting',
|
||||
'provider:write:archive',
|
||||
]],
|
||||
provider: ProviderProvider::class,
|
||||
processor: ProviderProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
|
||||
],
|
||||
@@ -140,12 +132,6 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
|
||||
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
|
||||
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
|
||||
|
||||
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
|
||||
private const string PAYMENT_TYPE_LCR = 'LCR';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -297,44 +283,6 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.07 / RG-3.08 : coherence du type de reglement comptable. Comme au M2
|
||||
* (decision figee ERP-89, jumeau Supplier::validatePaymentTypeConsistency),
|
||||
* ces RG inter-champs passent par une contrainte d'entite (Assert\Callback +
|
||||
* ->atPath()) et NON par le ProviderProcessor, afin que chaque 422 porte un
|
||||
* propertyPath exploitable par extractApiViolations (mapping inline sous le
|
||||
* champ, pas un toast — convention ERP-101).
|
||||
* - RG-3.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
|
||||
* - RG-3.08 : paymentType = LCR impose au moins un RIB -> violation sur
|
||||
* `paymentType` (les RIB n'ont pas de champ de formulaire ou s'ancrer quand
|
||||
* la liste est vide ; l'erreur s'affiche donc sous le select « Type de
|
||||
* règlement », binde cote front). Le 409 sur DELETE du dernier RIB en LCR est
|
||||
* porte par le ProviderRibProcessor (ERP-135).
|
||||
*
|
||||
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
|
||||
* n'expose que provider:write:main), la contrainte ne mord en pratique que sur
|
||||
* le PATCH de l'onglet Comptabilite.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
|
||||
{
|
||||
$paymentCode = $this->paymentType?->getCode();
|
||||
|
||||
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
|
||||
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
|
||||
->atPath('bank')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
|
||||
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
|
||||
->atPath('paymentType')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
@@ -40,64 +32,16 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
|
||||
*
|
||||
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
|
||||
* maillon (a)).
|
||||
*
|
||||
* Sous-ressource API (ERP-135, spec § 4.5) :
|
||||
* - POST /api/providers/{providerId}/addresses : creation rattachee au prestataire
|
||||
* parent (Link toProperty 'provider'), security technique.providers.manage.
|
||||
* - PATCH / DELETE /api/provider_addresses/{id} : security technique.providers.manage.
|
||||
* - GET /api/provider_addresses/{id} : lecture unitaire (security view) — la lecture
|
||||
* courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement
|
||||
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les
|
||||
* contraintes de l'entite (jouees avant le processor).
|
||||
* maillon (a)). L'exposition en SOUS-RESSOURCE API est un ticket ulterieur du M3 :
|
||||
* pas d'#[ApiResource] ici.
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// site:read + category:read : embarquent les Site / Category lies
|
||||
// (maillon (c)) plutot que des IRI nus dans le retour.
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/providers/{providerId}/addresses',
|
||||
uriVariables: [
|
||||
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT ProviderAddress ... WHERE provider = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par ProviderAddressProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
processor: ProviderAddressProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderAddressProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderAddressProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'provider_address')]
|
||||
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
|
||||
#[Auditable]
|
||||
class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||
class ProviderAddress implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
@@ -23,68 +15,24 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
/**
|
||||
* Contact d'un prestataire (1:n) — onglet Contacts. Un bloc est valide des qu'au
|
||||
* moins un champ est rempli (RG-3.04) : garantie portee par un CHECK BDD
|
||||
* (chk_provider_contact_name) + le ProviderContactProcessor (ERP-135) ; l'entite
|
||||
* reste permissive (tous les champs nullable).
|
||||
* (chk_provider_contact_name) + le ProviderProcessor (ERP-134) ; l'entite reste
|
||||
* permissive (tous les champs nullable).
|
||||
*
|
||||
* Embarque sous `provider.contacts` au detail (groupe provider:item:read,
|
||||
* maillon (a) du contrat de serialisation). Maximum 2 telephones
|
||||
* (phonePrimary + phoneSecondary).
|
||||
*
|
||||
* Sous-ressource API (ERP-135, spec § 4.5) :
|
||||
* - POST /api/providers/{providerId}/contacts : creation rattachee au prestataire
|
||||
* parent (Link toProperty 'provider'), security technique.providers.manage.
|
||||
* - PATCH / DELETE /api/provider_contacts/{id} : security technique.providers.manage.
|
||||
* Le DELETE est physique et libre (pas de garde « dernier contact » au M3 —
|
||||
* RG-3.12 front-driven, la collection peut rester vide cote back).
|
||||
* - GET /api/provider_contacts/{id} : lecture unitaire (security view) — la lecture
|
||||
* courante reste via le parent (le prestataire embarque ses contacts). Pas de GET
|
||||
* collection autonome.
|
||||
* Tout passe par le ProviderContactProcessor (normalisation RG-3.11, RG-3.04).
|
||||
* L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/contacts, PATCH /
|
||||
* DELETE) est un ticket ulterieur du M3 : pas d'#[ApiResource] ici (l'entite est
|
||||
* pour l'instant uniquement embarquee via le detail du prestataire).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable (pattern Shared standard).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
normalizationContext: ['groups' => ['provider:item:read']],
|
||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/providers/{providerId}/contacts',
|
||||
uriVariables: [
|
||||
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT ProviderContact ... WHERE provider = :id)
|
||||
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par ProviderContactProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:contacts']],
|
||||
processor: ProviderContactProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:contacts']],
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderContactProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderContactProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'provider_contact')]
|
||||
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
|
||||
#[Auditable]
|
||||
class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||
class ProviderContact implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
/**
|
||||
* Contrat des sous-ressources d'un prestataire (Contact, Adresse, RIB) : chacune
|
||||
* appartient a un Provider parent. Permet au provider decore
|
||||
* ProviderSubResourceItemProvider d'appliquer le cloisonnement par site du parent
|
||||
* (§ 2.13 / RG-3.17) de maniere uniforme, sans connaitre le type concret.
|
||||
*/
|
||||
interface ProviderOwnedInterface
|
||||
{
|
||||
public function getProvider(): ?Provider;
|
||||
}
|
||||
@@ -4,14 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
@@ -23,7 +15,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
/**
|
||||
* Coordonnees bancaires d'un prestataire (1:n) — onglet Comptabilite. Au moins un
|
||||
* RIB est obligatoire si le type de reglement est LCR (RG-3.08, verifie au
|
||||
* ProviderRibProcessor : refus du DELETE du dernier RIB sous LCR — ERP-135).
|
||||
* ProviderProcessor : refus du DELETE du dernier RIB sous LCR — ERP-134).
|
||||
*
|
||||
* Embarque sous `provider.ribs` UNIQUEMENT si l'user a accounting.view : le
|
||||
* read-group est `provider:read:accounting`, retire du contexte par le
|
||||
@@ -31,62 +23,19 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* piege n°4 du M1). Aucun #[AuditIgnore] sur iban/bic : l'audit etant admin-only,
|
||||
* la tracabilite RIB est conservee (decision M1 reportee, § 2.7).
|
||||
*
|
||||
* Sous-ressource API (ERP-135, spec § 4.5) — gating comptable renforce :
|
||||
* - POST /api/providers/{providerId}/ribs : creation rattachee au prestataire
|
||||
* parent (Link toProperty 'provider'), security technique.providers.accounting.manage.
|
||||
* - PATCH / DELETE /api/provider_ribs/{id} : security technique.providers.accounting.manage.
|
||||
* Le DELETE refuse la suppression du dernier RIB sous LCR (RG-3.08, 409).
|
||||
* - GET /api/provider_ribs/{id} : lecture unitaire, security
|
||||
* technique.providers.accounting.view (donnees bancaires sensibles). Pas de GET
|
||||
* collection autonome.
|
||||
* Tout passe par le ProviderRibProcessor (RG-3.08 sur DELETE).
|
||||
* L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/ribs, PATCH / DELETE,
|
||||
* gating accounting.manage) est un ticket ulterieur du M3 : pas d'#[ApiResource]
|
||||
* ici.
|
||||
*
|
||||
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
|
||||
* banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
|
||||
* (#[Auditable]) + Timestampable / Blamable.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.accounting.view')",
|
||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/providers/{providerId}/ribs',
|
||||
uriVariables: [
|
||||
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
|
||||
],
|
||||
// read:false : pas de stade lecture du parent. Le Link toProperty
|
||||
// resoudrait l'enfant (SELECT ProviderRib ... WHERE provider = :id) et
|
||||
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
|
||||
// manuellement par ProviderRibProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('technique.providers.accounting.manage')",
|
||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||
denormalizationContext: ['groups' => ['provider:write:accounting']],
|
||||
processor: ProviderRibProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('technique.providers.accounting.manage')",
|
||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||
denormalizationContext: ['groups' => ['provider:write:accounting']],
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderRibProcessor::class,
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('technique.providers.accounting.manage')",
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderRibProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'provider_rib')]
|
||||
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
|
||||
#[Auditable]
|
||||
class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||
class ProviderRib implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
|
||||
@@ -13,21 +13,6 @@ interface ProviderRepositoryInterface
|
||||
|
||||
public function save(Provider $provider): void;
|
||||
|
||||
/**
|
||||
* Restreint un QueryBuilder de liste aux prestataires rattaches au site donne
|
||||
* (relation DIRECTE provider.sites). Sert le cloisonnement par site pilote par
|
||||
* l'utilisateur (RG-3.17, § 2.13) : le ProviderProvider resout le site courant
|
||||
* (CurrentSiteProvider) puis appelle cette methode quand l'user n'a pas
|
||||
* `sites.bypass_scope`. Decouple ainsi la DECISION (Provider, qui connait
|
||||
* l'user) du DQL (repository, qui ne connait que l'id de site).
|
||||
*
|
||||
* Sous-requete IN (et non JOIN sur la M2M) pour ne pas perturber le
|
||||
* DISTINCT / ORDER BY / pagination du QueryBuilder de selection — meme parti
|
||||
* pris que les filtres ?categoryCode / ?siteId. Applique AVANT la pagination
|
||||
* (le COUNT du Paginator reflete alors le perimetre de l'user).
|
||||
*/
|
||||
public function applySiteScope(QueryBuilder $qb, int $siteId): void;
|
||||
|
||||
/**
|
||||
* Construit un QueryBuilder de liste pour le repertoire prestataires.
|
||||
* - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16).
|
||||
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\Serializer;
|
||||
|
||||
use ApiPlatform\State\SerializerContextBuilderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Decore le context builder de serialisation d'API Platform pour ajouter
|
||||
* DYNAMIQUEMENT le groupe de lecture `provider:read:accounting` sur les
|
||||
* ressources Provider, uniquement si l'utilisateur courant a la permission
|
||||
* `technique.providers.accounting.view` (cf. spec-back M3 § 2.9 / § 4.1 /
|
||||
* § 4.2). Jumeau de SupplierReadGroupContextBuilder (M2).
|
||||
*
|
||||
* Pourquoi un context builder et pas le Provider : un Provider retourne des
|
||||
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
|
||||
* de normalisation est construit ici, en amont du serializer — c'est le point
|
||||
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
|
||||
* l'utilisateur. Realise l'intention « gating du groupe accounting » de la spec
|
||||
* (le groupe n'est jamais pose par defaut sur l'operation : il est AJOUTE ici si
|
||||
* la permission est presente — resultat identique au « retrait » decrit en spec).
|
||||
*
|
||||
* S'applique aux operations de LECTURE (normalization) sur Provider : liste ET
|
||||
* detail. Sans la permission, les champs comptables (siren, accountNumber,
|
||||
* tvaMode, nTva, paymentDelay, paymentType, bank) ET les RIB embarques (groupe
|
||||
* provider:read:accounting porte par getRibs()) ne sont jamais serialises — la
|
||||
* cle est totalement absente du JSON (gating par omission, parade bug #4 M1).
|
||||
*
|
||||
* Priorite de decoration -20 : on s'empile APRES les decorateurs Commercial
|
||||
* (ClientReadGroupContextBuilder priorite 0, SupplierReadGroupContextBuilder
|
||||
* priorite -10) sur le meme service `api_platform.serializer.context_builder`.
|
||||
* Chaque decorateur passe la main pour toute ressource autre que la sienne :
|
||||
* l'ordre de chainage n'a donc aucun effet fonctionnel, la priorite explicite ne
|
||||
* sert qu'a lever l'ambiguite de plusieurs decorateurs sur un meme service.
|
||||
*/
|
||||
#[AsDecorator(decorates: 'api_platform.serializer.context_builder', priority: -20)]
|
||||
final readonly class ProviderReadGroupContextBuilder implements SerializerContextBuilderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[AutowireDecorated]
|
||||
private SerializerContextBuilderInterface $decorated,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
|
||||
{
|
||||
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
|
||||
|
||||
// Uniquement en lecture, sur la ressource Provider, avec la permission.
|
||||
if (!$normalization) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
if (Provider::class !== ($context['resource_class'] ?? null)) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('technique.providers.accounting.view')) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$groups = $context['groups'] ?? [];
|
||||
if (!in_array('provider:read:accounting', $groups, true)) {
|
||||
$groups[] = 'provider:read:accounting';
|
||||
}
|
||||
$context['groups'] = $groups;
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
-220
@@ -1,220 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Adresse d'un prestataire (M3,
|
||||
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2), recentre sur le
|
||||
* perimetre ERP-135, AVEC une garde supplementaire propre au M3 : le
|
||||
* cloisonnement d'ECRITURE des sites de l'adresse (§ 2.13).
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au prestataire parent puis cloisonnement des
|
||||
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
|
||||
* garanties en amont par des contraintes sur l'entite, jouees par API Platform
|
||||
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
|
||||
* Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
|
||||
* ProviderAddress::validateCategoryType).
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* La security de l'operation (technique.providers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
|
||||
*
|
||||
* @implements ProcessorInterface<ProviderAddress, null|ProviderAddress>
|
||||
*/
|
||||
final class ProviderAddressProcessor implements ProcessorInterface
|
||||
{
|
||||
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ProviderAddress) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->guardSiteScope($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache l'adresse au prestataire parent de la sous-ressource POST
|
||||
* (/providers/{providerId}/addresses) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(ProviderAddress $address, array $uriVariables): void
|
||||
{
|
||||
if (null !== $address->getProvider()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$providerId = $uriVariables['providerId'] ?? null;
|
||||
if (null === $providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $providerId instanceof Provider
|
||||
? $providerId
|
||||
: $this->em->getRepository(Provider::class)->find($providerId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte provider_id NOT NULL).
|
||||
if (!$provider instanceof Provider) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
|
||||
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
|
||||
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
|
||||
// (anti-enumeration). Distinct du guardSiteScope ci-dessous, qui cloisonne
|
||||
// les sites ATTACHES a l'adresse (et non l'acces au prestataire parent).
|
||||
$this->scopeChecker->assertInScope($provider);
|
||||
|
||||
$address->setProvider($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.05 / § 2.13 (cloisonnement d'ECRITURE — decision Matthieu 11/06) : un
|
||||
* user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse que des
|
||||
* sites figurant dans ses propres `user_site`. Tout site hors perimetre -> 422
|
||||
* sur `sites` (propertyPath consommable inline, convention ERP-101). Un user
|
||||
* `bypass_scope` (Admin) peut attacher n'importe quel site. Miroir de
|
||||
* ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse.
|
||||
*
|
||||
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
|
||||
* sites obligatoires RG-3.05) ou PATCH portant la cle `sites`. Un PATCH qui ne
|
||||
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
|
||||
* pose). La validation porte sur l'ETAT RESULTANT (address.getSites()).
|
||||
*/
|
||||
private function guardSiteScope(ProviderAddress $address): void
|
||||
{
|
||||
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// sites non soumis sur un PATCH : rien a cloisonner.
|
||||
if ($this->em->contains($address) && !in_array('sites', $this->payloadKeys(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedSiteIds = $this->currentUserSiteIds();
|
||||
|
||||
foreach ($address->getSites() as $site) {
|
||||
if (!$site instanceof SiteInterface) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($site->getId(), $allowedSiteIds, true)) {
|
||||
$this->throwSitesViolation($address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
|
||||
* Vide si pas d'user authentifie (cas defensif : la security d'operation
|
||||
* garantit deja l'authentification).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function currentUserSiteIds(): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($user->getSites() as $site) {
|
||||
if ($site instanceof SiteInterface && null !== $site->getId()) {
|
||||
$ids[] = $site->getId();
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles de premier niveau effectivement envoyees par le client (payload JSON
|
||||
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function payloadKeys(): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (null === $request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
if ('' === $content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
|
||||
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function throwSitesViolation(ProviderAddress $address): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
|
||||
null,
|
||||
[],
|
||||
$address,
|
||||
'sites',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Contact d'un prestataire (M3,
|
||||
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
|
||||
* perimetre ERP-135.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au prestataire parent, normalisation serveur
|
||||
* (RG-3.11 : prenom/nom Title Case, telephones reduits aux chiffres, email
|
||||
* lowercase) via le ProviderFieldNormalizer partage, puis validation RG-3.04
|
||||
* (au moins un champ parmi prenom / nom / telephone principal / email) avant
|
||||
* persistance.
|
||||
* - DELETE : aucune garde « dernier contact » au M3 — la collection peut rester
|
||||
* vide cote back (RG-3.12 front-driven, spec § 4.5). Suppression physique directe.
|
||||
*
|
||||
* La security de l'operation (technique.providers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
|
||||
* (Assert\Email, Assert\Length...).
|
||||
*
|
||||
* @implements ProcessorInterface<ProviderContact, null|ProviderContact>
|
||||
*/
|
||||
final class ProviderContactProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly ProviderFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ProviderContact) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
$this->validateName($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le contact au prestataire parent de la sous-ressource POST
|
||||
* (/providers/{providerId}/contacts). La relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une operation d'ecriture : on resout le
|
||||
* parent depuis l'uri variable. Sur PATCH (entite existante), le prestataire
|
||||
* est deja present -> no-op.
|
||||
*/
|
||||
private function linkParent(ProviderContact $contact, array $uriVariables): void
|
||||
{
|
||||
if (null !== $contact->getProvider()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$providerId = $uriVariables['providerId'] ?? null;
|
||||
if (null === $providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $providerId instanceof Provider
|
||||
? $providerId
|
||||
: $this->em->getRepository(Provider::class)->find($providerId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte provider_id NOT NULL).
|
||||
if (!$provider instanceof Provider) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
|
||||
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
|
||||
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
|
||||
// (anti-enumeration, coherent avec le detail Provider garde en 404).
|
||||
$this->scopeChecker->assertInScope($provider);
|
||||
|
||||
$contact->setProvider($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur (RG-3.11). Toutes les methodes du normalizer sont
|
||||
* null-safe : une chaine vide apres trim devient null.
|
||||
*/
|
||||
private function normalize(ProviderContact $contact): void
|
||||
{
|
||||
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
||||
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
||||
$contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
|
||||
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
|
||||
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
|
||||
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
|
||||
* nom / fonction / telephone principal / email est renseigne (double garde avec
|
||||
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
|
||||
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
||||
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
|
||||
* ramenees a null et ne suffisent pas a valider le bloc.
|
||||
*/
|
||||
private function validateName(ProviderContact $contact): void
|
||||
{
|
||||
if (null === $contact->getFirstName()
|
||||
&& null === $contact->getLastName()
|
||||
&& null === $contact->getJobTitle()
|
||||
&& null === $contact->getPhonePrimary()
|
||||
&& null === $contact->getEmail()) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'firstName',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
}
|
||||
-560
@@ -1,560 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du repertoire prestataires (M3). Cf. spec-back M3 § 4.3 /
|
||||
* § 4.4 + RG-3.10 / RG-3.13 / RG-3.14 / RG-3.15 / RG-3.17. Jumeau du
|
||||
* SupplierProcessor (M2), avec deux differences structurantes (§ 3.1) :
|
||||
* - PAS d'onglet Information (aucun champ description / competitors / ...) ni de
|
||||
* validation de completude comptable -> le prestataire est minimal ;
|
||||
* - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au
|
||||
* CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans
|
||||
* `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`.
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La
|
||||
* security d'operation du PATCH est elargie a `manage` OU `accounting.manage`
|
||||
* pour laisser entrer le role Compta ; ce processor re-gate alors finement :
|
||||
* - champ comptable modifie dans le payload -> exige accounting.manage (403) ;
|
||||
* - champ main (companyName / categories / sites) modifie -> exige manage
|
||||
* (guardManage, 403) : empeche Compta d'editer un autre onglet ;
|
||||
* - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et
|
||||
* interdit toute autre modification dans la meme requete (RG-3.13, 422).
|
||||
* 2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache
|
||||
* hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`.
|
||||
* 3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName).
|
||||
* 4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null).
|
||||
* 5. Persistance via le persist_processor Doctrine, avec traduction des
|
||||
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
|
||||
* restauration).
|
||||
*
|
||||
* Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
|
||||
* RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
|
||||
* ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
|
||||
* AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
|
||||
* extractApiViolations (mapping inline, pas un toast — convention ERP-101). Le 409
|
||||
* sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
|
||||
* ProviderRibProcessor (ERP-135).
|
||||
*
|
||||
* @implements ProcessorInterface<Provider, Provider>
|
||||
*/
|
||||
final class ProviderProcessor implements ProcessorInterface
|
||||
{
|
||||
/** Champs de l'onglet principal (groupe provider:write:main). */
|
||||
private const array MAIN_FIELDS = [
|
||||
'companyName', 'categories', 'sites',
|
||||
];
|
||||
|
||||
/** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */
|
||||
private const array ACCOUNTING_FIELDS = [
|
||||
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
|
||||
'paymentType', 'bank',
|
||||
];
|
||||
|
||||
/** Champ d'archivage (groupe provider:write:archive). */
|
||||
private const string ARCHIVE_FIELD = 'isArchived';
|
||||
|
||||
private const string PERM_MANAGE = 'technique.providers.manage';
|
||||
private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage';
|
||||
private const string PERM_ARCHIVE = 'technique.providers.archive';
|
||||
|
||||
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
|
||||
|
||||
/**
|
||||
* Memoisation du dernier corps de requete decode, clos par le contenu brut
|
||||
* (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete,
|
||||
* on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune
|
||||
* fuite entre requetes sur ce service partage.
|
||||
*/
|
||||
private ?string $decodedContent = null;
|
||||
|
||||
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
|
||||
private array $decodedPayloadKeys = [];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly ProviderFieldNormalizer $normalizer,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Provider) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Reinitialisation de la memoisation du payload : le service est partage
|
||||
// (stateful), on repart du corps de LA requete courante.
|
||||
$this->decodedContent = null;
|
||||
$this->decodedPayloadKeys = [];
|
||||
|
||||
$writableKeys = $this->writablePayloadKeys();
|
||||
|
||||
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
|
||||
$this->guardAccounting($data);
|
||||
$this->guardSiteScope($data);
|
||||
|
||||
$this->normalize($data);
|
||||
|
||||
// guardManage apres normalize : la comparaison « change vs etat persiste »
|
||||
// des champs texte (companyName) se fait sur des valeurs normalisees des
|
||||
// deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Le seul index unique partiel est uq_provider_company_name_active
|
||||
// (LOWER(company_name) parmi non-archives/non-deletes — § 2.6).
|
||||
if ($isArchiveRequest && false === $data->isArchived()) {
|
||||
// RG-3.14 : restauration en conflit avec un homonyme actif.
|
||||
throw new ConflictHttpException(
|
||||
'Restauration impossible : un autre prestataire a pris le nom entre-temps.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
// RG-3.10 : doublon de nom de societe.
|
||||
throw new ConflictHttpException(
|
||||
sprintf('Un prestataire nommé "%s" existe déjà.', (string) $data->getCompanyName()),
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.13 / RG-3.14 : si le payload bascule reellement isArchived, exige la
|
||||
* permission archive (403), interdit toute autre modification (422) et
|
||||
* pose/retire archivedAt. Retourne true si la requete est une requete
|
||||
* d'archivage. Restreint a la mise a jour d'un prestataire existant ET au seul
|
||||
* cas ou isArchived change vraiment (cf. SupplierProcessor).
|
||||
*
|
||||
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
|
||||
*/
|
||||
private function guardArchive(Provider $data, array $writableKeys): bool
|
||||
{
|
||||
// POST / entite non geree : l'archivage est une action de mise a jour.
|
||||
if (!$this->em->contains($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// isArchived inchange par rapport a l'etat persiste : pas une requete
|
||||
// d'archivage (cas du PATCH representation complete).
|
||||
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
self::ARCHIVE_FIELD,
|
||||
self::PERM_ARCHIVE,
|
||||
));
|
||||
}
|
||||
|
||||
// RG-3.13 : une requete d'archivage ne modifie aucun autre champ ecrivable.
|
||||
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
|
||||
throw new UnprocessableEntityHttpException(
|
||||
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
|
||||
);
|
||||
}
|
||||
|
||||
// RG-3.13 (true -> now) / RG-3.14 (false -> null).
|
||||
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.15 : la modification effective d'un champ comptable exige
|
||||
* accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de
|
||||
* filtrage silencieux). On ne gate que si un champ change reellement par
|
||||
* rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables
|
||||
* inchanges ne declenche pas de 403 parasite). Le message precise le premier
|
||||
* champ fautif.
|
||||
*/
|
||||
private function guardAccounting(Provider $data): void
|
||||
{
|
||||
$changed = $this->changedAccountingFields($data);
|
||||
|
||||
if ([] === $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
$changed[0],
|
||||
self::PERM_ACCOUNTING_MANAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet
|
||||
* principal : companyName / categories / sites) exige
|
||||
* `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du
|
||||
* payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le
|
||||
* role Compta — qui entre dans le PATCH via `accounting.manage` (security
|
||||
* d'operation elargie) — d'editer autre chose que l'onglet Comptabilite.
|
||||
*
|
||||
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
|
||||
* deja gardee par la security d'operation `manage`.
|
||||
*/
|
||||
private function guardManage(Provider $data): void
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changed = $this->changedBusinessFields($data);
|
||||
|
||||
if ([] === $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_MANAGE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
$changed[0],
|
||||
self::PERM_MANAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE — § 2.13) : un user SANS
|
||||
* `sites.bypass_scope` ne peut attacher au prestataire que des sites figurant
|
||||
* dans ses propres `user_site`. Tout site hors perimetre -> 422 sur `sites`
|
||||
* (propertyPath consommable inline, convention ERP-101). Un user `bypass_scope`
|
||||
* (Admin auto) peut attacher n'importe quel site.
|
||||
*
|
||||
* Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user
|
||||
* sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site
|
||||
* hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400
|
||||
* anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement
|
||||
* AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref`
|
||||
* (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne
|
||||
* doit rattacher que ses propres sites), et une defense en profondeur sinon.
|
||||
*
|
||||
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
|
||||
* sites obligatoires RG-3.03) ou PATCH portant la cle `sites`. Un PATCH qui ne
|
||||
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
|
||||
* pose). La validation porte sur l'ETAT RESULTANT (data.getSites()).
|
||||
*/
|
||||
private function guardSiteScope(Provider $data): void
|
||||
{
|
||||
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// sites non soumis sur un PATCH : rien a cloisonner.
|
||||
if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedSiteIds = $this->currentUserSiteIds();
|
||||
|
||||
foreach ($data->getSites() as $site) {
|
||||
if (!$site instanceof SiteInterface) {
|
||||
continue;
|
||||
}
|
||||
if (!in_array($site->getId(), $allowedSiteIds, true)) {
|
||||
$this->throwSitesViolation($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
|
||||
* Vide si pas d'user authentifie (cas defensif : la security d'operation
|
||||
* garantit deja l'authentification).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function currentUserSiteIds(): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($user->getSites() as $site) {
|
||||
if ($site instanceof SiteInterface && null !== $site->getId()) {
|
||||
$ids[] = $site->getId();
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs « metier » (onglet principal : companyName / categories / sites) dont
|
||||
* la valeur courante differe de l'etat persiste. Scalaires compares par valeur ;
|
||||
* collections M2M (categories / sites) comparees par ensemble d'identifiants
|
||||
* (cf. collectionChanged) — la simple presence dans le payload ne suffit pas,
|
||||
* sous peine de 403 parasite sur un PATCH representation complete.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function changedBusinessFields(Provider $data): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) {
|
||||
$changed[] = 'companyName';
|
||||
}
|
||||
|
||||
if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) {
|
||||
$changed[] = 'categories';
|
||||
}
|
||||
|
||||
if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) {
|
||||
$changed[] = 'sites';
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si une collection M2M (`categories` ou `sites`) differe reellement de
|
||||
* l'etat persiste. Ces collections ne sont pas tracees par
|
||||
* getOriginalEntityData : on compare par identifiants (independamment de
|
||||
* l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat
|
||||
* courant (apres application du payload). Symetrique des scalaires : seul un
|
||||
* changement effectif compte, pas la simple presence dans le payload.
|
||||
*
|
||||
* - POST / entite non geree : fournir la collection est un acte metier
|
||||
* (branche defensive, guardManage ne s'execute que sur entite geree).
|
||||
* - cle absente du payload (PATCH partiel) : aucun changement.
|
||||
*
|
||||
* @param array<int, object> $current
|
||||
*/
|
||||
private function collectionChanged(Provider $data, string $field, array $current): bool
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!in_array($field, $this->payloadKeys(), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$collection = 'categories' === $field ? $data->getCategories() : $data->getSites();
|
||||
|
||||
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat
|
||||
// persiste comparable, on se rabat sur la presence payload.
|
||||
if (!$collection instanceof PersistentCollection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->idSet($current) !== $this->idSet($collection->getSnapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensemble trie des identifiants d'une liste d'entites — pour une comparaison
|
||||
* par valeur independante de l'ordre.
|
||||
*
|
||||
* @param array<int, object> $entities
|
||||
*
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private function idSet(array $entities): array
|
||||
{
|
||||
$ids = array_map(
|
||||
static fn (object $entity): mixed => method_exists($entity, 'getId')
|
||||
? $entity->getId()
|
||||
: spl_object_id($entity),
|
||||
array_values($entities),
|
||||
);
|
||||
sort($ids);
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
|
||||
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
|
||||
* identite d'objet : l'identity map Doctrine renvoie la meme instance tant que
|
||||
* la reference est inchangee.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function changedAccountingFields(Provider $data): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach (self::ACCOUNTING_FIELDS as $field) {
|
||||
$newValue = match ($field) {
|
||||
'siren' => $data->getSiren(),
|
||||
'accountNumber' => $data->getAccountNumber(),
|
||||
'tvaMode' => $data->getTvaMode(),
|
||||
'nTva' => $data->getNTva(),
|
||||
'paymentDelay' => $data->getPaymentDelay(),
|
||||
'paymentType' => $data->getPaymentType(),
|
||||
'bank' => $data->getBank(),
|
||||
};
|
||||
|
||||
if ($this->fieldChanged($data, $field, $newValue)) {
|
||||
$changed[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
|
||||
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
|
||||
* non-null est alors un changement.
|
||||
*/
|
||||
private function fieldChanged(Provider $data, string $field, mixed $newValue): bool
|
||||
{
|
||||
$original = $this->originalData($data);
|
||||
|
||||
return $newValue !== ($original[$field] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
|
||||
* application du payload). Vide pour une entite non geree (POST).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function originalData(Provider $data): array
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est
|
||||
* porte par le Provider (les champs de contact sont normalises par le processor
|
||||
* de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est
|
||||
* touche que si une valeur est presente, pour ne jamais ecraser l'existant lors
|
||||
* d'un PATCH partiel.
|
||||
*/
|
||||
private function normalize(Provider $data): void
|
||||
{
|
||||
if (null !== $data->getCompanyName()) {
|
||||
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
|
||||
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
|
||||
* connu. Base du 422 d'archivage (RG-3.13).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function writablePayloadKeys(): array
|
||||
{
|
||||
$writable = array_merge(
|
||||
self::MAIN_FIELDS,
|
||||
self::ACCOUNTING_FIELDS,
|
||||
[self::ARCHIVE_FIELD],
|
||||
);
|
||||
|
||||
return array_values(array_intersect($this->payloadKeys(), $writable));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles de premier niveau effectivement envoyees par le client (payload JSON
|
||||
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function payloadKeys(): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (null === $request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
|
||||
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
|
||||
if ($content === $this->decodedContent) {
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
$this->decodedContent = $content;
|
||||
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
|
||||
|
||||
return $this->decodedPayloadKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
|
||||
* Corps vide ou JSON invalide -> aucune cle.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractPayloadKeys(string $content): array
|
||||
{
|
||||
if ('' === $content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
|
||||
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function throwSitesViolation(Provider $root): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
|
||||
null,
|
||||
[],
|
||||
$root,
|
||||
'sites',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderRib;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource RIB d'un prestataire (M3, spec-back
|
||||
* § 4.5). Jumeau du SupplierRibProcessor (M2), recentre sur le perimetre ERP-135.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au prestataire parent. Aucune normalisation
|
||||
* specifique ; la validite de l'IBAN et du BIC est garantie par Assert\Iban /
|
||||
* Assert\Bic sur l'entite (jouees en amont par API Platform). Aucun
|
||||
* #[AuditIgnore] sur iban/bic : la tracabilite comptable est volontaire
|
||||
* (decision M1/M2 reportee, spec § 2.7).
|
||||
* - DELETE : RG-3.08 — si le prestataire est en reglement LCR, la suppression de
|
||||
* son DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
|
||||
*
|
||||
* La security de l'operation (technique.providers.accounting.manage) est appliquee
|
||||
* par API Platform en amont : un utilisateur sans cette permission recoit 403 sur
|
||||
* POST/PATCH/DELETE avant d'atteindre ce processor — c'est le niveau de gating
|
||||
* renforce des donnees bancaires (distinct de manage, spec § 4.5).
|
||||
*
|
||||
* @implements ProcessorInterface<ProviderRib, null|ProviderRib>
|
||||
*/
|
||||
final class ProviderRibProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||
private readonly ProcessorInterface $removeProcessor,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof ProviderRib) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
if ($operation instanceof DeleteOperationInterface) {
|
||||
$this->guardLastRibDeletionUnderLcr($data);
|
||||
|
||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$this->linkParent($data, $uriVariables);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache le RIB au prestataire parent de la sous-ressource POST
|
||||
* (/providers/{providerId}/ribs) : la relation n'est pas peuplee
|
||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
||||
*/
|
||||
private function linkParent(ProviderRib $rib, array $uriVariables): void
|
||||
{
|
||||
if (null !== $rib->getProvider()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$providerId = $uriVariables['providerId'] ?? null;
|
||||
if (null === $providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $providerId instanceof Provider
|
||||
? $providerId
|
||||
: $this->em->getRepository(Provider::class)->find($providerId);
|
||||
|
||||
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
|
||||
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
|
||||
// contrainte provider_id NOT NULL).
|
||||
if (!$provider instanceof Provider) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
|
||||
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
|
||||
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
|
||||
// (anti-enumeration, coherent avec le detail Provider garde en 404).
|
||||
$this->scopeChecker->assertInScope($provider);
|
||||
|
||||
$rib->setProvider($provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.08 : un prestataire dont le type de reglement est LCR doit conserver au
|
||||
* moins un RIB. La collection inclut le RIB en cours de suppression : un
|
||||
* effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
|
||||
* type de reglement, les RIBs sont optionnels (suppression libre).
|
||||
*/
|
||||
private function guardLastRibDeletionUnderLcr(ProviderRib $rib): void
|
||||
{
|
||||
$provider = $rib->getProvider();
|
||||
if (null === $provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('LCR' === $provider->getPaymentType()?->getCode() && $provider->getRibs()->count() <= 1) {
|
||||
throw new ConflictHttpException(
|
||||
'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider du repertoire prestataires (M3). Cf. spec-back M3 § 4.1 / § 4.2 +
|
||||
* RG-3.16 / RG-3.17. Jumeau du SupplierProvider (M2), augmente du cloisonnement
|
||||
* par site pilote par l'utilisateur (§ 2.13).
|
||||
*
|
||||
* Collection (GET /api/providers) :
|
||||
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
|
||||
* (deleted_at IS NOT NULL) — RG-3.16 ;
|
||||
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
|
||||
* exclus au M3) — RG-3.16 ;
|
||||
* - tri par defaut companyName ASC — RG-3.16 ;
|
||||
* - filtres ?search=... (fuzzy companyName + contacts lies : firstName /
|
||||
* lastName / email), ?categoryCode=<code> (prestataires ayant >= 1 categorie
|
||||
* de ce code, repetable) et ?siteId=<id> (prestataires rattaches a ce site
|
||||
* via la relation DIRECTE provider.sites, repetable) ;
|
||||
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
|
||||
* ?pagination=false pour alimenter un <select> sans pagination.
|
||||
*
|
||||
* Cloisonnement par site (RG-3.17, § 2.13) — applique ICI (le QueryBuilder du
|
||||
* repository ne connait pas l'user courant) :
|
||||
* - si l'user N'A PAS `sites.bypass_scope` ET que CurrentSiteProvider::get()
|
||||
* retourne un site -> la liste est restreinte aux prestataires dont
|
||||
* provider.sites contient le currentSite (repository::applySiteScope), AVANT
|
||||
* pagination : totalItems reflete le perimetre de l'user ;
|
||||
* - le DETAIL (Get / provider de PATCH) d'un prestataire hors perimetre renvoie
|
||||
* 404 (null) — ne pas reveler l'existence d'une ligne hors site ;
|
||||
* - user `bypass_scope` (Admin auto, profils consolidation) -> aucun filtre ;
|
||||
* - currentSite = null (module Sites off / user sans site) -> no-op lecture
|
||||
* (aligne site-aware.md § 5).
|
||||
*
|
||||
* Item (GET /api/providers/{id} + provider de PATCH) :
|
||||
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
|
||||
* M3) ; les archives restent consultables/restaurables en detail ;
|
||||
* - 404 si hors perimetre site (cloisonnement, cf. ci-dessus).
|
||||
*
|
||||
* Le filtrage des champs comptables en lecture (groupe provider:read:accounting)
|
||||
* n'est PAS fait ici mais dans ProviderReadGroupContextBuilder : un Provider
|
||||
* retourne des donnees mais ne peut pas influencer les groupes de serialisation.
|
||||
*
|
||||
* @implements ProviderInterface<Provider>
|
||||
*/
|
||||
final class ProviderProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||
private readonly ProviderRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
// Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
|
||||
// source UNIQUE partagee avec le provider decore des sous-ressources
|
||||
// (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
|
||||
// eviter tout drift entre ces points d'application.
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<Provider>|Paginator<Provider>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
// categoryCode accepte un code unique (?categoryCode=NETTOYAGE, selects)
|
||||
// OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
|
||||
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($filters['siteId'] ?? []);
|
||||
|
||||
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
$categoryCodes,
|
||||
$siteIds,
|
||||
$archivedOnly,
|
||||
);
|
||||
|
||||
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
|
||||
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
|
||||
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
|
||||
$scopeSite = $this->scopeChecker->siteScopeOrNull();
|
||||
if (null !== $scopeSite) {
|
||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||
}
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (regle n°13 — utile pour un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
/** @var list<Provider> $providers */
|
||||
$providers = $qb->getQuery()->getResult();
|
||||
// Hydratation batchee des collections affichees (§ 2.12) : evite le
|
||||
// N+1 si la serialisation touche categories/sites, sans cartesien.
|
||||
$this->repository->hydrateListCollections($providers);
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le
|
||||
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
|
||||
// puis on hydrate ses collections en lot (memes entites managees).
|
||||
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
$this->repository->hydrateListCollections(iterator_to_array($paginator));
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?Provider
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = $this->repository->findById((int) $id);
|
||||
if (null === $provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose au M3 (HP-M4) — 404 via retour null.
|
||||
// Les archives restent visibles en detail (consultation + restauration).
|
||||
if (null !== $provider->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
|
||||
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
|
||||
// currentSite null (delegue au ProviderSiteScopeChecker).
|
||||
if (!$this->scopeChecker->isInScope($provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines. Tolere un code unique (string)
|
||||
* ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
|
||||
* valeur unique ou une liste (?key[]=1&key[]=2).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\ProviderOwnedInterface;
|
||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider d'item des sous-ressources d'un prestataire (Contact / Adresse / RIB).
|
||||
* Decore le provider Doctrine par defaut et applique le cloisonnement par site du
|
||||
* PARENT (§ 2.13 / RG-3.17) sur Get / Patch / Delete.
|
||||
*
|
||||
* Sans ce garde, un user cloisonne pourrait lire / editer / supprimer une
|
||||
* sous-ressource d'un prestataire hors de son site : le detail Provider est bien
|
||||
* garde en 404 (ProviderProvider), mais les sous-ressources ne passent pas par lui
|
||||
* (provider Doctrine par defaut, et SiteScopedQueryExtension ne filtre que les
|
||||
* resources SiteAwareInterface — ce que ces entites ne sont pas). Le RIB est
|
||||
* particulierement sensible (IBAN / BIC).
|
||||
*
|
||||
* Hors perimetre -> retour null -> 404 (anti-enumeration, coherent avec le detail
|
||||
* Provider). La decision de scope est deleguee a ProviderSiteScopeChecker (source
|
||||
* unique partagee avec le ProviderProvider et les processors).
|
||||
*
|
||||
* @implements ProviderInterface<ProviderOwnedInterface>
|
||||
*/
|
||||
final class ProviderSubResourceItemProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
|
||||
private readonly ProviderInterface $itemProvider,
|
||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
|
||||
{
|
||||
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
|
||||
|
||||
if ($entity instanceof ProviderOwnedInterface) {
|
||||
$parent = $entity->getProvider();
|
||||
if (null === $parent || !$this->scopeChecker->isInScope($parent)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX du repertoire prestataires (M3, spec-back § 4.6). Jumeau du
|
||||
* `SupplierExportController` (M2, module Commercial), augmente du cloisonnement
|
||||
* par site pilote par l'utilisateur (§ 2.13).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est
|
||||
* OBLIGATOIRE sur la route : sans cela API Platform capterait
|
||||
* `/api/providers/export.xlsx` comme l'item `GET /api/providers/{id}.{_format}`
|
||||
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des prestataires (memes filtres que
|
||||
* `GET /api/providers`, via {@see ProviderRepositoryInterface::createListQueryBuilder()}),
|
||||
* cloisonnement par site, et mapping metier des colonnes.
|
||||
*
|
||||
* Cloisonnement par site (RG-3.17, § 2.13) : replique la logique du
|
||||
* {@see ProviderProvider}
|
||||
* — un user sans `sites.bypass_scope` et possedant un currentSite n'exporte que
|
||||
* les prestataires rattaches a ce site (relation DIRECTE provider.sites). Le
|
||||
* QueryBuilder ne connait pas l'user : la decision est prise ICI, le DQL dans le
|
||||
* repository (applySiteScope).
|
||||
*
|
||||
* Colonnes de contact : alimentees par le CONTACT PRINCIPAL du prestataire — le
|
||||
* ProviderContact de plus petit `position` (decision D2, spec § 4.6).
|
||||
*
|
||||
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
|
||||
* `technique.providers.accounting.view` (gating identique a la lecture, § 2.9).
|
||||
*/
|
||||
#[AsController]
|
||||
final class ProviderExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||
private readonly ProviderRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
// Outillage site-aware (cf. ProviderProvider) : resout le site courant pour
|
||||
// appliquer le cloisonnement RG-3.17 a l'export comme a la liste.
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
#[Route('/api/providers/export.xlsx', name: 'technique_providers_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('technique.providers.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// Memes filtres d'archivage que la vue liste (ProviderProvider) pour que
|
||||
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
|
||||
// - includeArchived : inclut les archives en plus des actifs ;
|
||||
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
|
||||
// createListQueryBuilder).
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
|
||||
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
|
||||
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
|
||||
// ne pas lever d'exception sur une valeur scalaire.
|
||||
$query = $request->query->all();
|
||||
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($query['siteId'] ?? []);
|
||||
|
||||
$qb = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
|
||||
;
|
||||
|
||||
// Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint
|
||||
// au currentSite pour un user non-bypass (s'intersecte avec un eventuel
|
||||
// ?siteId du client). No-op pour bypass_scope ou currentSite null.
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null !== $scopeSite) {
|
||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||
}
|
||||
|
||||
/** @var list<Provider> $providers */
|
||||
$providers = $qb->getQuery()->getResult();
|
||||
|
||||
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
|
||||
// selection ne fetch-join pas les to-many. On remplit categories + sites en
|
||||
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
|
||||
// contact principal) — chacune en requetes IN bornees, anti N+1.
|
||||
$this->repository->hydrateListCollections($providers);
|
||||
$this->repository->hydrateContacts($providers);
|
||||
|
||||
$withSiren = $this->security->isGranted('technique.providers.accounting.view');
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Répertoire prestataires',
|
||||
$this->buildHeaders($withSiren),
|
||||
$this->buildRows($providers, $withSiren),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
|
||||
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
|
||||
* / user sans currentSite). Miroir de ProviderProvider::siteScopeOrNull().
|
||||
*/
|
||||
private function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
|
||||
* uniquement si l'utilisateur a accounting.view.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(bool $withSiren): array
|
||||
{
|
||||
$headers = [
|
||||
'Nom prestataire',
|
||||
'Contact principal',
|
||||
'Téléphone principal',
|
||||
'Téléphone secondaire',
|
||||
'Email',
|
||||
'Catégories',
|
||||
'Sites',
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$headers[] = 'SIREN';
|
||||
}
|
||||
|
||||
$headers[] = 'Date de création';
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Provider> $providers
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $providers, bool $withSiren): iterable
|
||||
{
|
||||
foreach ($providers as $provider) {
|
||||
$contact = $this->principalContact($provider);
|
||||
|
||||
$row = [
|
||||
$provider->getCompanyName(),
|
||||
null !== $contact ? $this->formatContactName($contact) : '',
|
||||
$contact?->getPhonePrimary() ?? '',
|
||||
$contact?->getPhoneSecondary() ?? '',
|
||||
$contact?->getEmail() ?? '',
|
||||
$this->formatCategories($provider),
|
||||
$this->formatSites($provider),
|
||||
];
|
||||
|
||||
if ($withSiren) {
|
||||
$row[] = $provider->getSiren();
|
||||
}
|
||||
|
||||
$row[] = $provider->getCreatedAt()?->format('d/m/Y');
|
||||
|
||||
yield $row;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact principal du prestataire : le ProviderContact de plus petit
|
||||
* `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun
|
||||
* contact (les colonnes contact restent vides).
|
||||
*/
|
||||
private function principalContact(Provider $provider): ?ProviderContact
|
||||
{
|
||||
$contacts = $provider->getContacts()->toArray();
|
||||
if ([] === $contacts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort(
|
||||
$contacts,
|
||||
static fn (ProviderContact $a, ProviderContact $b): int => $a->getPosition() <=> $b->getPosition(),
|
||||
);
|
||||
|
||||
return $contacts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
|
||||
* sont optionnelles (RG-3.04 : au moins l'une des deux), d'ou le trim final.
|
||||
*/
|
||||
private function formatContactName(ProviderContact $contact): string
|
||||
{
|
||||
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des categories du prestataire, dedupliques, tries, joints par
|
||||
* virgule.
|
||||
*/
|
||||
private function formatCategories(Provider $provider): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($provider->getCategories() as $category) {
|
||||
// @var CategoryInterface $category
|
||||
$name = $category->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 — contrairement
|
||||
* au fournisseur M2 dont les sites sont portes par les adresses). La colonne
|
||||
* « Sites » agrege l'union distincte des sites rattaches.
|
||||
*/
|
||||
private function formatSites(Provider $provider): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($provider->getSites() as $site) {
|
||||
// @var SiteInterface $site
|
||||
$name = $site->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $names ensemble de libelles (cles)
|
||||
*/
|
||||
private function joinSorted(array $names): string
|
||||
{
|
||||
$list = array_keys($names);
|
||||
sort($list);
|
||||
|
||||
return implode(', ', $list);
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('repertoire-prestataires-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
* Aligne sur ProviderProvider pour un comportement identique a la liste.
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou liste).
|
||||
* Aligne sur ProviderProvider pour un comportement identique a la liste.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||
* ou liste). Aligne sur ProviderProvider.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
|
||||
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\TvaMode;
|
||||
use App\Module\Commercial\Infrastructure\DataFixtures\CommercialReferentialFixtures;
|
||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||
use App\Module\Technique\Domain\Entity\ProviderRib;
|
||||
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 Technique : prestataires de demonstration couvrant
|
||||
* les cas metier RG-3.xx du repertoire prestataires (M3), jumelles des fixtures
|
||||
* fournisseurs (M2). Theme : prestations techniques (maintenance, nettoyage,
|
||||
* transport).
|
||||
*
|
||||
* Cas pivots couverts (§ 8.4) :
|
||||
* - prestataire COMPLET : >= 1 site sur le formulaire principal (RG-3.03), >= 1
|
||||
* contact, >= 1 adresse multi-sites (RG-3.05), comptabilite + RIB ;
|
||||
* - reglement LCR avec RIB (RG-3.08) ; reglement VIREMENT avec banque (RG-3.07) ;
|
||||
* - 1 prestataire archive (isArchived + archivedAt) pour l'exclusion de la liste
|
||||
* (RG-3.16) ;
|
||||
* - prestataires repartis sur des sites DIFFERENTS (86 / 17 / 82) pour exercer le
|
||||
* cloisonnement par site (RG-3.17) ;
|
||||
* - mono et multi-categories de type PRESTATAIRE (RG-3.09).
|
||||
*
|
||||
* Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) :
|
||||
* - categories resolues via le contrat Shared CategoryInterface ;
|
||||
* - sites resolus via le contrat Shared SiteProviderInterface.
|
||||
*
|
||||
* Normalisation : valeurs fournies BRUTES, normalisees par ProviderFieldNormalizer
|
||||
* avant persist, exactement comme le ferait le ProviderProcessor via l'API
|
||||
* (companyName UPPERCASE, first/last Capitalize, telephones chiffres seuls, emails
|
||||
* lowercase — RG-3.11).
|
||||
*
|
||||
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
|
||||
* partiel uq_provider_company_name_active). Un prestataire deja present n'est pas
|
||||
* reconstruit (sous-collections non redupliquees). Rejouable sans doublon.
|
||||
*
|
||||
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la
|
||||
* fixture ne charge rien : les tests seedent et nettoient leurs propres
|
||||
* prestataires et comptent sur une table `provider` vierge. Meme garde-fou que
|
||||
* SupplierFixtures / CategoryFixtures.
|
||||
*/
|
||||
class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/**
|
||||
* Type de categorie exige pour un prestataire et ses adresses (RG-3.09).
|
||||
* Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
|
||||
*/
|
||||
private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
|
||||
/** Cache des categories resolues par nom. */
|
||||
private array $categoryCache = [];
|
||||
|
||||
/** Cache des sites resolus par nom. */
|
||||
private array $siteCache = [];
|
||||
|
||||
/** ObjectManager courant, capture en debut de load. */
|
||||
private ObjectManager $manager;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProviderFieldNormalizer $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;
|
||||
|
||||
// === Prestataire COMPLET — VIREMENT + banque (RG-3.07), compta + RIB, ===
|
||||
// === multi-sites sur le formulaire principal ET sur l'adresse. ===
|
||||
[$maintenance, $isNew] = $this->ensureProvider($manager, 'Maintenance Pro SAS', ['Maintenance industrielle'], ['Chatellerault', 'Saint-Jean']);
|
||||
if ($isNew) {
|
||||
$maintenance->setSiren('841611054');
|
||||
$maintenance->setAccountNumber('P0001');
|
||||
$maintenance->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
||||
$maintenance->setNTva('FR12841611054');
|
||||
$maintenance->setPaymentDelay($this->paymentDelay($manager, 'J30'));
|
||||
$maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
||||
$maintenance->setBank($this->bank($manager, 'SG'));
|
||||
$this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr');
|
||||
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']);
|
||||
$this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
|
||||
}
|
||||
|
||||
// === LCR avec RIB (RG-3.08) — site Pommevic ===
|
||||
[$nettoyage, $isNew] = $this->ensureProvider($manager, 'Nettoyage Sud-Ouest', ['Nettoyage'], ['Pommevic']);
|
||||
if ($isNew) {
|
||||
$nettoyage->setSiren('775680459');
|
||||
$nettoyage->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
|
||||
$nettoyage->setPaymentDelay($this->paymentDelay($manager, 'J15'));
|
||||
$nettoyage->setPaymentType($this->paymentType($manager, 'LCR'));
|
||||
$this->addContact($nettoyage, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@nettoyage-so.fr', 0);
|
||||
$this->addContact($nettoyage, 'Marc', 'Girard', 'Chef d\'equipe', '05 56 10 20 31', null, 'marc.girard@nettoyage-so.fr', 1);
|
||||
$this->addAddress($nettoyage, ['Pommevic'], '82400', 'Pommevic', '8 route des Prestations');
|
||||
$this->addRib($nettoyage, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
|
||||
}
|
||||
|
||||
// === Multi-categories PRESTATAIRE + reglement CHEQUE (sans banque ni RIB) ===
|
||||
[$transport, $isNew] = $this->ensureProvider($manager, 'Transport Express Atlantique', ['Transport', 'Maintenance industrielle'], ['Saint-Jean']);
|
||||
if ($isNew) {
|
||||
$transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
||||
$transport->setPaymentType($this->paymentType($manager, 'CHEQUE'));
|
||||
$this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr');
|
||||
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']);
|
||||
}
|
||||
|
||||
// === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 ===
|
||||
[$petit, $isNew] = $this->ensureProvider($manager, 'Atelier Soudure Locale', ['Maintenance industrielle'], ['Chatellerault']);
|
||||
if ($isNew) {
|
||||
$this->addContact($petit, null, 'Caron', 'Gerant', '05 49 81 82 83', null, 'contact@atelier-soudure.fr');
|
||||
$this->addAddress($petit, ['Chatellerault'], '86100', 'Châtellerault', '6 chemin de l\'Atelier');
|
||||
}
|
||||
|
||||
// === Prestataire archive (RG-3.16) ===
|
||||
[$ancien, $isNew] = $this->ensureProvider($manager, 'Ancien Prestataire Ferme', ['Nettoyage'], ['Chatellerault'], isArchived: true);
|
||||
if ($isNew) {
|
||||
$this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-prestataire.fr');
|
||||
$this->addAddress($ancien, ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un prestataire (base normalisee + categories PRESTATAIRE + sites directs)
|
||||
* s'il n'existe pas, sinon retourne l'existant. Retourne [Provider, isNew] :
|
||||
* isNew=false bloque la reconstruction des sous-collections (idempotence).
|
||||
*
|
||||
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
|
||||
* @param list<string> $siteNames sites du formulaire principal (RG-3.03, >= 1)
|
||||
*
|
||||
* @return array{0: Provider, 1: bool}
|
||||
*/
|
||||
private function ensureProvider(
|
||||
ObjectManager $manager,
|
||||
string $companyName,
|
||||
array $categoryNames,
|
||||
array $siteNames,
|
||||
bool $isArchived = false,
|
||||
): array {
|
||||
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
|
||||
|
||||
$existing = $manager->getRepository(Provider::class)->findOneBy(['companyName' => $normalizedName]);
|
||||
if ($existing instanceof Provider) {
|
||||
return [$existing, false];
|
||||
}
|
||||
|
||||
$provider = new Provider();
|
||||
$provider->setCompanyName($normalizedName);
|
||||
|
||||
foreach ($categoryNames as $categoryName) {
|
||||
$provider->addCategory($this->category($manager, $categoryName));
|
||||
}
|
||||
foreach ($siteNames as $siteName) {
|
||||
$provider->addSite($this->site($siteName));
|
||||
}
|
||||
|
||||
if ($isArchived) {
|
||||
$provider->setIsArchived(true);
|
||||
$provider->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
|
||||
$manager->persist($provider);
|
||||
|
||||
return [$provider, true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un contact normalise au prestataire (cascade persist via
|
||||
* Provider.contacts). Au moins un champ est rempli (RG-3.04).
|
||||
*/
|
||||
private function addContact(
|
||||
Provider $provider,
|
||||
?string $firstName,
|
||||
?string $lastName,
|
||||
?string $jobTitle,
|
||||
?string $phonePrimary,
|
||||
?string $phoneSecondary,
|
||||
?string $email,
|
||||
int $position = 0,
|
||||
): void {
|
||||
$contact = new ProviderContact();
|
||||
$contact->setProvider($provider);
|
||||
$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);
|
||||
|
||||
$provider->addContact($contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une adresse au prestataire (cascade persist via Provider.addresses).
|
||||
* Adresse simplifiee M3 : PAS de addressType / bennes / triageProvider. Au
|
||||
* moins un site est rattache (RG-3.05) ; categories d'adresse de type
|
||||
* PRESTATAIRE (RG-3.09).
|
||||
*
|
||||
* @param list<string> $siteNames au moins un site (RG-3.05)
|
||||
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
|
||||
*/
|
||||
private function addAddress(
|
||||
Provider $provider,
|
||||
array $siteNames,
|
||||
string $postalCode,
|
||||
string $city,
|
||||
string $street,
|
||||
?string $streetComplement = null,
|
||||
array $categoryNames = [],
|
||||
int $position = 0,
|
||||
): void {
|
||||
$address = new ProviderAddress();
|
||||
$address->setProvider($provider);
|
||||
$address->setCountry('France');
|
||||
$address->setPostalCode($postalCode);
|
||||
$address->setCity($city);
|
||||
$address->setStreet($street);
|
||||
$address->setStreetComplement($streetComplement);
|
||||
$address->setPosition($position);
|
||||
|
||||
foreach ($siteNames as $siteName) {
|
||||
$address->addSite($this->site($siteName));
|
||||
}
|
||||
foreach ($categoryNames as $categoryName) {
|
||||
$address->addCategory($this->category($this->manager, $categoryName));
|
||||
}
|
||||
|
||||
$provider->addAddress($address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un RIB au prestataire (cascade persist via Provider.ribs).
|
||||
*/
|
||||
private function addRib(Provider $provider, string $label, string $bic, string $iban, int $position = 0): void
|
||||
{
|
||||
$rib = new ProviderRib();
|
||||
$rib->setProvider($provider);
|
||||
$rib->setLabel($label);
|
||||
$rib->setBic($bic);
|
||||
$rib->setIban($iban);
|
||||
$rib->setPosition($position);
|
||||
|
||||
$provider->addRib($rib);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout une categorie par son nom via le contrat Shared CategoryInterface,
|
||||
* sans importer le module Catalog (regle n°1). Verifie le type PRESTATAIRE
|
||||
* (RG-3.09). Mise en cache par nom.
|
||||
*/
|
||||
private function category(ObjectManager $manager, string $name): CategoryInterface
|
||||
{
|
||||
if (isset($this->categoryCache[$name])) {
|
||||
return $this->categoryCache[$name];
|
||||
}
|
||||
|
||||
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
|
||||
'name' => $name,
|
||||
'deletedAt' => null,
|
||||
]);
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate instanceof CategoryInterface
|
||||
&& in_array(self::PROVIDER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
|
||||
return $this->categoryCache[$name] = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf(
|
||||
'Categorie PRESTATAIRE "%s" introuvable : CategoryFixtures doit tourner avant ProviderFixtures.',
|
||||
$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 ProviderFixtures.',
|
||||
$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 ProviderFixtures.',
|
||||
$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 ProviderFixtures.',
|
||||
$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 ProviderFixtures.',
|
||||
$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 ProviderFixtures.',
|
||||
$code,
|
||||
));
|
||||
}
|
||||
|
||||
return $bank;
|
||||
}
|
||||
}
|
||||
@@ -89,26 +89,6 @@ class DoctrineProviderRepository extends ServiceEntityRepository implements Prov
|
||||
;
|
||||
}
|
||||
|
||||
public function applySiteScope(QueryBuilder $qb, int $siteId): void
|
||||
{
|
||||
// Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires
|
||||
// dont provider.sites contient le site donne. Sous-requete IN (alias p5
|
||||
// distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du
|
||||
// QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds.
|
||||
// Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour
|
||||
// que les deux clauses puissent coexister (intersection) sans collision.
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('p5.id')
|
||||
->from(Provider::class, 'p5')
|
||||
->join('p5.sites', 'site5')
|
||||
->where('site5.id = :scopeSiteId')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
|
||||
->setParameter('scopeSiteId', $siteId)
|
||||
;
|
||||
}
|
||||
|
||||
public function hydrateContacts(array $providers): void
|
||||
{
|
||||
$ids = $this->collectIds($providers);
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Technique\Infrastructure\Security;
|
||||
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Decision centralisee du cloisonnement par site des prestataires (§ 2.13 /
|
||||
* RG-3.17). Source UNIQUE partagee par le ProviderProvider (liste + detail), le
|
||||
* provider decore des sous-ressources (ProviderSubResourceItemProvider) et les
|
||||
* processors d'ecriture des sous-ressources — afin d'eviter tout drift entre ces
|
||||
* points d'application.
|
||||
*
|
||||
* Regle : un user SANS `sites.bypass_scope` ET avec un site courant ne voit /
|
||||
* n'opere que sur les prestataires rattaches (relation directe provider.sites) a
|
||||
* son site courant. `bypass_scope` (Admin inclus via isAdmin) ou absence de site
|
||||
* courant (module Sites off / user sans currentSite) -> aucun cloisonnement
|
||||
* (no-op, aligne site-aware.md § 5).
|
||||
*/
|
||||
final class ProviderSiteScopeChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Site de cloisonnement a appliquer, ou null si aucun cloisonnement
|
||||
* (`bypass_scope`, ou pas de site courant resolu).
|
||||
*/
|
||||
public function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le prestataire est dans le perimetre site de l'user courant — ou si
|
||||
* aucun cloisonnement ne s'applique.
|
||||
*/
|
||||
public function isInScope(Provider $provider): bool
|
||||
{
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null === $scopeSite) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->providerHasSite($provider, (int) $scopeSite->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve un 404 si le prestataire est hors perimetre (anti-enumeration : ne pas
|
||||
* reveler l'existence d'une ligne hors site). No-op si dans le perimetre.
|
||||
*/
|
||||
public function assertInScope(Provider $provider): void
|
||||
{
|
||||
if (!$this->isInScope($provider)) {
|
||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le prestataire est rattache (relation directe provider.sites) au site
|
||||
* d'id donne. Comparaison en memoire sur l'entite deja chargee.
|
||||
*/
|
||||
private function providerHasSite(Provider $provider, int $siteId): bool
|
||||
{
|
||||
foreach ($provider->getSites() as $site) {
|
||||
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
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\TvaMode;
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||
use App\Module\Technique\Domain\Entity\ProviderRib;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du repertoire prestataires (M3 — module Technique).
|
||||
* Jumelle de la base fournisseurs (M2), recentree sur le perimetre ERP-134
|
||||
* (Provider + Processor + cloisonnement site).
|
||||
*
|
||||
* Donnees (RETEX M1/M2 — pas de fixtures globales pour les tests) : chaque test
|
||||
* seede ses prestataires en base via les helpers ci-dessous, puis le tearDown les
|
||||
* purge. Les 3 sites (Chatellerault 86 / Saint-Jean 17 / Pommevic 82) sont seedes
|
||||
* par SitesFixtures (make test-db-setup) ; on les recupere par code postal.
|
||||
*
|
||||
* Categories : `providerCategory('NETTOYAGE')` fetch-or-create une categorie de
|
||||
* type PRESTATAIRE (requis par RG-3.09). Pour fabriquer une categorie d'un AUTRE
|
||||
* type (test de rejet RG-3.09), utiliser `foreignCategory()`.
|
||||
*
|
||||
* Cleanup : tearDown purge prestataires AVANT categories/users (provider_category
|
||||
* et provider_site sont ON DELETE CASCADE cote provider — le DELETE DQL sur
|
||||
* Provider libere categories et sites pour les purges suivantes).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string LD = 'application/ld+json';
|
||||
protected const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
protected const string TEST_CATEGORY_PREFIX = 'test_prov_cat_';
|
||||
|
||||
/** Codes postaux des 3 sites fixtures (cf. SitesFixtures). */
|
||||
protected const string SITE_86 = '86100'; // Chatellerault
|
||||
protected const string SITE_17 = '17400'; // Saint-Jean
|
||||
protected const string SITE_82 = '82400'; // Pommevic
|
||||
|
||||
/** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */
|
||||
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
protected const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
/** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */
|
||||
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$em->createQuery('DELETE FROM '.Provider::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix')
|
||||
->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix')
|
||||
->setParameter('prefix', 'test_%')->execute()
|
||||
;
|
||||
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix')
|
||||
->setParameter('prefix', 'test_%')->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function createAdminClient(): Client
|
||||
{
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere (ou cree) le type PRESTATAIRE. Idempotent (unicite category_type.code).
|
||||
*/
|
||||
protected function providerCategoryType(): CategoryType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$type = new CategoryType();
|
||||
$type->setCode('PRESTATAIRE');
|
||||
$type->setLabel('Prestataire');
|
||||
$em->persist($type);
|
||||
$em->flush();
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch-or-create une categorie de type PRESTATAIRE par code (defaut NETTOYAGE).
|
||||
* Idempotent (lookup par code, aligne sur l'index unique partiel uq_category_code)
|
||||
* et auto-suffisant. Nom prefixe -> purge par tearDown.
|
||||
*/
|
||||
protected function providerCategory(string $code = 'NETTOYAGE'): 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.strtolower($code));
|
||||
$category->setCode($code);
|
||||
$category->addCategoryType($this->providerCategoryType());
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une categorie d'un type DIFFERENT de PRESTATAIRE (pour tester le rejet
|
||||
* RG-3.09). Code unique pour ne pas collisionner avec une categorie existante.
|
||||
*/
|
||||
protected function foreignCategory(): Category
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
|
||||
$type = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'CLIENT']);
|
||||
if (null === $type) {
|
||||
$type = new CategoryType();
|
||||
$type->setCode('CLIENT');
|
||||
$type->setLabel('Client');
|
||||
$em->persist($type);
|
||||
}
|
||||
|
||||
$category = new Category();
|
||||
$category->setName(self::TEST_CATEGORY_PREFIX.'foreign_'.$suffix);
|
||||
$category->setCode('FOREIGN_'.strtoupper($suffix));
|
||||
$category->addCategoryType($type);
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere un site fixture par code postal (cf. SitesFixtures). Echoue
|
||||
* explicitement si absent (fixtures non chargees / module Sites off).
|
||||
*/
|
||||
protected function site(string $postalCode): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['postalCode' => $postalCode]);
|
||||
|
||||
self::assertNotNull(
|
||||
$site,
|
||||
sprintf('Site fixture "%s" introuvable : SitesFixtures charge (make test-db-setup) ?', $postalCode),
|
||||
);
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede directement un Provider minimal (sans passer par l'API), pour les tests
|
||||
* de liste / archivage / cloisonnement. Nom stocke en MAJUSCULES pour refleter
|
||||
* l'etat normalise (RG-3.11) qu'aurait produit le ProviderProcessor. Porte une
|
||||
* categorie PRESTATAIRE + les sites donnes (par code postal).
|
||||
*
|
||||
* @param list<string> $sitePostalCodes codes postaux des sites a rattacher
|
||||
*/
|
||||
protected function seedProvider(
|
||||
string $companyName,
|
||||
array $sitePostalCodes = [self::SITE_86],
|
||||
bool $isArchived = false,
|
||||
string $categoryCode = 'NETTOYAGE',
|
||||
?string $siren = null,
|
||||
): Provider {
|
||||
$em = $this->getEm();
|
||||
$provider = new Provider();
|
||||
$provider->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
|
||||
$provider->addCategory($this->providerCategory($categoryCode));
|
||||
foreach ($sitePostalCodes as $postalCode) {
|
||||
$provider->addSite($this->site($postalCode));
|
||||
}
|
||||
if (null !== $siren) {
|
||||
$provider->setSiren($siren);
|
||||
}
|
||||
$provider->setIsArchived($isArchived);
|
||||
if ($isArchived) {
|
||||
$provider->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
$em->persist($provider);
|
||||
$em->flush();
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload minimal valide du formulaire principal (companyName + 1 categorie
|
||||
* PRESTATAIRE + sites donnes). Categorie NETTOYAGE par defaut.
|
||||
*
|
||||
* @param list<string> $sitePostalCodes
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validMainPayload(string $companyName, array $sitePostalCodes = [self::SITE_86]): array
|
||||
{
|
||||
$siteIris = array_map(fn (string $pc): string => '/api/sites/'.$this->site($pc)->getId(), $sitePostalCodes);
|
||||
|
||||
return [
|
||||
'companyName' => $companyName,
|
||||
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
|
||||
'sites' => $siteIris,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un utilisateur non-admin CLOISONNE : porte les permissions donnees via
|
||||
* un role jetable, rattache aux seuls sites donnes (par code postal), avec un
|
||||
* currentSite positionne. N'a PAS `sites.bypass_scope` (sauf si fourni dans
|
||||
* $permissionCodes) -> sujet ideal des tests de cloisonnement (RG-3.17).
|
||||
*
|
||||
* Contrairement a createUserWithPermissions() (parent, qui attache TOUS les
|
||||
* sites et ne pose pas de currentSite), ce helper controle finement le
|
||||
* perimetre site de l'user.
|
||||
*
|
||||
* @param list<string> $permissionCodes
|
||||
* @param list<string> $sitePostalCodes sites a rattacher (user_site)
|
||||
*
|
||||
* @return array{username: string, password: string}
|
||||
*/
|
||||
protected function createScopedUser(
|
||||
array $permissionCodes,
|
||||
array $sitePostalCodes,
|
||||
?string $currentSitePostalCode = null,
|
||||
): array {
|
||||
$em = $this->getEm();
|
||||
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$username = 'test_scoped_'.$suffix;
|
||||
$password = 'testpass';
|
||||
|
||||
/** @var UserPasswordHasherInterface $hasher */
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
|
||||
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
|
||||
foreach ($permissionCodes as $code) {
|
||||
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $code]);
|
||||
self::assertNotNull($permission, sprintf('Permission "%s" introuvable (app:sync-permissions ?).', $code));
|
||||
$role->addPermission($permission);
|
||||
}
|
||||
$em->persist($role);
|
||||
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setIsAdmin(false);
|
||||
$user->setPassword($hasher->hashPassword($user, $password));
|
||||
$user->addRbacRole($role);
|
||||
|
||||
foreach ($sitePostalCodes as $postalCode) {
|
||||
$user->addSite($this->site($postalCode));
|
||||
}
|
||||
if (null !== $currentSitePostalCode) {
|
||||
$user->setCurrentSite($this->site($currentSitePostalCode));
|
||||
}
|
||||
|
||||
$em->persist($user);
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
return ['username' => $username, 'password' => $password];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un contact a un prestataire deja persiste (seed direct).
|
||||
*/
|
||||
protected function addContact(
|
||||
Provider $provider,
|
||||
?string $firstName = 'Marie',
|
||||
?string $lastName = 'Martin',
|
||||
?string $phonePrimary = null,
|
||||
?string $email = null,
|
||||
int $position = 0,
|
||||
): ProviderContact {
|
||||
$contact = new ProviderContact();
|
||||
$contact->setProvider($provider);
|
||||
$contact->setFirstName($firstName);
|
||||
$contact->setLastName($lastName);
|
||||
$contact->setPhonePrimary($phonePrimary);
|
||||
$contact->setEmail($email);
|
||||
$contact->setPosition($position);
|
||||
$provider->addContact($contact);
|
||||
$this->getEm()->persist($contact);
|
||||
$this->getEm()->flush();
|
||||
|
||||
return $contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un RIB a un prestataire deja persiste (seed direct).
|
||||
*/
|
||||
protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib
|
||||
{
|
||||
$rib = new ProviderRib();
|
||||
$rib->setProvider($provider);
|
||||
$rib->setLabel($label);
|
||||
$rib->setBic(self::VALID_BIC);
|
||||
$rib->setIban(self::VALID_IBAN);
|
||||
$provider->addRib($rib);
|
||||
$this->getEm()->persist($rib);
|
||||
$this->getEm()->flush();
|
||||
|
||||
return $rib;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un prestataire COMPLET (sans passer par l'API — validations applicatives
|
||||
* non rejouees mais CHECK BDD respectes) : bloc comptable non nul (SIREN + refs),
|
||||
* >= 1 RIB, >= 2 sites en relation DIRECTE (formulaire principal, RG-3.03), >= 1
|
||||
* adresse multi-sites (>= 2 sites) avec >= 1 categorie PRESTATAIRE et >= 1 contact,
|
||||
* >= 1 contact et >= 1 categorie sur le prestataire. Socle du contrat de
|
||||
* serialisation et de la DoD (§ 4.0.bis), jumeau de seedCompleteSupplier (M2)
|
||||
* mais SANS onglet Information (absent au M3) et AVEC sites directs sur le
|
||||
* prestataire (NOUVEAU M3 — la liste affiche provider.sites, pas un agregat
|
||||
* d'adresses).
|
||||
*
|
||||
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
|
||||
* coherent avec le RIB seede ; RG-3.08)
|
||||
*/
|
||||
protected function seedCompleteProvider(string $companyName, string $paymentTypeCode = 'LCR'): Provider
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Nom unique parmi les actifs (index partiel uq_provider_company_name_active).
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$provider = new Provider();
|
||||
$provider->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
|
||||
$provider->addCategory($this->providerCategory('NETTOYAGE'));
|
||||
|
||||
// Bloc comptable non nul (gating par omission cote sans accounting.view).
|
||||
$provider->setSiren('987654321');
|
||||
$provider->setAccountNumber('P0001');
|
||||
$provider->setNTva('FR00987654321');
|
||||
$provider->setTvaMode($this->tvaMode('FRANCE_VENTES'));
|
||||
$provider->setPaymentDelay($this->paymentDelay('J30'));
|
||||
$provider->setPaymentType($this->paymentType($paymentTypeCode));
|
||||
if ('VIREMENT' === $paymentTypeCode) {
|
||||
$provider->setBank($this->bank('SG'));
|
||||
}
|
||||
|
||||
// >= 2 sites fixtures : relation DIRECTE provider.sites (RG-3.03) pour la
|
||||
// LISTE + reutilises sur l'adresse multi-sites pour le DETAIL.
|
||||
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
|
||||
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
|
||||
foreach ($sites as $site) {
|
||||
$provider->addSite($site);
|
||||
}
|
||||
$em->persist($provider);
|
||||
|
||||
$contact = new ProviderContact();
|
||||
$contact->setProvider($provider);
|
||||
$contact->setFirstName('Marie');
|
||||
$contact->setLastName('Martin');
|
||||
$contact->setJobTitle('Responsable');
|
||||
$contact->setPhonePrimary('0612345678');
|
||||
$contact->setEmail('marie.martin@seed.test');
|
||||
$provider->addContact($contact);
|
||||
$em->persist($contact);
|
||||
|
||||
// Adresse simplifiee M3 (PAS de addressType / bennes / triageProvider).
|
||||
$address = new ProviderAddress();
|
||||
$address->setProvider($provider);
|
||||
$address->setCountry('France');
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
foreach ($sites as $site) {
|
||||
$address->addSite($site);
|
||||
}
|
||||
$address->addCategory($this->providerCategory('NETTOYAGE'));
|
||||
$address->addContact($contact);
|
||||
$provider->addAddress($address);
|
||||
$em->persist($address);
|
||||
|
||||
$rib = new ProviderRib();
|
||||
$rib->setProvider($provider);
|
||||
$rib->setLabel('Compte principal');
|
||||
$rib->setBic(self::VALID_BIC);
|
||||
$rib->setIban(self::VALID_IBAN);
|
||||
$provider->addRib($rib);
|
||||
$em->persist($rib);
|
||||
|
||||
$em->flush();
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere un mode de TVA seede (CommercialReferentialFixtures) par code (ex.
|
||||
* FRANCE_VENTES). Echoue explicitement si absent (fixtures non chargees).
|
||||
*/
|
||||
protected function tvaMode(string $code): TvaMode
|
||||
{
|
||||
$tvaMode = $this->getEm()->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
|
||||
|
||||
self::assertNotNull(
|
||||
$tvaMode,
|
||||
sprintf('Mode de TVA "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
|
||||
);
|
||||
|
||||
return $tvaMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere un delai de reglement seede (CommercialReferentialFixtures) par code
|
||||
* (ex. J30). Echoue explicitement si absent (fixtures non chargees).
|
||||
*/
|
||||
protected function paymentDelay(string $code): PaymentDelay
|
||||
{
|
||||
$paymentDelay = $this->getEm()->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
|
||||
|
||||
self::assertNotNull(
|
||||
$paymentDelay,
|
||||
sprintf('Delai de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
|
||||
);
|
||||
|
||||
return $paymentDelay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere un type de reglement seede (CommercialReferentialFixtures) par code
|
||||
* (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees).
|
||||
*/
|
||||
protected function paymentType(string $code): PaymentType
|
||||
{
|
||||
$paymentType = $this->getEm()->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
|
||||
|
||||
self::assertNotNull(
|
||||
$paymentType,
|
||||
sprintf('Type de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
|
||||
);
|
||||
|
||||
return $paymentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere une banque seedee (CommercialReferentialFixtures) par code (ex. SG).
|
||||
* Echoue explicitement si absente (fixtures non chargees).
|
||||
*/
|
||||
protected function bank(string $code): Bank
|
||||
{
|
||||
$bank = $this->getEm()->getRepository(Bank::class)->findOneBy(['code' => $code]);
|
||||
|
||||
self::assertNotNull(
|
||||
$bank,
|
||||
sprintf('Banque "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
|
||||
);
|
||||
|
||||
return $bank;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexe les violations d'un corps 422 par propertyPath (assert ciblee).
|
||||
*
|
||||
* @param array<string, mixed> $body corps decode (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,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
|
||||
* de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet
|
||||
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
|
||||
* propertyPath de la violation (consommable par extractApiViolations cote front,
|
||||
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de
|
||||
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires
|
||||
* comptables (spec M3 § 3.1).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
// === RG-3.07 : Virement impose une banque ===
|
||||
|
||||
public function testVirementWithoutBankReturns422OnBankPath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Virement No Bank');
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$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->seedProvider('Virement With Bank');
|
||||
|
||||
$client->request('PATCH', '/api/providers/'.$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-3.08 : LCR impose au moins un RIB (volet ecriture du formulaire) ===
|
||||
|
||||
public function testLcrWithoutRibReturns422OnPaymentTypePath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Lcr No Rib');
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
|
||||
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// Miroir client : violation portee sur `paymentType` (select « Type de
|
||||
// règlement »), les RIB n'ayant pas de champ de formulaire pour l'ancrer.
|
||||
self::assertArrayHasKey('paymentType', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testLcrWithRibReturns200(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Lcr With Rib');
|
||||
$this->addRib($seed);
|
||||
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
// violationsByPath() : helper mutualise dans AbstractProviderApiTestCase.
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels du formulaire principal prestataire (POST + PATCH) — ERP-134.
|
||||
* Couvre : creation (RG-3.03 sites obligatoires, RG-3.09 type categorie),
|
||||
* normalisation companyName (RG-3.11), 409 doublon (RG-3.10).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderApiTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
public function testPostMainCreatesProvider(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Maintenance Pro', [self::SITE_86]),
|
||||
]);
|
||||
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
$body = $response->toArray();
|
||||
// RG-3.11 : companyName normalise en MAJUSCULES.
|
||||
self::assertSame('MAINTENANCE PRO', $body['companyName']);
|
||||
self::assertArrayHasKey('id', $body);
|
||||
// sites embarque (relation directe, site:read) avec name/postalCode.
|
||||
self::assertCount(1, $body['sites']);
|
||||
self::assertSame('86100', $body['sites'][0]['postalCode']);
|
||||
}
|
||||
|
||||
public function testPostWithoutSiteIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$payload = $this->validMainPayload('Sans Site', [self::SITE_86]);
|
||||
$payload['sites'] = [];
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $payload,
|
||||
]);
|
||||
|
||||
// RG-3.03 : au moins un site obligatoire.
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testPostWithoutCategoryIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$payload = $this->validMainPayload('Sans Categorie', [self::SITE_86]);
|
||||
$payload['categories'] = [];
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $payload,
|
||||
]);
|
||||
|
||||
// RG-3.09 : au moins une categorie obligatoire.
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testPostWithForeignCategoryTypeIsRejected(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$foreign = $this->foreignCategory();
|
||||
|
||||
$payload = $this->validMainPayload('Mauvais Type', [self::SITE_86]);
|
||||
$payload['categories'] = ['/api/categories/'.$foreign->getId()];
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $payload,
|
||||
]);
|
||||
|
||||
// RG-3.09 : categorie hors type PRESTATAIRE -> 422 sur `categories`.
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testDuplicateCompanyNameReturns409(): void
|
||||
{
|
||||
$this->seedProvider('Doublon Sarl', [self::SITE_86]);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
// Casse differente : l'unicite est insensible a la casse (LOWER).
|
||||
'json' => $this->validMainPayload('doublon sarl', [self::SITE_86]),
|
||||
]);
|
||||
|
||||
// RG-3.10 : doublon de nom (case-insensitive) -> 409.
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testSameNameAfterArchiveIsAllowed(): void
|
||||
{
|
||||
// Index partiel : l'unicite ignore les archives -> reutilisation du nom OK.
|
||||
$this->seedProvider('Recyclage Express', [self::SITE_86], isArchived: true);
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Recyclage Express', [self::SITE_86]),
|
||||
]);
|
||||
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Tests Audit du repertoire prestataires (M3, spec § 6). Jumeau du
|
||||
* {@see \App\Tests\Module\Commercial\Api\SupplierAuditTest} (M2). Couvre :
|
||||
* - POST / PATCH / archivage -> ligne audit_log entity_type='technique.Provider'
|
||||
* 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/M2) ;
|
||||
* - M2M `sites` : un PATCH du formulaire principal qui modifie les sites trace la
|
||||
* relation many-to-many (audit M2M automatique, § 2.7).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderAuditTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
private const string PROVIDER_TYPE = 'technique.Provider';
|
||||
private const string RIB_TYPE = 'technique.ProviderRib';
|
||||
|
||||
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 testPostProviderIsAudited(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$payload = $this->validMainPayload('Audit Created Co', [self::SITE_86]);
|
||||
|
||||
$created = $admin->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $payload,
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
self::assertGreaterThanOrEqual(
|
||||
1,
|
||||
$this->countAudit(self::PROVIDER_TYPE, (string) $created['id'], 'create'),
|
||||
'Un audit_log "create" doit etre genere pour le prestataire.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchProviderIsAudited(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Audit Patch Co', [self::SITE_86]);
|
||||
|
||||
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Audit Patch Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
self::assertGreaterThanOrEqual(
|
||||
1,
|
||||
$this->countAudit(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'),
|
||||
'Un audit_log "update" doit etre genere pour le PATCH.',
|
||||
);
|
||||
}
|
||||
|
||||
public function testArchiveProviderIsAudited(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Audit Archive Co', [self::SITE_86]);
|
||||
|
||||
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update');
|
||||
self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.');
|
||||
}
|
||||
|
||||
public function testPatchSitesIsAuditedAsManyToMany(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Audit Sites Co', [self::SITE_86]);
|
||||
|
||||
// PATCH du formulaire principal : on passe a 2 sites (86 + 17). L'audit M2M
|
||||
// automatique (§ 2.7) doit tracer la relation `sites` dans le diff.
|
||||
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['sites' => [
|
||||
'/api/sites/'.$this->site(self::SITE_86)->getId(),
|
||||
'/api/sites/'.$this->site(self::SITE_17)->getId(),
|
||||
]],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
$changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update');
|
||||
self::assertArrayHasKey('sites', $changes, 'La modification M2M des sites doit etre tracee.');
|
||||
}
|
||||
|
||||
public function testRibCreateAuditIncludesIbanAndBic(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib Audit Host', [self::SITE_86]);
|
||||
|
||||
$rib = $admin->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Compte audite',
|
||||
'bic' => self::VALID_BIC,
|
||||
'iban' => self::VALID_IBAN,
|
||||
],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
$changes = $this->latestChanges(self::RIB_TYPE, (string) $rib['id'], 'create');
|
||||
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']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode le `changes` (diff) de la derniere ligne audit_log correspondante.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function latestChanges(string $type, string $id, string $action): array
|
||||
{
|
||||
$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' => $type, 'id' => $id, 'action' => $action],
|
||||
);
|
||||
self::assertGreaterThanOrEqual(1, count($rows), sprintf('Un audit_log "%s" doit exister pour %s#%s.', $action, $type, $id));
|
||||
|
||||
return json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du repertoire prestataires (M3, § 4.6).
|
||||
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}
|
||||
* (M2), augmente du cloisonnement par site (§ 2.13, propre au M3).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
|
||||
* archives par defaut, respect du filtre ?search, peuplement des colonnes contact
|
||||
* principal / categories / sites (relation directe provider.sites), gating de la
|
||||
* colonne SIREN selon technique.providers.accounting.view (admin ET user minimal a
|
||||
* permission explicite), dedup (prestataire multi-categories rendu sur une seule
|
||||
* ligne), cloisonnement par site (un user cloisonne n'exporte que son site), 403
|
||||
* sans technique.providers.view, 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderExportControllerTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/providers/export.xlsx';
|
||||
|
||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Export Alpha');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="repertoire-prestataires-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="repertoire-prestataires-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$grid = $this->gridFromResponse($response->getContent());
|
||||
$headers = $grid[0];
|
||||
self::assertSame('Nom prestataire', $headers[0]);
|
||||
self::assertContains('Contact principal', $headers);
|
||||
self::assertContains('Téléphone principal', $headers);
|
||||
self::assertContains('Téléphone secondaire', $headers);
|
||||
self::assertContains('Email', $headers);
|
||||
self::assertContains('Catégories', $headers);
|
||||
self::assertContains('Sites', $headers);
|
||||
self::assertContains('Date de création', $headers);
|
||||
}
|
||||
|
||||
public function testExportExcludesArchivedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Active One');
|
||||
$this->seedProvider('Archived One', [self::SITE_86], true);
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('ACTIVE ONE', $names);
|
||||
self::assertNotContains('ARCHIVED ONE', $names);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Searchable Alpha');
|
||||
$this->seedProvider('Other Beta');
|
||||
|
||||
$names = $this->companyNames(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('SEARCHABLE ALPHA', $names);
|
||||
self::assertNotContains('OTHER BETA', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
|
||||
* de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
|
||||
* ordre de position inverse pour garantir que c'est bien le principal (et non
|
||||
* le premier insere) qui alimente la ligne.
|
||||
*/
|
||||
public function testExportUsesPrincipalContactColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$provider = $this->seedProvider('Contact Co');
|
||||
|
||||
// position 1 (secondaire) insere en premier...
|
||||
$this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
|
||||
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
|
||||
$principal = $this->addContact($provider, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
|
||||
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
|
||||
// directement sur le contact principal pour alimenter la colonne dediee.
|
||||
$principal->setPhoneSecondary('0698765432');
|
||||
$this->getEm()->flush();
|
||||
|
||||
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
|
||||
|
||||
self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
|
||||
self::assertSame('Principal Alice', $row[1]);
|
||||
self::assertSame('0612345678', $row[2]);
|
||||
self::assertSame('0698765432', $row[3]);
|
||||
self::assertSame('alice@contact.co', $row[4]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
|
||||
* vides sans erreur (cf. ERP-100 cote client). Le site est porte EN DIRECT par
|
||||
* le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse).
|
||||
*/
|
||||
public function testExportPopulatesCategoryAndSiteColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE');
|
||||
|
||||
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||
|
||||
// Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()).
|
||||
// Derive du helper de base (idempotent) plutot que de hardcoder le prefixe.
|
||||
self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat);
|
||||
// Colonne « Sites » : site rattache en direct au prestataire (RG-3.03).
|
||||
self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat);
|
||||
}
|
||||
|
||||
public function testSirenColumnPresentWithAccountingView(): void
|
||||
{
|
||||
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789');
|
||||
|
||||
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('SIREN', $grid[0]);
|
||||
self::assertStringContainsString('123456789', $this->flatten($grid));
|
||||
}
|
||||
|
||||
public function testSirenColumnAbsentWithoutAccountingView(): void
|
||||
{
|
||||
// Seed via admin, puis relecture par un user qui n'a QUE providers.view.
|
||||
$this->createAdminClient();
|
||||
$this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321');
|
||||
|
||||
$creds = $this->createUserWithPermission('technique.providers.view');
|
||||
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertNotContains('SIREN', $grid[0]);
|
||||
self::assertStringNotContainsString('987654321', $this->flatten($grid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
|
||||
* un user minimal portant uniquement technique.providers.view +
|
||||
* technique.providers.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 est couvert par testSirenColumnAbsentWithoutAccountingView.
|
||||
*/
|
||||
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
|
||||
{
|
||||
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
|
||||
$this->createAdminClient();
|
||||
$this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123');
|
||||
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.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 : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par
|
||||
* la jointure (selection/hydratation des collections) ; l'export doit le rendre
|
||||
* sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il
|
||||
* n'apparait qu'une fois dans la colonne « Nom prestataire ».
|
||||
*/
|
||||
public function testExportDeduplicatesProviderWithMultipleCategories(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE');
|
||||
// 2e categorie PRESTATAIRE sur le meme prestataire.
|
||||
$provider->addCategory($this->providerCategory('SECURITE'));
|
||||
$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 prestataire multi-categories doit apparaitre sur une seule ligne (dedup).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur
|
||||
* le site 86 n'exporte QUE les prestataires rattaches au site 86 — les
|
||||
* prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant
|
||||
* export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser.
|
||||
*/
|
||||
public function testExportIsScopedToCurrentSiteForNonBypassUser(): void
|
||||
{
|
||||
// Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement
|
||||
// no-op et ce test perd son sens).
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$this->createAdminClient();
|
||||
$this->seedProvider('Presta Site 86', [self::SITE_86]);
|
||||
$this->seedProvider('Presta Site 17', [self::SITE_17]);
|
||||
$this->seedProvider('Presta Site 82', [self::SITE_82]);
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('PRESTA SITE 86', $names);
|
||||
self::assertNotContains('PRESTA SITE 17', $names);
|
||||
self::assertNotContains('PRESTA SITE 82', $names);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutProvidersViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la colonne « Nom prestataire » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function companyNames(string $binary): array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
$rows = array_slice($grid, 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowFor(string $binary, string $companyName): ?array
|
||||
{
|
||||
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $companyName) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplatit toute la grille en une chaine, pour les assertions de presence.
|
||||
*
|
||||
* @param array<int, array<int, mixed>> $grid
|
||||
*/
|
||||
private function flatten(array $grid): string
|
||||
{
|
||||
return implode('|', array_map(
|
||||
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
|
||||
$grid,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests de la liste paginee /api/providers (ProviderProvider) — ERP-134.
|
||||
* Couvre : envelope Hydra, tri companyName ASC, exclusion des archives,
|
||||
* ?includeArchived (RG-3.16). Joue en admin (bypass_scope -> pas de cloisonnement).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderListTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
public function testListReturnsHydraEnvelopeSortedByName(): void
|
||||
{
|
||||
$this->seedProvider('Zeta Services', [self::SITE_86]);
|
||||
$this->seedProvider('Alpha Nettoyage', [self::SITE_86]);
|
||||
$this->seedProvider('Mu Maintenance', [self::SITE_86]);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/providers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = $response->toArray();
|
||||
|
||||
// Envelope Hydra : totalItems present + member.
|
||||
self::assertSame(3, $body['totalItems']);
|
||||
$names = array_column($body['member'], 'companyName');
|
||||
// Tri companyName ASC (RG-3.16) — noms normalises en MAJUSCULES.
|
||||
self::assertSame(['ALPHA NETTOYAGE', 'MU MAINTENANCE', 'ZETA SERVICES'], $names);
|
||||
}
|
||||
|
||||
public function testListExcludesArchivedByDefault(): void
|
||||
{
|
||||
$this->seedProvider('Actif Sas', [self::SITE_86]);
|
||||
$this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/providers', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = $response->toArray();
|
||||
self::assertSame(1, $body['totalItems']);
|
||||
self::assertSame('ACTIF SAS', $body['member'][0]['companyName']);
|
||||
}
|
||||
|
||||
public function testListIncludeArchivedReintegratesArchived(): void
|
||||
{
|
||||
$this->seedProvider('Actif Sas', [self::SITE_86]);
|
||||
$this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/providers?includeArchived=true', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame(2, $response->toArray()['totalItems']);
|
||||
}
|
||||
|
||||
public function testListFiltersBySiteIdViaDirectRelation(): void
|
||||
{
|
||||
$this->seedProvider('Site 86 Only', [self::SITE_86]);
|
||||
$this->seedProvider('Site 17 Only', [self::SITE_17]);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$site17 = $this->site(self::SITE_17);
|
||||
$response = $client->request('GET', '/api/providers?siteId='.$site17->getId(), [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = $response->toArray();
|
||||
self::assertSame(1, $body['totalItems']);
|
||||
self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']);
|
||||
}
|
||||
|
||||
public function testPaginationDisabledReturnsFullCollection(): void
|
||||
{
|
||||
$token = $this->token();
|
||||
for ($i = 0; $i < 3; ++$i) {
|
||||
$this->seedProvider($token.' Item'.$i, [self::SITE_86]);
|
||||
}
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
// ?pagination=false : echappatoire pour alimenter un <select> (regle n°13).
|
||||
$data = $client->request('GET', '/api/providers?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 prestataires. On mesure pour N=2 puis N=4 (memes relations
|
||||
* embarquees : categories + sites directs + adresses.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();
|
||||
|
||||
$this->seedCompleteProvider($token.' A');
|
||||
$this->seedCompleteProvider($token.' B');
|
||||
$countFor2 = $this->countListQueries($token);
|
||||
|
||||
$this->seedCompleteProvider($token.' C');
|
||||
$this->seedCompleteProvider($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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre ?typeCode= (cree au M2, reutilise au M3) : GET /api/categories?typeCode=
|
||||
* PRESTATAIRE ne renvoie QUE les categories de type PRESTATAIRE — prerequis des
|
||||
* multi-selects Categorie du prestataire (DoD § 4.7).
|
||||
*/
|
||||
public function testCategoriesTypeCodeFilterReturnsOnlyPrestataire(): void
|
||||
{
|
||||
$prestataire = $this->providerCategory('NETTOYAGE');
|
||||
$foreign = $this->foreignCategory();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$data = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE&pagination=false', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray();
|
||||
|
||||
$ids = array_column($data['member'], 'id');
|
||||
self::assertContains($prestataire->getId(), $ids, 'La categorie PRESTATAIRE doit etre presente.');
|
||||
self::assertNotContains($foreign->getId(), $ids, 'Une categorie d\'un autre type doit etre filtree.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
|
||||
* debug Doctrine. 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/providers?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,279 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\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 prestataires par role metier (spec-back M3
|
||||
* § 2.9 + § 2.13, ERP-138). 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 cloisonnement par site de l'Usine.
|
||||
*
|
||||
* 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 SupplierRBACMatrixTest (M2), avec la difference
|
||||
* structurante du M3 : l'Usine n'est plus « 403 partout » mais possede
|
||||
* `technique.providers.view` en lecture seule, CLOISONNEE a son site courant
|
||||
* (pas de `sites.bypass_scope`).
|
||||
*
|
||||
* Matrice § 2.9 (ERP-138) — rappel :
|
||||
* - bureau : providers.view + manage (ni accounting, ni archive) + bypass_scope
|
||||
* - compta : providers.view + accounting.view + accounting.manage (PAS manage) + bypass_scope
|
||||
* - commerciale : providers.view + manage (PAS accounting) + bypass_scope
|
||||
* - usine : providers.view seul, SANS bypass_scope (cloisonne a son site)
|
||||
* - archive : admin seul (aucun role metier)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderRBACMatrixTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
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 technique.providers.* sont-elles synchronisees (app:sync-permissions) ?',
|
||||
);
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Bureau Cible');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : creation OK (bypass_scope -> peut attacher le site 86)
|
||||
$client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Bureau Cree'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// manage : edition onglet principal OK
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Bureau Renomme'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/providers/'.$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).
|
||||
$provider = $this->seedProvider('Bureau Gating Co', [self::SITE_86], siren: '123456789');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
$data = $client->request('GET', '/api/providers/'.$provider->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 testComptaCanEditAccountingOnly(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Compta Cible');
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : creation refusee
|
||||
$client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Compta Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// accounting.manage : edition onglet Comptabilite OK
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : edition onglet principal refusee (mode strict RG-3.15)
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Compta Renomme'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/providers/'.$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.
|
||||
$provider = $this->seedProvider('Compta View Co', [self::SITE_86], siren: '987654321');
|
||||
$this->addRib($provider);
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('siren', $data);
|
||||
self::assertSame('987654321', $data['siren']);
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
}
|
||||
|
||||
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Commerciale Cible');
|
||||
$client = $this->authAs('commerciale');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : creation OK
|
||||
$client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Commerciale Cree'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCommercialeDetailHasNoAccountingFields(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Commerciale Gating Co', [self::SITE_86], siren: '123456789');
|
||||
$client = $this->authAs('commerciale');
|
||||
|
||||
$data = $client->request('GET', '/api/providers/'.$provider->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 testUsineHasReadOnlyAccessScopedToItsSite(): void
|
||||
{
|
||||
// Usine a view (lecture seule), SANS manage / accounting / archive, et
|
||||
// SANS bypass_scope -> cloisonnee a son site courant (Chatellerault,
|
||||
// site 86, pose par ensureDemoUsers).
|
||||
$inScope = $this->seedProvider('Usine InScope', [self::SITE_86]);
|
||||
$client = $this->authAs('usine');
|
||||
|
||||
// view : liste OK (pas un 403 comme au M2)
|
||||
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// view : detail d'un prestataire de SON site OK
|
||||
$client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : creation refusee
|
||||
$client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Usine Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS manage : edition onglet principal refusee
|
||||
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Renomme Par Usine'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUsineCannotSeeProviderOutOfItsSite(): void
|
||||
{
|
||||
// Cloisonnement § 2.13 : un prestataire hors du site courant de l'Usine
|
||||
// (site 17, l'Usine est sur le site 86) -> 404 (ne pas reveler la ligne).
|
||||
$outOfScope = $this->seedProvider('Usine OutOfScope', [self::SITE_17]);
|
||||
$client = $this->authAs('usine');
|
||||
|
||||
$client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
private function authAs(string $role): Client
|
||||
{
|
||||
return $this->authenticatedClient($role, self::PWD);
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
|
||||
/**
|
||||
* Tests du gating comptabilite + mode strict par groupe (ProviderProcessor /
|
||||
* ProviderReadGroupContextBuilder) — ERP-134.
|
||||
*
|
||||
* Couvre : gating accounting PAR OMISSION (siren/ribs absents sans accounting.view,
|
||||
* bug #4 M1), mode strict RG-3.15 (403 sur tout le payload), gating archive (RG-3.13).
|
||||
*
|
||||
* Les users sont crees via createUserWithPermissions() (parent) : rattaches a TOUS
|
||||
* les sites SANS currentSite -> CurrentSiteProvider::get() = null -> aucun
|
||||
* cloisonnement, on isole ainsi le comportement RBAC du comportement site.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderRbacGatingTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
public function testAccountingFieldsOmittedWithoutAccountingView(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Compta Masquee', [self::SITE_86], siren: '123456789');
|
||||
$id = $provider->getId();
|
||||
|
||||
// Profil type Commerciale : view + manage SANS accounting.view.
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$body = $response->toArray();
|
||||
// Gating par omission : scalaires comptables ET ribs totalement absents.
|
||||
self::assertArrayNotHasKey('siren', $body);
|
||||
self::assertArrayNotHasKey('ribs', $body);
|
||||
// isArchived reste expose (bug #3 M1 : la cle ne doit pas etre droppee).
|
||||
self::assertArrayHasKey('isArchived', $body);
|
||||
}
|
||||
|
||||
public function testAccountingFieldsPresentWithAccountingView(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Compta Visible', [self::SITE_86], siren: '987654321');
|
||||
$id = $provider->getId();
|
||||
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.accounting.view',
|
||||
]);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$body = $response->toArray();
|
||||
self::assertSame('987654321', $body['siren']);
|
||||
// La cle ribs apparait (collection vide ici, mais presente).
|
||||
self::assertArrayHasKey('ribs', $body);
|
||||
}
|
||||
|
||||
public function testStrictModeRejectsMixedGroupsForManageOnlyUser(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Strict Cible', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
// Profil type Bureau : manage SANS accounting.manage.
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
]);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Renomme', 'siren' => '111222333'],
|
||||
]);
|
||||
|
||||
// RG-3.15 : payload melangeant main + accounting sans accounting.manage
|
||||
// -> 403 sur tout le payload (mode strict, pas de filtrage silencieux).
|
||||
self::assertSame(403, $response->getStatusCode());
|
||||
|
||||
// Aucun champ n'a ete persiste (rollback du mode strict).
|
||||
$this->getEm()->clear();
|
||||
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
|
||||
self::assertSame('STRICT CIBLE', $reloaded->getCompanyName());
|
||||
self::assertNull($reloaded->getSiren());
|
||||
}
|
||||
|
||||
public function testAccountingOnlyUserCanPatchAccountingButNotMain(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Compta Editrice', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
// Profil type Compta : accounting.view + accounting.manage SANS manage.
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
]);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
// PATCH accounting -> 200.
|
||||
$ok = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '555666777'],
|
||||
]);
|
||||
self::assertSame(200, $ok->getStatusCode());
|
||||
|
||||
// PATCH main (companyName) -> 403 (pas de permission manage).
|
||||
$ko = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Interdit'],
|
||||
]);
|
||||
self::assertSame(403, $ko->getStatusCode());
|
||||
}
|
||||
|
||||
public function testArchiveRequiresArchivePermission(): void
|
||||
{
|
||||
$provider = $this->seedProvider('A Archiver', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
// Bureau (manage) sans archive -> 403.
|
||||
$creds = $this->createUserWithPermissions([
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
]);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
|
||||
// RG-3.13 : l'archivage exige technique.providers.archive.
|
||||
self::assertSame(403, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAdminCanArchiveAndSetsArchivedAt(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Archivable', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$this->getEm()->clear();
|
||||
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
|
||||
self::assertTrue($reloaded->isArchived());
|
||||
self::assertNotNull($reloaded->getArchivedAt());
|
||||
}
|
||||
|
||||
public function testRestoreWithNameConflictReturns409(): void
|
||||
{
|
||||
// Un prestataire archive porte un nom qu'un prestataire ACTIF a repris
|
||||
// entre-temps (autorise par l'index partiel : l'archive n'y figure pas).
|
||||
$archived = $this->seedProvider('Conflit Co', [self::SITE_86], isArchived: true);
|
||||
$this->seedProvider('Conflit Co', [self::SITE_86]); // actif, meme nom normalise
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('PATCH', '/api/providers/'.$archived->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => false],
|
||||
]);
|
||||
|
||||
// RG-3.14 : restaurer ferait deux actifs homonymes -> 409 (pas de 500 SQL).
|
||||
self::assertSame(409, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire prestataires
|
||||
* (M3, spec-back § 4.0 / § 4.0.bis). Jumeau du
|
||||
* {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest} (M2),
|
||||
* il reverifie sur le JSON REEL les pieges silencieux herites du M1/M2 :
|
||||
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> cle `ribs`
|
||||
* ABSENTE pour un profil type Commerciale (gating par omission).
|
||||
* - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
|
||||
* -> isArchived present dans le detail.
|
||||
* - #1 : categories embarquees sans code/name -> code + name presents en LISTE
|
||||
* ET DETAIL (provider ET adresse).
|
||||
* - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
|
||||
* (relation DIRECTE provider.sites — RG-3.03) ET DETAIL (addresses[].sites[]).
|
||||
* - ERP-92 : refs comptables (tvaMode/paymentDelay/paymentType/bank) embarquees
|
||||
* {id, code, label} et non IRI nu (le groupe provider:read:accounting doit
|
||||
* etre porte par les entites partagees — fix ERP-139, sinon IRI nu).
|
||||
* Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives exclus).
|
||||
*
|
||||
* 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 ProviderSerializationContractTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
// === #4 — Gating des RIB par accounting.view ===
|
||||
|
||||
public function testRibsPresentForAdminWithAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Rib Admin Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->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 testRibsAbsentForUserWithoutAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Rib Commerciale Co');
|
||||
|
||||
// Profil type Commerciale : technique.providers.view SANS accounting.view.
|
||||
// createUserWithPermissions n'attache pas de currentSite -> pas de
|
||||
// cloisonnement, on isole le gating comptable du comportement site.
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// La cle `ribs` est ABSENTE (pas null) : le groupe provider: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();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Compta Gating Co');
|
||||
$id = $provider->getId();
|
||||
|
||||
// Admin : scalaires comptables presents.
|
||||
$admin = $this->createAdminClient();
|
||||
$adminData = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('siren', $adminData);
|
||||
self::assertSame('987654321', $adminData['siren']);
|
||||
self::assertArrayHasKey('accountNumber', $adminData);
|
||||
self::assertArrayHasKey('paymentType', $adminData);
|
||||
|
||||
// Sans accounting.view : scalaires comptables ABSENTS (omission, pas null).
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$data = $http->request('GET', '/api/providers/'.$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);
|
||||
}
|
||||
|
||||
// === ERP-139 — Refs comptables embarquees {id, code, label} et non IRI nu ===
|
||||
|
||||
public function testAccountingReferentialsEmbedIdCodeLabel(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
// Reglement VIREMENT -> banque renseignee : on couvre les 4 referentiels.
|
||||
$provider = $this->seedCompleteProvider('Refs Embed Co', 'VIREMENT');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Avant fix ERP-139 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
|
||||
// car les entites partagees ne portaient que client:/supplier:read:accounting,
|
||||
// pas provider:read:accounting. Apres fix : objet {id, code, label} embarque
|
||||
// (le front consultation/edition affiche le libelle sans fetch — § 4.0.bis).
|
||||
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 — Booleen isArchived present dans le JSON ===
|
||||
|
||||
public function testProviderIsArchivedBooleanIsPresentInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Bool Archived Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->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();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Embed Cat Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertNotEmpty($data['categories']);
|
||||
$category = $data['categories'][0];
|
||||
// Avant correctif : seuls @id/@type (category:read absent du contexte).
|
||||
// Apres : code + name embarques.
|
||||
self::assertArrayHasKey('code', $category);
|
||||
self::assertArrayHasKey('name', $category);
|
||||
self::assertSame('NETTOYAGE', $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);
|
||||
$provider = $this->seedCompleteProvider($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $provider->getId());
|
||||
self::assertNotNull($row, 'Le prestataire 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('NETTOYAGE', $row['categories'][0]['code']);
|
||||
}
|
||||
|
||||
// === #2 — Embed name/postalCode des Site (liste via relation directe + detail) ===
|
||||
|
||||
public function testSitesEmbedNameAndPostalCodeInList(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$provider = $this->seedCompleteProvider($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $provider->getId());
|
||||
self::assertNotNull($row);
|
||||
// sites en relation DIRECTE provider.sites (RG-3.03) : 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();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Site Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Sites du formulaire principal (relation directe).
|
||||
self::assertArrayHasKey('sites', $data);
|
||||
self::assertGreaterThanOrEqual(2, count($data['sites']));
|
||||
self::assertArrayHasKey('name', $data['sites'][0]);
|
||||
self::assertArrayHasKey('postalCode', $data['sites'][0]);
|
||||
|
||||
// Sites de l'adresse (addresses[].sites[]).
|
||||
$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();
|
||||
|
||||
$provider = $this->seedCompleteProvider('Embed Subres Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->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']);
|
||||
// M3 : adresse simplifiee, PAS de addressType.
|
||||
self::assertArrayNotHasKey('addressType', $data['addresses'][0]);
|
||||
self::assertSame('Poitiers', $data['addresses'][0]['city']);
|
||||
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
}
|
||||
|
||||
// === refonte-contact V0.2 : pas de contact inline sur le prestataire ===
|
||||
|
||||
public function testProviderHasNoInlineContactFields(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$provider = $this->seedCompleteProvider('No Inline Contact Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/providers/'.$provider->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 prestataire.', $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->seedProvider($token.' Active', [self::SITE_86]);
|
||||
$this->seedProvider($token.' Archived', [self::SITE_86], isArchived: true);
|
||||
|
||||
// Liste par defaut filtree sur le token : enveloppe member/totalItems sans
|
||||
// prefixe hydra:, archive EXCLU du totalItems (RG-3.16).
|
||||
$default = $http->request('GET', '/api/providers?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/providers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertSame(2, $all['totalItems']);
|
||||
|
||||
// `view` (PartialCollectionView) sans prefixe hydra:.
|
||||
$paged = $http->request('GET', '/api/providers?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 sans accounting.view) pour les coller dans la spec avant de lancer les
|
||||
* tickets front. Le test asserte la forme ; si la variable d'env
|
||||
* PROVIDER_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);
|
||||
$provider = $this->seedCompleteProvider($token);
|
||||
$id = (int) $provider->getId();
|
||||
|
||||
$admin = $this->createAdminClient();
|
||||
$list = $admin->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
$detailAdmin = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$creds = $this->createUserWithPermissions(['technique.providers.view']);
|
||||
$restricted = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$detailRestricted = $restricted->request('GET', '/api/providers/'.$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', $detailRestricted);
|
||||
self::assertArrayNotHasKey('ribs', $detailRestricted);
|
||||
|
||||
if (false !== getenv('PROVIDER_DOD_DUMP')) {
|
||||
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
file_put_contents('/tmp/provider-dod-list.json', json_encode($list, $flags));
|
||||
file_put_contents('/tmp/provider-dod-detail-admin.json', json_encode($detailAdmin, $flags));
|
||||
file_put_contents('/tmp/provider-dod-detail-restricted.json', json_encode($detailRestricted, $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,171 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
/**
|
||||
* Tests du cloisonnement par site pilote par l'utilisateur (RG-3.17 / § 2.13) —
|
||||
* ERP-134. Couvre la LECTURE (liste filtree avant pagination + totalItems, detail
|
||||
* 404 hors perimetre, bypass voit tout) et l'ECRITURE (sites hors user_site -> 422).
|
||||
*
|
||||
* Cloisonnement pilote par l'USER (pas le role) : on cree des users non-admin SANS
|
||||
* `sites.bypass_scope`, rattaches a un site precis avec un currentSite. L'admin
|
||||
* (isAdmin -> bypass total) sert de temoin « voit tout ».
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderSiteScopeTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Pre-requis : le module Sites doit etre actif (sinon currentSite = null,
|
||||
// cloisonnement no-op et ces tests perdent leur sens).
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
}
|
||||
|
||||
public function testListIsScopedToCurrentSiteForNonBypassUser(): void
|
||||
{
|
||||
$this->seedProvider('Presta Site 86', [self::SITE_86]);
|
||||
$this->seedProvider('Presta Site 17', [self::SITE_17]);
|
||||
$this->seedProvider('Presta Site 82', [self::SITE_82]);
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$body = $response->toArray();
|
||||
// totalItems reflete le PERIMETRE de l'user (filtre avant pagination).
|
||||
self::assertSame(1, $body['totalItems']);
|
||||
self::assertSame('PRESTA SITE 86', $body['member'][0]['companyName']);
|
||||
}
|
||||
|
||||
public function testDetailOutOfScopeReturns404(): void
|
||||
{
|
||||
$inScope = $this->seedProvider('Dans Perimetre', [self::SITE_86]);
|
||||
$outOfScope = $this->seedProvider('Hors Perimetre', [self::SITE_17]);
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
// In-scope -> 200.
|
||||
$ok = $client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $ok->getStatusCode());
|
||||
|
||||
// Out-of-scope -> 404 (ne pas reveler l'existence hors perimetre).
|
||||
$ko = $client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(404, $ko->getStatusCode());
|
||||
}
|
||||
|
||||
public function testBypassUserSeesAllSites(): void
|
||||
{
|
||||
$this->seedProvider('Presta Site 86', [self::SITE_86]);
|
||||
$this->seedProvider('Presta Site 17', [self::SITE_17]);
|
||||
$this->seedProvider('Presta Site 82', [self::SITE_82]);
|
||||
|
||||
// Admin = bypass total.
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame(3, $response->toArray()['totalItems']);
|
||||
}
|
||||
|
||||
public function testWriteOutOfScopeSiteRejectedAtIriResolution(): void
|
||||
{
|
||||
// User non-bypass / non-read_ref : la resolution de l'IRI du site hors
|
||||
// perimetre echoue en amont (SiteCollectionScopedExtension : item Site
|
||||
// « introuvable ») -> 400 anti-enumeration, avant le ProviderProcessor.
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Hors Scope Sas', [self::SITE_17]),
|
||||
]);
|
||||
|
||||
self::assertSame(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testWriteOutOfScopeSiteRejectedByProcessorGuard(): void
|
||||
{
|
||||
// User `sites.read_ref` : peut RESOUDRE n'importe quel site (referentiel
|
||||
// transverse) mais n'opere que sur ses user_site. La garde guardSiteScope
|
||||
// du ProviderProcessor est alors l'enforcement autoritaire de RG-3.17
|
||||
// -> 422 sur `sites` (mappable inline, ERP-101).
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Hors Scope Guard', [self::SITE_17]),
|
||||
]);
|
||||
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testWriteAllowsSiteWithinUserScope(): void
|
||||
{
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
// Site 86 = un des user_site -> 201.
|
||||
$response = $client->request('POST', '/api/providers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Dans Scope Sas', [self::SITE_86]),
|
||||
]);
|
||||
|
||||
self::assertSame(201, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPatchAddingOutOfScopeSiteIsRejected(): void
|
||||
{
|
||||
$provider = $this->seedProvider('Patch Sites', [self::SITE_86]);
|
||||
$id = $provider->getId();
|
||||
|
||||
// read_ref pour pouvoir resoudre l'IRI du site 17 (sinon 400 en amont) et
|
||||
// exercer la garde guardSiteScope sur le PATCH.
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$site86 = $this->site(self::SITE_86)->getId();
|
||||
$site17 = $this->site(self::SITE_17)->getId();
|
||||
|
||||
$response = $client->request('PATCH', '/api/providers/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['sites' => ['/api/sites/'.$site86, '/api/sites/'.$site17]],
|
||||
]);
|
||||
|
||||
// RG-3.17 : ajouter un site hors user_site -> 422 (garde Processor).
|
||||
self::assertSame(422, $response->getStatusCode());
|
||||
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use App\Module\Technique\Domain\Entity\Provider;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
|
||||
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
|
||||
* (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur
|
||||
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
|
||||
* le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`),
|
||||
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
|
||||
* garde « dernier contact ») et le gating selon permission (Contacts/Adresses =
|
||||
* manage, RIB = accounting.manage). Jumeau de SupplierSubResourceApiTest.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// seedProvider exige >= 1 site (RG-3.03) : le module Sites doit etre actif.
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
}
|
||||
|
||||
// === Contacts (security: technique.providers.manage) ===
|
||||
|
||||
public function testPostContactNormalizesFields(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact Host');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$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-3.11 : 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']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
|
||||
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
|
||||
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
|
||||
*/
|
||||
public function testPostContactWithOnlyJobTitleReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact JobTitle Only');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['jobTitle' => 'Directeur'],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('Directeur', $data['jobTitle']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK
|
||||
* chk_provider_contact_name) est rejete avant la base -> 422 rattachee a
|
||||
* firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider.
|
||||
*/
|
||||
public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact No Field');
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['jobTitle' => ' '],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testPostContactOnMissingProviderReturns404(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/providers/999999/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['firstName' => 'Orphan'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testPatchContactNormalizesFields(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact Patch');
|
||||
$contact = $this->addContact($seed, 'Marie', 'Martin');
|
||||
|
||||
$data = $client->request('PATCH', '/api/provider_contacts/'.$contact->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['lastName' => 'durand'],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
// Normalisation aussi sur PATCH : "durand" -> "Durand".
|
||||
self::assertSame('Durand', $data['lastName']);
|
||||
}
|
||||
|
||||
public function testDeleteLastContactReturns204(): void
|
||||
{
|
||||
// M3 : pas de garde « dernier contact » (RG-3.12 front-driven) — la
|
||||
// suppression du dernier contact est libre (204).
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Contact Solo');
|
||||
$contact = $this->addContact($seed, 'Unique', 'Contact');
|
||||
|
||||
$client->request('DELETE', '/api/provider_contacts/'.$contact->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testContactWriteWithoutManageReturns403(): void
|
||||
{
|
||||
// Un user sans permission technique.providers.manage -> 403 sur la sous-ressource.
|
||||
$seed = $this->seedProvider('Contact Forbidden');
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['firstName' => 'Nope'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// === Adresses (security: technique.providers.manage) ===
|
||||
|
||||
public function testPostAddressWithValidPayloadReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Host');
|
||||
$category = $this->providerCategory('NETTOYAGE');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('Châtellerault', $data['city']);
|
||||
}
|
||||
|
||||
public function testPostAddressWithoutSiteReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address No Site');
|
||||
|
||||
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [],
|
||||
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-3.05 (Assert\Count min 1 sur sites).
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostAddressWithInvalidPostalCodeReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Bad CP');
|
||||
|
||||
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '123',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-3.06 (Assert\Regex ^[0-9]{4,5}$).
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostAddressWithNonPrestataireCategoryReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Bad Cat');
|
||||
$foreign = $this->foreignCategory(); // type CLIENT -> interdite (RG-3.09).
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$foreign->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-3.09 -> 422 rattachee a categories.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testDeleteAddressReturns204(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Delete');
|
||||
$category = $this->providerCategory('NETTOYAGE');
|
||||
|
||||
$created = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
$client->request('DELETE', $created['@id']);
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testAddressWriteWithoutManageReturns403(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Address Forbidden');
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* § 2.13 / RG-3.05 (cloisonnement d'ECRITURE sur l'adresse) : un user non-bypass
|
||||
* `sites.read_ref` (qui peut resoudre n'importe quel IRI de site, sinon 400 en
|
||||
* amont) ne peut attacher a l'adresse que ses propres user_site. Site hors
|
||||
* perimetre -> 422 sur `sites` (garde ProviderAddressProcessor).
|
||||
*/
|
||||
public function testPostAddressWithOutOfScopeSiteReturns422OnSitesPath(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Address Scope', [self::SITE_86]);
|
||||
$category = $this->providerCategory('NETTOYAGE');
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '17400',
|
||||
'city' => 'Saint-Jean-d\'Angély',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_17)->getId()], // hors user_site
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
// === RIBs (security: technique.providers.accounting.manage) ===
|
||||
|
||||
public function testPostRibByAdminReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib Host');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$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->seedProvider('Rib Bad Iban');
|
||||
|
||||
$client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
|
||||
* un IBAN (FR) valides isolement mais de pays differents -> 422 sur `bic`.
|
||||
*/
|
||||
public function testPostRibWithBicIbanCountryMismatchReturns422OnBic(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib Pays Mismatch');
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$byPath = $this->violationsByPath($response->toArray(false));
|
||||
self::assertArrayHasKey('bic', $byPath);
|
||||
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
|
||||
}
|
||||
|
||||
public function testDeleteRibNonLcrReturns204(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib Non LCR');
|
||||
$rib = $this->addRib($seed);
|
||||
|
||||
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testDeleteLastRibUnderLcrReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Rib LCR Solo');
|
||||
$rib = $this->addRib($seed);
|
||||
|
||||
// Passe le prestataire en LCR (seed direct).
|
||||
$em = $this->getEm();
|
||||
$managed = $em->getRepository(Provider::class)->find($seed->getId());
|
||||
$managed->setPaymentType($this->paymentType('LCR'));
|
||||
$em->flush();
|
||||
|
||||
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
|
||||
|
||||
// RG-3.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testRibWriteWithoutAccountingManageReturns403(): void
|
||||
{
|
||||
// Un user portant seulement technique.providers.manage (sans accounting.manage)
|
||||
// ne peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
|
||||
$seed = $this->seedProvider('Rib Forbidden');
|
||||
$rib = $this->addRib($seed);
|
||||
$creds = $this->createUserWithPermission('technique.providers.manage');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('POST', '/api/providers/'.$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/provider_ribs/'.$rib->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['label' => 'Y'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$http->request('DELETE', '/api/provider_ribs/'.$rib->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Technique\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
|
||||
/**
|
||||
* Tests du cloisonnement par site des SOUS-RESSOURCES d'un prestataire (Contacts /
|
||||
* Adresses / RIB) — § 2.13 / RG-3.17. Complement de ProviderSiteScopeTest (qui ne
|
||||
* couvrait que le Provider lui-meme).
|
||||
*
|
||||
* Sans garde dedie, un user cloisonne pouvait lire / editer / supprimer une
|
||||
* sous-ressource d'un prestataire HORS de son site (le detail Provider est garde en
|
||||
* 404, mais les sous-ressources passent par le provider Doctrine par defaut, non
|
||||
* cloisonne — et SiteScopedQueryExtension ne filtre que les SiteAwareInterface).
|
||||
* Le RIB est particulierement sensible (IBAN / BIC).
|
||||
*
|
||||
* Garde pose par ProviderSubResourceItemProvider (Get/Patch/Delete -> 404 hors
|
||||
* perimetre) + ProviderSiteScopeChecker::assertInScope dans les processors (POST
|
||||
* sur parent hors perimetre -> 404). Decision de scope partagee (source unique).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProviderSubResourceSiteScopeTest extends AbstractProviderApiTestCase
|
||||
{
|
||||
/** Permissions completes pour exercer view + manage + accounting sur tous les chemins. */
|
||||
private const array FULL_PERMS = [
|
||||
'technique.providers.view',
|
||||
'technique.providers.manage',
|
||||
'technique.providers.accounting.view',
|
||||
'technique.providers.accounting.manage',
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
}
|
||||
|
||||
public function testGetContactOutOfScopeReturns404ButInScope200(): void
|
||||
{
|
||||
$inScope = $this->seedProvider('Presta In Scope', [self::SITE_86]);
|
||||
$inContactId = $this->addContact($inScope, 'Marie', 'Martin')->getId();
|
||||
|
||||
$outScope = $this->seedProvider('Presta Out Scope', [self::SITE_17]);
|
||||
$outContactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$ok = $client->request('GET', '/api/provider_contacts/'.$inContactId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $ok->getStatusCode());
|
||||
|
||||
// Hors perimetre : 404 (ne pas reveler l'existence du contact d'un autre site).
|
||||
$ko = $client->request('GET', '/api/provider_contacts/'.$outContactId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(404, $ko->getStatusCode());
|
||||
}
|
||||
|
||||
public function testGetRibOutOfScopeReturns404(): void
|
||||
{
|
||||
// RIB = donnee bancaire sensible (IBAN/BIC) : le cas le plus critique.
|
||||
$outScope = $this->seedProvider('Presta Out Rib', [self::SITE_17]);
|
||||
$ribId = $this->addRib($outScope)->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('GET', '/api/provider_ribs/'.$ribId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPatchRibOutOfScopeReturns404(): void
|
||||
{
|
||||
$outScope = $this->seedProvider('Presta Patch Rib', [self::SITE_17]);
|
||||
$ribId = $this->addRib($outScope)->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('PATCH', '/api/provider_ribs/'.$ribId, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['label' => 'Hacked'],
|
||||
]);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDeleteContactOutOfScopeReturns404(): void
|
||||
{
|
||||
$outScope = $this->seedProvider('Presta Del Contact', [self::SITE_17]);
|
||||
$contactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('DELETE', '/api/provider_contacts/'.$contactId);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPostContactOnOutOfScopeProviderReturns404(): void
|
||||
{
|
||||
$outScope = $this->seedProvider('Presta Post Contact', [self::SITE_17]);
|
||||
$id = $outScope->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$id.'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['firstName' => 'Intrus'],
|
||||
]);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testPostRibOnOutOfScopeProviderReturns404(): void
|
||||
{
|
||||
$outScope = $this->seedProvider('Presta Post Rib', [self::SITE_17]);
|
||||
$id = $outScope->getId();
|
||||
|
||||
$client = $this->scopedClient();
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$id.'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Intrus',
|
||||
'iban' => self::VALID_IBAN,
|
||||
'bic' => self::VALID_BIC,
|
||||
],
|
||||
]);
|
||||
self::assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testBypassUserReachesSubResourceOnAnySite(): void
|
||||
{
|
||||
// Temoin : l'admin (bypass total) lit bien un contact hors « son » site.
|
||||
$outScope = $this->seedProvider('Presta Admin Reach', [self::SITE_17]);
|
||||
$contactId = $this->addContact($outScope, 'Marie', 'Martin')->getId();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/provider_contacts/'.$contactId, ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Client authentifie comme un user NON-bypass rattache au seul site 86 (avec
|
||||
* currentSite 86) — sujet des tests de cloisonnement des sous-ressources.
|
||||
*/
|
||||
private function scopedClient(): Client
|
||||
{
|
||||
$creds = $this->createScopedUser(
|
||||
self::FULL_PERMS,
|
||||
sitePostalCodes: [self::SITE_86],
|
||||
currentSitePostalCode: self::SITE_86,
|
||||
);
|
||||
|
||||
return $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user