Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a0da4de63 | |||
| 0ca1fb159a | |||
| 58474404b4 | |||
| 779d5be04e | |||
| 6ceef62056 |
@@ -79,7 +79,6 @@ Regles :
|
|||||||
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
|
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
|
||||||
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
|
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
|
||||||
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
|
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
|
||||||
- **Message back technique → surcharge i18n par code** : la plupart des contraintes back portent un message FR explicite (affiche tel quel). Mais une 422 peut porter un message TECHNIQUE non montrable (ex. erreur de type API Platform sur une date non parsable : « Cette valeur doit être de type DateTimeImmutable|null. », voire en anglais selon la negociation). On le surcharge **cote front** via le `code` de violation (UUID Symfony fige, robuste — pas un match sur le texte) : table `VIOLATION_MESSAGE_I18N` + `resolveViolationMessage` dans `shared/utils/api.ts`, appliquee par `useFormErrors`. Ajouter un cas = une entree `code -> cle i18n`. Cas reference : date invalide (MalioDate forwarde la saisie brute via `@update:rawValue`, le back renvoie 422 sur `foundedAt` grace a `collectDenormalizationErrors`, le front affiche `errors.validation.invalidDate`).
|
|
||||||
|
|
||||||
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
|
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
|
||||||
|
|
||||||
|
|||||||
@@ -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:wrench-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
|
// Section "Administration" : regroupe toutes les pages de configuration
|
||||||
// applicative (RBAC, users, sites, audit log).
|
// applicative (RBAC, users, sites, audit log).
|
||||||
//
|
//
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.114'
|
app.version: '0.1.109'
|
||||||
|
|||||||
@@ -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 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).
|
- **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).
|
- **É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).
|
> **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,9 +624,7 @@ 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`).
|
> 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`).
|
> 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 — un membre, forme attendue) :
|
||||||
|
|
||||||
`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) :
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@context": "/api/contexts/Provider",
|
"@context": "/api/contexts/Provider",
|
||||||
@@ -635,142 +633,58 @@ Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamab
|
|||||||
"totalItems": 1,
|
"totalItems": 1,
|
||||||
"member": [
|
"member": [
|
||||||
{
|
{
|
||||||
"@id": "/api/providers/572",
|
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
|
||||||
"@type": "Provider",
|
"companyName": "MAINTENANCE PRO SAS",
|
||||||
"id": 572,
|
|
||||||
"companyName": "DOD21AADC 0E3CCE",
|
|
||||||
"categories": [
|
"categories": [
|
||||||
{
|
{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE",
|
||||||
"@type": "Category",
|
"categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}}
|
||||||
"@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": [
|
"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/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}
|
||||||
{"@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",
|
"siren": "987654321", "accountNumber": "P0001",
|
||||||
"accountNumber": "P0001",
|
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||||
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
|
||||||
"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": [
|
"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"}
|
{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
|
||||||
],
|
],
|
||||||
"createdAt": "2026-06-12T15:17:29+02:00",
|
"updatedAt": "2026-06-11T10:00:00+02:00",
|
||||||
"updatedAt": "2026-06-12T15:17:29+02:00",
|
|
||||||
"isArchived": false
|
"isArchived": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"}
|
"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
|
```json
|
||||||
{
|
{
|
||||||
"@context": "/api/contexts/Provider",
|
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
|
||||||
"@id": "/api/providers/572",
|
"companyName": "MAINTENANCE PRO SAS",
|
||||||
"@type": "Provider",
|
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}],
|
||||||
"id": 572,
|
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
|
||||||
"companyName": "DOD21AADC 0E3CCE",
|
"siren": "987654321", "accountNumber": "P0001",
|
||||||
"categories": [
|
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||||
{"@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",
|
"nTva": "FR00987654321",
|
||||||
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
|
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
|
||||||
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
|
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
|
||||||
"contacts": [
|
"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"}
|
{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
|
||||||
],
|
],
|
||||||
"addresses": [
|
"addresses": [
|
||||||
{
|
{"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
||||||
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
|
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
|
||||||
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
"contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}],
|
||||||
"sites": [
|
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]}
|
||||||
{"@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": [
|
"ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}],
|
||||||
{"@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
|
"isArchived": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) :
|
> 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).
|
||||||
```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`.
|
|
||||||
|
|
||||||
### 4.1 `GET /api/providers` — Liste
|
### 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] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
|
||||||
- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only**
|
- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only**
|
||||||
- [x] **Réponses JSON RÉELLES** 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] 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] 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`)
|
- [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",
|
"clients": "Répertoire clients",
|
||||||
"suppliers": "Répertoire fournisseurs"
|
"suppliers": "Répertoire fournisseurs"
|
||||||
},
|
},
|
||||||
"technique": {
|
|
||||||
"section": "Technique",
|
|
||||||
"providers": "Répertoire prestataires"
|
|
||||||
},
|
|
||||||
"core": {
|
"core": {
|
||||||
"roles": "Gestion des rôles",
|
"roles": "Gestion des rôles",
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
@@ -390,10 +386,7 @@
|
|||||||
},
|
},
|
||||||
"title": "Erreur",
|
"title": "Erreur",
|
||||||
"generic": "Une erreur est survenue.",
|
"generic": "Une erreur est survenue.",
|
||||||
"unknown": "Erreur inconnue.",
|
"unknown": "Erreur inconnue."
|
||||||
"validation": {
|
|
||||||
"invalidDate": "Date invalide"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ import {
|
|||||||
addressTypeFromFlags,
|
addressTypeFromFlags,
|
||||||
isBillingEmailRequired,
|
isBillingEmailRequired,
|
||||||
type AddressType,
|
type AddressType,
|
||||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
|
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
||||||
|
|||||||
@@ -116,7 +116,6 @@
|
|||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -402,7 +401,7 @@ import {
|
|||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -418,7 +417,7 @@ import {
|
|||||||
type ClientEditAbilities,
|
type ClientEditAbilities,
|
||||||
type InformationFormDraft,
|
type InformationFormDraft,
|
||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
} from '~/modules/commercial/utils/clientEdit'
|
||||||
import {
|
import {
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -430,7 +429,7 @@ import {
|
|||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
|
|||||||
@@ -280,7 +280,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||||
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditClient,
|
canEditClient,
|
||||||
@@ -297,7 +297,7 @@ import {
|
|||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||||
|
|||||||
@@ -111,7 +111,6 @@
|
|||||||
:readonly="isValidated('information')"
|
:readonly="isValidated('information')"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -402,12 +401,12 @@ import {
|
|||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
lastFillableTabKey,
|
lastFillableTabKey,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
import {
|
import {
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
buildRibPayload,
|
buildRibPayload,
|
||||||
} from '~/modules/commercial/utils/forms/clientEdit'
|
} from '~/modules/commercial/utils/clientEdit'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -652,8 +651,6 @@ const information = reactive({
|
|||||||
description: null as string | null,
|
description: null as string | null,
|
||||||
competitors: null as string | null,
|
competitors: null as string | null,
|
||||||
foundedAt: null as string | null,
|
foundedAt: null as string | null,
|
||||||
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
|
||||||
foundedAtRaw: '',
|
|
||||||
employeesCount: null as string | null,
|
employeesCount: null as string | null,
|
||||||
revenueAmount: null as string | null,
|
revenueAmount: null as string | null,
|
||||||
profitAmount: null as string | null,
|
profitAmount: null as string | null,
|
||||||
@@ -669,8 +666,7 @@ async function submitInformation(): Promise<void> {
|
|||||||
await api.patch(`/clients/${clientId.value}`, {
|
await api.patch(`/clients/${clientId.value}`, {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
// Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw).
|
foundedAt: information.foundedAt || null,
|
||||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
|
||||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
revenueAmount: information.revenueAmount || null,
|
revenueAmount: information.revenueAmount || null,
|
||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
|
|||||||
@@ -77,7 +77,6 @@
|
|||||||
:readonly="businessReadonly"
|
:readonly="businessReadonly"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -371,7 +370,7 @@ import {
|
|||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
type SupplierDetail,
|
type SupplierDetail,
|
||||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
} from '~/modules/commercial/utils/supplierConsultation'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -387,7 +386,7 @@ import {
|
|||||||
type InformationFormDraft,
|
type InformationFormDraft,
|
||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
type SupplierEditAbilities,
|
type SupplierEditAbilities,
|
||||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
} from '~/modules/commercial/utils/supplierEdit'
|
||||||
import {
|
import {
|
||||||
buildSupplierFormTabKeys,
|
buildSupplierFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -397,7 +396,7 @@ import {
|
|||||||
isRibBlank,
|
isRibBlank,
|
||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
} from '~/modules/commercial/utils/supplierFormRules'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
|
|||||||
@@ -263,7 +263,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
|
||||||
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
|
||||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||||
import {
|
import {
|
||||||
canEditSupplier,
|
canEditSupplier,
|
||||||
@@ -280,7 +280,7 @@ import {
|
|||||||
showRestoreAction,
|
showRestoreAction,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
type SupplierDetail,
|
type SupplierDetail,
|
||||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
} from '~/modules/commercial/utils/supplierConsultation'
|
||||||
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
||||||
|
|
||||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||||
|
|||||||
@@ -71,7 +71,6 @@
|
|||||||
:readonly="isValidated('information')"
|
:readonly="isValidated('information')"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:error="informationErrors.errors.foundedAt"
|
:error="informationErrors.errors.foundedAt"
|
||||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="information.employeesCount"
|
v-model="information.employeesCount"
|
||||||
@@ -362,7 +361,7 @@ import {
|
|||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
lastFillableTabKey,
|
lastFillableTabKey,
|
||||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
} from '~/modules/commercial/utils/supplierFormRules'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -370,7 +369,7 @@ import {
|
|||||||
buildInformationPayload,
|
buildInformationPayload,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
buildRibPayload,
|
buildRibPayload,
|
||||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
} from '~/modules/commercial/utils/supplierEdit'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -550,8 +549,6 @@ const information = reactive({
|
|||||||
description: null as string | null,
|
description: null as string | null,
|
||||||
competitors: null as string | null,
|
competitors: null as string | null,
|
||||||
foundedAt: null as string | null,
|
foundedAt: null as string | null,
|
||||||
// Saisie brute invalide remontee par MalioDate (cf. foundedAtRaw, MUI-44).
|
|
||||||
foundedAtRaw: '',
|
|
||||||
employeesCount: null as string | null,
|
employeesCount: null as string | null,
|
||||||
revenueAmount: null as string | null,
|
revenueAmount: null as string | null,
|
||||||
profitAmount: null as string | null,
|
profitAmount: null as string | null,
|
||||||
|
|||||||
-11
@@ -36,7 +36,6 @@ function informationDraft(overrides: Partial<InformationFormDraft> = {}): Inform
|
|||||||
description: 'desc',
|
description: 'desc',
|
||||||
competitors: 'concurrents',
|
competitors: 'concurrents',
|
||||||
foundedAt: '2010-05-01',
|
foundedAt: '2010-05-01',
|
||||||
foundedAtRaw: '',
|
|
||||||
employeesCount: '42',
|
employeesCount: '42',
|
||||||
revenueAmount: '1000000',
|
revenueAmount: '1000000',
|
||||||
profitAmount: '50000',
|
profitAmount: '50000',
|
||||||
@@ -141,16 +140,6 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa
|
|||||||
expect(payload.description).toBeNull()
|
expect(payload.description).toBeNull()
|
||||||
expect(payload.directorName).toBeNull()
|
expect(payload.directorName).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
|
||||||
// Saisie malformee : on transmet le texte brut tel quel pour declencher la
|
|
||||||
// 422 back sur foundedAt (validation autoritaire du format, MUI-44).
|
|
||||||
expect(buildInformationPayload(informationDraft({ foundedAt: null, foundedAtRaw: '32/13/2026' })).foundedAt)
|
|
||||||
.toBe('32/13/2026')
|
|
||||||
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
|
||||||
expect(buildInformationPayload(informationDraft({ foundedAt: '2010-05-01', foundedAtRaw: '' })).foundedAt)
|
|
||||||
.toBe('2010-05-01')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
||||||
+2
-11
@@ -11,7 +11,7 @@ import {
|
|||||||
mapMainDraft,
|
mapMainDraft,
|
||||||
resolveTabEditability,
|
resolveTabEditability,
|
||||||
} from '../supplierEdit'
|
} from '../supplierEdit'
|
||||||
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
||||||
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
|
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
|
||||||
|
|
||||||
describe('buildMainPayload (groupe supplier:write:main)', () => {
|
describe('buildMainPayload (groupe supplier:write:main)', () => {
|
||||||
@@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => {
|
|||||||
|
|
||||||
describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
||||||
const base = {
|
const base = {
|
||||||
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
|
description: null, competitors: null, foundedAt: null, employeesCount: null,
|
||||||
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,15 +48,6 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => {
|
|||||||
})
|
})
|
||||||
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
|
expect(buildInformationPayload(base)).toMatchObject({ employeesCount: null, volumeForecast: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('envoie la saisie invalide (foundedAtRaw) en priorite -> le back tranchera (422)', () => {
|
|
||||||
// Saisie malformee transmise telle quelle pour declencher la 422 back (MUI-44).
|
|
||||||
expect(buildInformationPayload({ ...base, foundedAt: null, foundedAtRaw: '32/13/2026' }).foundedAt)
|
|
||||||
.toBe('32/13/2026')
|
|
||||||
// Saisie valide : foundedAtRaw vide -> on envoie la date ISO.
|
|
||||||
expect(buildInformationPayload({ ...base, foundedAt: '2008-04-01', foundedAtRaw: '' }).foundedAt)
|
|
||||||
.toBe('2008-04-01')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
|
describe('buildContactPayload (sous-ressource supplier_contact)', () => {
|
||||||
+3
-14
@@ -20,14 +20,14 @@ import {
|
|||||||
iriOf,
|
iriOf,
|
||||||
relationOf,
|
relationOf,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
} from '~/modules/commercial/utils/clientConsultation'
|
||||||
import {
|
import {
|
||||||
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
blankEmptyRequired,
|
blankEmptyRequired,
|
||||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
omitEmptyRequired,
|
omitEmptyRequired,
|
||||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
} from '~/modules/commercial/utils/forms/clientFormRules'
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,13 +53,6 @@ export interface InformationFormDraft {
|
|||||||
competitors: string | null
|
competitors: string | null
|
||||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||||
foundedAt: string | null
|
foundedAt: string | null
|
||||||
/**
|
|
||||||
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
|
||||||
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
|
||||||
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
|
||||||
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
|
||||||
*/
|
|
||||||
foundedAtRaw: string
|
|
||||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||||
employeesCount: string | null
|
employeesCount: string | null
|
||||||
revenueAmount: string | null
|
revenueAmount: string | null
|
||||||
@@ -125,8 +118,6 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft
|
|||||||
competitors: client.competitors ?? null,
|
competitors: client.competitors ?? null,
|
||||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||||
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
||||||
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
|
||||||
foundedAtRaw: '',
|
|
||||||
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
||||||
revenueAmount: client.revenueAmount ?? null,
|
revenueAmount: client.revenueAmount ?? null,
|
||||||
profitAmount: client.profitAmount ?? null,
|
profitAmount: client.profitAmount ?? null,
|
||||||
@@ -200,9 +191,7 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
|||||||
return {
|
return {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
foundedAt: information.foundedAt || null,
|
||||||
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
|
||||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
|
||||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
revenueAmount: information.revenueAmount || null,
|
revenueAmount: information.revenueAmount || null,
|
||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
+3
-14
@@ -17,8 +17,8 @@ import {
|
|||||||
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
MAIN_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
omitEmptyRequired,
|
omitEmptyRequired,
|
||||||
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
RIB_REQUIRED_NON_NULLABLE_KEYS,
|
||||||
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
} from '~/modules/commercial/utils/supplierFormRules'
|
||||||
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
||||||
import type {
|
import type {
|
||||||
SupplierAddressFormDraft,
|
SupplierAddressFormDraft,
|
||||||
SupplierContactFormDraft,
|
SupplierContactFormDraft,
|
||||||
@@ -38,13 +38,6 @@ export interface InformationFormDraft {
|
|||||||
competitors: string | null
|
competitors: string | null
|
||||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||||
foundedAt: string | null
|
foundedAt: string | null
|
||||||
/**
|
|
||||||
* Saisie brute invalide remontee par MalioDate (`@update:rawValue`) : '' tant
|
|
||||||
* que la saisie est valide/vide, sinon le texte tel que tape. On l'envoie au
|
|
||||||
* back en priorite sur `foundedAt` pour que la 422 (validation autoritaire du
|
|
||||||
* format, ERP-101) porte sur le champ et s'affiche inline. Cf. MUI-44.
|
|
||||||
*/
|
|
||||||
foundedAtRaw: string
|
|
||||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||||
employeesCount: string | null
|
employeesCount: string | null
|
||||||
revenueAmount: string | null
|
revenueAmount: string | null
|
||||||
@@ -102,8 +95,6 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr
|
|||||||
competitors: supplier.competitors ?? null,
|
competitors: supplier.competitors ?? null,
|
||||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||||
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
foundedAt: supplier.foundedAt ? supplier.foundedAt.slice(0, 10) : null,
|
||||||
// Aucune saisie brute invalide au chargement (la valeur stockee est valide).
|
|
||||||
foundedAtRaw: '',
|
|
||||||
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
employeesCount: supplier.employeesCount != null ? String(supplier.employeesCount) : null,
|
||||||
revenueAmount: supplier.revenueAmount ?? null,
|
revenueAmount: supplier.revenueAmount ?? null,
|
||||||
profitAmount: supplier.profitAmount ?? null,
|
profitAmount: supplier.profitAmount ?? null,
|
||||||
@@ -186,9 +177,7 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
|||||||
return {
|
return {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
foundedAt: information.foundedAt || null,
|
||||||
// pour que le back renvoie une 422 sur foundedAt (cf. foundedAtRaw).
|
|
||||||
foundedAt: information.foundedAtRaw || information.foundedAt || null,
|
|
||||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
revenueAmount: information.revenueAmount || null,
|
revenueAmount: information.revenueAmount || null,
|
||||||
profitAmount: information.profitAmount || null,
|
profitAmount: information.profitAmount || null,
|
||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.10",
|
"@malio/layer-ui": "^1.7.8",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -1866,9 +1866,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.7.10",
|
"version": "1.7.8",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
|
||||||
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
|
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.10",
|
"@malio/layer-ui": "^1.7.8",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -41,22 +41,6 @@ describe('useFormErrors', () => {
|
|||||||
expect(hasErrors.value).toBe(true)
|
expect(hasErrors.value).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('setServerErrors surcharge un message technique (erreur de type) par la cle i18n', () => {
|
|
||||||
const { errors, setServerErrors } = useFormErrors()
|
|
||||||
const mapped = setServerErrors({
|
|
||||||
violations: [
|
|
||||||
// Code Symfony Type::INVALID_TYPE_ERROR (date non parsable) : surcharge.
|
|
||||||
{ propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: 'ba785a8c-82cb-4283-967c-3cf342181b40' },
|
|
||||||
// Violation metier classique : message back conserve.
|
|
||||||
{ propertyPath: 'companyName', message: 'Obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(mapped).toBe(true)
|
|
||||||
// Stub i18n -> renvoie la cle telle quelle.
|
|
||||||
expect(errors.foundedAt).toBe('errors.validation.invalidDate')
|
|
||||||
expect(errors.companyName).toBe('Obligatoire.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setServerErrors retourne false et ne touche rien sans violation', () => {
|
it('setServerErrors retourne false et ne touche rien sans violation', () => {
|
||||||
const { errors, setServerErrors } = useFormErrors()
|
const { errors, setServerErrors } = useFormErrors()
|
||||||
expect(setServerErrors({})).toBe(false)
|
expect(setServerErrors({})).toBe(false)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
|
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
|
||||||
*/
|
*/
|
||||||
import { computed, reactive } from 'vue'
|
import { computed, reactive } from 'vue'
|
||||||
import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
|
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
|
||||||
@@ -69,16 +69,13 @@ export function useFormErrors() {
|
|||||||
* violation exploitable).
|
* violation exploitable).
|
||||||
*/
|
*/
|
||||||
function setServerErrors(data: unknown): boolean {
|
function setServerErrors(data: unknown): boolean {
|
||||||
const violations = extractApiViolations(data)
|
const mapped = mapViolationsToRecord(data)
|
||||||
let mapped = false
|
const keys = Object.keys(mapped)
|
||||||
for (const v of violations) {
|
if (keys.length === 0) return false
|
||||||
if (!v.propertyPath) continue
|
for (const key of keys) {
|
||||||
// Message back tel quel, sauf code surcharge par une cle i18n (ex.
|
errors[key] = mapped[key]
|
||||||
// erreur de type sur une date non parsable -> « Date invalide »).
|
|
||||||
errors[v.propertyPath] = resolveViolationMessage(v, t)
|
|
||||||
mapped = true
|
|
||||||
}
|
}
|
||||||
return mapped
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { mapViolationsToRecord, resolveViolationMessage } from '../api'
|
import { mapViolationsToRecord } from '../api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
||||||
@@ -56,30 +56,3 @@ describe('mapViolationsToRecord', () => {
|
|||||||
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
|
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests de `resolveViolationMessage` — surcharge i18n d'un message back par code
|
|
||||||
* de violation. Le back peut renvoyer un message technique (erreur de type sur
|
|
||||||
* une date non parsable) : on le remplace via le `code` Symfony (stable) par une
|
|
||||||
* cle i18n, sans toucher au back. Le `t` ici renvoie la cle telle quelle.
|
|
||||||
*/
|
|
||||||
describe('resolveViolationMessage', () => {
|
|
||||||
const t = (key: string) => key
|
|
||||||
// Code Symfony Constraints\Type::INVALID_TYPE_ERROR (fige).
|
|
||||||
const TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40'
|
|
||||||
|
|
||||||
it('surcharge le message technique d\'une erreur de type par la cle i18n', () => {
|
|
||||||
const v = { propertyPath: 'foundedAt', message: 'Cette valeur doit être de type DateTimeImmutable|null.', code: TYPE_ERROR }
|
|
||||||
expect(resolveViolationMessage(v, t)).toBe('errors.validation.invalidDate')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renvoie le message back tel quel quand le code n\'est pas surcharge', () => {
|
|
||||||
const v = { propertyPath: 'companyName', message: 'Le nom est obligatoire.', code: 'c1051bb4-d103-4f74-8988-acbcafc7fdc3' }
|
|
||||||
expect(resolveViolationMessage(v, t)).toBe('Le nom est obligatoire.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renvoie le message back tel quel quand il n\'y a pas de code', () => {
|
|
||||||
const v = { propertyPath: 'siren', message: 'SIREN deja utilise.', code: '' }
|
|
||||||
expect(resolveViolationMessage(v, t)).toBe('SIREN deja utilise.')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -34,15 +34,11 @@ export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||||
* pointe le champ concerne, `message` est le libelle a afficher, `code` est le
|
* pointe le champ concerne, `message` est le libelle a afficher.
|
||||||
* code de contrainte Symfony (UUID stable, independant de la langue) — il sert
|
|
||||||
* a surcharger un message back technique par une cle i18n (cf.
|
|
||||||
* `VIOLATION_MESSAGE_I18N` / `resolveViolationMessage`).
|
|
||||||
*/
|
*/
|
||||||
export interface ApiViolation {
|
export interface ApiViolation {
|
||||||
propertyPath: string
|
propertyPath: string
|
||||||
message: string
|
message: string
|
||||||
code: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,7 +61,6 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
|
|||||||
out.push({
|
out.push({
|
||||||
propertyPath: String(obj.propertyPath ?? ''),
|
propertyPath: String(obj.propertyPath ?? ''),
|
||||||
message: String(obj.message ?? ''),
|
message: String(obj.message ?? ''),
|
||||||
code: String(obj.code ?? ''),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -90,45 +85,6 @@ export function mapViolationsToRecord(data: unknown): Record<string, string> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Surcharge i18n d'un message back par CODE de violation.
|
|
||||||
*
|
|
||||||
* La plupart des contraintes back portent deja un message FR explicite (ex.
|
|
||||||
* `#[Assert\NotBlank(message: '...')]`) : on l'affiche tel quel. Mais certaines
|
|
||||||
* 422 portent un message TECHNIQUE non montrable a l'utilisateur — typiquement
|
|
||||||
* l'erreur de TYPE renvoyee par API Platform quand le back ne peut pas
|
|
||||||
* denormaliser la valeur (date non parsable envoyee sur un champ
|
|
||||||
* `DateTimeImmutable` : « Cette valeur doit être de type DateTimeImmutable|null. »,
|
|
||||||
* voire en anglais selon la negociation de langue).
|
|
||||||
*
|
|
||||||
* Plutot que de traduire/maquiller cote back, on surcharge ces messages cote
|
|
||||||
* front via leur `code` de violation. Ce code est un UUID Symfony FIGE (contrat
|
|
||||||
* de compatibilite : il ne change pas entre versions), donc bien plus robuste
|
|
||||||
* qu'un match sur le texte du message (qui depend de la langue). La table
|
|
||||||
* associe un code -> une cle i18n ; `resolveViolationMessage` l'applique.
|
|
||||||
*
|
|
||||||
* Limite a connaitre : le code de type-error est GENERIQUE (toute valeur de
|
|
||||||
* mauvais type). Dans nos formulaires, seul un champ date saisi en texte libre
|
|
||||||
* (MalioDate, qui forwarde la saisie brute invalide) le declenche, d'ou le
|
|
||||||
* libelle « Date invalide ». Si un autre champ typé en saisie libre apparait,
|
|
||||||
* affiner la resolution via `propertyPath` plutot que par code seul.
|
|
||||||
*/
|
|
||||||
export const VIOLATION_MESSAGE_I18N: Record<string, string> = {
|
|
||||||
// Symfony `Constraints\Type::INVALID_TYPE_ERROR` — valeur de mauvais type.
|
|
||||||
'ba785a8c-82cb-4283-967c-3cf342181b40': 'errors.validation.invalidDate',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resout le message a afficher pour une violation : si son `code` est surcharge
|
|
||||||
* par `VIOLATION_MESSAGE_I18N`, renvoie la traduction de la cle associee ;
|
|
||||||
* sinon, le message back tel quel (cas nominal). `t` est passe par l'appelant
|
|
||||||
* (les utils sont purs, sans acces a useI18n).
|
|
||||||
*/
|
|
||||||
export function resolveViolationMessage(v: ApiViolation, t: (key: string) => string): string {
|
|
||||||
const i18nKey = VIOLATION_MESSAGE_I18N[v.code]
|
|
||||||
return i18nKey ? t(i18nKey) : v.message
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||||
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||||
|
|||||||
@@ -84,17 +84,6 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
'commercial.suppliers.accounting.view',
|
'commercial.suppliers.accounting.view',
|
||||||
'commercial.suppliers.accounting.manage',
|
'commercial.suppliers.accounting.manage',
|
||||||
'commercial.suppliers.archive',
|
'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'],
|
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ final class Version20260612100000 extends AbstractMigration
|
|||||||
updated_by INT DEFAULT NULL,
|
updated_by INT DEFAULT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
CONSTRAINT chk_provider_contact_name
|
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
|
CONSTRAINT fk_provider_contact_provider
|
||||||
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_provider_contact_created_by
|
CONSTRAINT fk_provider_contact_created_by
|
||||||
@@ -263,12 +263,12 @@ final class Version20260612100000 extends AbstractMigration
|
|||||||
SQL);
|
SQL);
|
||||||
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
|
$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', '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', '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', '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', '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_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', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
|
||||||
$this->comment('provider_contact', 'email', 'Email du contact (lowercase 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
|
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||||
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
||||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
|
* `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).
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -49,15 +48,15 @@ class Bank
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[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;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[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;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[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;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ use DateTimeImmutable;
|
|||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Context;
|
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
@@ -96,12 +94,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
security: "is_granted('commercial.clients.manage')",
|
security: "is_granted('commercial.clients.manage')",
|
||||||
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['client:write:main']],
|
denormalizationContext: ['groups' => ['client:write:main']],
|
||||||
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
|
|
||||||
// doit produire un 422 porte sur le champ (violations[].propertyPath,
|
|
||||||
// mappable inline par useFormErrors) plutot qu'un 400 generique non
|
|
||||||
// exploitable. Le front (MalioDate, MUI-44) forwarde la saisie brute
|
|
||||||
// invalide : le back reste la couche autoritaire du format (ERP-101).
|
|
||||||
collectDenormalizationErrors: true,
|
|
||||||
processor: ClientProcessor::class,
|
processor: ClientProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
@@ -125,10 +117,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'client:write:accounting',
|
'client:write:accounting',
|
||||||
'client:write:archive',
|
'client:write:archive',
|
||||||
]],
|
]],
|
||||||
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
|
|
||||||
// au lieu d'un 400 generique. Indispensable au mapping inline du
|
|
||||||
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
|
|
||||||
collectDenormalizationErrors: true,
|
|
||||||
provider: ClientProvider::class,
|
provider: ClientProvider::class,
|
||||||
processor: ClientProcessor::class,
|
processor: ClientProcessor::class,
|
||||||
),
|
),
|
||||||
@@ -218,13 +206,6 @@ class Client implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
#[Groups(['client:read', 'client:write:information'])]
|
#[Groups(['client:read', 'client:write:information'])]
|
||||||
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
|
|
||||||
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
|
|
||||||
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
|
|
||||||
// M/J/AAAA -> 25 decembre 2026, et accepte a tort. Avec le format, toute
|
|
||||||
// saisie brute non-ISO (forwardee par MalioDate sur date invalide) echoue la
|
|
||||||
// denormalisation -> 422 sur foundedAt (cf. collectDenormalizationErrors).
|
|
||||||
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
|
||||||
private ?DateTimeImmutable $foundedAt = null;
|
private ?DateTimeImmutable $foundedAt = null;
|
||||||
|
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||||
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
||||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
|
* `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).
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -49,15 +48,15 @@ class PaymentDelay
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||||
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
||||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
|
* `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).
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -52,15 +51,15 @@ class PaymentType
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ use DateTimeImmutable;
|
|||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Context;
|
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||||
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
@@ -96,12 +94,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
security: "is_granted('commercial.suppliers.manage')",
|
security: "is_granted('commercial.suppliers.manage')",
|
||||||
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
|
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['supplier:write:main']],
|
denormalizationContext: ['groups' => ['supplier:write:main']],
|
||||||
// Une valeur de mauvais type (ex. date non parsable sur foundedAt)
|
|
||||||
// doit produire un 422 porte sur le champ (violations[].propertyPath,
|
|
||||||
// mappable inline par useFormErrors) plutot qu'un 400 generique. Le
|
|
||||||
// front (MalioDate, MUI-44) forwarde la saisie brute invalide : le
|
|
||||||
// back reste la couche autoritaire du format (ERP-101). Cf. Client.
|
|
||||||
collectDenormalizationErrors: true,
|
|
||||||
processor: SupplierProcessor::class,
|
processor: SupplierProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
@@ -121,10 +113,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'supplier:write:accounting',
|
'supplier:write:accounting',
|
||||||
'supplier:write:archive',
|
'supplier:write:archive',
|
||||||
]],
|
]],
|
||||||
// Cf. Post : date non parsable (foundedAt) -> 422 porte sur le champ
|
|
||||||
// au lieu d'un 400 generique. Indispensable au mapping inline du
|
|
||||||
// front (MalioDate MUI-44 forwarde la saisie brute invalide).
|
|
||||||
collectDenormalizationErrors: true,
|
|
||||||
provider: SupplierProvider::class,
|
provider: SupplierProvider::class,
|
||||||
processor: SupplierProcessor::class,
|
processor: SupplierProcessor::class,
|
||||||
),
|
),
|
||||||
@@ -199,11 +187,6 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
|||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||||
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
|
|
||||||
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
|
|
||||||
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
|
|
||||||
// saisie brute non-ISO echoue -> 422 sur foundedAt. Cf. Client.
|
|
||||||
#[Context(denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
|
|
||||||
private ?DateTimeImmutable $foundedAt = null;
|
private ?DateTimeImmutable $foundedAt = null;
|
||||||
|
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
||||||
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
||||||
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
|
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
|
||||||
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0) ;
|
* 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).
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -56,15 +55,15 @@ class TvaMode
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[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,
|
* 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
|
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
||||||
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
|
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
|
||||||
* `commercial.clients.archive`, `commercial.suppliers.archive` et
|
* bypass tout via isAdmin ; `commercial.clients.archive` et
|
||||||
* `technique.providers.archive` ne sont attaches a aucun role metier —
|
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
|
||||||
* admin seul).
|
* 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>}>
|
* @var array<string, array{label: string, permissions: list<string>}>
|
||||||
*/
|
*/
|
||||||
private const array MATRIX = [
|
private const array MATRIX = [
|
||||||
@@ -74,11 +66,6 @@ final class RbacSeeder
|
|||||||
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
|
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
|
||||||
'commercial.suppliers.view',
|
'commercial.suppliers.view',
|
||||||
'commercial.suppliers.manage',
|
'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).
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||||
'catalog.categories.read_ref',
|
'catalog.categories.read_ref',
|
||||||
'sites.read_ref',
|
'sites.read_ref',
|
||||||
@@ -95,13 +82,6 @@ final class RbacSeeder
|
|||||||
'commercial.suppliers.view',
|
'commercial.suppliers.view',
|
||||||
'commercial.suppliers.accounting.view',
|
'commercial.suppliers.accounting.view',
|
||||||
'commercial.suppliers.accounting.manage',
|
'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).
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||||
'catalog.categories.read_ref',
|
'catalog.categories.read_ref',
|
||||||
'sites.read_ref',
|
'sites.read_ref',
|
||||||
@@ -116,12 +96,6 @@ final class RbacSeeder
|
|||||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||||
'commercial.suppliers.view',
|
'commercial.suppliers.view',
|
||||||
'commercial.suppliers.manage',
|
'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).
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||||
'catalog.categories.read_ref',
|
'catalog.categories.read_ref',
|
||||||
'sites.read_ref',
|
'sites.read_ref',
|
||||||
@@ -129,12 +103,7 @@ final class RbacSeeder
|
|||||||
],
|
],
|
||||||
self::ROLE_USINE => [
|
self::ROLE_USINE => [
|
||||||
'label' => 'Usine',
|
'label' => 'Usine',
|
||||||
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
|
'permissions' => [],
|
||||||
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
|
|
||||||
// courant. Aucun autre acces metier.
|
|
||||||
'permissions' => [
|
|
||||||
'technique.providers.view',
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -203,15 +203,6 @@ final class SeedE2ECommand extends Command
|
|||||||
'commercial.suppliers.accounting.view',
|
'commercial.suppliers.accounting.view',
|
||||||
'commercial.suppliers.accounting.manage',
|
'commercial.suppliers.accounting.manage',
|
||||||
'commercial.suppliers.archive',
|
'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',
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -65,23 +65,6 @@ final class ProviderFieldNormalizer
|
|||||||
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
|
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" ->
|
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
|
||||||
* "0612345678". Une valeur sans aucun chiffre devient null.
|
* "0612345678". Une valeur sans aucun chiffre devient null.
|
||||||
|
|||||||
@@ -140,12 +140,6 @@ class Provider implements TimestampableInterface, BlamableInterface
|
|||||||
*/
|
*/
|
||||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
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\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
@@ -297,44 +291,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
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use ApiPlatform\Metadata\Link;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
|
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\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
use App\Shared\Domain\Contract\CategoryInterface;
|
use App\Shared\Domain\Contract\CategoryInterface;
|
||||||
@@ -61,8 +60,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
// site:read + category:read : embarquent les Site / Category lies
|
// site:read + category:read : embarquent les Site / Category lies
|
||||||
// (maillon (c)) plutot que des IRI nus dans le retour.
|
// (maillon (c)) plutot que des IRI nus dans le retour.
|
||||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
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(
|
new Post(
|
||||||
uriTemplate: '/providers/{providerId}/addresses',
|
uriTemplate: '/providers/{providerId}/addresses',
|
||||||
@@ -83,12 +80,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
security: "is_granted('technique.providers.manage')",
|
security: "is_granted('technique.providers.manage')",
|
||||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||||
provider: ProviderSubResourceItemProvider::class,
|
|
||||||
processor: ProviderAddressProcessor::class,
|
processor: ProviderAddressProcessor::class,
|
||||||
),
|
),
|
||||||
new Delete(
|
new Delete(
|
||||||
security: "is_granted('technique.providers.manage')",
|
security: "is_granted('technique.providers.manage')",
|
||||||
provider: ProviderSubResourceItemProvider::class,
|
|
||||||
processor: ProviderAddressProcessor::class,
|
processor: ProviderAddressProcessor::class,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -97,7 +92,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
#[ORM\Table(name: 'provider_address')]
|
#[ORM\Table(name: 'provider_address')]
|
||||||
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
|
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
class ProviderAddress implements TimestampableInterface, BlamableInterface
|
||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use ApiPlatform\Metadata\Link;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
|
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\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
@@ -48,8 +47,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
new Get(
|
new Get(
|
||||||
security: "is_granted('technique.providers.view')",
|
security: "is_granted('technique.providers.view')",
|
||||||
normalizationContext: ['groups' => ['provider:item:read']],
|
normalizationContext: ['groups' => ['provider:item:read']],
|
||||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
|
||||||
provider: ProviderSubResourceItemProvider::class,
|
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
uriTemplate: '/providers/{providerId}/contacts',
|
uriTemplate: '/providers/{providerId}/contacts',
|
||||||
@@ -70,12 +67,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
security: "is_granted('technique.providers.manage')",
|
security: "is_granted('technique.providers.manage')",
|
||||||
normalizationContext: ['groups' => ['provider:item:read']],
|
normalizationContext: ['groups' => ['provider:item:read']],
|
||||||
denormalizationContext: ['groups' => ['provider:write:contacts']],
|
denormalizationContext: ['groups' => ['provider:write:contacts']],
|
||||||
provider: ProviderSubResourceItemProvider::class,
|
|
||||||
processor: ProviderContactProcessor::class,
|
processor: ProviderContactProcessor::class,
|
||||||
),
|
),
|
||||||
new Delete(
|
new Delete(
|
||||||
security: "is_granted('technique.providers.manage')",
|
security: "is_granted('technique.providers.manage')",
|
||||||
provider: ProviderSubResourceItemProvider::class,
|
|
||||||
processor: ProviderContactProcessor::class,
|
processor: ProviderContactProcessor::class,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -84,7 +79,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ORM\Table(name: 'provider_contact')]
|
#[ORM\Table(name: 'provider_contact')]
|
||||||
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
|
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
class ProviderContact implements TimestampableInterface, BlamableInterface
|
||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
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;
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ use ApiPlatform\Metadata\Link;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
|
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\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
@@ -50,8 +49,6 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
new Get(
|
new Get(
|
||||||
security: "is_granted('technique.providers.accounting.view')",
|
security: "is_granted('technique.providers.accounting.view')",
|
||||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
|
||||||
provider: ProviderSubResourceItemProvider::class,
|
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
uriTemplate: '/providers/{providerId}/ribs',
|
uriTemplate: '/providers/{providerId}/ribs',
|
||||||
@@ -72,12 +69,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
security: "is_granted('technique.providers.accounting.manage')",
|
security: "is_granted('technique.providers.accounting.manage')",
|
||||||
normalizationContext: ['groups' => ['provider:read:accounting']],
|
normalizationContext: ['groups' => ['provider:read:accounting']],
|
||||||
denormalizationContext: ['groups' => ['provider:write:accounting']],
|
denormalizationContext: ['groups' => ['provider:write:accounting']],
|
||||||
provider: ProviderSubResourceItemProvider::class,
|
|
||||||
processor: ProviderRibProcessor::class,
|
processor: ProviderRibProcessor::class,
|
||||||
),
|
),
|
||||||
new Delete(
|
new Delete(
|
||||||
security: "is_granted('technique.providers.accounting.manage')",
|
security: "is_granted('technique.providers.accounting.manage')",
|
||||||
provider: ProviderSubResourceItemProvider::class,
|
|
||||||
processor: ProviderRibProcessor::class,
|
processor: ProviderRibProcessor::class,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -86,7 +81,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ORM\Table(name: 'provider_rib')]
|
#[ORM\Table(name: 'provider_rib')]
|
||||||
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
|
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
class ProviderRib implements TimestampableInterface, BlamableInterface
|
||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
|||||||
-8
@@ -11,7 +11,6 @@ use ApiPlatform\Validator\Exception\ValidationException;
|
|||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
use App\Module\Technique\Domain\Entity\ProviderAddress;
|
||||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use JsonException;
|
use JsonException;
|
||||||
@@ -54,7 +53,6 @@ final class ProviderAddressProcessor implements ProcessorInterface
|
|||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
@@ -100,12 +98,6 @@ final class ProviderAddressProcessor implements ProcessorInterface
|
|||||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
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);
|
$address->setProvider($provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-14
@@ -11,7 +11,6 @@ use ApiPlatform\Validator\Exception\ValidationException;
|
|||||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Module\Technique\Domain\Entity\ProviderContact;
|
use App\Module\Technique\Domain\Entity\ProviderContact;
|
||||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
@@ -47,7 +46,6 @@ final class ProviderContactProcessor implements ProcessorInterface
|
|||||||
private readonly ProcessorInterface $removeProcessor,
|
private readonly ProcessorInterface $removeProcessor,
|
||||||
private readonly ProviderFieldNormalizer $normalizer,
|
private readonly ProviderFieldNormalizer $normalizer,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
@@ -96,11 +94,6 @@ final class ProviderContactProcessor implements ProcessorInterface
|
|||||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
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);
|
$contact->setProvider($provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +105,6 @@ final class ProviderContactProcessor implements ProcessorInterface
|
|||||||
{
|
{
|
||||||
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
||||||
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
||||||
$contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
|
|
||||||
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
|
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
|
||||||
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
|
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
|
||||||
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
|
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
|
||||||
@@ -120,22 +112,21 @@ final class ProviderContactProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
|
* 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
|
* nom / telephone principal / email est renseigne (double garde avec le CHECK
|
||||||
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
|
* BDD chk_provider_contact_name — leve une 422 propre rattachee au champ
|
||||||
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
* `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
||||||
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
|
* chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja
|
||||||
* ramenees a null et ne suffisent pas a valider le bloc.
|
* ramenees a null et ne suffisent pas a valider le bloc.
|
||||||
*/
|
*/
|
||||||
private function validateName(ProviderContact $contact): void
|
private function validateName(ProviderContact $contact): void
|
||||||
{
|
{
|
||||||
if (null === $contact->getFirstName()
|
if (null === $contact->getFirstName()
|
||||||
&& null === $contact->getLastName()
|
&& null === $contact->getLastName()
|
||||||
&& null === $contact->getJobTitle()
|
|
||||||
&& null === $contact->getPhonePrimary()
|
&& null === $contact->getPhonePrimary()
|
||||||
&& null === $contact->getEmail()) {
|
&& null === $contact->getEmail()) {
|
||||||
$violations = new ConstraintViolationList();
|
$violations = new ConstraintViolationList();
|
||||||
$violations->add(new ConstraintViolation(
|
$violations->add(new ConstraintViolation(
|
||||||
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
|
'Au moins un champ du contact est obligatoire (nom, prénom, téléphone ou email).',
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
$contact,
|
$contact,
|
||||||
|
|||||||
+6
-7
@@ -52,13 +52,12 @@ use Symfony\Component\Validator\ConstraintViolationList;
|
|||||||
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
|
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
|
||||||
* restauration).
|
* restauration).
|
||||||
*
|
*
|
||||||
* Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
|
* La RG-3.09 (categorie de type PRESTATAIRE) est portee par un Assert\Callback +
|
||||||
* RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
|
* ->atPath() sur l'entite Provider (joue par API Platform AVANT ce processor),
|
||||||
* ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
|
* pour que la 422 porte un propertyPath consommable par extractApiViolations
|
||||||
* AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
|
* (mapping inline, pas un toast — convention ERP-101). Les RG-3.07 (Virement ->
|
||||||
* extractApiViolations (mapping inline, pas un toast — convention ERP-101). Le 409
|
* banque) et RG-3.08 (LCR -> RIB) relevent de l'onglet Comptabilite / sous-ressource
|
||||||
* sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
|
* RIB (ticket dedie) et ne sont pas portees ici.
|
||||||
* ProviderRibProcessor (ERP-135).
|
|
||||||
*
|
*
|
||||||
* @implements ProcessorInterface<Provider, Provider>
|
* @implements ProcessorInterface<Provider, Provider>
|
||||||
*/
|
*/
|
||||||
|
|||||||
-7
@@ -9,7 +9,6 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Module\Technique\Domain\Entity\ProviderRib;
|
use App\Module\Technique\Domain\Entity\ProviderRib;
|
||||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
@@ -43,7 +42,6 @@ final class ProviderRibProcessor implements ProcessorInterface
|
|||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||||
private readonly ProcessorInterface $removeProcessor,
|
private readonly ProcessorInterface $removeProcessor,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
@@ -90,11 +88,6 @@ final class ProviderRibProcessor implements ProcessorInterface
|
|||||||
throw new NotFoundHttpException('Prestataire introuvable.');
|
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);
|
$rib->setProvider($provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-9
@@ -9,10 +9,12 @@ use ApiPlatform\Metadata\CollectionOperationInterface;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\Pagination\Pagination;
|
use ApiPlatform\State\Pagination\Pagination;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||||
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,11 +64,12 @@ final class ProviderProvider implements ProviderInterface
|
|||||||
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||||
private readonly ProviderRepositoryInterface $repository,
|
private readonly ProviderRepositoryInterface $repository,
|
||||||
private readonly Pagination $pagination,
|
private readonly Pagination $pagination,
|
||||||
// Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
|
private readonly Security $security,
|
||||||
// source UNIQUE partagee avec le provider decore des sous-ressources
|
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
|
||||||
// (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
|
// CurrentSiteProvider dans le service et ajouter la clause WHERE
|
||||||
// eviter tout drift entre ces points d'application.
|
// manuellement » pour les cas multi-site non couverts par
|
||||||
private readonly ProviderSiteScopeChecker $scopeChecker,
|
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
|
||||||
|
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
|
||||||
@@ -106,7 +109,7 @@ final class ProviderProvider implements ProviderInterface
|
|||||||
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
|
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
|
||||||
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
|
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
|
||||||
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
|
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
|
||||||
$scopeSite = $this->scopeChecker->siteScopeOrNull();
|
$scopeSite = $this->siteScopeOrNull();
|
||||||
if (null !== $scopeSite) {
|
if (null !== $scopeSite) {
|
||||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||||
}
|
}
|
||||||
@@ -161,14 +164,44 @@ final class ProviderProvider implements ProviderInterface
|
|||||||
|
|
||||||
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
|
// 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
|
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
|
||||||
// currentSite null (delegue au ProviderSiteScopeChecker).
|
// currentSite null.
|
||||||
if (!$this->scopeChecker->isInScope($provider)) {
|
$scopeSite = $this->siteScopeOrNull();
|
||||||
|
if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $provider;
|
return $provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, aligne site-aware.md § 5).
|
||||||
|
*/
|
||||||
|
private function siteScopeOrNull(): ?SiteInterface
|
||||||
|
{
|
||||||
|
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->currentSiteProvider->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrai si le prestataire est rattache (relation directe provider.sites) au
|
||||||
|
* site d'id donne. Comparaison en memoire sur l'entite deja chargee (detail).
|
||||||
|
*/
|
||||||
|
private function providerHasSite(Provider $provider, int $siteId): bool
|
||||||
|
{
|
||||||
|
foreach ($provider->getSites() as $site) {
|
||||||
|
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||||
*/
|
*/
|
||||||
|
|||||||
-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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
|
|
||||||
* onglet Information).
|
|
||||||
*
|
|
||||||
* Le front (MalioDate, cf. MUI-44) forwarde desormais la saisie brute invalide
|
|
||||||
* au serveur plutot que de l'avaler. Cote back, une date non parsable doit
|
|
||||||
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors),
|
|
||||||
* et non un 400 generique. Repose sur `collectDenormalizationErrors` actif sur
|
|
||||||
* l'operation Patch du Client.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientFoundedAtFormatTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string MERGE = 'application/merge-patch+json';
|
|
||||||
|
|
||||||
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
|
|
||||||
public function testFoundedAtNonParsableEst422(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Founded Format SARL');
|
|
||||||
|
|
||||||
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['foundedAt' => '32/13/2026'],
|
|
||||||
])->toArray(false);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
|
|
||||||
* mais PHP DateTime l'accepterait en M/J/AAAA (25 decembre). Le format d'entree
|
|
||||||
* strict ISO `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
|
|
||||||
*/
|
|
||||||
public function testFoundedAtFormatAmbiguUsEst422(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Founded Ambigu SARL');
|
|
||||||
|
|
||||||
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['foundedAt' => '12/25/2026'],
|
|
||||||
])->toArray(false);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Non-regression : une date ISO valide reste acceptee (200). */
|
|
||||||
public function testFoundedAtIsoValideEst200(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Founded Ok SARL');
|
|
||||||
|
|
||||||
$data = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['foundedAt' => '2010-05-01'],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(200);
|
|
||||||
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
|
|
||||||
* onglet Information) du fournisseur. Miroir de {@see ClientFoundedAtFormatTest}.
|
|
||||||
*
|
|
||||||
* Une date non parsable (saisie brute forwardee par MalioDate, MUI-44) doit
|
|
||||||
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors), et
|
|
||||||
* non un 400 generique. Repose sur `collectDenormalizationErrors` sur les
|
|
||||||
* operations write du Supplier.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class SupplierFoundedAtFormatTest extends AbstractSupplierApiTestCase
|
|
||||||
{
|
|
||||||
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
|
|
||||||
public function testFoundedAtNonParsableEst422(): void
|
|
||||||
{
|
|
||||||
$seed = $this->seedSupplier('Founded Format Negoce');
|
|
||||||
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
|
||||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
|
||||||
|
|
||||||
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['foundedAt' => '32/13/2026'],
|
|
||||||
])->toArray(false);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
|
|
||||||
* mais PHP DateTime l'accepterait en M/J/AAAA. Le format d'entree strict ISO
|
|
||||||
* `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
|
|
||||||
*/
|
|
||||||
public function testFoundedAtFormatAmbiguUsEst422(): void
|
|
||||||
{
|
|
||||||
$seed = $this->seedSupplier('Founded Ambigu Negoce');
|
|
||||||
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
|
||||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
|
||||||
|
|
||||||
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['foundedAt' => '12/25/2026'],
|
|
||||||
])->toArray(false);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Non-regression : une date ISO valide reste acceptee (200). */
|
|
||||||
public function testFoundedAtIsoValideEst200(): void
|
|
||||||
{
|
|
||||||
$seed = $this->seedSupplier('Founded Ok Negoce');
|
|
||||||
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
|
||||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
|
||||||
|
|
||||||
$data = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['foundedAt' => '2010-05-01'],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(200);
|
|
||||||
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,16 +7,12 @@ namespace App\Tests\Module\Technique\Api;
|
|||||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||||
use App\Module\Catalog\Domain\Entity\Category;
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
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\PaymentType;
|
||||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
|
||||||
use App\Module\Core\Domain\Entity\Permission;
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
use App\Module\Core\Domain\Entity\Role;
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
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\ProviderContact;
|
||||||
use App\Module\Technique\Domain\Entity\ProviderRib;
|
use App\Module\Technique\Domain\Entity\ProviderRib;
|
||||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||||
@@ -323,121 +319,6 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
|
|||||||
return $rib;
|
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
|
* Recupere un type de reglement seede (CommercialReferentialFixtures) par code
|
||||||
* (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees).
|
* (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees).
|
||||||
@@ -454,22 +335,6 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
|
|||||||
return $paymentType;
|
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).
|
* Indexe les violations d'un corps 422 par propertyPath (assert ciblee).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,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,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -80,91 +80,4 @@ final class ProviderListTest extends AbstractProviderApiTestCase
|
|||||||
self::assertSame(1, $body['totalItems']);
|
self::assertSame(1, $body['totalItems']);
|
||||||
self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -156,21 +156,4 @@ final class ProviderRbacGatingTest extends AbstractProviderApiTestCase
|
|||||||
self::assertTrue($reloaded->isArchived());
|
self::assertTrue($reloaded->isArchived());
|
||||||
self::assertNotNull($reloaded->getArchivedAt());
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
|
|||||||
/**
|
/**
|
||||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
|
* 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
|
* (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
|
* (au moins un champ parmi prenom/nom/telephone/email), RG-3.05 (>= 1 site sur
|
||||||
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
|
* 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`),
|
* 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
|
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
|
||||||
@@ -53,37 +53,18 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
|
* RG-3.04 : un bloc sans aucun champ du CHECK (prenom/nom/telephone/email) est
|
||||||
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
|
* rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName.
|
||||||
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
|
* Ici seul jobTitle est fourni (hors CHECK).
|
||||||
*/
|
*/
|
||||||
public function testPostContactWithOnlyJobTitleReturns201(): void
|
public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedProvider('Contact JobTitle Only');
|
$seed = $this->seedProvider('Contact No Name');
|
||||||
|
|
||||||
$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', [
|
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
'json' => ['jobTitle' => ' '],
|
'json' => ['jobTitle' => 'Directeur'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
|||||||
@@ -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