Compare commits

..

8 Commits

Author SHA1 Message Date
Matthieu bfed6ddca9 feat(technique) : câbler le RBAC technique.providers.* (3 sources + matrice rôles + bypass_scope) (ERP-138)
Câble les permissions du module Technique dans toutes les sources RBAC (règle
ABSOLUE n°8, dans le même commit) :

- RbacSeeder::MATRIX : bureau/compta/commerciale reçoivent technique.providers.*
  selon la matrice § 2.9 + sites.bypass_scope (visibilité multi-site, § 2.13) ;
  usine = technique.providers.view seul, SANS bypass (cloisonnée à son site).
- config/sidebar.php : nouvelle section Technique + item Répertoire prestataires
  (/providers, module technique, permission technique.providers.view).
- personas.ts + SeedE2ECommand.php : 5 perms technique.providers.* sur le persona
  user-full (porte déjà sites.bypass_scope) — pas de nouveau persona (règle n°7).
- i18n fr.json : clés sidebar.technique.section / sidebar.technique.providers.

Test : ProviderRBACMatrixTest (miroir SupplierRBACMatrixTest) valide la matrice
rôle×verbe via app:seed-rbac, dont le cloisonnement par site de l'Usine
(détail hors site → 404). 8 tests, 65 assertions.
2026-06-12 14:51:07 +02:00
Matthieu 11eeb13bff feat(technique) : export XLSX du repertoire prestataires (ProviderExportController, priority:1) (ERP-137) 2026-06-12 14:22:40 +02:00
Matthieu 9a6ec71981 feat(technique) : validations RG comptables server-side (RG-3.07 Virement/banque, RG-3.08 LCR/RIB) (ERP-136)
- Provider::validatePaymentTypeConsistency (Assert\Callback, miroir Supplier ERP-89) :
  RG-3.07 VIREMENT impose une banque (violation sur bank),
  RG-3.08 LCR impose au moins un RIB (violation sur paymentType).
