Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee1f344764 | |||
| 3fe0f676f6 | |||
| d5462bcf42 | |||
| 54d8327fa5 | |||
| 09a4b9d464 | |||
| d97b9ce6d0 | |||
| b36520d3b1 | |||
| a340d8139a |
@@ -79,6 +79,7 @@ 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,6 +61,23 @@ 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.109'
|
app.version: '0.1.114'
|
||||||
|
|||||||
@@ -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** (`/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`).
|
- **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.
|
||||||
|
|
||||||
> **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,67 +624,153 @@ 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`).
|
||||||
|
|
||||||
`GET /api/providers` (liste, ADMIN — un membre, forme attendue) :
|
> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front.
|
||||||
|
|
||||||
|
`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) :
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@context": "/api/contexts/Provider",
|
"@context": "/api/contexts/Provider",
|
||||||
"@id": "/api/providers",
|
"@id": "/api/providers",
|
||||||
"@type": "Collection",
|
"@type": "Collection",
|
||||||
"totalItems": 1,
|
"totalItems": 1,
|
||||||
"member": [
|
"member": [
|
||||||
{
|
{
|
||||||
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
|
"@id": "/api/providers/572",
|
||||||
"companyName": "MAINTENANCE PRO SAS",
|
"@type": "Provider",
|
||||||
"categories": [
|
"id": 572,
|
||||||
{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE",
|
"companyName": "DOD21AADC 0E3CCE",
|
||||||
"categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}}
|
"categories": [
|
||||||
],
|
{
|
||||||
"sites": [
|
"@type": "Category",
|
||||||
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}
|
"@id": "/api/categories/3006",
|
||||||
],
|
"id": 3006,
|
||||||
"siren": "987654321", "accountNumber": "P0001",
|
"name": "test_prov_cat_nettoyage",
|
||||||
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
"code": "NETTOYAGE",
|
||||||
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
|
"categoryTypes": [
|
||||||
"ribs": [
|
{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}
|
||||||
{"@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
|
}
|
||||||
}
|
],
|
||||||
],
|
"sites": [
|
||||||
"view": {"@id": "/api/providers", "@type": "PartialCollectionView"}
|
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||||
|
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||||
|
],
|
||||||
|
"siren": "987654321",
|
||||||
|
"accountNumber": "P0001",
|
||||||
|
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||||
|
"nTva": "FR00987654321",
|
||||||
|
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
|
||||||
|
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
|
||||||
|
"ribs": [
|
||||||
|
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-12T15:17:29+02:00",
|
||||||
|
"updatedAt": "2026-06-12T15:17:29+02:00",
|
||||||
|
"isArchived": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 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.
|
> 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).
|
||||||
|
|
||||||
`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) :
|
`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) :
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
|
"@context": "/api/contexts/Provider",
|
||||||
"companyName": "MAINTENANCE PRO SAS",
|
"@id": "/api/providers/572",
|
||||||
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}],
|
"@type": "Provider",
|
||||||
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
|
"id": 572,
|
||||||
"siren": "987654321", "accountNumber": "P0001",
|
"companyName": "DOD21AADC 0E3CCE",
|
||||||
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
"categories": [
|
||||||
"nTva": "FR00987654321",
|
{"@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"}
|
||||||
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
|
],
|
||||||
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
|
"sites": [
|
||||||
"contacts": [
|
{"@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"},
|
||||||
{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
|
{"@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"}
|
||||||
],
|
],
|
||||||
"addresses": [
|
"siren": "987654321",
|
||||||
{"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
"accountNumber": "P0001",
|
||||||
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
|
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
|
||||||
"contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}],
|
"nTva": "FR00987654321",
|
||||||
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]}
|
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
|
||||||
],
|
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
|
||||||
"ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}],
|
"contacts": [
|
||||||
"isArchived": false
|
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||||
|
],
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
|
||||||
|
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
||||||
|
"sites": [
|
||||||
|
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||||
|
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||||
|
],
|
||||||
|
"contacts": [
|
||||||
|
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ribs": [
|
||||||
|
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-12T15:17:29+02:00",
|
||||||
|
"updatedAt": "2026-06-12T15:17:29+02:00",
|
||||||
|
"isArchived": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 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).
|
`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@context": "/api/contexts/Provider",
|
||||||
|
"@id": "/api/providers/572",
|
||||||
|
"@type": "Provider",
|
||||||
|
"id": 572,
|
||||||
|
"companyName": "DOD21AADC 0E3CCE",
|
||||||
|
"categories": [
|
||||||
|
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||||
|
],
|
||||||
|
"sites": [
|
||||||
|
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||||
|
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||||
|
],
|
||||||
|
"contacts": [
|
||||||
|
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||||
|
],
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
|
||||||
|
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
||||||
|
"sites": [
|
||||||
|
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
|
||||||
|
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
|
||||||
|
],
|
||||||
|
"contacts": [
|
||||||
|
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createdAt": "2026-06-12T15:17:29+02:00",
|
||||||
|
"updatedAt": "2026-06-12T15:17:29+02:00",
|
||||||
|
"isArchived": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`.
|
||||||
|
>
|
||||||
|
> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`.
|
||||||
|
|
||||||
### 4.1 `GET /api/providers` — Liste
|
### 4.1 `GET /api/providers` — Liste
|
||||||
|
|
||||||
@@ -923,7 +1009,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**
|
||||||
- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front)
|
- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139)
|
||||||
- [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,6 +30,10 @@
|
|||||||
"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",
|
||||||
@@ -386,7 +390,10 @@
|
|||||||
},
|
},
|
||||||
"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/clientFormRules'
|
} from '~/modules/commercial/utils/forms/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/clientConsultation'
|
import type { ClientDetail } from '~/modules/commercial/utils/forms/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/supplierConsultation'
|
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
: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"
|
||||||
@@ -401,7 +402,7 @@ import {
|
|||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -417,7 +418,7 @@ import {
|
|||||||
type ClientEditAbilities,
|
type ClientEditAbilities,
|
||||||
type InformationFormDraft,
|
type InformationFormDraft,
|
||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
} from '~/modules/commercial/utils/clientEdit'
|
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||||
import {
|
import {
|
||||||
buildClientFormTabKeys,
|
buildClientFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -429,7 +430,7 @@ import {
|
|||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/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/clientFormRules'
|
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/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/clientConsultation'
|
} from '~/modules/commercial/utils/forms/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,6 +111,7 @@
|
|||||||
: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"
|
||||||
@@ -401,12 +402,12 @@ import {
|
|||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
lastFillableTabKey,
|
lastFillableTabKey,
|
||||||
showsRelationAndTriageFields,
|
showsRelationAndTriageFields,
|
||||||
} from '~/modules/commercial/utils/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import {
|
import {
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
buildRibPayload,
|
buildRibPayload,
|
||||||
} from '~/modules/commercial/utils/clientEdit'
|
} from '~/modules/commercial/utils/forms/clientEdit'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -651,6 +652,8 @@ 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,
|
||||||
@@ -666,7 +669,8 @@ 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,
|
||||||
foundedAt: information.foundedAt || null,
|
// Saisie invalide prioritaire -> 422 back 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,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
: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"
|
||||||
@@ -370,7 +371,7 @@ import {
|
|||||||
mapAddressToDraft,
|
mapAddressToDraft,
|
||||||
mapRibToDraft,
|
mapRibToDraft,
|
||||||
type SupplierDetail,
|
type SupplierDetail,
|
||||||
} from '~/modules/commercial/utils/supplierConsultation'
|
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -386,7 +387,7 @@ import {
|
|||||||
type InformationFormDraft,
|
type InformationFormDraft,
|
||||||
type MainFormDraft,
|
type MainFormDraft,
|
||||||
type SupplierEditAbilities,
|
type SupplierEditAbilities,
|
||||||
} from '~/modules/commercial/utils/supplierEdit'
|
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||||
import {
|
import {
|
||||||
buildSupplierFormTabKeys,
|
buildSupplierFormTabKeys,
|
||||||
isAddressValid,
|
isAddressValid,
|
||||||
@@ -396,7 +397,7 @@ import {
|
|||||||
isRibBlank,
|
isRibBlank,
|
||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
} from '~/modules/commercial/utils/supplierFormRules'
|
} from '~/modules/commercial/utils/forms/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/supplierFormRules'
|
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/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/supplierConsultation'
|
} from '~/modules/commercial/utils/forms/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,6 +71,7 @@
|
|||||||
: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"
|
||||||
@@ -361,7 +362,7 @@ import {
|
|||||||
isRibComplete,
|
isRibComplete,
|
||||||
isRibRequiredForPaymentType,
|
isRibRequiredForPaymentType,
|
||||||
lastFillableTabKey,
|
lastFillableTabKey,
|
||||||
} from '~/modules/commercial/utils/supplierFormRules'
|
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import {
|
import {
|
||||||
buildAccountingPayload,
|
buildAccountingPayload,
|
||||||
buildAddressPayload,
|
buildAddressPayload,
|
||||||
@@ -369,7 +370,7 @@ import {
|
|||||||
buildInformationPayload,
|
buildInformationPayload,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
buildRibPayload,
|
buildRibPayload,
|
||||||
} from '~/modules/commercial/utils/supplierEdit'
|
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||||
import {
|
import {
|
||||||
emptyAddress,
|
emptyAddress,
|
||||||
emptyContact,
|
emptyContact,
|
||||||
@@ -549,6 +550,8 @@ 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,6 +36,7 @@ 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',
|
||||||
@@ -140,6 +141,16 @@ 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', () => {
|
||||||
+11
-2
@@ -11,7 +11,7 @@ import {
|
|||||||
mapMainDraft,
|
mapMainDraft,
|
||||||
resolveTabEditability,
|
resolveTabEditability,
|
||||||
} from '../supplierEdit'
|
} from '../supplierEdit'
|
||||||
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
import type { SupplierDetail } from '~/modules/commercial/utils/forms/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, employeesCount: null,
|
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
|
||||||
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +48,15 @@ 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)', () => {
|
||||||
+14
-3
@@ -20,14 +20,14 @@ import {
|
|||||||
iriOf,
|
iriOf,
|
||||||
relationOf,
|
relationOf,
|
||||||
type ClientDetail,
|
type ClientDetail,
|
||||||
} from '~/modules/commercial/utils/clientConsultation'
|
} from '~/modules/commercial/utils/forms/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/clientFormRules'
|
} from '~/modules/commercial/utils/forms/clientFormRules'
|
||||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +53,13 @@ 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
|
||||||
@@ -118,6 +125,8 @@ 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,
|
||||||
@@ -191,7 +200,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
|||||||
return {
|
return {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
foundedAt: information.foundedAt || null,
|
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||||
|
// 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,
|
||||||
+14
-3
@@ -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/supplierFormRules'
|
} from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||||
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
|
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||||
import type {
|
import type {
|
||||||
SupplierAddressFormDraft,
|
SupplierAddressFormDraft,
|
||||||
SupplierContactFormDraft,
|
SupplierContactFormDraft,
|
||||||
@@ -38,6 +38,13 @@ 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
|
||||||
@@ -95,6 +102,8 @@ 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,
|
||||||
@@ -177,7 +186,9 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
|
|||||||
return {
|
return {
|
||||||
description: information.description || null,
|
description: information.description || null,
|
||||||
competitors: information.competitors || null,
|
competitors: information.competitors || null,
|
||||||
foundedAt: information.foundedAt || null,
|
// Saisie invalide (foundedAtRaw) prioritaire : on l'envoie telle quelle
|
||||||
|
// 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.8",
|
"@malio/layer-ui": "^1.7.10",
|
||||||
"@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.8",
|
"version": "1.7.10",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
|
||||||
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
|
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
|
||||||
"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.8",
|
"@malio/layer-ui": "^1.7.10",
|
||||||
"@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,6 +41,22 @@ 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, mapViolationsToRecord } from '~/shared/utils/api'
|
import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } 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,13 +69,16 @@ export function useFormErrors() {
|
|||||||
* violation exploitable).
|
* violation exploitable).
|
||||||
*/
|
*/
|
||||||
function setServerErrors(data: unknown): boolean {
|
function setServerErrors(data: unknown): boolean {
|
||||||
const mapped = mapViolationsToRecord(data)
|
const violations = extractApiViolations(data)
|
||||||
const keys = Object.keys(mapped)
|
let mapped = false
|
||||||
if (keys.length === 0) return false
|
for (const v of violations) {
|
||||||
for (const key of keys) {
|
if (!v.propertyPath) continue
|
||||||
errors[key] = mapped[key]
|
// Message back tel quel, sauf code surcharge par une cle i18n (ex.
|
||||||
|
// erreur de type sur une date non parsable -> « Date invalide »).
|
||||||
|
errors[v.propertyPath] = resolveViolationMessage(v, t)
|
||||||
|
mapped = true
|
||||||
}
|
}
|
||||||
return true
|
return mapped
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { mapViolationsToRecord } from '../api'
|
import { mapViolationsToRecord, resolveViolationMessage } from '../api'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
|
||||||
@@ -56,3 +56,30 @@ 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,11 +34,15 @@ 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.
|
* pointe le champ concerne, `message` est le libelle a afficher, `code` est le
|
||||||
|
* 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +65,7 @@ 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
|
||||||
@@ -85,6 +90,45 @@ 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,6 +84,17 @@ 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 phone_primary IS NOT NULL OR email IS NOT NULL),
|
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),
|
||||||
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/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/fonction/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).');
|
$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', '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,7 +21,8 @@ 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: [
|
||||||
@@ -48,15 +49,15 @@ class Bank
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ 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;
|
||||||
|
|
||||||
@@ -94,6 +96,12 @@ 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(
|
||||||
@@ -117,6 +125,10 @@ 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,
|
||||||
),
|
),
|
||||||
@@ -206,6 +218,13 @@ 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,7 +21,8 @@ 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: [
|
||||||
@@ -48,15 +49,15 @@ class PaymentDelay
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ 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: [
|
||||||
@@ -51,15 +52,15 @@ class PaymentType
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ 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;
|
||||||
|
|
||||||
@@ -94,6 +96,12 @@ 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(
|
||||||
@@ -113,6 +121,10 @@ 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,
|
||||||
),
|
),
|
||||||
@@ -187,6 +199,11 @@ 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,7 +25,8 @@ 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: [
|
||||||
@@ -55,15 +56,15 @@ class TvaMode
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -50,11 +50,19 @@ 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 (vide pour usine : aucun acces ; admin n'apparait pas car il
|
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
|
||||||
* bypass tout via isAdmin ; `commercial.clients.archive` et
|
* `commercial.clients.archive`, `commercial.suppliers.archive` et
|
||||||
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
|
* `technique.providers.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 = [
|
||||||
@@ -66,6 +74,11 @@ 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',
|
||||||
@@ -82,6 +95,13 @@ 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',
|
||||||
@@ -96,14 +116,25 @@ 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',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
self::ROLE_USINE => [
|
self::ROLE_USINE => [
|
||||||
'label' => 'Usine',
|
'label' => 'Usine',
|
||||||
'permissions' => [],
|
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
|
||||||
|
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
|
||||||
|
// courant. Aucun autre acces metier.
|
||||||
|
'permissions' => [
|
||||||
|
'technique.providers.view',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,15 @@ 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,6 +65,23 @@ 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.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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;
|
||||||
@@ -60,6 +61,8 @@ 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',
|
||||||
@@ -80,10 +83,12 @@ 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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -92,7 +97,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
|
class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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;
|
||||||
@@ -47,6 +48,8 @@ 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',
|
||||||
@@ -67,10 +70,12 @@ 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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -79,7 +84,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
|
class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?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,6 +11,7 @@ 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;
|
||||||
@@ -49,6 +50,8 @@ 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',
|
||||||
@@ -69,10 +72,12 @@ 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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -81,7 +86,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
|
class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
|
||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
|||||||
+8
@@ -11,6 +11,7 @@ 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;
|
||||||
@@ -53,6 +54,7 @@ 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
|
||||||
@@ -98,6 +100,12 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-5
@@ -11,6 +11,7 @@ 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;
|
||||||
@@ -46,6 +47,7 @@ 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
|
||||||
@@ -94,6 +96,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +112,7 @@ 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()));
|
||||||
@@ -112,21 +120,22 @@ 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 / telephone principal / email est renseigne (double garde avec le CHECK
|
* nom / fonction / telephone principal / email est renseigne (double garde avec
|
||||||
* BDD chk_provider_contact_name — leve une 422 propre rattachee au champ
|
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
|
||||||
* `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
||||||
* chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja
|
* chaines vides (y compris une fonction ou un phone_secondary vides) 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, téléphone ou email).',
|
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
|
||||||
null,
|
null,
|
||||||
[],
|
[],
|
||||||
$contact,
|
$contact,
|
||||||
|
|||||||
+7
@@ -9,6 +9,7 @@ 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;
|
||||||
@@ -42,6 +43,7 @@ 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
|
||||||
@@ -88,6 +90,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-42
@@ -9,12 +9,10 @@ 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\Shared\Domain\Contract\SiteInterface;
|
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||||
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,12 +62,11 @@ 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,
|
||||||
private readonly Security $security,
|
// Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
|
||||||
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
|
// source UNIQUE partagee avec le provider decore des sous-ressources
|
||||||
// CurrentSiteProvider dans le service et ajouter la clause WHERE
|
// (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
|
||||||
// manuellement » pour les cas multi-site non couverts par
|
// eviter tout drift entre ces points d'application.
|
||||||
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
|
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||||
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
|
||||||
@@ -109,7 +106,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->siteScopeOrNull();
|
$scopeSite = $this->scopeChecker->siteScopeOrNull();
|
||||||
if (null !== $scopeSite) {
|
if (null !== $scopeSite) {
|
||||||
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||||
}
|
}
|
||||||
@@ -164,44 +161,14 @@ 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.
|
// currentSite null (delegue au ProviderSiteScopeChecker).
|
||||||
$scopeSite = $this->siteScopeOrNull();
|
if (!$this->scopeChecker->isInScope($provider)) {
|
||||||
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
@@ -0,0 +1,52 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,15 @@ 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\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;
|
||||||
@@ -320,6 +323,121 @@ 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).
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,4 +80,91 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<?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,4 +156,21 @@ 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<?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/telephone/email), RG-3.05 (>= 1 site sur
|
* (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur
|
||||||
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
|
* 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,18 +53,37 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG-3.04 : un bloc sans aucun champ du CHECK (prenom/nom/telephone/email) est
|
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
|
||||||
* rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName.
|
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
|
||||||
* Ici seul jobTitle est fourni (hors CHECK).
|
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
|
||||||
*/
|
*/
|
||||||
public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void
|
public function testPostContactWithOnlyJobTitleReturns201(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedProvider('Contact No Name');
|
$seed = $this->seedProvider('Contact JobTitle Only');
|
||||||
|
|
||||||
|
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||||
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
|
'json' => ['jobTitle' => 'Directeur'],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
self::assertSame('Directeur', $data['jobTitle']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK
|
||||||
|
* chk_provider_contact_name) est rejete avant la base -> 422 rattachee a
|
||||||
|
* firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider.
|
||||||
|
*/
|
||||||
|
public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$seed = $this->seedProvider('Contact No Field');
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
$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' => 'Directeur'],
|
'json' => ['jobTitle' => ' '],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?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