- ProviderProcessor : docblock realigne (RG-3.07/3.08 portees par l'entite).
- AbstractProviderApiTestCase::bank() helper referentiel.
- ProviderAccountingValidationTest : 4 cas (negatif 422 / positif 200) par RG.

Les RG-3.03/3.05/3.09 (contraintes d'entite) et l'ecriture cloisonnee (gardes
processors, RG-3.17/2.13) etaient deja posees en ERP-133/134/135 et restent couvertes.
2026-06-12 11:51:12 +02:00
Matthieu 9a0da4de63 feat(technique) : sous-ressources Contacts / Adresses / RIBs (ERP-135)
Expose les sous-collections du prestataire en #[ApiResource] (POST sur le
parent + PATCH/DELETE/GET unitaires), edition complete par onglet (pas de
POST-only, RETEX M1/M2) :

- ProviderContact : POST /providers/{id}/contacts, PATCH/DELETE
  /provider_contacts/{id} (security technique.providers.manage).
  ProviderContactProcessor : normalisation RG-3.11 (nom/prenom Title Case,
  telephones chiffres, email lowercase) + RG-3.04 (au moins un champ parmi
  prenom/nom/telephone/email, miroir du CHECK chk_provider_contact_name -> 422).
- ProviderAddress : POST /providers/{id}/addresses, PATCH/DELETE
  /provider_addresses/{id} (security technique.providers.manage).
  ProviderAddressProcessor : rattachement parent + cloisonnement d'ecriture des
  sites de l'adresse (RG-3.05 / § 2.13 : site hors user_site -> 422 sur sites).
- ProviderRib : POST /providers/{id}/ribs, PATCH/DELETE /provider_ribs/{id}
  (security technique.providers.accounting.manage). ProviderRibProcessor :
  RG-3.08 (DELETE du dernier RIB sous LCR -> 409).

Tests : ProviderSubResourceApiTest (19 cas) — CRUD chaque sous-ressource, 403
selon permission (Contacts/Adresses=manage, RIB=accounting.manage), 409 dernier
RIB LCR, 422 cloisonnement site adresse. Helpers addContact/addRib/paymentType
ajoutes a AbstractProviderApiTestCase.
2026-06-12 11:32:08 +02:00
Matthieu 0ca1fb159a feat(technique) : ProviderProvider + ProviderProcessor + cloisonnement site (ERP-134)
Coeur API du repertoire prestataires (M3), jumeau du M2 fournisseurs :

- ProviderProvider : liste paginee (Paginator ORM), filtres
  search/categoryCode/siteId/includeArchived, tri companyName ASC,
  exclusion archives + soft-deletes (RG-3.16). Cloisonnement par site
  pilote par l'utilisateur (RG-3.17 / § 2.13) : liste restreinte au
  currentSite avant pagination (totalItems = perimetre), detail hors
  perimetre -> 404, bypass via sites.bypass_scope.
- ProviderProcessor : normalisation companyName (RG-3.11), POST formulaire
  principal (companyName + categories + sites), PATCH partiels par groupe
  en mode strict (RG-3.15, 403 sur tout le payload), archivage
  (RG-3.13/3.14), 409 doublon de nom (RG-3.10), garde d'ecriture cloisonnee
  des sites (RG-3.03/3.17, 422 sur sites pour les users sites.read_ref).
- ProviderReadGroupContextBuilder : gating comptabilite par AJOUT du groupe
  provider:read:accounting si accounting.view (jamais par retrait).
- ProviderFieldNormalizer : miroir SupplierFieldNormalizer.
- ApiResource cable (provider + processor) sur l'entite Provider.

Tests : ProviderApiTest, ProviderListTest, ProviderRbacGatingTest,
ProviderSiteScopeTest (26 tests). Suite complete verte (612 tests).
2026-06-12 11:03:19 +02:00
Matthieu 58474404b4 feat(technique) : entités + repositories Provider* (ERP-133)
- 4 entités Provider / ProviderContact / ProviderAddress / ProviderRib
  (#[Auditable] + Timestampable/Blamable), miroir Supplier* amputé de
  l'onglet Information et augmenté de provider.sites (M2M direct, RG-3.03).
- Contrat de sérialisation à 3 maillons (groupes liste/détail, getter
  isArchived + SerializedName) ; référentiels comptables consommés en
  relation ORM partagée, Site/Category via contrats Shared.
- DoctrineProviderRepository : createListQueryBuilder (filtres + tri) +
  hydratation anti-N+1 categories puis sites (relation directe) en requêtes
  IN bornées séparées.
- Mapping ORM du module Technique (doctrine.yaml), catalogue COMMENT des
  tables provider*, index partiel uq_provider_company_name_active
  (test-db-setup), libellés audit i18n technique_*, whitelist Length du CP
  ProviderAddress.

ApiResource posé en squelette : ProviderProvider / ProviderProcessor
(hydratation effective, gating accounting, cloisonnement site, normalisation,
409, RG-3.07/3.08) relèvent d'ERP-134.
2026-06-12 10:31:33 +02:00
Matthieu 779d5be04e feat(technique) : migration schema repertoire prestataires (ERP-132)
Cree tout le schema BDD M3 du prestataire (jumeau du M2 fournisseur), sous
le namespace racine DoctrineMigrations (FK cross-module user/category/site +
referentiels comptables M1) :

- provider : company_name + bloc Comptabilite (siren/account_number/n_tva +
  FK tva_mode/payment_delay/payment_type/bank ON DELETE RESTRICT) +
  is_archived/archived_at/deleted_at + Timestampable/Blamable. Pas d onglet
  Information (contrairement a supplier).
- M2M formulaire principal : provider_category (RG-3.09), provider_site
  (sites du prestataire, RG-3.03 — nouveau vs supplier, + idx_provider_site_site).
- Sous-collections : provider_contact (CHECK chk_provider_contact_name :
  >=1 champ parmi first_name/last_name/phone_primary/email), provider_address
  (sans address_type/bennes/triage), provider_rib.
- Jointures adresse : provider_address_site (RG-3.05), provider_address_contact,
  provider_address_category.
- Index partiel unique uq_provider_company_name_active (LOWER(company_name)
  WHERE non archive/non supprime — RG-3.10) + index FK.
- COMMENT ON COLUMN/TABLE inline sur toutes les colonnes (regle n°12).

CategoryType PRESTATAIRE non re-seede (deja cree par ERP-131). Catalogue
ColumnCommentsCatalog et ligne dbal:run-sql differes au ticket entites (ERP-133),
comme supplier : tant que les entites Provider* n existent pas, schema:update du
setup test droppe ces tables non mappees et app:apply-column-comments planterait.
2026-06-12 09:48:07 +02:00
Matthieu 6ceef62056 feat(technique) : module Technique + taxonomie categories prestataires
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m13s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m27s
Cree le nouveau module Technique (pole distinct du Commercial) prerequis du
M3 repertoire prestataires :
- TechniqueModule (ID=technique, REQUIRED=false) + 5 permissions RBAC
  technique.providers.* (view / manage / accounting.view / accounting.manage
  / archive), declarees pour app:sync-permissions.
- Activation dans config/modules.php + layer front frontend/modules/technique/.
- Seed taxonomie : nouveau CategoryType PRESTATAIRE + 3 categories
  (Maintenance industrielle, Nettoyage, Transport) via migration idempotente
  (ON CONFLICT / NOT EXISTS, jonction M2M category_category_type) ET fixtures
  CategoryType/Category (survivent au purger db-reset).
- Tests : structure du module (5 permissions figees) + filtre
  GET /api/categories?typeCode=PRESTATAIRE.

Inclut la spec back/front M3 et le RETEX M1.
2026-06-12 09:23:08 +02:00
59 changed files with 170 additions and 2074 deletions
-1
View File
@@ -79,7 +79,6 @@ Regles :
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
- **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`.
- **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`.
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.114'
app.version: '0.1.109'
+52 -138
View File
@@ -176,7 +176,7 @@ Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS
- **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter).
- **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant****404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre).
- **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses).
- **Cohérence sous-ressources** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site.
- **Cohérence sous-ressources** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`).
> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle).
@@ -624,153 +624,67 @@ Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamab
> 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`).
> 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`).
> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front.
`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) :
`GET /api/providers` (liste, ADMIN — un membre, forme attendue) :
```json
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{
"@type": "Category",
"@id": "/api/categories/3006",
"id": 3006,
"name": "test_prov_cat_nettoyage",
"code": "NETTOYAGE",
"categoryTypes": [
{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"siren": "987654321",
"accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00987654321",
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
"ribs": [
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
}
],
"view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"}
"@context": "/api/contexts/Provider",
"@id": "/api/providers",
"@type": "Collection",
"totalItems": 1,
"member": [
{
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
"companyName": "MAINTENANCE PRO SAS",
"categories": [
{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE",
"categoryType": {"@id": "/api/category_types/3", "@type": "CategoryType", "id": 3, "code": "PRESTATAIRE", "label": "Prestataire"}}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}
],
"siren": "987654321", "accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
"ribs": [
{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
],
"updatedAt": "2026-06-11T10:00:00+02:00",
"isArchived": false
}
],
"view": {"@id": "/api/providers", "@type": "PartialCollectionView"}
}
```
> Les `sites[]` de la liste sont la **relation directe** `provider.sites` (formulaire principal — RG-3.03), objet `Site` entier (pas un IRI nu). Les catégories embarquent `code` + `name`. Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour un profil **sans `accounting.view`** (ex. Commerciale), `siren`/`accountNumber`/`tvaMode`/`nTva`/`paymentDelay`/`paymentType`/`bank`/`ribs` **disparaissent** de chaque membre (gating par omission — cf. détail restreint ci-dessous).
> Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs` **disparaissent** de chaque membre.
`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) :
`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) :
```json
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"siren": "987654321",
"accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00987654321",
"paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"},
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"addresses": [
{
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"ribs": [
{"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
"@id": "/api/providers/1", "@type": "Provider", "id": 1,
"companyName": "MAINTENANCE PRO SAS",
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}],
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
"siren": "987654321", "accountNumber": "P0001",
"tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"},
"nTva": "FR00987654321",
"paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"},
"paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"},
"contacts": [
{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
],
"addresses": [
{"@id": "/api/provider_addresses/1", "@type": "ProviderAddress", "id": 1, "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [{"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}],
"contacts": [{"@id": "/api/provider_contacts/1", "@type": "ProviderContact", "id": 1, "firstName": "Marie", "lastName": "Martin"}],
"categories": [{"@type": "Category", "@id": "/api/categories/300", "id": 300, "name": "Maintenance industrielle", "code": "MAINTENANCE"}]}
],
"ribs": [{"@id": "/api/provider_ribs/1", "@type": "ProviderRib", "id": 1, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}],
"isArchived": false
}
```
`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) :
```json
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"addresses": [
{
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
}
```
> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`.
>
> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`.
> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (gating par omission — à confirmer par test).
### 4.1 `GET /api/providers` — Liste
@@ -1009,7 +923,7 @@ Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur `
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only**
- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139)
- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front)
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15)
- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés
- [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`)
+1 -4
View File
@@ -390,10 +390,7 @@
},
"title": "Erreur",
"generic": "Une erreur est survenue.",
"unknown": "Erreur inconnue.",
"validation": {
"invalidDate": "Date invalide"
}
"unknown": "Erreur inconnue."
},
"sites": {
"selector": {
@@ -187,7 +187,7 @@ import {
addressTypeFromFlags,
isBillingEmailRequired,
type AddressType,
} from '~/modules/commercial/utils/forms/clientFormRules'
} from '~/modules/commercial/utils/clientFormRules'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
@@ -1,5 +1,5 @@
import { ref } from 'vue'
import type { ClientDetail } from '~/modules/commercial/utils/forms/clientConsultation'
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
/**
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
@@ -1,5 +1,5 @@
import { ref } from 'vue'
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
/**
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
@@ -116,7 +116,6 @@
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -402,7 +401,7 @@ import {
mapAddressToDraft,
mapRibToDraft,
type ClientDetail,
} from '~/modules/commercial/utils/forms/clientConsultation'
} from '~/modules/commercial/utils/clientConsultation'
import {
buildAccountingPayload,
buildAddressPayload,
@@ -418,7 +417,7 @@ import {
type ClientEditAbilities,
type InformationFormDraft,
type MainFormDraft,
} from '~/modules/commercial/utils/forms/clientEdit'
} from '~/modules/commercial/utils/clientEdit'
import {
buildClientFormTabKeys,
isAddressValid,
@@ -430,7 +429,7 @@ import {
isRibComplete,
isRibRequiredForPaymentType,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/forms/clientFormRules'
} from '~/modules/commercial/utils/clientFormRules'
import {
emptyAddress,
emptyContact,
@@ -280,7 +280,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditClient,
@@ -297,7 +297,7 @@ import {
showRestoreAction,
type ClientDetail,
type SelectOption,
} from '~/modules/commercial/utils/forms/clientConsultation'
} from '~/modules/commercial/utils/clientConsultation'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
@@ -111,7 +111,6 @@
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -402,12 +401,12 @@ import {
isRibRequiredForPaymentType,
lastFillableTabKey,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/forms/clientFormRules'
} from '~/modules/commercial/utils/clientFormRules'
import {
buildAddressPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/forms/clientEdit'
} from '~/modules/commercial/utils/clientEdit'
import {
emptyAddress,
emptyContact,
@@ -652,8 +651,6 @@ const information = reactive({
description: null as string | null,
competitors: 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,
revenueAmount: null as string | null,
profitAmount: null as string | null,
@@ -669,8 +666,7 @@ async function submitInformation(): Promise<void> {
await api.patch(`/clients/${clientId.value}`, {
description: information.description || null,
competitors: information.competitors || null,
// Saisie invalide prioritaire -> 422 back sur foundedAt (cf. foundedAtRaw).
foundedAt: information.foundedAtRaw || information.foundedAt || null,
foundedAt: information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
@@ -77,7 +77,6 @@
:readonly="businessReadonly"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -371,7 +370,7 @@ import {
mapAddressToDraft,
mapRibToDraft,
type SupplierDetail,
} from '~/modules/commercial/utils/forms/supplierConsultation'
} from '~/modules/commercial/utils/supplierConsultation'
import {
buildAccountingPayload,
buildAddressPayload,
@@ -387,7 +386,7 @@ import {
type InformationFormDraft,
type MainFormDraft,
type SupplierEditAbilities,
} from '~/modules/commercial/utils/forms/supplierEdit'
} from '~/modules/commercial/utils/supplierEdit'
import {
buildSupplierFormTabKeys,
isAddressValid,
@@ -397,7 +396,7 @@ import {
isRibBlank,
isRibComplete,
isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/forms/supplierFormRules'
} from '~/modules/commercial/utils/supplierFormRules'
import {
emptyAddress,
emptyContact,
@@ -263,7 +263,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditSupplier,
@@ -280,7 +280,7 @@ import {
showRestoreAction,
type SelectOption,
type SupplierDetail,
} from '~/modules/commercial/utils/forms/supplierConsultation'
} from '~/modules/commercial/utils/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
@@ -71,7 +71,6 @@
:readonly="isValidated('information')"
:editable="true"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
<MalioInputText
v-model="information.employeesCount"
@@ -362,7 +361,7 @@ import {
isRibComplete,
isRibRequiredForPaymentType,
lastFillableTabKey,
} from '~/modules/commercial/utils/forms/supplierFormRules'
} from '~/modules/commercial/utils/supplierFormRules'
import {
buildAccountingPayload,
buildAddressPayload,
@@ -370,7 +369,7 @@ import {
buildInformationPayload,
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/forms/supplierEdit'
} from '~/modules/commercial/utils/supplierEdit'
import {
emptyAddress,
emptyContact,
@@ -550,8 +549,6 @@ const information = reactive({
description: null as string | null,
competitors: 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,
revenueAmount: null as string | null,
profitAmount: null as string | null,
@@ -36,7 +36,6 @@ function informationDraft(overrides: Partial<InformationFormDraft> = {}): Inform
description: 'desc',
competitors: 'concurrents',
foundedAt: '2010-05-01',
foundedAtRaw: '',
employeesCount: '42',
revenueAmount: '1000000',
profitAmount: '50000',
@@ -141,16 +140,6 @@ describe('buildInformationPayload — scoping strict groupe client:write:informa
expect(payload.description).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', () => {
@@ -11,7 +11,7 @@ import {
mapMainDraft,
resolveTabEditability,
} from '../supplierEdit'
import type { SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/supplierForm'
describe('buildMainPayload (groupe supplier:write:main)', () => {
@@ -37,7 +37,7 @@ describe('buildMainPayload (groupe supplier:write:main)', () => {
describe('buildInformationPayload (groupe supplier:write:information)', () => {
const base = {
description: null, competitors: null, foundedAt: null, foundedAtRaw: '', employeesCount: null,
description: null, competitors: null, foundedAt: null, employeesCount: null,
revenueAmount: null, profitAmount: null, directorName: null, volumeForecast: null,
}
@@ -48,15 +48,6 @@ describe('buildInformationPayload (groupe supplier:write:information)', () => {
})
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)', () => {
@@ -20,14 +20,14 @@ import {
iriOf,
relationOf,
type ClientDetail,
} from '~/modules/commercial/utils/forms/clientConsultation'
} from '~/modules/commercial/utils/clientConsultation'
import {
ADDRESS_REQUIRED_NON_NULLABLE_KEYS,
blankEmptyRequired,
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/forms/clientFormRules'
} from '~/modules/commercial/utils/clientFormRules'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/**
@@ -53,13 +53,6 @@ export interface InformationFormDraft {
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
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. */
employeesCount: string | null
revenueAmount: string | null
@@ -125,8 +118,6 @@ export function mapInformationDraft(client: ClientDetail): InformationFormDraft
competitors: client.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
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,
revenueAmount: client.revenueAmount ?? null,
profitAmount: client.profitAmount ?? null,
@@ -200,9 +191,7 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
return {
description: information.description || null,
competitors: information.competitors || 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,
foundedAt: information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
@@ -17,8 +17,8 @@ import {
MAIN_REQUIRED_NON_NULLABLE_KEYS,
omitEmptyRequired,
RIB_REQUIRED_NON_NULLABLE_KEYS,
} from '~/modules/commercial/utils/forms/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/forms/supplierConsultation'
} from '~/modules/commercial/utils/supplierFormRules'
import { iriOf, type SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
import type {
SupplierAddressFormDraft,
SupplierContactFormDraft,
@@ -38,13 +38,6 @@ export interface InformationFormDraft {
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
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. */
employeesCount: string | null
revenueAmount: string | null
@@ -102,8 +95,6 @@ export function mapInformationDraft(supplier: SupplierDetail): InformationFormDr
competitors: supplier.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
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,
revenueAmount: supplier.revenueAmount ?? null,
profitAmount: supplier.profitAmount ?? null,
@@ -186,9 +177,7 @@ export function buildInformationPayload(information: InformationFormDraft): Reco
return {
description: information.description || null,
competitors: information.competitors || 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,
foundedAt: information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.7.10",
"@malio/layer-ui": "^1.7.8",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.7.10",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.10/layer-ui-1.7.10.tgz",
"integrity": "sha512-ZWYaKvl+VpGAqeTE+4xdyKOmuRd4zwjlUYVppeIBZwGeNAK16kZnrztR+4eQmnzUqPZVybBhEBdKP9weqWHSUg==",
"version": "1.7.8",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz",
"integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@malio/layer-ui": "^1.7.10",
"@malio/layer-ui": "^1.7.8",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -41,22 +41,6 @@ describe('useFormErrors', () => {
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', () => {
const { errors, setServerErrors } = useFormErrors()
expect(setServerErrors({})).toBe(false)
+7 -10
View File
@@ -17,7 +17,7 @@
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
*/
import { computed, reactive } from 'vue'
import { extractApiErrorMessage, extractApiViolations, resolveViolationMessage } from '~/shared/utils/api'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
/**
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
@@ -69,16 +69,13 @@ export function useFormErrors() {
* violation exploitable).
*/
function setServerErrors(data: unknown): boolean {
const violations = extractApiViolations(data)
let mapped = false
for (const v of violations) {
if (!v.propertyPath) continue
// 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
const mapped = mapViolationsToRecord(data)
const keys = Object.keys(mapped)
if (keys.length === 0) return false
for (const key of keys) {
errors[key] = mapped[key]
}
return mapped
return true
}
/**
+1 -28
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { mapViolationsToRecord, resolveViolationMessage } from '../api'
import { mapViolationsToRecord } from '../api'
/**
* Tests de `mapViolationsToRecord` — fondation du mapping erreur→champ des
@@ -56,30 +56,3 @@ describe('mapViolationsToRecord', () => {
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.')
})
})
+1 -45
View File
@@ -34,15 +34,11 @@ export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
/**
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
* 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`).
* pointe le champ concerne, `message` est le libelle a afficher.
*/
export interface ApiViolation {
propertyPath: string
message: string
code: string
}
/**
@@ -65,7 +61,6 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
out.push({
propertyPath: String(obj.propertyPath ?? ''),
message: String(obj.message ?? ''),
code: String(obj.code ?? ''),
})
}
return out
@@ -90,45 +85,6 @@ export function mapViolationsToRecord(data: unknown): Record<string, string> {
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
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
+3 -3
View File
@@ -252,7 +252,7 @@ final class Version20260612100000 extends AbstractMigration
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_provider_contact_name
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
CONSTRAINT fk_provider_contact_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_contact_created_by
@@ -263,12 +263,12 @@ final class Version20260612100000 extends AbstractMigration
SQL);
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.');
$this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
$this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
$this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
+4 -5
View File
@@ -21,8 +21,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
*/
#[ApiResource(
operations: [
@@ -49,15 +48,15 @@ class Bank
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -22,10 +22,8 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -96,12 +94,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
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,
),
new Patch(
@@ -125,10 +117,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'client:write:accounting',
'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,
processor: ClientProcessor::class,
),
@@ -218,13 +206,6 @@ class Client implements TimestampableInterface, BlamableInterface
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[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;
#[ORM\Column(nullable: true)]
@@ -21,8 +21,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
*/
#[ApiResource(
operations: [
@@ -49,15 +48,15 @@ class PaymentDelay
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -24,8 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
*/
#[ApiResource(
operations: [
@@ -52,15 +51,15 @@ class PaymentType
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -22,10 +22,8 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -96,12 +94,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('commercial.suppliers.manage')",
normalizationContext: ['groups' => ['supplier:read', 'default:read', 'category:read', 'site:read']],
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,
),
new Patch(
@@ -121,10 +113,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
'supplier:write:accounting',
'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,
processor: SupplierProcessor::class,
),
@@ -199,11 +187,6 @@ class Supplier implements TimestampableInterface, BlamableInterface
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[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;
#[ORM\Column(nullable: true)]
@@ -25,8 +25,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0).
*/
#[ApiResource(
operations: [
@@ -56,15 +55,15 @@ class TvaMode
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -65,23 +65,6 @@ final class ProviderFieldNormalizer
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Texte libre simplement trim (ex : jobTitle / Fonction du contact). Pas de
* changement de casse — on preserve la saisie. Une chaine vide apres trim
* devient null (evite de persister "" et de faire passer a tort le garde-fou
* RG-3.04 / le CHECK chk_provider_contact_name sur une Fonction vide).
*/
public function normalizeText(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
/**
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
* "0612345678". Une valeur sans aucun chiffre devient null.
@@ -11,7 +11,6 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
@@ -61,8 +60,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
// site:read + category:read : embarquent les Site / Category lies
// (maillon (c)) plutot que des IRI nus dans le retour.
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/addresses',
@@ -83,12 +80,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:addresses']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class,
),
],
@@ -97,7 +92,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'provider_address')]
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
class ProviderAddress implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -11,7 +11,6 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
@@ -48,8 +47,6 @@ use Symfony\Component\Validator\Constraints as Assert;
new Get(
security: "is_granted('technique.providers.view')",
normalizationContext: ['groups' => ['provider:item:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/contacts',
@@ -70,12 +67,10 @@ use Symfony\Component\Validator\Constraints as Assert;
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read']],
denormalizationContext: ['groups' => ['provider:write:contacts']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class,
),
],
@@ -84,7 +79,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'provider_contact')]
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
class ProviderContact implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
/**
* Contrat des sous-ressources d'un prestataire (Contact, Adresse, RIB) : chacune
* appartient a un Provider parent. Permet au provider decore
* ProviderSubResourceItemProvider d'appliquer le cloisonnement par site du parent
* (§ 2.13 / RG-3.17) de maniere uniforme, sans connaitre le type concret.
*/
interface ProviderOwnedInterface
{
public function getProvider(): ?Provider;
}
@@ -11,7 +11,6 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
@@ -50,8 +49,6 @@ use Symfony\Component\Validator\Constraints as Assert;
new Get(
security: "is_granted('technique.providers.accounting.view')",
normalizationContext: ['groups' => ['provider:read:accounting']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/ribs',
@@ -72,12 +69,10 @@ use Symfony\Component\Validator\Constraints as Assert;
security: "is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read:accounting']],
denormalizationContext: ['groups' => ['provider:write:accounting']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.accounting.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class,
),
],
@@ -86,7 +81,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'provider_rib')]
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
class ProviderRib implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
@@ -11,7 +11,6 @@ use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
@@ -54,7 +53,6 @@ final class ProviderAddressProcessor implements ProcessorInterface
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -100,12 +98,6 @@ final class ProviderAddressProcessor implements ProcessorInterface
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);
}
@@ -11,7 +11,6 @@ use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -47,7 +46,6 @@ final class ProviderContactProcessor implements ProcessorInterface
private readonly ProcessorInterface $removeProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -96,11 +94,6 @@ final class ProviderContactProcessor implements ProcessorInterface
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);
}
@@ -112,7 +105,6 @@ final class ProviderContactProcessor implements ProcessorInterface
{
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
@@ -120,22 +112,21 @@ final class ProviderContactProcessor implements ProcessorInterface
/**
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
* nom / fonction / telephone principal / email est renseigne (double garde avec
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
* nom / telephone principal / email est renseigne (double garde avec le CHECK
* BDD chk_provider_contact_name — leve une 422 propre rattachee au champ
* `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja
* ramenees a null et ne suffisent pas a valider le bloc.
*/
private function validateName(ProviderContact $contact): void
{
if (null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
'Au moins un champ du contact est obligatoire (nom, prénom, téléphone ou email).',
null,
[],
$contact,
@@ -9,7 +9,6 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
@@ -43,7 +42,6 @@ final class ProviderRibProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -90,11 +88,6 @@ final class ProviderRibProcessor implements ProcessorInterface
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);
}
@@ -9,10 +9,12 @@ use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
@@ -62,11 +64,12 @@ final class ProviderProvider implements ProviderInterface
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly Pagination $pagination,
// Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
// source UNIQUE partagee avec le provider decore des sous-ressources
// (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
// eviter tout drift entre ces points d'application.
private readonly ProviderSiteScopeChecker $scopeChecker,
private readonly Security $security,
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
// CurrentSiteProvider dans le service et ajouter la clause WHERE
// manuellement » pour les cas multi-site non couverts par
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
@@ -106,7 +109,7 @@ final class ProviderProvider implements ProviderInterface
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
$scopeSite = $this->scopeChecker->siteScopeOrNull();
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
@@ -161,14 +164,44 @@ final class ProviderProvider implements ProviderInterface
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
// currentSite null (delegue au ProviderSiteScopeChecker).
if (!$this->scopeChecker->isInScope($provider)) {
// currentSite null.
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) {
return null;
}
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".
*/
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Technique\Domain\Entity\ProviderOwnedInterface;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider d'item des sous-ressources d'un prestataire (Contact / Adresse / RIB).
* Decore le provider Doctrine par defaut et applique le cloisonnement par site du
* PARENT (§ 2.13 / RG-3.17) sur Get / Patch / Delete.
*
* Sans ce garde, un user cloisonne pourrait lire / editer / supprimer une
* sous-ressource d'un prestataire hors de son site : le detail Provider est bien
* garde en 404 (ProviderProvider), mais les sous-ressources ne passent pas par lui
* (provider Doctrine par defaut, et SiteScopedQueryExtension ne filtre que les
* resources SiteAwareInterface — ce que ces entites ne sont pas). Le RIB est
* particulierement sensible (IBAN / BIC).
*
* Hors perimetre -> retour null -> 404 (anti-enumeration, coherent avec le detail
* Provider). La decision de scope est deleguee a ProviderSiteScopeChecker (source
* unique partagee avec le ProviderProvider et les processors).
*
* @implements ProviderInterface<ProviderOwnedInterface>
*/
final class ProviderSubResourceItemProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
private readonly ProviderInterface $itemProvider,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
{
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
if ($entity instanceof ProviderOwnedInterface) {
$parent = $entity->getProvider();
if (null === $parent || !$this->scopeChecker->isInScope($parent)) {
return null;
}
}
return $entity;
}
}
@@ -1,393 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\DataFixtures;
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Commercial\Infrastructure\DataFixtures\CommercialReferentialFixtures;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/test du module Technique : prestataires de demonstration couvrant
* les cas metier RG-3.xx du repertoire prestataires (M3), jumelles des fixtures
* fournisseurs (M2). Theme : prestations techniques (maintenance, nettoyage,
* transport).
*
* Cas pivots couverts (§ 8.4) :
* - prestataire COMPLET : >= 1 site sur le formulaire principal (RG-3.03), >= 1
* contact, >= 1 adresse multi-sites (RG-3.05), comptabilite + RIB ;
* - reglement LCR avec RIB (RG-3.08) ; reglement VIREMENT avec banque (RG-3.07) ;
* - 1 prestataire archive (isArchived + archivedAt) pour l'exclusion de la liste
* (RG-3.16) ;
* - prestataires repartis sur des sites DIFFERENTS (86 / 17 / 82) pour exercer le
* cloisonnement par site (RG-3.17) ;
* - mono et multi-categories de type PRESTATAIRE (RG-3.09).
*
* Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) :
* - categories resolues via le contrat Shared CategoryInterface ;
* - sites resolus via le contrat Shared SiteProviderInterface.
*
* Normalisation : valeurs fournies BRUTES, normalisees par ProviderFieldNormalizer
* avant persist, exactement comme le ferait le ProviderProcessor via l'API
* (companyName UPPERCASE, first/last Capitalize, telephones chiffres seuls, emails
* lowercase — RG-3.11).
*
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
* partiel uq_provider_company_name_active). Un prestataire deja present n'est pas
* reconstruit (sous-collections non redupliquees). Rejouable sans doublon.
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la
* fixture ne charge rien : les tests seedent et nettoient leurs propres
* prestataires et comptent sur une table `provider` vierge. Meme garde-fou que
* SupplierFixtures / CategoryFixtures.
*/
class ProviderFixtures extends Fixture implements DependentFixtureInterface
{
/**
* Type de categorie exige pour un prestataire et ses adresses (RG-3.09).
* Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
*/
private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
/** Cache des categories resolues par nom. */
private array $categoryCache = [];
/** Cache des sites resolus par nom. */
private array $siteCache = [];
/** ObjectManager courant, capture en debut de load. */
private ObjectManager $manager;
public function __construct(
private readonly ProviderFieldNormalizer $normalizer,
private readonly SiteProviderInterface $siteProvider,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [
CategoryFixtures::class,
SitesFixtures::class,
CommercialReferentialFixtures::class,
];
}
public function load(ObjectManager $manager): void
{
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
$this->manager = $manager;
// === Prestataire COMPLET — VIREMENT + banque (RG-3.07), compta + RIB, ===
// === multi-sites sur le formulaire principal ET sur l'adresse. ===
[$maintenance, $isNew] = $this->ensureProvider($manager, 'Maintenance Pro SAS', ['Maintenance industrielle'], ['Chatellerault', 'Saint-Jean']);
if ($isNew) {
$maintenance->setSiren('841611054');
$maintenance->setAccountNumber('P0001');
$maintenance->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$maintenance->setNTva('FR12841611054');
$maintenance->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$maintenance->setBank($this->bank($manager, 'SG'));
$this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr');
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']);
$this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
}
// === LCR avec RIB (RG-3.08) — site Pommevic ===
[$nettoyage, $isNew] = $this->ensureProvider($manager, 'Nettoyage Sud-Ouest', ['Nettoyage'], ['Pommevic']);
if ($isNew) {
$nettoyage->setSiren('775680459');
$nettoyage->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$nettoyage->setPaymentDelay($this->paymentDelay($manager, 'J15'));
$nettoyage->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($nettoyage, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@nettoyage-so.fr', 0);
$this->addContact($nettoyage, 'Marc', 'Girard', 'Chef d\'equipe', '05 56 10 20 31', null, 'marc.girard@nettoyage-so.fr', 1);
$this->addAddress($nettoyage, ['Pommevic'], '82400', 'Pommevic', '8 route des Prestations');
$this->addRib($nettoyage, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
}
// === Multi-categories PRESTATAIRE + reglement CHEQUE (sans banque ni RIB) ===
[$transport, $isNew] = $this->ensureProvider($manager, 'Transport Express Atlantique', ['Transport', 'Maintenance industrielle'], ['Saint-Jean']);
if ($isNew) {
$transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$transport->setPaymentType($this->paymentType($manager, 'CHEQUE'));
$this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr');
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']);
}
// === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 ===
[$petit, $isNew] = $this->ensureProvider($manager, 'Atelier Soudure Locale', ['Maintenance industrielle'], ['Chatellerault']);
if ($isNew) {
$this->addContact($petit, null, 'Caron', 'Gerant', '05 49 81 82 83', null, 'contact@atelier-soudure.fr');
$this->addAddress($petit, ['Chatellerault'], '86100', 'Châtellerault', '6 chemin de l\'Atelier');
}
// === Prestataire archive (RG-3.16) ===
[$ancien, $isNew] = $this->ensureProvider($manager, 'Ancien Prestataire Ferme', ['Nettoyage'], ['Chatellerault'], isArchived: true);
if ($isNew) {
$this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-prestataire.fr');
$this->addAddress($ancien, ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
}
$manager->flush();
}
/**
* Cree un prestataire (base normalisee + categories PRESTATAIRE + sites directs)
* s'il n'existe pas, sinon retourne l'existant. Retourne [Provider, isNew] :
* isNew=false bloque la reconstruction des sous-collections (idempotence).
*
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
* @param list<string> $siteNames sites du formulaire principal (RG-3.03, >= 1)
*
* @return array{0: Provider, 1: bool}
*/
private function ensureProvider(
ObjectManager $manager,
string $companyName,
array $categoryNames,
array $siteNames,
bool $isArchived = false,
): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
$existing = $manager->getRepository(Provider::class)->findOneBy(['companyName' => $normalizedName]);
if ($existing instanceof Provider) {
return [$existing, false];
}
$provider = new Provider();
$provider->setCompanyName($normalizedName);
foreach ($categoryNames as $categoryName) {
$provider->addCategory($this->category($manager, $categoryName));
}
foreach ($siteNames as $siteName) {
$provider->addSite($this->site($siteName));
}
if ($isArchived) {
$provider->setIsArchived(true);
$provider->setArchivedAt(new DateTimeImmutable());
}
$manager->persist($provider);
return [$provider, true];
}
/**
* Ajoute un contact normalise au prestataire (cascade persist via
* Provider.contacts). Au moins un champ est rempli (RG-3.04).
*/
private function addContact(
Provider $provider,
?string $firstName,
?string $lastName,
?string $jobTitle,
?string $phonePrimary,
?string $phoneSecondary,
?string $email,
int $position = 0,
): void {
$contact = new ProviderContact();
$contact->setProvider($provider);
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
$contact->setJobTitle($jobTitle);
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$contact->setEmail($this->normalizer->normalizeEmail($email));
$contact->setPosition($position);
$provider->addContact($contact);
}
/**
* Ajoute une adresse au prestataire (cascade persist via Provider.addresses).
* Adresse simplifiee M3 : PAS de addressType / bennes / triageProvider. Au
* moins un site est rattache (RG-3.05) ; categories d'adresse de type
* PRESTATAIRE (RG-3.09).
*
* @param list<string> $siteNames au moins un site (RG-3.05)
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
*/
private function addAddress(
Provider $provider,
array $siteNames,
string $postalCode,
string $city,
string $street,
?string $streetComplement = null,
array $categoryNames = [],
int $position = 0,
): void {
$address = new ProviderAddress();
$address->setProvider($provider);
$address->setCountry('France');
$address->setPostalCode($postalCode);
$address->setCity($city);
$address->setStreet($street);
$address->setStreetComplement($streetComplement);
$address->setPosition($position);
foreach ($siteNames as $siteName) {
$address->addSite($this->site($siteName));
}
foreach ($categoryNames as $categoryName) {
$address->addCategory($this->category($this->manager, $categoryName));
}
$provider->addAddress($address);
}
/**
* Ajoute un RIB au prestataire (cascade persist via Provider.ribs).
*/
private function addRib(Provider $provider, string $label, string $bic, string $iban, int $position = 0): void
{
$rib = new ProviderRib();
$rib->setProvider($provider);
$rib->setLabel($label);
$rib->setBic($bic);
$rib->setIban($iban);
$rib->setPosition($position);
$provider->addRib($rib);
}
/**
* Resout une categorie par son nom via le contrat Shared CategoryInterface,
* sans importer le module Catalog (regle n°1). Verifie le type PRESTATAIRE
* (RG-3.09). Mise en cache par nom.
*/
private function category(ObjectManager $manager, string $name): CategoryInterface
{
if (isset($this->categoryCache[$name])) {
return $this->categoryCache[$name];
}
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
'name' => $name,
'deletedAt' => null,
]);
foreach ($candidates as $candidate) {
if ($candidate instanceof CategoryInterface
&& in_array(self::PROVIDER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
return $this->categoryCache[$name] = $candidate;
}
}
throw new RuntimeException(sprintf(
'Categorie PRESTATAIRE "%s" introuvable : CategoryFixtures doit tourner avant ProviderFixtures.',
$name,
));
}
/**
* Resout un site par son nom via le contrat Shared SiteProviderInterface, sans
* importer le module Sites (regle n°1). Mise en cache par nom.
*/
private function site(string $name): SiteInterface
{
if (isset($this->siteCache[$name])) {
return $this->siteCache[$name];
}
$site = $this->siteProvider->findByName($name);
if (!$site instanceof SiteInterface) {
throw new RuntimeException(sprintf(
'Site "%s" introuvable : SitesFixtures doit tourner avant ProviderFixtures.',
$name,
));
}
return $this->siteCache[$name] = $site;
}
private function tvaMode(ObjectManager $manager, string $code): TvaMode
{
$mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
if (!$mode instanceof TvaMode) {
throw new RuntimeException(sprintf(
'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $mode;
}
private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay
{
$delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
if (!$delay instanceof PaymentDelay) {
throw new RuntimeException(sprintf(
'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $delay;
}
private function paymentType(ObjectManager $manager, string $code): PaymentType
{
$type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
if (!$type instanceof PaymentType) {
throw new RuntimeException(sprintf(
'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $type;
}
private function bank(ObjectManager $manager, string $code): Bank
{
$bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
if (!$bank instanceof Bank) {
throw new RuntimeException(sprintf(
'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $bank;
}
}
@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Security;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Decision centralisee du cloisonnement par site des prestataires (§ 2.13 /
* RG-3.17). Source UNIQUE partagee par le ProviderProvider (liste + detail), le
* provider decore des sous-ressources (ProviderSubResourceItemProvider) et les
* processors d'ecriture des sous-ressources — afin d'eviter tout drift entre ces
* points d'application.
*
* Regle : un user SANS `sites.bypass_scope` ET avec un site courant ne voit /
* n'opere que sur les prestataires rattaches (relation directe provider.sites) a
* son site courant. `bypass_scope` (Admin inclus via isAdmin) ou absence de site
* courant (module Sites off / user sans currentSite) -> aucun cloisonnement
* (no-op, aligne site-aware.md § 5).
*/
final class ProviderSiteScopeChecker
{
public function __construct(
private readonly Security $security,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
/**
* Site de cloisonnement a appliquer, ou null si aucun cloisonnement
* (`bypass_scope`, ou pas de site courant resolu).
*/
public function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Vrai si le prestataire est dans le perimetre site de l'user courant — ou si
* aucun cloisonnement ne s'applique.
*/
public function isInScope(Provider $provider): bool
{
$scopeSite = $this->siteScopeOrNull();
if (null === $scopeSite) {
return true;
}
return $this->providerHasSite($provider, (int) $scopeSite->getId());
}
/**
* Leve un 404 si le prestataire est hors perimetre (anti-enumeration : ne pas
* reveler l'existence d'une ligne hors site). No-op si dans le perimetre.
*/
public function assertInScope(Provider $provider): void
{
if (!$this->isInScope($provider)) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
}
/**
* Vrai si le prestataire est rattache (relation directe provider.sites) au site
* d'id donne. Comparaison en memoire sur l'entite deja chargee.
*/
private function providerHasSite(Provider $provider, int $siteId): bool
{
foreach ($provider->getSites() as $site) {
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
return true;
}
}
return false;
}
}
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
* onglet Information).
*
* Le front (MalioDate, cf. MUI-44) forwarde desormais la saisie brute invalide
* au serveur plutot que de l'avaler. Cote back, une date non parsable doit
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors),
* et non un 400 generique. Repose sur `collectDenormalizationErrors` actif sur
* l'operation Patch du Client.
*
* @internal
*/
final class ClientFoundedAtFormatTest extends AbstractCommercialApiTestCase
{
private const string MERGE = 'application/merge-patch+json';
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
public function testFoundedAtNonParsableEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Format SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '32/13/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/**
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
* mais PHP DateTime l'accepterait en M/J/AAAA (25 decembre). Le format d'entree
* strict ISO `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
*/
public function testFoundedAtFormatAmbiguUsEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Ambigu SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '12/25/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Non-regression : une date ISO valide reste acceptee (200). */
public function testFoundedAtIsoValideEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Ok SARL');
$data = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2010-05-01'],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
}
}
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Validation back-autoritative du FORMAT de la date de creation (foundedAt,
* onglet Information) du fournisseur. Miroir de {@see ClientFoundedAtFormatTest}.
*
* Une date non parsable (saisie brute forwardee par MalioDate, MUI-44) doit
* produire un 422 porte sur `foundedAt` (mappable inline par useFormErrors), et
* non un 400 generique. Repose sur `collectDenormalizationErrors` sur les
* operations write du Supplier.
*
* @internal
*/
final class SupplierFoundedAtFormatTest extends AbstractSupplierApiTestCase
{
/** Date non parsable -> 422 porte sur foundedAt (et pas un 400 generique). */
public function testFoundedAtNonParsableEst422(): void
{
$seed = $this->seedSupplier('Founded Format Negoce');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '32/13/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/**
* Cas piege : « 12/25/2026 » est invalide cote front (JJ/MM/AAAA -> mois 25)
* mais PHP DateTime l'accepterait en M/J/AAAA. Le format d'entree strict ISO
* `Y-m-d` (Context sur foundedAt) doit le rejeter -> 422.
*/
public function testFoundedAtFormatAmbiguUsEst422(): void
{
$seed = $this->seedSupplier('Founded Ambigu Negoce');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '12/25/2026'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Non-regression : une date ISO valide reste acceptee (200). */
public function testFoundedAtIsoValideEst200(): void
{
$seed = $this->seedSupplier('Founded Ok Negoce');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$data = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2010-05-01'],
])->toArray();
self::assertResponseStatusCodeSame(200);
self::assertStringStartsWith('2010-05-01', $data['foundedAt']);
}
}
@@ -8,15 +8,12 @@ use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
@@ -323,121 +320,6 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
return $rib;
}
/**
* Seede un prestataire COMPLET (sans passer par l'API — validations applicatives
* non rejouees mais CHECK BDD respectes) : bloc comptable non nul (SIREN + refs),
* >= 1 RIB, >= 2 sites en relation DIRECTE (formulaire principal, RG-3.03), >= 1
* adresse multi-sites (>= 2 sites) avec >= 1 categorie PRESTATAIRE et >= 1 contact,
* >= 1 contact et >= 1 categorie sur le prestataire. Socle du contrat de
* serialisation et de la DoD (§ 4.0.bis), jumeau de seedCompleteSupplier (M2)
* mais SANS onglet Information (absent au M3) et AVEC sites directs sur le
* prestataire (NOUVEAU M3 — la liste affiche provider.sites, pas un agregat
* d'adresses).
*
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
* coherent avec le RIB seede ; RG-3.08)
*/
protected function seedCompleteProvider(string $companyName, string $paymentTypeCode = 'LCR'): Provider
{
$em = $this->getEm();
// Nom unique parmi les actifs (index partiel uq_provider_company_name_active).
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$provider = new Provider();
$provider->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$provider->addCategory($this->providerCategory('NETTOYAGE'));
// Bloc comptable non nul (gating par omission cote sans accounting.view).
$provider->setSiren('987654321');
$provider->setAccountNumber('P0001');
$provider->setNTva('FR00987654321');
$provider->setTvaMode($this->tvaMode('FRANCE_VENTES'));
$provider->setPaymentDelay($this->paymentDelay('J30'));
$provider->setPaymentType($this->paymentType($paymentTypeCode));
if ('VIREMENT' === $paymentTypeCode) {
$provider->setBank($this->bank('SG'));
}
// >= 2 sites fixtures : relation DIRECTE provider.sites (RG-3.03) pour la
// LISTE + reutilises sur l'adresse multi-sites pour le DETAIL.
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
foreach ($sites as $site) {
$provider->addSite($site);
}
$em->persist($provider);
$contact = new ProviderContact();
$contact->setProvider($provider);
$contact->setFirstName('Marie');
$contact->setLastName('Martin');
$contact->setJobTitle('Responsable');
$contact->setPhonePrimary('0612345678');
$contact->setEmail('marie.martin@seed.test');
$provider->addContact($contact);
$em->persist($contact);
// Adresse simplifiee M3 (PAS de addressType / bennes / triageProvider).
$address = new ProviderAddress();
$address->setProvider($provider);
$address->setCountry('France');
$address->setPostalCode('86000');
$address->setCity('Poitiers');
$address->setStreet('12 rue des Acacias');
foreach ($sites as $site) {
$address->addSite($site);
}
$address->addCategory($this->providerCategory('NETTOYAGE'));
$address->addContact($contact);
$provider->addAddress($address);
$em->persist($address);
$rib = new ProviderRib();
$rib->setProvider($provider);
$rib->setLabel('Compte principal');
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$provider->addRib($rib);
$em->persist($rib);
$em->flush();
return $provider;
}
/**
* Recupere un mode de TVA seede (CommercialReferentialFixtures) par code (ex.
* FRANCE_VENTES). Echoue explicitement si absent (fixtures non chargees).
*/
protected function tvaMode(string $code): TvaMode
{
$tvaMode = $this->getEm()->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$tvaMode,
sprintf('Mode de TVA "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $tvaMode;
}
/**
* Recupere un delai de reglement seede (CommercialReferentialFixtures) par code
* (ex. J30). Echoue explicitement si absent (fixtures non chargees).
*/
protected function paymentDelay(string $code): PaymentDelay
{
$paymentDelay = $this->getEm()->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$paymentDelay,
sprintf('Delai de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $paymentDelay;
}
/**
* Recupere un type de reglement seede (CommercialReferentialFixtures) par code
* (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees).
@@ -1,162 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use Doctrine\DBAL\Connection;
/**
* Tests Audit du repertoire prestataires (M3, spec § 6). Jumeau du
* {@see \App\Tests\Module\Commercial\Api\SupplierAuditTest} (M2). Couvre :
* - POST / PATCH / archivage -> ligne audit_log entity_type='technique.Provider'
* avec l'action et le diff attendus ;
* - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles
* DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1/M2) ;
* - M2M `sites` : un PATCH du formulaire principal qui modifie les sites trace la
* relation many-to-many (audit M2M automatique, § 2.7).
*
* @internal
*/
final class ProviderAuditTest extends AbstractProviderApiTestCase
{
private const string PROVIDER_TYPE = 'technique.Provider';
private const string RIB_TYPE = 'technique.ProviderRib';
private ?Connection $auditConnection = null;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
}
protected function tearDown(): void
{
if (null !== $this->auditConnection) {
$this->auditConnection->close();
}
parent::tearDown();
}
public function testPostProviderIsAudited(): void
{
$admin = $this->createAdminClient();
$payload = $this->validMainPayload('Audit Created Co', [self::SITE_86]);
$created = $admin->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::PROVIDER_TYPE, (string) $created['id'], 'create'),
'Un audit_log "create" doit etre genere pour le prestataire.',
);
}
public function testPatchProviderIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Audit Patch Co', [self::SITE_86]);
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Audit Patch Renamed'],
]);
self::assertResponseStatusCodeSame(200);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'),
'Un audit_log "update" doit etre genere pour le PATCH.',
);
}
public function testArchiveProviderIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Audit Archive Co', [self::SITE_86]);
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update');
self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.');
}
public function testPatchSitesIsAuditedAsManyToMany(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Audit Sites Co', [self::SITE_86]);
// PATCH du formulaire principal : on passe a 2 sites (86 + 17). L'audit M2M
// automatique (§ 2.7) doit tracer la relation `sites` dans le diff.
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['sites' => [
'/api/sites/'.$this->site(self::SITE_86)->getId(),
'/api/sites/'.$this->site(self::SITE_17)->getId(),
]],
]);
self::assertResponseStatusCodeSame(200);
$changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update');
self::assertArrayHasKey('sites', $changes, 'La modification M2M des sites doit etre tracee.');
}
public function testRibCreateAuditIncludesIbanAndBic(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Rib Audit Host', [self::SITE_86]);
$rib = $admin->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte audite',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
$changes = $this->latestChanges(self::RIB_TYPE, (string) $rib['id'], 'create');
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertSame(self::VALID_IBAN, $changes['iban']);
self::assertSame(self::VALID_BIC, $changes['bic']);
}
/**
* Decode le `changes` (diff) de la derniere ligne audit_log correspondante.
*
* @return array<string, mixed>
*/
private function latestChanges(string $type, string $id, string $action): array
{
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
['type' => $type, 'id' => $id, 'action' => $action],
);
self::assertGreaterThanOrEqual(1, count($rows), sprintf('Un audit_log "%s" doit exister pour %s#%s.', $action, $type, $id));
return json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
}
private function countAudit(string $type, string $id, string $action): int
{
return (int) $this->auditConnection->fetchOne(
'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action',
['type' => $type, 'id' => $id, 'action' => $action],
);
}
}
@@ -80,91 +80,4 @@ final class ProviderListTest extends AbstractProviderApiTestCase
self::assertSame(1, $body['totalItems']);
self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']);
}
public function testPaginationDisabledReturnsFullCollection(): void
{
$token = $this->token();
for ($i = 0; $i < 3; ++$i) {
$this->seedProvider($token.' Item'.$i, [self::SITE_86]);
}
$client = $this->createAdminClient();
// ?pagination=false : echappatoire pour alimenter un <select> (regle n°13).
$data = $client->request('GET', '/api/providers?search='.$token.'&pagination=false', [
'headers' => ['Accept' => self::LD],
])->toArray();
self::assertArrayHasKey('member', $data);
self::assertCount(3, $data['member']);
}
/**
* Anti N+1 (§ 2.12) : le nombre de requetes SQL de la liste ne doit PAS croitre
* avec le nombre de prestataires. On mesure pour N=2 puis N=4 (memes relations
* embarquees : categories + sites directs + adresses.sites) et on exige un
* compte IDENTIQUE — preuve que l'hydratation est batchee (WHERE IN) et non par
* ligne.
*/
public function testListQueryCountDoesNotGrowWithRowCount(): void
{
$this->skipIfSitesModuleDisabled();
$token = $this->token();
$this->seedCompleteProvider($token.' A');
$this->seedCompleteProvider($token.' B');
$countFor2 = $this->countListQueries($token);
$this->seedCompleteProvider($token.' C');
$this->seedCompleteProvider($token.' D');
$countFor4 = $this->countListQueries($token);
self::assertSame(
$countFor2,
$countFor4,
sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
);
}
/**
* Filtre ?typeCode= (cree au M2, reutilise au M3) : GET /api/categories?typeCode=
* PRESTATAIRE ne renvoie QUE les categories de type PRESTATAIRE — prerequis des
* multi-selects Categorie du prestataire (DoD § 4.7).
*/
public function testCategoriesTypeCodeFilterReturnsOnlyPrestataire(): void
{
$prestataire = $this->providerCategory('NETTOYAGE');
$foreign = $this->foreignCategory();
$client = $this->createAdminClient();
$data = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE&pagination=false', [
'headers' => ['Accept' => self::LD],
])->toArray();
$ids = array_column($data['member'], 'id');
self::assertContains($prestataire->getId(), $ids, 'La categorie PRESTATAIRE doit etre presente.');
self::assertNotContains($foreign->getId(), $ids, 'Une categorie d\'un autre type doit etre filtree.');
}
/**
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
* debug Doctrine. Le holder est remis a zero juste avant la requete pour isoler
* ses requetes (hors login).
*/
private function countListQueries(string $token): int
{
$http = $this->createAdminClient();
$holder = self::getContainer()->get('doctrine.debug_data_holder');
$holder->reset();
$http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]]);
$data = $holder->getData();
return count($data['default'] ?? []);
}
private function token(): string
{
return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
}
}
@@ -156,21 +156,4 @@ final class ProviderRbacGatingTest extends AbstractProviderApiTestCase
self::assertTrue($reloaded->isArchived());
self::assertNotNull($reloaded->getArchivedAt());
}
public function testRestoreWithNameConflictReturns409(): void
{
// Un prestataire archive porte un nom qu'un prestataire ACTIF a repris
// entre-temps (autorise par l'index partiel : l'archive n'y figure pas).
$archived = $this->seedProvider('Conflit Co', [self::SITE_86], isArchived: true);
$this->seedProvider('Conflit Co', [self::SITE_86]); // actif, meme nom normalise
$client = $this->createAdminClient();
$response = $client->request('PATCH', '/api/providers/'.$archived->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
// RG-3.14 : restaurer ferait deux actifs homonymes -> 409 (pas de 500 SQL).
self::assertSame(409, $response->getStatusCode());
}
}
@@ -1,365 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire prestataires
* (M3, spec-back § 4.0 / § 4.0.bis). Jumeau du
* {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest} (M2),
* il reverifie sur le JSON REEL les pieges silencieux herites du M1/M2 :
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> cle `ribs`
* ABSENTE pour un profil type Commerciale (gating par omission).
* - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
* -> isArchived present dans le detail.
* - #1 : categories embarquees sans code/name -> code + name presents en LISTE
* ET DETAIL (provider ET adresse).
* - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
* (relation DIRECTE provider.sites — RG-3.03) ET DETAIL (addresses[].sites[]).
* - ERP-92 : refs comptables (tvaMode/paymentDelay/paymentType/bank) embarquees
* {id, code, label} et non IRI nu (le groupe provider:read:accounting doit
* etre porte par les entites partagees — fix ERP-139, sinon IRI nu).
* Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives exclus).
*
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
* annotations. Toute regression de groupe de serialisation casse ici.
*
* @internal
*/
final class ProviderSerializationContractTest extends AbstractProviderApiTestCase
{
// === #4 — Gating des RIB par accounting.view ===
public function testRibsPresentForAdminWithAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Rib Admin Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
self::assertSame('Compte principal', $data['ribs'][0]['label']);
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
}
public function testRibsAbsentForUserWithoutAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Rib Commerciale Co');
// Profil type Commerciale : technique.providers.view SANS accounting.view.
// createUserWithPermissions n'attache pas de currentSite -> pas de
// cloisonnement, on isole le gating comptable du comportement site.
$creds = $this->createUserWithPermissions(['technique.providers.view']);
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// La cle `ribs` est ABSENTE (pas null) : le groupe provider:read:accounting
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
// fuite IBAN/BIC (piege n°4 du M1).
self::assertArrayNotHasKey('ribs', $data);
}
// === #4.bis — Gating par OMISSION des scalaires comptables ===
public function testAccountingScalarsGatedByOmission(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Compta Gating Co');
$id = $provider->getId();
// Admin : scalaires comptables presents.
$admin = $this->createAdminClient();
$adminData = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $adminData);
self::assertSame('987654321', $adminData['siren']);
self::assertArrayHasKey('accountNumber', $adminData);
self::assertArrayHasKey('paymentType', $adminData);
// Sans accounting.view : scalaires comptables ABSENTS (omission, pas null).
$creds = $this->createUserWithPermissions(['technique.providers.view']);
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
// === ERP-139 — Refs comptables embarquees {id, code, label} et non IRI nu ===
public function testAccountingReferentialsEmbedIdCodeLabel(): void
{
$this->skipIfSitesModuleDisabled();
// Reglement VIREMENT -> banque renseignee : on couvre les 4 referentiels.
$provider = $this->seedCompleteProvider('Refs Embed Co', 'VIREMENT');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Avant fix ERP-139 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
// car les entites partagees ne portaient que client:/supplier:read:accounting,
// pas provider:read:accounting. Apres fix : objet {id, code, label} embarque
// (le front consultation/edition affiche le libelle sans fetch — § 4.0.bis).
foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
self::assertArrayHasKey('id', $data[$ref]);
self::assertArrayHasKey('label', $data[$ref]);
self::assertNotSame('', (string) $data[$ref]['label']);
}
// paymentType embarque aussi son code (logique front VIREMENT/LCR).
self::assertArrayHasKey('code', $data['paymentType']);
self::assertSame('VIREMENT', $data['paymentType']['code']);
}
// === #3 — Booleen isArchived present dans le JSON ===
public function testProviderIsArchivedBooleanIsPresentInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Bool Archived Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// isArchived expose via Groups + SerializedName('isArchived') sur le getter :
// sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
self::assertArrayHasKey('isArchived', $data);
self::assertFalse($data['isArchived']);
}
// === #1 — Embed code/name des Category (liste ET detail) ===
public function testCategoriesEmbedCodeAndNameInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Embed Cat Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['categories']);
$category = $data['categories'][0];
// Avant correctif : seuls @id/@type (category:read absent du contexte).
// Apres : code + name embarques.
self::assertArrayHasKey('code', $category);
self::assertArrayHasKey('name', $category);
self::assertSame('NETTOYAGE', $category['code']);
// Categories d'adresse aussi (category:read dans le contexte du detail).
self::assertArrayHasKey('categories', $data['addresses'][0]);
self::assertNotEmpty($data['addresses'][0]['categories']);
self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
}
public function testCategoriesEmbedCodeAndNameInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
$provider = $this->seedCompleteProvider($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $provider->getId());
self::assertNotNull($row, 'Le prestataire seede doit apparaitre dans la liste filtree.');
self::assertNotEmpty($row['categories']);
self::assertArrayHasKey('code', $row['categories'][0]);
self::assertArrayHasKey('name', $row['categories'][0]);
self::assertSame('NETTOYAGE', $row['categories'][0]['code']);
}
// === #2 — Embed name/postalCode des Site (liste via relation directe + detail) ===
public function testSitesEmbedNameAndPostalCodeInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
$provider = $this->seedCompleteProvider($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $provider->getId());
self::assertNotNull($row);
// sites en relation DIRECTE provider.sites (RG-3.03) : objet Site entier
// (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
self::assertArrayHasKey('sites', $row);
self::assertGreaterThanOrEqual(2, count($row['sites']));
self::assertArrayHasKey('name', $row['sites'][0]);
self::assertArrayHasKey('postalCode', $row['sites'][0]);
self::assertNotSame('', (string) $row['sites'][0]['name']);
}
public function testSitesEmbedNameAndPostalCodeInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Site Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Sites du formulaire principal (relation directe).
self::assertArrayHasKey('sites', $data);
self::assertGreaterThanOrEqual(2, count($data['sites']));
self::assertArrayHasKey('name', $data['sites'][0]);
self::assertArrayHasKey('postalCode', $data['sites'][0]);
// Sites de l'adresse (addresses[].sites[]).
$address = $data['addresses'][0];
self::assertArrayHasKey('sites', $address);
self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
self::assertArrayHasKey('name', $address['sites'][0]);
self::assertArrayHasKey('postalCode', $address['sites'][0]);
self::assertNotSame('', (string) $address['sites'][0]['name']);
}
// === Detail : sous-collections embarquees ===
public function testDetailEmbedsContactsAddressesRibs(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Embed Subres Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['contacts']);
self::assertSame('Marie', $data['contacts'][0]['firstName']);
self::assertSame('Martin', $data['contacts'][0]['lastName']);
self::assertArrayHasKey('email', $data['contacts'][0]);
self::assertNotEmpty($data['addresses']);
// M3 : adresse simplifiee, PAS de addressType.
self::assertArrayNotHasKey('addressType', $data['addresses'][0]);
self::assertSame('Poitiers', $data['addresses'][0]['city']);
self::assertNotEmpty($data['ribs']);
}
// === refonte-contact V0.2 : pas de contact inline sur le prestataire ===
public function testProviderHasNoInlineContactFields(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('No Inline Contact Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du prestataire.', $key));
}
}
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
{
$this->skipIfSitesModuleDisabled();
$http = $this->createAdminClient();
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
$this->seedProvider($token.' Active', [self::SITE_86]);
$this->seedProvider($token.' Archived', [self::SITE_86], isArchived: true);
// Liste par defaut filtree sur le token : enveloppe member/totalItems sans
// prefixe hydra:, archive EXCLU du totalItems (RG-3.16).
$default = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $default);
self::assertArrayHasKey('totalItems', $default);
self::assertArrayNotHasKey('hydra:member', $default);
self::assertArrayNotHasKey('hydra:totalItems', $default);
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
// includeArchived : l'archive reintegre le total.
$all = $http->request('GET', '/api/providers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems']);
// `view` (PartialCollectionView) sans prefixe hydra:.
$paged = $http->request('GET', '/api/providers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('view', $paged);
self::assertArrayNotHasKey('hydra:view', $paged);
}
/**
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
* detail sans accounting.view) pour les coller dans la spec avant de lancer les
* tickets front. Le test asserte la forme ; si la variable d'env
* PROVIDER_DOD_DUMP est positionnee, il ecrit aussi les 3 corps formates sous
* /tmp pour copie.
*/
public function testDodReferenceJsonShape(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
$provider = $this->seedCompleteProvider($token);
$id = (int) $provider->getId();
$admin = $this->createAdminClient();
$list = $admin->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$detailAdmin = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$creds = $this->createUserWithPermissions(['technique.providers.view']);
$restricted = $this->authenticatedClient($creds['username'], $creds['password']);
$detailRestricted = $restricted->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
// Forme minimale attendue (la DoD valide que tout champ front est present).
self::assertArrayHasKey('member', $list);
self::assertArrayHasKey('siren', $detailAdmin);
self::assertArrayHasKey('ribs', $detailAdmin);
self::assertArrayNotHasKey('siren', $detailRestricted);
self::assertArrayNotHasKey('ribs', $detailRestricted);
if (false !== getenv('PROVIDER_DOD_DUMP')) {
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/provider-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/provider-dod-detail-admin.json', json_encode($detailAdmin, $flags));
file_put_contents('/tmp/provider-dod-detail-restricted.json', json_encode($detailRestricted, $flags));
}
}
/**
* Retrouve un membre de la collection par son id (liste filtree).
*
* @param array<string, mixed> $collection
*
* @return array<string, mixed>|null
*/
private function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
* (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur
* (au moins un champ parmi prenom/nom/telephone/email), RG-3.05 (>= 1 site sur
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
* 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
@@ -53,37 +53,18 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
}
/**
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
* RG-3.04 : un bloc sans aucun champ du CHECK (prenom/nom/telephone/email) est
* rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName.
* Ici seul jobTitle est fourni (hors CHECK).
*/
public function testPostContactWithOnlyJobTitleReturns201(): void
public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact JobTitle Only');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Directeur', $data['jobTitle']);
}
/**
* RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK
* chk_provider_contact_name) est rejete avant la base -> 422 rattachee a
* firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider.
*/
public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Field');
$seed = $this->seedProvider('Contact No Name');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => ' '],
'json' => ['jobTitle' => 'Directeur'],
]);
self::assertResponseStatusCodeSame(422);
@@ -1,154 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
/**
* Tests du cloisonnement par site des SOUS-RESSOURCES d'un prestataire (Contacts /
* Adresses / RIB) — § 2.13 / RG-3.17. Complement de ProviderSiteScopeTest (qui ne
* couvrait que le Provider lui-meme).
*
* Sans garde dedie, un user cloisonne pouvait lire / editer / supprimer une
* sous-ressource d'un prestataire HORS de son site (le detail Provider est garde en
* 404, mais les sous-ressources passent par le provider Doctrine par defaut, non
* cloisonne — et SiteScopedQueryExtension ne filtre que les SiteAwareInterface).
* Le RIB est particulierement sensible (IBAN / BIC).
*
* Garde pose par ProviderSubResourceItemProvider (Get/Patch/Delete -> 404 hors
* perimetre) + ProviderSiteScopeChecker::assertInScope dans les processors (POST
* sur parent hors perimetre -> 404). Decision de scope partagee (source unique).
*
* @internal
*/
final class ProviderSubResourceSiteScopeTest extends AbstractProviderApiTestCase
{
/** Permissions completes pour exercer view + manage + accounting sur tous les chemins. */
private const array FULL_PERMS = [
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
];
protected function setUp(): void
{
parent::setUp();
$this->skipIfSitesModuleDisabled();
}
public function testGetContactOutOfScopeReturns404ButInScope200(): void
{
$inScope = $this->seedProvider('Presta In Scope', [self::SITE_86]);
$inContactId = $this->addContact($inScope, 'Marie', 'Martin')->getId();
$outScope = $this->seedProvider('Presta Out Scope', [self::SITE_17]);
$outContactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
$client = $this->scopedClient();
$ok = $client->request('GET', '/api/provider_contacts/'.$inContactId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $ok->getStatusCode());
// Hors perimetre : 404 (ne pas reveler l'existence du contact d'un autre site).
$ko = $client->request('GET', '/api/provider_contacts/'.$outContactId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(404, $ko->getStatusCode());
}
public function testGetRibOutOfScopeReturns404(): void
{
// RIB = donnee bancaire sensible (IBAN/BIC) : le cas le plus critique.
$outScope = $this->seedProvider('Presta Out Rib', [self::SITE_17]);
$ribId = $this->addRib($outScope)->getId();
$client = $this->scopedClient();
$response = $client->request('GET', '/api/provider_ribs/'.$ribId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(404, $response->getStatusCode());
}
public function testPatchRibOutOfScopeReturns404(): void
{
$outScope = $this->seedProvider('Presta Patch Rib', [self::SITE_17]);
$ribId = $this->addRib($outScope)->getId();
$client = $this->scopedClient();
$response = $client->request('PATCH', '/api/provider_ribs/'.$ribId, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['label' => 'Hacked'],
]);
self::assertSame(404, $response->getStatusCode());
}
public function testDeleteContactOutOfScopeReturns404(): void
{
$outScope = $this->seedProvider('Presta Del Contact', [self::SITE_17]);
$contactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
$client = $this->scopedClient();
$response = $client->request('DELETE', '/api/provider_contacts/'.$contactId);
self::assertSame(404, $response->getStatusCode());
}
public function testPostContactOnOutOfScopeProviderReturns404(): void
{
$outScope = $this->seedProvider('Presta Post Contact', [self::SITE_17]);
$id = $outScope->getId();
$client = $this->scopedClient();
$response = $client->request('POST', '/api/providers/'.$id.'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Intrus'],
]);
self::assertSame(404, $response->getStatusCode());
}
public function testPostRibOnOutOfScopeProviderReturns404(): void
{
$outScope = $this->seedProvider('Presta Post Rib', [self::SITE_17]);
$id = $outScope->getId();
$client = $this->scopedClient();
$response = $client->request('POST', '/api/providers/'.$id.'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'label' => 'Intrus',
'iban' => self::VALID_IBAN,
'bic' => self::VALID_BIC,
],
]);
self::assertSame(404, $response->getStatusCode());
}
public function testBypassUserReachesSubResourceOnAnySite(): void
{
// Temoin : l'admin (bypass total) lit bien un contact hors « son » site.
$outScope = $this->seedProvider('Presta Admin Reach', [self::SITE_17]);
$contactId = $this->addContact($outScope, 'Marie', 'Martin')->getId();
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/provider_contacts/'.$contactId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
}
/**
* Client authentifie comme un user NON-bypass rattache au seul site 86 (avec
* currentSite 86) — sujet des tests de cloisonnement des sous-ressources.
*/
private function scopedClient(): Client
{
$creds = $this->createScopedUser(
self::FULL_PERMS,
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
return $this->authenticatedClient($creds['username'], $creds['password']);
}
}