Compare commits

...

9 Commits

Author SHA1 Message Date
gitea-actions a6f01400ba chore: bump version to v0.1.116
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 40s
2026-06-15 09:02:10 +00:00
tristan d0e9f48983 feat(front) : page ajout prestataire + formulaire principal (ERP-141) (#103)
Auto Tag Develop / tag (push) Successful in 8s
Empilée sur ERP-140 (#102).

## Périmètre ERP-141
Écran `/providers/new` — création par onglets + formulaire principal (POST).

- **Page** `modules/technique/pages/providers/new.vue` : en-tête + retour, formulaire principal (Nom, Catégorie, Site), barre d'onglets **Contact · Adresse · Comptabilité** (pas d'onglet Information ; Rapports/Échanges absents en création). Contenu des onglets = placeholders « À venir » (ERP-142→144).
- **`useProviderForm()`** : POST principal (groupe `provider:write:main`, IRIs catégories/sites), pré-check front RG-3.03 (≥1 site) / RG-3.09 (≥1 catégorie), 409 doublon (RG-3.10) inline, 422 mapping par champ via `useFormErrors`, orchestration des onglets (verrouillage + bascule auto sur Contact au succès), `patchProvider` (PATCH partiel mode strict pour les onglets à venir).
- **`useProviderReferentials()`** : catégories type PRESTATAIRE + sites (`?pagination=false`, Hydra).
- i18n `technique.providers.form/tab/toast`.

## Conformité
- `useApi()` uniquement, composants `Malio*`, aucun texte FR en dur, bouton « Valider » toujours actif + erreurs sous les champs (ERP-101).

## Vérifications
- Vitest : 402/402 (dont 9 nouveaux tests `useProviderForm`).
- ESLint : OK.
- `nuxi typecheck` : 0 erreur sur les fichiers source du ticket.
- Golden path navigateur : page rendue, catégories filtrées PRESTATAIRE, sélecteur site, onglets désactivés avant validation, erreurs inline RG-3.03/3.09.

Reviewed-on: #103
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 08:59:39 +00:00
gitea-actions c1206fa29c chore: bump version to v0.1.115
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 43s
2026-06-15 08:51:28 +00:00
tristan 090ea5eb49 feat(front) : page repertoire prestataires (ERP-140) (#102)
Auto Tag Develop / tag (push) Successful in 9s
Page d'entree du pole Technique : repertoire prestataires (route /providers).

## Perimetre (ERP-140)
- Page `modules/technique/pages/providers/index.vue` (route /providers, titre i18n technique.providers.title).
- `MalioDataTable` branche sur `usePaginatedList<Provider>({ url: '/providers' })` : colonnes Nom / Categories / Site (badges) / Derniere activite (updatedAt, format JJ-MM-AAAA).
- Clic ligne -> /providers/{id} ; bouton + Ajouter -> /providers/new (gate technique.providers.manage).
- Drawer Filtres : recherche, categorie (type PRESTATAIRE), site, inclure archives. Etat 100% local (jamais dans l'URL).
- Bouton Exporter -> /api/providers/export.xlsx (memes filtres).
- Pagination standard 10/25/50.
- Composable `useProvidersRepository` + cles i18n `technique.providers.*`.

## Garde-fous
- `useApi()` uniquement, composants `Malio*`, pas de `<table>` brut, aucun texte FR en dur.
- Cloisonnement par site laisse au back.

## Tests
- `make nuxt-test` : 393/393 verts (dont 3 nouveaux sur useProvidersRepository : ciblage /providers, enveloppe Hydra, exclusion archives par defaut).
- ESLint clean.
- Note : `nuxi typecheck` non concluant dans l'env (develop produit deja ~303 erreurs d'auto-imports non resolus, independamment de cette branche). La page et le composable sont type-clean.

Reviewed-on: #102
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-15 08:51:19 +00:00
gitea-actions ee1f344764 chore: bump version to v0.1.114
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 54s
2026-06-12 14:44:56 +00:00
matthieu 3fe0f676f6 test(technique) : couvrir RG-3.x PHPUnit + capturer le contrat JSON (ERP-139) (#100)
Auto Tag Develop / tag (push) Successful in 11s
Ticket Lesstime #139 (M3 — Répertoire prestataires, position 1.9). DoD back avant le front : suite PHPUnit consolidée sur la matrice § 8.1 + captures JSON réelles dans la spec § 4.0.bis.

## Contenu
- **Fix réfs comptables** : `provider:read:accounting` ajouté sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` — sans ça elles sortaient en IRI nu dans le détail prestataire (réplique du fix ERP-92 du M2, piège #1 § 4.0.bis).
- **`ProviderSerializationContractTest`** (13 tests) : gating RIB/scalaires par omission, réfs compta en objet `{id,code,label}`, `isArchived`, embed categories/sites liste+détail, sous-collections, enveloppe AP4 ; `testDodReferenceJsonShape` dumpe le JSON réel (`PROVIDER_DOD_DUMP=1`).
- **`ProviderAuditTest`** (5 tests) : create/update/archive (`technique.Provider`), iban/bic dans le diff (`technique.ProviderRib`, pas dAuditIgnore), trace M2M `sites`.
- **`ProviderListTest`** étendu : `?pagination=false`, anti-N+1, filtre `?typeCode=PRESTATAIRE`.
- **`ProviderRbacGatingTest`** étendu : restauration en conflit de nom → 409 (RG-3.14).
- **`ProviderFixtures`** (§ 8.4) : démo idempotente (complet VIREMENT+banque+RIB, LCR+RIB, CHEQUE multi-cat, minimal, archivé) répartie sur sites 86/17/82 ; skip en env `test`.
- Helper `seedCompleteProvider` ; spec § 4.0.bis : gabarits remplacés par les captures réelles (liste + détail avec/sans accounting.view).

## Vérifications
- `make php-cs-fixer-allow-risky` → 0 fichier
- `make test` → OK, 677 tests, 3328 assertions (garde-fous globaux verts)

## Notes
- MR stackée sur ERP-138 (base = sa branche).
- Fixtures démo exercées en dev via `make fixtures` (autowiring vérifié).

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #100
2026-06-12 14:44:43 +00:00
gitea-actions d5462bcf42 chore: bump version to v0.1.113
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 48s
2026-06-12 14:34:35 +00:00
matthieu 54d8327fa5 feat(technique) : entités + repositories Provider* (ERP-133) (#91)
Auto Tag Develop / tag (push) Successful in 9s
PR **empilée sur ERP-132** (#90) — base = \`feature/ERP-132-migrer-schema-bdd-m3\` (ERP-132 pas encore mergé dans develop). À rebaser sur develop une fois #90 mergée.

## Périmètre (ticket Lesstime #133, M3 § 3.3/3.4/2.12/4.0)
Entités Doctrine + mapping ApiResource (squelette) + repository avec hydratation anti-N+1. Miroir des entités `Supplier*` (M2), **amputé de l'onglet Information** et **augmenté de `provider.sites`** (M2M direct, RG-3.03).

### Créé
- `Provider`, `ProviderContact`, `ProviderAddress` (simplifiée : pas de `addressType`/`bennes`/`triageProvider`), `ProviderRib` — `#[Auditable]` + Timestampable/Blamable.
- `ProviderRepositoryInterface` + `DoctrineProviderRepository` : `createListQueryBuilder` (filtres + tri seuls) + `hydrateListCollections` anti-N+1 (catégories puis **sites en relation directe**, requêtes `IN` bornées séparées — § 2.12).

### Contrat de sérialisation (RETEX M1 — 3 maillons)
Groupes posés sur l'entité (source unique) : liste = `provider:read`+`category:read`+`site:read` ; détail = +`provider:item:read`. Piège booléen `isArchived` traité (`#[Groups]`+`#[SerializedName]` sur le getter). Embed `categories[].code/name` + `sites[].name/postalCode` (objet, pas IRI).

### Consommation cross-module (§ 2.1)
- Site/Category via contrats Shared (`SiteInterface`/`CategoryInterface` + `resolve_target_entities`) — comme Supplier, conforme règle ABSOLUE n°1.
- Référentiels comptables (`TvaMode`/`PaymentDelay`/`PaymentType`/`Bank`) en relation ORM partagée directe (décision § 2.1, remontée Shared tracée HP-M4-2).

### Garde-fous / infra (requis pour le vert)
- Mapping ORM du module `Technique` dans `doctrine.yaml` (sinon les 9 tables `provider*` vues orphelines → DROP).
- Tables `provider*` ajoutées à `ColumnCommentsCatalog` + ligne `dbal:run-sql uq_provider_company_name_active` au makefile `test-db-setup`.
- 4 libellés `audit.entity.technique_*` (fr.json) ; `ProviderAddress::postalCode` whitelisté dans `EXCLUDED_LENGTH_MIRROR` (Regex CP {4,5}).

## Hors périmètre (→ ERP-134)
ApiResource **sans** `ProviderProvider`/`ProviderProcessor` ; sous-entités **sans** `#[ApiResource]`. Hydratation effective, gating accounting, cloisonnement par site, normalisation, 409 doublon, RG-3.07/3.08 → ERP-134. Sous-ressources POST/PATCH/DELETE → ticket ultérieur.

## Tests
- \`make test\` → **589/589 ✓** · \`php-cs-fixer\` → 0 correction.
- \`schema:validate\` : mapping OK ; « not in sync » résiduel strictement homologue à supplier (COMMENT via catalogue + index FK auto-Doctrine), non régressif.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #91
2026-06-12 14:25:27 +00:00
matthieu 09a4b9d464 feat(technique) : migration schema repertoire prestataires (ERP-132) (#90)
Auto Tag Develop / tag (push) Successful in 9s
## ERP-132 — Migrer le schéma BDD M3 (provider + sous-collections)

> ⚠️ **MR stackée** sur `feat/erp-m3-technique-module-taxonomie` (ERP-131, module Technique + type PRESTATAIRE). À merger **après** ERP-131. Base volontairement ≠ develop tant qu'ERP-131 n'est pas mergé.

### Contenu
Crée tout le schéma Postgres du répertoire prestataires (1 migration, namespace racine `DoctrineMigrations` — FK cross-module user/category/site + référentiels comptables M1).

**Tables (9)** :
- `provider` : company_name + bloc Comptabilité (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** (≠ supplier).
- M2M formulaire principal : `provider_category` (RG-3.09), `provider_site` (sites du prestataire — RG-3.03, **nouveau vs supplier** + `idx_provider_site_site` pour le cloisonnement par 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 archivé/non supprimé — RG-3.10) + index FK.
- `COMMENT ON COLUMN/TABLE` inline sur **toutes** les colonnes (règle ABSOLUE n°12).

### Décisions
- **CategoryType PRESTATAIRE non re-seedé** : déjà créé par ERP-131. Migration purement structurelle.
- **COMMENT inline (pas via ColumnCommentsCatalog)** : tant que les entités Provider* n'existent pas (ERP-133), `schema:update --force` du setup test droppe les tables non mappées → les référencer dans le catalogue ferait planter `app:apply-column-comments`. Catalogue + ligne `dbal:run-sql uq_provider` différés à ERP-133, exactement comme supplier (ERP-86 après ERP-85).

### Tests
-  `make db-reset` (dev + test-db-setup)
-  `make test` — 589 tests, `ColumnsHaveSqlCommentTest` vert
-  Index partiel vérifié partiel (clause WHERE), `idx_provider_site_site` présent, 0 colonne sans COMMENT
-  Cycle `down()`/`up()` OK
-  `make php-cs-fixer-allow-risky` (0 fichier)

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #90
2026-06-12 14:19:46 +00:00
54 changed files with 9086 additions and 75 deletions
+10
View File
@@ -80,6 +80,16 @@ doctrine:
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
prefix: 'App\Module\Commercial\Domain\Entity'
alias: Commercial
# Mapping inconditionnel du module Technique (meme logique que Commercial) :
# les tables prestataires (provider + sous-collections + jointures M2M)
# creees par la migration M3 (Version20260612100000) doivent etre connues
# de l'ORM. L'activation fonctionnelle passe par config/modules.php.
Technique:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity'
prefix: 'App\Module\Technique\Domain\Entity'
alias: Technique
controller_resolver:
auto_mapping: false
+17
View File
@@ -61,6 +61,23 @@ return [
],
],
],
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
// la section disparait automatiquement (SidebarProvider) si le module
// `technique` est desactive ou si l'user n'a pas la permission.
[
'label' => 'sidebar.technique.section',
'icon' => 'mdi:account-convert-outline',
'items' => [
[
'label' => 'sidebar.technique.providers',
'to' => '/providers',
'icon' => 'mdi:account-wrench-outline',
'module' => 'technique',
'permission' => 'technique.providers.view',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log).
//
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.110'
app.version: '0.1.116'
+138 -52
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** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`).
- **Cohérence sous-ressources** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site.
> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle).
@@ -624,67 +624,153 @@ Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamab
> 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`).
> 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`).
`GET /api/providers` (liste, ADMIN — un membre, forme attendue) :
> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front.
`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) :
```json
{
"@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"}
"@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"}
}
```
> Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs` **disparaissent** de chaque membre.
> Les `sites[]` de la liste sont la **relation directe** `provider.sites` (formulaire principal — RG-3.03), objet `Site` entier (pas un IRI nu). Les catégories embarquent `code` + `name`. Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour un profil **sans `accounting.view`** (ex. Commerciale), `siren`/`accountNumber`/`tvaMode`/`nTva`/`paymentDelay`/`paymentType`/`bank`/`ribs` **disparaissent** de chaque membre (gating par omission — cf. détail restreint ci-dessous).
`GET /api/providers/{id}` (détail — user avec `accounting.view`, forme attendue) :
`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) :
```json
{
"@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
"@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
}
```
> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (gating par omission — à confirmer par test).
`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) :
```json
{
"@context": "/api/contexts/Provider",
"@id": "/api/providers/572",
"@type": "Provider",
"id": 572,
"companyName": "DOD21AADC 0E3CCE",
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"addresses": [
{
"@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35,
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
"sites": [
{"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"},
{"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"}
],
"contacts": [
{"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"categories": [
{"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"}
],
"createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"
}
],
"createdAt": "2026-06-12T15:17:29+02:00",
"updatedAt": "2026-06-12T15:17:29+02:00",
"isArchived": false
}
```
> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`.
>
> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`.
### 4.1 `GET /api/providers` — Liste
@@ -923,7 +1009,7 @@ Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur `
- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0)
- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only**
- [ ] **Réponses JSON RÉELLES** à capturer (§ 4.0.bis) — gabarit posé, capture à faire au 1er ticket back (DoD avant front)
- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139)
- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15)
- [x] 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`)
+58 -1
View File
@@ -30,6 +30,10 @@
"clients": "Répertoire clients",
"suppliers": "Répertoire fournisseurs"
},
"technique": {
"section": "Technique",
"providers": "Répertoire prestataires"
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs",
@@ -362,6 +366,55 @@
}
}
},
"technique": {
"providers": {
"title": "Répertoire prestataires",
"add": "Ajouter",
"export": "Exporter",
"empty": "Aucun prestataire pour l'instant.",
"column": {
"companyName": "Nom",
"categories": "Catégories",
"sites": "Site",
"lastActivity": "Dernière activité"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"categories": "Catégories",
"sites": "Sites",
"status": "Statut",
"includeArchived": "Inclure les archivés",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
"tab": {
"contact": "Contact",
"address": "Adresse",
"accounting": "Comptabilité"
},
"form": {
"title": "Ajouter un prestataire",
"back": "Précédent",
"submit": "Valider",
"duplicateCompany": "Un prestataire portant ce nom de société existe déjà.",
"main": {
"companyName": "Nom du prestataire (Entreprise)",
"categories": "Catégorie",
"sites": "Site"
},
"errors": {
"siteRequired": "Sélectionnez au moins un site.",
"categoryRequired": "Sélectionnez au moins une catégorie."
}
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
"createSuccess": "Prestataire créé avec succès"
}
}
},
"auth": {
"login": "Connexion",
"logout": "Deconnexion",
@@ -416,7 +469,11 @@
"commercial_supplier": "Fournisseur",
"commercial_supplieraddress": "Adresse fournisseur",
"commercial_suppliercontact": "Contact fournisseur",
"commercial_supplierrib": "RIB fournisseur"
"commercial_supplierrib": "RIB fournisseur",
"technique_provider": "Prestataire",
"technique_provideraddress": "Adresse prestataire",
"technique_providercontact": "Contact prestataire",
"technique_providerrib": "RIB prestataire"
},
"empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres",
@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141).
*
* `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et
* l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la
* creation :
* - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie
* -> POST bloque, erreurs inline, aucun appel reseau.
* - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json
* + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact +
* reaffichage du nom normalise.
* - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName.
* - 422 -> mapping inline par champ (propertyPath).
* - Onglets : « Comptabilite » present uniquement avec accounting.view ;
* completeTab deverrouille/avance et signale le dernier onglet.
*/
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite).
const permState = vi.hoisted(() => ({ accountingView: false }))
vi.stubGlobal('useApi', () => ({
get: vi.fn(),
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useToast', () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}))
vi.stubGlobal('usePermissions', () => ({
can: (perm: string) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true,
}))
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
const SITE_86 = '/api/sites/1'
const CAT_MAINT = '/api/categories/7'
describe('useProviderForm', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
permState.accountingView = false
})
it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
const created = await form.submitMain()
expect(created).toBe(false)
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired')
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
expect(form.mainLocked.value).toBe(false)
})
it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => {
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
form.main.siteIris = [SITE_86]
await form.submitMain()
expect(mockPost).not.toHaveBeenCalled()
expect(form.mainErrors.errors.sites).toBeUndefined()
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
})
it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => {
mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' })
const form = useProviderForm()
form.main.companyName = 'Maintenance Pro'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(true)
expect(mockPost).toHaveBeenCalledTimes(1)
const [url, body, opts] = mockPost.mock.calls[0] ?? []
expect(url).toBe('/providers')
expect(body).toEqual({
companyName: 'Maintenance Pro',
categories: [CAT_MAINT],
sites: [SITE_86],
})
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.providerId.value).toBe(42)
// RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur.
expect(form.main.companyName).toBe('MAINTENANCE PRO')
expect(form.mainLocked.value).toBe(true)
expect(form.activeTab.value).toBe('contact')
expect(form.unlockedIndex.value).toBe(0)
})
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
const form = useProviderForm()
form.main.companyName = ' '
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
expect(body).not.toHaveProperty('companyName')
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
})
it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 409 } })
const form = useProviderForm()
form.main.companyName = 'Doublon'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany')
expect(form.mainLocked.value).toBe(false)
})
it('422 : mappe les violations serveur inline par champ', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] },
},
})
const form = useProviderForm()
form.main.companyName = 'X'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
const created = await form.submitMain()
expect(created).toBe(false)
expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.')
})
it('onglet Comptabilite : absent sans accounting.view, present avec', () => {
expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address'])
expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting'])
permState.accountingView = true
const form = useProviderForm()
expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting'])
})
it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => {
const form = useProviderForm()
// Contact -> Adresse (pas le dernier).
expect(form.completeTab('contact')).toBe(false)
expect(form.isValidated('contact')).toBe(true)
expect(form.activeTab.value).toBe('address')
expect(form.unlockedIndex.value).toBe(1)
// Adresse = dernier onglet remplissable (sans accounting.view) -> true.
expect(form.completeTab('address')).toBe(true)
expect(form.isValidated('address')).toBe(true)
})
it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => {
const form = useProviderForm()
await form.patchProvider({ siren: '123456789' })
expect(mockPatch).not.toHaveBeenCalled()
mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' })
form.main.companyName = 'Acme'
form.main.categoryIris = [CAT_MAINT]
form.main.siteIris = [SITE_86]
await form.submitMain()
await form.patchProvider({ siren: '123456789' })
expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false })
})
})
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useProvidersRepository, type Provider } from '../useProvidersRepository'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests du repertoire prestataires (ERP-140).
*
* `useProvidersRepository` est une fine enveloppe de `usePaginatedList<Provider>`
* sur `/providers`. Les invariants generiques de pagination sont deja couverts
* par `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire :
* - la ressource ciblee est bien `/providers`
* - l'enveloppe Hydra (member / totalItems) est consommee
* - le header `Accept: application/ld+json` est envoye (sinon API Platform 4
* renvoie un tableau plat sans pagination)
* - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye
* tant que l'utilisateur ne coche pas le filtre (le back masque alors les
* archives) ; le filtre `includeArchived` est bien transmis une fois applique.
*/
describe('useProvidersRepository', () => {
beforeEach(() => {
mockApiGet.mockReset()
})
/** Une page de prestataires Hydra, avec categories[] et sites[] embarques. */
const PAGE: Provider[] = [
{
id: 1,
companyName: 'ACME MAINTENANCE',
categories: [{ code: 'MAINTENANCE_INDUSTRIELLE', name: 'Maintenance industrielle' }],
sites: [{ id: 4, name: 'Chatellerault', color: '#056CF2' }],
updatedAt: '2026-06-15T08:12:01+02:00',
isArchived: false,
},
]
it('cible /providers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/providers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(opts).toMatchObject({
toast: false,
headers: { Accept: 'application/ld+json' },
})
expect(repo.items.value).toEqual(PAGE)
expect(repo.totalItems.value).toBe(1)
})
it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
const query = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(query.includeArchived).toBeUndefined()
})
it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => {
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
const repo = useProvidersRepository()
await repo.fetch()
mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 })
await repo.setFilters({ includeArchived: true })
expect(repo.currentPage.value).toBe(1)
const query = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(query.includeArchived).toBe(true)
})
})
@@ -0,0 +1,207 @@
import { computed, reactive, ref } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import {
emptyProviderMain,
type ProviderMainDraft,
type ProviderMainResponse,
} from '~/modules/technique/types/providerForm'
/**
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
* miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en
* composable.
*
* Particularites M3 (cf. spec-front § « Ecran Ajouter ») :
* - PAS d'onglet « Information » : le formulaire principal est minimal (Nom +
* Categorie + Site).
* - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe
* `provider.sites`).
* - Creation incrementale par onglets (Contact · Adresse · Comptabilite) :
* POST principal puis PATCH partiels par groupe de serialisation
* (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets
* arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et
* l'orchestration des onglets.
*
* Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL.
*/
/**
* Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ;
* « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification.
* L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir
* (`technique.providers.accounting.view` — Admin, Compta).
*/
export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] {
return canAccountingView
? ['contact', 'address', 'accounting']
: ['contact', 'address']
}
export function useProviderForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
const { can } = usePermissions()
// Erreurs de validation par champ (ERP-101) du formulaire principal.
const mainErrors = useFormErrors()
// ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null)
const mainLocked = ref(false)
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
// ── Formulaire principal ──────────────────────────────────────────────────
const main = reactive<ProviderMainDraft>(emptyProviderMain())
// ── Onglets : ordre + gating progressif ───────────────────────────────────
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
const unlockedIndex = ref(-1)
const activeTab = ref<string>('contact')
// Onglets valides (passent en lecture seule).
const validated = reactive<Record<string, boolean>>({})
function isValidated(key: string): boolean {
return validated[key] === true
}
function tabIndex(key: string): number {
return tabKeys.value.indexOf(key)
}
/**
* Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09
* (>= 1 categorie). Pose les erreurs inline et retourne false si invalide.
* Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un
* aller-retour inutile et porte la garantie RG-3.03 cote front.
*/
function validateMainFront(): boolean {
let valid = true
if (main.siteIris.length === 0) {
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
valid = false
}
if (main.categoryIris.length === 0) {
mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired'))
valid = false
}
return valid
}
/**
* Payload du POST principal (groupe `provider:write:main`). `companyName` est
* omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur
* le champ plutot qu'une erreur de type. Les relations M2M partent en IRI.
*/
function buildMainPayload(): Record<string, unknown> {
const payload: Record<string, unknown> = {
categories: [...main.categoryIris],
sites: [...main.siteIris],
}
if (main.companyName?.trim()) {
payload.companyName = main.companyName
}
return payload
}
/**
* POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09,
* puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er
* onglet et bascule sur « Contact ». Retourne true si cree, false sinon.
*/
async function submitMain(): Promise<boolean> {
if (mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
const created = await api.post<ProviderMainResponse>('/providers', buildMainPayload(), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
providerId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11).
main.companyName = created.companyName ?? main.companyName
mainLocked.value = true
unlockedIndex.value = 0
activeTab.value = tabKeys.value[0] ?? 'contact'
toast.success({ title: t('technique.providers.toast.createSuccess') })
return true
}
catch (error) {
// 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ;
// 422 → mapping inline par champ ; autre → toast de fallback (ERP-101).
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('technique.providers.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('technique.providers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
}
return false
}
finally {
mainSubmitting.value = false
}
}
/**
* PATCH partiel du prestataire (mode strict : un seul groupe de serialisation
* par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires
* (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources
* (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas.
*/
async function patchProvider(payload: Record<string, unknown>): Promise<void> {
if (providerId.value === null) return
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
}
/**
* Marque un onglet valide (passe en lecture seule), deverrouille et avance a
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
* (creation terminee), false sinon.
*/
function completeTab(key: string): boolean {
validated[key] = true
const index = tabIndex(key)
const next = tabKeys.value[index + 1]
if (next === undefined) {
return true
}
unlockedIndex.value = Math.max(unlockedIndex.value, index + 1)
activeTab.value = next
return false
}
return {
// etat
main,
providerId,
mainLocked,
mainSubmitting,
tabSubmitting,
mainErrors,
// onglets
canAccountingView,
tabKeys,
activeTab,
unlockedIndex,
validated,
isValidated,
// actions
validateMainFront,
buildMainPayload,
submitMain,
patchProvider,
completeTab,
}
}
@@ -0,0 +1,85 @@
import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects du formulaire
* principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) :
* categories (type PRESTATAIRE) et sites (86 / 17 / 82).
*
* Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire
* principal) seuls categories + sites sont necessaires. Les referentiels
* comptables (modes de TVA, delais/types de reglement, banques) seront charges
* par l'onglet Comptabilite (ERP-144).
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques entrees), avec l'en-tete
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
* quelle dans le payload POST (relations M2M).
*
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un
* echec (permission manquante, reseau) laisse simplement la liste vide.
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
interface HydraMember {
'@id': string
}
interface CategoryMember extends HydraMember {
code: string
name: string
}
interface SiteMember extends HydraMember {
name: string
postalCode: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useProviderReferentials() {
const api = useApi()
const categories = ref<RefOption[]>([])
const sites = ref<RefOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
url: string,
query: Record<string, string | string[]> = {},
): Promise<T[]> {
const res = await api.get<{ member?: T[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return res.member ?? []
}
/** Charge en parallele les referentiels du formulaire principal (categories + sites). */
async function loadMain(): Promise<void> {
await Promise.allSettled([
// RG-3.09 : un prestataire ne porte que des categories de type
// PRESTATAIRE -> filtre cote API. Libelle affiche = `name`.
fetchAll<CategoryMember>('/categories', { typeCode: 'PRESTATAIRE' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }),
// Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
fetchAll<SiteMember>('/sites')
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
])
}
return {
categories,
sites,
loadMain,
}
}
@@ -0,0 +1,62 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
/**
* Site Starseed rattache DIRECTEMENT au prestataire (M2M `provider_site`,
* RG-3.03), tel qu'embarque en LISTE (groupe site:read) pour la colonne « Site »
* du Repertoire (badges colores).
*
* Difference M3 vs M2 : au M2 les sites venaient de l'agregat dedoublonne des
* adresses (`Supplier::getSites()`) ; ici c'est une relation directe portee par
* le formulaire principal (cf. spec-back M3 § 2.12).
*/
export interface ProviderSite {
id: number
name: string
color: string
}
/**
* Categorie (type PRESTATAIRE) rattachee au prestataire, embarquee en LISTE
* (groupe category:read). La colonne « Catégories » affiche le `name` (cohérence
* M1/M2 — libellé = `name`, pas `code`).
*/
export interface ProviderCategory {
code: string
name: string
}
/**
* Vue MINIMALE d'un prestataire pour le Repertoire (datatable). Volontairement
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-140).
*/
export interface Provider {
id: number
companyName: string
categories: ProviderCategory[]
sites: ProviderSite[]
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
updatedAt: string | null
isArchived: boolean
}
/**
* Repertoire prestataires (ERP-140) — simple enveloppe de `usePaginatedList<Provider>`
* sur la ressource `/providers` (pagination serveur obligatoire ; jamais de
* chargement integral en memoire). Miroir de `useSuppliersRepository` (M2).
*
* Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes
* par la page via `setFilters` du composable partage — la remise en page 1 est
* garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque
* donc les prestataires archives (exclusion par defaut, spec-back § 2.11).
*
* Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en
* fonction de l'utilisateur — rien a filtrer cote front.
*
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
* `usePaginatedList`. Aucun reset au logout a gerer.
*/
export function useProvidersRepository() {
return usePaginatedList<Provider>({ url: '/providers' })
}
@@ -0,0 +1,438 @@
<template>
<div>
<PageHeader>
{{ t('technique.providers.title') }}
<template #actions>
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter. -->
<div class="flex items-center gap-8">
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('technique.providers.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList via useProvidersRepository :
pagination serveur, tri companyName ASC par defaut (cote back),
archives masques par defaut. Cloisonnement par site cote back. -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
table-class="table-fixed providers-table"
:empty-message="t('technique.providers.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Categories : libelles (name) separes par une virgule. -->
<template #cell-categories="{ item }">
{{ formatCategories(item) }}
</template>
<!-- Sites : badges colores (name + color), relation directe du prestataire. -->
<template #cell-sites="{ item }">
<span class="flex flex-wrap gap-1">
<span
v-for="site in (item.sites as ProviderSite[])"
:key="site.id"
class="inline-flex items-center rounded-full px-2 py-0.5 font-medium text-white"
:style="{ backgroundColor: site.color }"
>
{{ site.name }}
</span>
</span>
</template>
<!-- Derniere activite : date de derniere modification (updatedAt), format JJ-MM-AAAA. -->
<template #cell-lastActivity="{ item }">
{{ formatLastActivity(item) }}
</template>
</MalioDataTable>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('technique.providers.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Voir les résultats ». Meme pattern que le repertoire fournisseurs.
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('technique.providers.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche : nom entreprise + contact + email (param `search`). -->
<MalioAccordionItem :title="t('technique.providers.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Categories (type PRESTATAIRE) : cases a cocher (multi). Valeur = code stable. -->
<MalioAccordionItem :title="t('technique.providers.filters.categories')" value="categories">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in categoryOptions"
:id="`filter-category-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftCategoryCodes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
<MalioAccordionItem :title="t('technique.providers.filters.sites')" value="sites">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in siteOptions"
:id="`filter-site-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftSiteIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Statut : bool unique. Coche = inclut aussi les archives (sinon actifs seuls). -->
<MalioAccordionItem :title="t('technique.providers.filters.status')" value="status">
<MalioCheckbox
id="filter-include-archived"
:label="t('technique.providers.filters.includeArchived')"
:model-value="draftIncludeArchived"
@update:model-value="(val: boolean) => draftIncludeArchived = val"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('technique.providers.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('technique.providers.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Provider, ProviderSite } from '~/modules/technique/composables/useProvidersRepository'
interface FilterOption {
value: string
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('technique.providers.title') })
// Bouton « Ajouter » reserve a `manage` (POST /providers garde manage seul —
// Compta cree pas). « Exporter » et « Filtrer » suivent `view`.
const canManage = computed(() => can('technique.providers.manage'))
const canView = computed(() => can('technique.providers.view'))
const {
items: providers,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadProviders,
goToPage,
setItemsPerPage,
setFilters,
} = useProvidersRepository()
// Mappe les prestataires en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Provider. Meme pattern que fournisseurs.
const rows = computed(() => providers.value.map(provider => ({
id: provider.id,
companyName: provider.companyName,
categories: provider.categories,
sites: provider.sites,
updatedAt: provider.updatedAt,
})))
const columns = [
{ key: 'companyName', label: t('technique.providers.column.companyName') },
{ key: 'categories', label: t('technique.providers.column.categories') },
{ key: 'sites', label: t('technique.providers.column.sites') },
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
]
/** Libelles des categories du prestataire, separes par une virgule (name). */
function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Provider['categories']) ?? []
return categories.map(c => c.name).join(', ')
}
/**
* Derniere activite : date de derniere modification de la fiche (updatedAt,
* expose en liste via default:read). Format court francais JJ-MM-AAAA (tirets,
* cf. spec-front M3 § Datatable).
*/
function formatLastActivity(item: Record<string, unknown>): string {
const value = item.updatedAt as string | null | undefined
if (!value) {
return ''
}
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = date.getFullYear()
return `${day}-${month}-${year}`
}
/** Clic sur une ligne → ecran Consultation (route a plat /providers/{id}). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/providers/${item.id}`)
}
function goToCreate(): void {
router.push('/providers/new')
}
// ── Filtres (drawer) ────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern repertoire fournisseurs) :
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryCodes = ref<string[]>([])
const draftSiteIds = ref<string[]>([])
const draftIncludeArchived = ref(false)
const appliedSearch = ref('')
const appliedCategoryCodes = ref<string[]>([])
const appliedSiteIds = ref<string[]>([])
const appliedIncludeArchived = ref(false)
// Options des selects multi, chargees une fois (referentiels courts).
const categoryOptions = ref<FilterOption[]>([])
const siteOptions = ref<FilterOption[]>([])
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryCodes.value.length > 0) count++
if (appliedSiteIds.value.length > 0) count++
if (appliedIncludeArchived.value) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('technique.providers.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
// reouverture reflete les filtres actifs.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryCodes.value = [...appliedCategoryCodes.value]
draftSiteIds.value = [...appliedSiteIds.value]
draftIncludeArchived.value = appliedIncludeArchived.value
filterDrawerOpen.value = true
}
function toggleCategory(code: string, selected: boolean): void {
draftCategoryCodes.value = selected
? [...draftCategoryCodes.value, code]
: draftCategoryCodes.value.filter(c => c !== code)
}
function toggleSite(id: string, selected: boolean): void {
draftSiteIds.value = selected
? [...draftSiteIds.value, id]
: draftSiteIds.value.filter(s => s !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
* Les filtres vides sont omis pour une query propre.
*/
function buildFilterPayload(): Record<string, string | string[] | boolean> {
const payload: Record<string, string | string[] | boolean> = {}
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
if (appliedIncludeArchived.value) payload.includeArchived = true
return payload
}
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryCodes.value = [...draftCategoryCodes.value]
appliedSiteIds.value = [...draftSiteIds.value]
appliedIncludeArchived.value = draftIncludeArchived.value
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftCategoryCodes.value = []
draftSiteIds.value = []
draftIncludeArchived.value = false
appliedSearch.value = ''
appliedCategoryCodes.value = []
appliedSiteIds.value = []
appliedIncludeArchived.value = false
setFilters({}, { replace: true })
}
/** Charge les referentiels du drawer (categories PRESTATAIRE + sites) via ?pagination=false. */
async function loadFilterOptions(): Promise<void> {
const [cats, sites] = await Promise.all([
api.get<{ member?: Array<{ code: string, name: string }> }>(
'/categories',
// Taxonomie multi-types : le filtre du repertoire prestataires ne
// propose que les categories de type PRESTATAIRE.
{ pagination: 'false', typeCode: 'PRESTATAIRE' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
api.get<{ member?: Array<{ id: number, name: string }> }>(
'/sites',
{ pagination: 'false' },
{ headers: { Accept: 'application/ld+json' }, toast: false },
),
])
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
}
// ── Export XLSX ─────────────────────────────────────────────────────────────
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
// l'utilisateur a accounting.view (gere cote back).
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage — meme approche que
// l'export fournisseurs.
const blob = await api.get<Blob>('/providers/export.xlsx', buildFilterPayload(), {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'repertoire-prestataires.xlsx')
}
catch {
toast.error({
title: t('technique.providers.toast.error'),
message: t('technique.providers.toast.exportError'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadProviders()
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
// l'utilisateur perd juste les options de filtre.
loadFilterOptions().catch(() => {
categoryOptions.value = []
siteOptions.value = []
})
})
</script>
<style scoped>
/*
* Colonne Sites uniquement (3e colonne : companyName, categories, SITES,
* lastActivity) : ses badges rendent la cellule trop haute. On reduit le padding
* vertical de SON td (16px Malio -> 8px) sans toucher les autres colonnes ni les
* couleurs/tailles (qui restent sur les defauts Malio).
*/
:deep(.providers-table tbody td:nth-child(3)) {
padding-top: 8px;
padding-bottom: 8px;
}
</style>
@@ -0,0 +1,127 @@
<template>
<div>
<!-- En-tete : retour vers le repertoire + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('technique.providers.form.title') }}</h1>
</div>
<!-- Formulaire principal (pre-onglets)
Sans validation de ce bloc, les onglets restent inaccessibles. Au
succes du POST, les champs passent en lecture seule et on bascule
automatiquement sur l'onglet Contact (PAS d'onglet Information au M3).
Selecteur de site present ici (RG-3.03, relation directe). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelectCheckbox
:model-value="main.siteIris"
:options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')"
:display-tag="true"
:readonly="mainLocked"
:required="true"
:error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
/>
</div>
<div v-if="!mainLocked" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('technique.providers.form.submit')"
:disabled="mainSubmitting"
@click="submitMain"
/>
</div>
<!-- ── Onglets a validation incrementale ─────────────────────────────
Le contenu des onglets (Contact / Adresse / Comptabilite) arrive aux
tickets ERP-142 → 144 : placeholders « A venir » pour l'instant. -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #contact><ComingSoonPlaceholder /></template>
<template #address><ComingSoonPlaceholder /></template>
<template v-if="canAccountingView" #accounting><ComingSoonPlaceholder /></template>
</MalioTabList>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProviderReferentials } from '~/modules/technique/composables/useProviderReferentials'
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
const { t } = useI18n()
const router = useRouter()
const { can } = usePermissions()
useHead({ title: t('technique.providers.form.title') })
// Gating de la route : la creation est reservee a `manage` (POST /providers garde
// manage seul — Compta ne cree pas). Compta (accounting seul) et Usine sont
// rediriges vers le repertoire.
if (!can('technique.providers.manage')) {
await navigateTo('/providers')
}
const referentials = useProviderReferentials()
const {
main,
mainLocked,
mainSubmitting,
mainErrors,
canAccountingView,
tabKeys,
activeTab,
unlockedIndex,
submitMain,
} = useProviderForm()
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
function goBack(): void {
router.push('/providers')
}
// Icone (Iconify) affichee dans l'onglet, par cle.
const TAB_ICONS: Record<string, string> = {
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
accounting: 'mdi:bank-circle-outline',
}
// Onglets desactives tant que le formulaire principal n'est pas valide
// (unlockedIndex = -1 au depart) ; deverrouillage progressif ensuite.
const tabs = computed(() => tabKeys.value.map((key, index) => ({
key,
label: t(`technique.providers.tab.${key}`),
icon: TAB_ICONS[key],
disabled: index > unlockedIndex.value,
})))
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadMain().catch(() => {})
})
</script>
@@ -0,0 +1,41 @@
/**
* Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique).
*
* Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet
* Information, et porte en plus un selecteur de site SUR le formulaire principal
* (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse).
*
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
* DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet
* Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent.
*
* Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`),
* envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 :
* `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`).
*/
/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */
export interface ProviderMainDraft {
/** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */
companyName: string | null
/** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */
categoryIris: string[]
/** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */
siteIris: string[]
}
/** Fabrique un formulaire principal vierge. */
export function emptyProviderMain(): ProviderMainDraft {
return {
companyName: null,
categoryIris: [],
siteIris: [],
}
}
/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */
export interface ProviderMainResponse {
id: number
/** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */
companyName: string | null
}
+11
View File
@@ -84,6 +84,17 @@ export const personas: Record<PersonaKey, Persona> = {
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
// clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
// persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
// donc il voit les prestataires de tous les sites (M3 § 2.13).
// technique.providers.view n'ajoute pas de lien dans la section
// Administration, donc expectedAdminLinks reste inchange.
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
},
+1
View File
@@ -231,6 +231,7 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
+451
View File
@@ -0,0 +1,451 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M3 — Repertoire prestataires (ERP-132) : creation de toute la structure BDD
* des prestataires sous le nouveau module Technique (jumeau du M2 fournisseur).
*
* Tables creees :
* - Table principale : provider (formulaire principal + Comptabilite + archive
* + soft-delete + Timestampable/Blamable). PAS d onglet Information.
* - M2M du formulaire principal : provider_category (RG-3.09),
* provider_site (sites du prestataire, RG-3.03 — NOUVEAU vs supplier).
* - Sous-collections : provider_contact (1:n), provider_address (1:n),
* provider_rib (1:n).
* - Jointures de provider_address : provider_address_site (RG-3.05),
* provider_address_contact, provider_address_category.
*
* Differences vs le M2 `supplier` (cf. spec M3 § 3.1) :
* - PAS d onglet Information : aucun champ description / competitors /
* founded_at / employees_count / revenue_amount / director_name /
* profit_amount / volume_forecast. Le provider est minimal : nom + compta.
* - AJOUT de provider_site (M2M) : sites rattaches au prestataire directement
* sur le formulaire principal (RG-3.03, >= 1). Sert aussi le cloisonnement
* par site (idx_provider_site_site, § 2.13).
* - provider_address SIMPLIFIEE : pas de address_type / bennes /
* triage_provider (specifiques fournisseur). Champs : country / postal_code
* / city / street / street_complement / position + M2M sites/contacts/categories.
*
* Referentiels comptables NON recrees : tva_mode / payment_delay / payment_type
* / bank sont ceux du M1 (FK partagees, zero duplication — spec § 2.3).
*
* CategoryType PRESTATAIRE NON re-seede : il est cree par ERP-131
* (Version20260612080000) avec ses categories de demonstration. Le M2M
* provider_category / provider_address_category s appuie sur ce type existant.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
* `App\Module\Technique\...` : la migration cree un schema avec FK cross-module
* (user, category, site, et les referentiels comptables M1). Avec plusieurs
* migrations_paths, Doctrine Migrations 3.x trie par FQCN alphabetique — un
* namespace modulaire s executerait avant la creation de user/category/site sur
* base vide -> echec des FK. Le namespace racine garantit l ordre par timestamp.
*
* Style DDL aligne sur le M1/M2 (Version20260605130000) : `INT GENERATED BY
* DEFAULT AS IDENTITY` (et non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non
* TIMESTAMPTZ, car le TimestampableBlamableTrait mappe `datetime_immutable`).
* Garantit que `schema:update` restera un no-op quand les entites arriveront
* (ticket ERP-133).
*
* Decision unicite (alignee Q4 M1 / § 2.6 M2) : unicite metier sur le NOM DE
* SOCIETE uniquement (uq_provider_company_name_active, partiel). Pas d index
* unique sur siren ni email.
*
* COMMENT ON COLUMN inline (regle ABSOLUE n°12) : chaque colonne metier porte sa
* description ici-meme. Volontairement NON ajoutees a `ColumnCommentsCatalog` /
* `makefile test-db-setup` a ce stade : tant que les entites Provider* n existent
* pas (ERP-133), `schema:update --force` du setup de test droppe ces tables non
* mappees — les referencer dans le catalogue ferait planter
* `app:apply-column-comments`. Le catalogue + la ligne `dbal:run-sql`
* (uq_provider_company_name_active) seront ajoutes au ticket entites (ERP-133),
* exactement comme supplier (ERP-86) apres sa migration (ERP-85). Les 4 colonnes
* Timestampable/Blamable reutilisent les textes standardises du catalogue
* (`timestampableBlamableComments()`, simple tableau statique sans dependance DB).
*/
final class Version20260612100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-132 (M3) : tables provider + sous-collections + jointures M2M (referentiels comptables et CategoryType PRESTATAIRE reutilises).';
}
public function up(Schema $schema): void
{
$this->createProviderTable();
$this->createProviderCategory();
$this->createProviderSite();
$this->createProviderContact();
$this->createProviderAddress();
$this->createProviderAddressJoinTables();
$this->createProviderRib();
}
public function down(Schema $schema): void
{
// Ordre inverse des dependances FK : jointures et sous-collections
// d abord, puis provider. Les referentiels comptables et le
// CategoryType PRESTATAIRE ne sont pas touches (crees ailleurs).
$this->addSql('DROP TABLE IF EXISTS provider_address_category');
$this->addSql('DROP TABLE IF EXISTS provider_address_contact');
$this->addSql('DROP TABLE IF EXISTS provider_address_site');
$this->addSql('DROP TABLE IF EXISTS provider_rib');
$this->addSql('DROP TABLE IF EXISTS provider_address');
$this->addSql('DROP TABLE IF EXISTS provider_contact');
$this->addSql('DROP TABLE IF EXISTS provider_site');
$this->addSql('DROP TABLE IF EXISTS provider_category');
$this->addSql('DROP TABLE IF EXISTS provider');
}
// =================================================================
// Table principale `provider`
// =================================================================
private function createProviderTable(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
company_name VARCHAR(180) NOT NULL,
siren VARCHAR(20) DEFAULT NULL,
account_number VARCHAR(40) DEFAULT NULL,
tva_mode_id INT DEFAULT NULL,
n_tva VARCHAR(40) DEFAULT NULL,
payment_delay_id INT DEFAULT NULL,
payment_type_id INT DEFAULT NULL,
bank_id INT DEFAULT NULL,
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_provider_tva_mode
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
CONSTRAINT fk_provider_payment_delay
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
CONSTRAINT fk_provider_payment_type
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_provider_bank
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
CONSTRAINT fk_provider_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_provider_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_provider_is_archived ON provider (is_archived)');
$this->addSql('CREATE INDEX idx_provider_deleted_at ON provider (deleted_at)');
$this->addSql('CREATE INDEX idx_provider_created_by ON provider (created_by)');
$this->addSql('CREATE INDEX idx_provider_updated_by ON provider (updated_by)');
// Index sur les FK des referentiels comptables (Postgres n indexe pas
// automatiquement les colonnes portant une FOREIGN KEY).
$this->addSql('CREATE INDEX idx_provider_tva_mode_id ON provider (tva_mode_id)');
$this->addSql('CREATE INDEX idx_provider_payment_delay_id ON provider (payment_delay_id)');
$this->addSql('CREATE INDEX idx_provider_payment_type_id ON provider (payment_type_id)');
$this->addSql('CREATE INDEX idx_provider_bank_id ON provider (bank_id)');
// Unicite metier partielle : nom de societe insensible a la casse, parmi
// les non-archives ET non soft-deletes uniquement (RG-3.10). Pas d index
// unique sur siren ni email.
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_provider_company_name_active
ON provider (LOWER(company_name))
WHERE is_archived = FALSE AND deleted_at IS NULL
SQL);
$this->comment('provider', '_table', 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).');
$this->comment('provider', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider', 'company_name', 'Raison sociale du prestataire (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_provider_company_name_active, RG-3.10).');
$this->comment('provider', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).');
$this->comment('provider', 'account_number', 'Onglet Comptabilite : numero de compte comptable du prestataire.');
$this->comment('provider', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.');
$this->comment('provider', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
$this->comment('provider', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.');
$this->comment('provider', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque si VIREMENT) et RG-3.08 (RIB).');
$this->comment('provider', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.');
$this->comment('provider', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.');
$this->comment('provider', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.');
$this->comment('provider', 'deleted_at', 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.');
$this->addTimestampableBlamableComments('provider');
}
// =================================================================
// M2M provider <-> category (type PRESTATAIRE — RG-3.09)
// =================================================================
private function createProviderCategory(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_category (
provider_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (provider_id, category_id),
CONSTRAINT fk_provider_category_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_category_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
)
SQL);
$this->addSql('CREATE INDEX idx_provider_category_category ON provider_category (category_id)');
$this->comment('provider_category', '_table', 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).');
$this->comment('provider_category', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.');
$this->comment('provider_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).');
}
// =================================================================
// M2M provider <-> site (formulaire principal — RG-3.03)
// =================================================================
private function createProviderSite(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_site (
provider_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (provider_id, site_id),
CONSTRAINT fk_provider_site_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
)
SQL);
// Index sur site_id : sert le filtre de cloisonnement par site
// (WHERE site = :currentSite, § 2.13).
$this->addSql('CREATE INDEX idx_provider_site_site ON provider_site (site_id)');
$this->comment('provider_site', '_table', 'Jointure M2M provider <-> site (Sites) — sites du prestataire, selecteur du formulaire principal, au moins un obligatoire (RG-3.03). Sert le cloisonnement par site (§ 2.13).');
$this->comment('provider_site', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.');
$this->comment('provider_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).');
}
// =================================================================
// Sous-collection : contacts (1:n)
// =================================================================
private function createProviderContact(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_contact (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
provider_id INT NOT NULL,
first_name VARCHAR(120) DEFAULT NULL,
last_name VARCHAR(120) DEFAULT NULL,
job_title VARCHAR(120) DEFAULT NULL,
phone_primary VARCHAR(20) DEFAULT NULL,
phone_secondary VARCHAR(20) DEFAULT NULL,
email VARCHAR(180) DEFAULT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
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),
CONSTRAINT fk_provider_contact_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_contact_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_provider_contact_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
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', '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', '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).');
$this->comment('provider_contact', 'position', 'Ordre d affichage du contact dans la liste du prestataire (croissant).');
$this->addTimestampableBlamableComments('provider_contact');
}
// =================================================================
// Sous-collection : adresses (1:n) — SANS address_type / bennes / triage
// =================================================================
private function createProviderAddress(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_address (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
provider_id INT NOT NULL,
country VARCHAR(80) DEFAULT 'France' NOT NULL,
postal_code VARCHAR(20) NOT NULL,
city VARCHAR(120) NOT NULL,
street VARCHAR(255) NOT NULL,
street_complement VARCHAR(255) DEFAULT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_provider_address_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_address_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_provider_address_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_provider_address_provider ON provider_address (provider_id)');
$this->comment('provider_address', '_table', 'Adresses d un prestataire (1:n) — >= 1 site rattache (RG-3.05). SANS address_type / bennes / triage_provider (specifiques fournisseur).');
$this->comment('provider_address', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider_address', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire de l adresse.');
$this->comment('provider_address', 'country', 'Pays de l adresse — defaut France.');
$this->comment('provider_address', 'postal_code', 'Code postal (4-5 chiffres attendus) — declenche l autocompletion ville via l API BAN cote front (RG-3.06).');
$this->comment('provider_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front.');
$this->comment('provider_address', 'street', 'Numero et voie de l adresse.');
$this->comment('provider_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
$this->comment('provider_address', 'position', 'Ordre d affichage de l adresse dans la liste du prestataire (croissant).');
$this->addTimestampableBlamableComments('provider_address');
}
// =================================================================
// Jointures de provider_address (M2M)
// =================================================================
private function createProviderAddressJoinTables(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_address_site (
provider_address_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (provider_address_id, site_id),
CONSTRAINT fk_provider_address_site_address
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_address_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
)
SQL);
$this->comment('provider_address_site', '_table', 'Jointure M2M provider_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-3.05).');
$this->comment('provider_address_site', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('provider_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
$this->addSql(<<<'SQL'
CREATE TABLE provider_address_contact (
provider_address_id INT NOT NULL,
provider_contact_id INT NOT NULL,
PRIMARY KEY (provider_address_id, provider_contact_id),
CONSTRAINT fk_provider_address_contact_address
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_address_contact_contact
FOREIGN KEY (provider_contact_id) REFERENCES provider_contact (id) ON DELETE CASCADE
)
SQL);
$this->comment('provider_address_contact', '_table', 'Jointure M2M provider_address <-> provider_contact — contacts associes a une adresse.');
$this->comment('provider_address_contact', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('provider_address_contact', 'provider_contact_id', 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
$this->addSql(<<<'SQL'
CREATE TABLE provider_address_category (
provider_address_id INT NOT NULL,
category_id INT NOT NULL,
PRIMARY KEY (provider_address_id, category_id),
CONSTRAINT fk_provider_address_category_address
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_address_category_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
)
SQL);
$this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).');
$this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
$this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).');
}
// =================================================================
// Sous-collection : RIB (1:n)
// =================================================================
private function createProviderRib(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE provider_rib (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
provider_id INT NOT NULL,
label VARCHAR(120) NOT NULL,
bic VARCHAR(20) NOT NULL,
iban VARCHAR(34) NOT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_provider_rib_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_rib_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_provider_rib_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
$this->addSql('CREATE INDEX idx_provider_rib_provider ON provider_rib (provider_id)');
$this->comment('provider_rib', '_table', 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).');
$this->comment('provider_rib', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider_rib', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du RIB.');
$this->comment('provider_rib', 'label', 'Libelle du RIB (ex: compte principal).');
$this->comment('provider_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
$this->comment('provider_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
$this->comment('provider_rib', 'position', 'Ordre d affichage du RIB dans la liste du prestataire (croissant).');
$this->addTimestampableBlamableComments('provider_rib');
}
// =================================================================
// Helpers
// =================================================================
/**
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
* en reutilisant le catalogue partage (source unique, cf. ERP-67). Seul le
* tableau statique des textes est reutilise — aucune dependance a l etat DB.
*/
private function addTimestampableBlamableComments(string $table): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
$this->comment($table, $column, $description);
}
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
* tout echappement d apostrophe.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+5 -4
View File
@@ -21,7 +21,8 @@ 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).
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
*/
#[ApiResource(
operations: [
@@ -48,15 +49,15 @@ class Bank
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -21,7 +21,8 @@ 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).
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
*/
#[ApiResource(
operations: [
@@ -48,15 +49,15 @@ class PaymentDelay
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -24,7 +24,8 @@ 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).
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
*/
#[ApiResource(
operations: [
@@ -51,15 +52,15 @@ class PaymentType
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -25,7 +25,8 @@ 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).
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0) ;
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
*/
#[ApiResource(
operations: [
@@ -55,15 +56,15 @@ class TvaMode
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?int $id = null;
#[ORM\Column(length: 30)]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
private ?string $label = null;
#[ORM\Column(options: ['default' => 0])]
@@ -50,11 +50,19 @@ final class RbacSeeder
/**
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
* bypass tout via isAdmin ; `commercial.clients.archive` et
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
* `commercial.clients.archive`, `commercial.suppliers.archive` et
* `technique.providers.archive` ne sont attaches a aucun role metier —
* admin seul).
*
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
* Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
* reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
* C'est un cloisonnement pilote par user/permission, pas par code de role :
* pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
* ici, aucun autre code a changer.
*
* @var array<string, array{label: string, permissions: list<string>}>
*/
private const array MATRIX = [
@@ -66,6 +74,11 @@ final class RbacSeeder
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
'technique.providers.view',
'technique.providers.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -82,6 +95,13 @@ final class RbacSeeder
'commercial.suppliers.view',
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
// (pas de manage global -> ne peut pas creer un prestataire).
'technique.providers.view',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
@@ -96,14 +116,25 @@ final class RbacSeeder
// (onglet Comptabilite masque/filtre pour la Commerciale).
'commercial.suppliers.view',
'commercial.suppliers.manage',
// Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
// (onglet Comptabilite masque/filtre pour la Commerciale).
'technique.providers.view',
'technique.providers.manage',
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
'sites.bypass_scope',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
],
],
self::ROLE_USINE => [
'label' => 'Usine',
'permissions' => [],
'label' => 'Usine',
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
// courant. Aucun autre acces metier.
'permissions' => [
'technique.providers.view',
],
],
];
@@ -203,6 +203,15 @@ final class SeedE2ECommand extends Command
'commercial.suppliers.accounting.view',
'commercial.suppliers.accounting.manage',
'commercial.suppliers.archive',
// Technique — Repertoire prestataires (M3, ERP-138). Meme
// logique : mappe sur le persona "tout". user-full porte deja
// sites.bypass_scope -> voit les prestataires de tous les
// sites (M3 § 2.13). Miroir de personas.ts.
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
'technique.providers.archive',
],
],
[
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Application\Service;
/**
* Normalisation serveur des champs texte d'un Provider / ProviderContact,
* appliquee par le ProviderProcessor (et les processors de sous-ressources,
* ticket ulterieur M3) AVANT persistance. Cf. spec-back M3 § 2.11 + RG-3.11.
* Jumeau de SupplierFieldNormalizer (M2) — duplique volontairement (isolation
* Commercial / Technique, decision § 2.1).
*
* - companyName : UPPERCASE integral (RG-3.11)
* - firstName / lastName (personnes, sur ProviderContact) : Title Case (RG-3.11)
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-3.11).
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
* - email : lowercase integral (RG-3.11)
*
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
* apres trim devient null (evite de persister "" dans des colonnes nullable).
*/
final class ProviderFieldNormalizer
{
/**
* Nom de societe en majuscules (RG-3.11). Conserve null tel quel ; une
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
*/
public function normalizeCompanyName(?string $value): ?string
{
if (null === $value) {
return null;
}
return mb_strtoupper(trim($value), 'UTF-8');
}
/**
* Nom/prenom de personne en Title Case (RG-3.11) : "JEAN dupont" ->
* "Jean Dupont". Une chaine vide apres trim devient null.
*/
public function normalizePersonName(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
}
/**
* Email en minuscules (RG-3.11). Une chaine vide apres trim devient null.
*/
public function normalizeEmail(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
}
/**
* Texte libre simplement trim (ex : jobTitle / Fonction du contact). Pas de
* changement de casse — on preserve la saisie. Une chaine vide apres trim
* devient null (evite de persister "" et de faire passer a tort le garde-fou
* RG-3.04 / le CHECK chk_provider_contact_name sur une Fonction vide).
*/
public function normalizeText(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
/**
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
* "0612345678". Une valeur sans aucun chiffre devient null.
*/
public function normalizePhone(?string $value): ?string
{
if (null === $value) {
return null;
}
$digits = preg_replace('/\D+/', '', $value) ?? '';
return '' === $digits ? null : $digits;
}
}
@@ -0,0 +1,607 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Prestataire (M3 Technique) — entite racine du repertoire prestataires, jumelle
* du Fournisseur (M2). Porte le formulaire principal (nom + categories + sites),
* l'onglet Comptabilite, le mecanisme d'archivage (is_archived / archived_at) et
* le soft-delete technique prepare mais non expose au M3 (deleted_at, HP M4).
*
* Differences structurantes vs Supplier (cf. spec M3 § 3.1) :
* - PAS d'onglet Information : aucun champ description / competitors / founded_at
* / employees_count / revenue_amount / director_name / profit_amount /
* volume_forecast. Le prestataire est minimal : nom + comptabilite.
* - AJOUT de `sites` (M2M `provider_site`) : sites rattaches DIRECTEMENT au
* prestataire sur le formulaire principal (RG-3.03, >= 1). Nouveau vs supplier
* (qui n'avait des sites que sur l'adresse). Sert aussi le cloisonnement par
* site (§ 2.13, ticket Provider/Processor ERP-134).
*
* Referentiels comptables (TvaMode / PaymentDelay / PaymentType / Bank) et Site /
* Category : consommes en RELATION ORM PARTAGEE (decision Matthieu, § 2.1). Site /
* Category passent par les contrats Shared (SiteInterface / CategoryInterface +
* resolve_target_entities) comme le fait deja Supplier (regle ABSOLUE n°1). Les 4
* referentiels comptables vivent dans le module Commercial et sont references en
* direct, faute de contrat Shared dedie (remontee dans Shared tracee HP-M4-2) —
* reference de donnees de reference, pas de logique inter-module.
*
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups
* sont poses ICI (source unique). L'#[ApiResource] cable (ERP-134) le ProviderProvider
* (liste paginee anti-N+1, exclusion archives, cloisonnement site lecture + detail
* 404) et le ProviderProcessor (normalisation, archivage, mode strict par groupe,
* cloisonnement site ecriture, 409 doublon). Le groupe provider:read:accounting est
* ajoute dynamiquement au contexte par le ProviderReadGroupContextBuilder selon la
* permission accounting.view (ERP-134) — jamais pose en dur sur l'operation.
*
* Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) +
* Timestampable / Blamable via le trait Shared.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('technique.providers.view')",
// La liste embarque les categories (code/name, groupe category:read) et
// les sites du prestataire (name/postalCode, groupe site:read — relation
// DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read +
// site:read presents dans le contexte. Hydratation anti-N+1 cablee par
// le ProviderProvider (cf. DoctrineProviderRepository::hydrateListCollections).
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
provider: ProviderProvider::class,
),
new Get(
security: "is_granted('technique.providers.view')",
// Detail : prestataire + sous-collections embarquees (contacts, adresses
// + leurs sites/categories/contacts) + RIB (gates compta). Le groupe
// provider:read:accounting est volontairement ABSENT : il est ajoute au
// contexte par le ProviderReadGroupContextBuilder selon la permission
// accounting.view (parade fuite IBAN/BIC — bug #4 M1).
normalizationContext: ['groups' => [
'provider:read',
'provider:item:read',
'category:read',
'site:read',
'default:read',
]],
provider: ProviderProvider::class,
),
new Post(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:main']],
processor: ProviderProcessor::class,
),
new Patch(
// Security elargie : `manage` OU `accounting.manage` — le role Compta n'a
// pas `manage` global mais doit pouvoir editer l'onglet Comptabilite d'un
// prestataire existant (§ 2.9). Le re-gating onglet par onglet (mode strict
// RG-3.15) est porte par le ProviderProcessor (ERP-134).
security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
denormalizationContext: ['groups' => [
'provider:write:main',
'provider:write:accounting',
'provider:write:archive',
]],
provider: ProviderProvider::class,
processor: ProviderProcessor::class,
),
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
],
)]
#[ORM\Entity(repositoryClass: DoctrineProviderRepository::class)]
#[ORM\Table(name: 'provider')]
// Index nommes pour matcher la migration (Version20260612100000). L'index unique
// partiel uq_provider_company_name_active reste possede par la migration : Doctrine
// ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel (WHERE) via
// attribut. Pas de #[ORM\UniqueConstraint] (§ 2.6).
#[ORM\Index(name: 'idx_provider_is_archived', columns: ['is_archived'])]
#[ORM\Index(name: 'idx_provider_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_provider_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_provider_updated_by', columns: ['updated_by'])]
#[Auditable]
class Provider implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
/**
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur le
* prestataire (entite principale) ET sur ses adresses. Miroir de
* ProviderAddress. S'appuie sur CategoryInterface::getCategoryTypeCodes()
* (pas d'import du module Catalog — regle ABSOLUE n°1).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
private const string PAYMENT_TYPE_LCR = 'LCR';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:read'])]
private ?int $id = null;
// === Formulaire principal ===
#[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read', 'provider:write:main'])]
private ?string $companyName = null;
// RG-3.09 : au moins une categorie (Count min 1), de type PRESTATAIRE (verifie
// par validateCategoryType). M2M vers Category via le contrat CategoryInterface
// (resolve_target_entities -> Category). Embarquee en LISTE ET DETAIL ; maillon
// (c) : le contexte inclut 'category:read' pour exposer id/code/name.
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'provider_category')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['provider:read', 'provider:write:main'])]
private Collection $categories;
// RG-3.03 (SPECIFICITE M3) : au moins un site (Count min 1). Sites rattaches
// DIRECTEMENT au prestataire sur le formulaire principal (le fournisseur n'avait
// des sites que sur l'adresse). M2M vers Site via le contrat SiteInterface
// (resolve_target_entities -> Site). Embarquee en LISTE ET DETAIL ; maillon (c) :
// le contexte inclut 'site:read' pour exposer name/postalCode (Site n'a pas de
// `code`). L'ecriture cloisonnee par user_site (§ 2.13) est portee par le
// ProviderProcessor (ERP-134).
/** @var Collection<int, SiteInterface> */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
#[ORM\JoinTable(name: 'provider_site')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
#[Groups(['provider:read', 'provider:write:main'])]
private Collection $sites;
// === Onglet Comptabilite ===
// Lecture conditionnee via le groupe `provider:read:accounting` (ajoute au
// contexte par le ProviderProvider / ReadGroupContextBuilder si l'user a
// accounting.view — ERP-134). Ecriture via `provider:write:accounting` (le
// Processor exige accounting.manage).
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $siren = null;
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $accountNumber = null;
#[ORM\ManyToOne(targetEntity: TvaMode::class)]
#[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?TvaMode $tvaMode = null;
#[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $nTva = null;
#[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
#[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?PaymentDelay $paymentDelay = null;
#[ORM\ManyToOne(targetEntity: PaymentType::class)]
#[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?PaymentType $paymentType = null;
#[ORM\ManyToOne(targetEntity: Bank::class)]
#[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?Bank $bank = null;
// === Sous-collections — EMBARQUEES dans le DETAIL (RETEX M1 §2) ===
// Maillon (a) : le read-group est porte par le GETTER (getContacts / getAddresses
// / getRibs) — sans #[Groups], jamais serialisees. Edition via sous-ressources
// (ticket ulterieur M3).
/** @var Collection<int, ProviderContact> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contacts;
/** @var Collection<int, ProviderAddress> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $addresses;
/** @var Collection<int, ProviderRib> */
#[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $ribs;
// === Archive / Soft delete ===
// Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH archive).
// Le groupe de LECTURE est declare sur le getter isArchived() avec
// SerializedName('isArchived') : sans cela, Symfony strip le prefixe "is" et
// exposerait la cle JSON "archived" — en pratique la cle est totalement DROPPEE
// (piege n°3 du M1). Pattern corrige : Groups + SerializedName sur le getter.
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
#[Groups(['provider:write:archive'])]
private bool $isArchived = false;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['provider:read'])]
private ?DateTimeImmutable $archivedAt = null;
// Soft delete technique (HP M4) : non expose en lecture/ecriture au M3.
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->categories = new ArrayCollection();
$this->sites = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->addresses = new ArrayCollection();
$this->ribs = new ArrayCollection();
}
/**
* RG-3.09 : toute categorie posee sur le prestataire doit etre de type
* PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
* ProviderAddress::validateCategoryType. S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
* Platform, sur POST (categories ∈ provider:write:main) comme sur PATCH.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
->atPath('categories')
->addViolation()
;
return;
}
}
}
/**
* RG-3.07 / RG-3.08 : coherence du type de reglement comptable. Comme au M2
* (decision figee ERP-89, jumeau Supplier::validatePaymentTypeConsistency),
* ces RG inter-champs passent par une contrainte d'entite (Assert\Callback +
* ->atPath()) et NON par le ProviderProcessor, afin que chaque 422 porte un
* propertyPath exploitable par extractApiViolations (mapping inline sous le
* champ, pas un toast — convention ERP-101).
* - RG-3.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
* - RG-3.08 : paymentType = LCR impose au moins un RIB -> violation sur
* `paymentType` (les RIB n'ont pas de champ de formulaire ou s'ancrer quand
* la liste est vide ; l'erreur s'affiche donc sous le select « Type de
* règlement », binde cote front). Le 409 sur DELETE du dernier RIB en LCR est
* porte par le ProviderRibProcessor (ERP-135).
*
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
* n'expose que provider:write:main), la contrainte ne mord en pratique que sur
* le PATCH de l'onglet Comptabilite.
*/
#[Assert\Callback]
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
{
$paymentCode = $this->paymentType?->getCode();
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
->atPath('bank')
->addViolation()
;
}
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
->atPath('paymentType')
->addViolation()
;
}
}
public function getId(): ?int
{
return $this->id;
}
public function getCompanyName(): ?string
{
return $this->companyName;
}
public function setCompanyName(string $companyName): static
{
$this->companyName = $companyName;
return $this;
}
/** @return Collection<int, CategoryInterface> */
public function getCategories(): Collection
{
return $this->categories;
}
public function addCategory(CategoryInterface $category): static
{
if (!$this->categories->contains($category)) {
$this->categories->add($category);
}
return $this;
}
public function removeCategory(CategoryInterface $category): static
{
$this->categories->removeElement($category);
return $this;
}
/** @return Collection<int, SiteInterface> */
public function getSites(): Collection
{
return $this->sites;
}
public function addSite(SiteInterface $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
}
return $this;
}
public function removeSite(SiteInterface $site): static
{
$this->sites->removeElement($site);
return $this;
}
public function getSiren(): ?string
{
return $this->siren;
}
public function setSiren(?string $siren): static
{
$this->siren = $siren;
return $this;
}
public function getAccountNumber(): ?string
{
return $this->accountNumber;
}
public function setAccountNumber(?string $accountNumber): static
{
$this->accountNumber = $accountNumber;
return $this;
}
public function getTvaMode(): ?TvaMode
{
return $this->tvaMode;
}
public function setTvaMode(?TvaMode $tvaMode): static
{
$this->tvaMode = $tvaMode;
return $this;
}
public function getNTva(): ?string
{
return $this->nTva;
}
public function setNTva(?string $nTva): static
{
$this->nTva = $nTva;
return $this;
}
public function getPaymentDelay(): ?PaymentDelay
{
return $this->paymentDelay;
}
public function setPaymentDelay(?PaymentDelay $paymentDelay): static
{
$this->paymentDelay = $paymentDelay;
return $this;
}
public function getPaymentType(): ?PaymentType
{
return $this->paymentType;
}
public function setPaymentType(?PaymentType $paymentType): static
{
$this->paymentType = $paymentType;
return $this;
}
public function getBank(): ?Bank
{
return $this->bank;
}
public function setBank(?Bank $bank): static
{
$this->bank = $bank;
return $this;
}
/** @return Collection<int, ProviderContact> */
#[Groups(['provider:item:read'])]
public function getContacts(): Collection
{
return $this->contacts;
}
public function addContact(ProviderContact $contact): static
{
if (!$this->contacts->contains($contact)) {
$this->contacts->add($contact);
$contact->setProvider($this);
}
return $this;
}
public function removeContact(ProviderContact $contact): static
{
if ($this->contacts->removeElement($contact) && $contact->getProvider() === $this) {
$contact->setProvider(null);
}
return $this;
}
/** @return Collection<int, ProviderAddress> */
#[Groups(['provider:item:read'])]
public function getAddresses(): Collection
{
return $this->addresses;
}
public function addAddress(ProviderAddress $address): static
{
if (!$this->addresses->contains($address)) {
$this->addresses->add($address);
$address->setProvider($this);
}
return $this;
}
public function removeAddress(ProviderAddress $address): static
{
if ($this->addresses->removeElement($address) && $address->getProvider() === $this) {
$address->setProvider(null);
}
return $this;
}
// Embed gate sur le groupe COMPTABLE (et non provider:item:read comme contacts/
// adresses) : provider:read:accounting n'est ajoute au contexte que si l'user a
// accounting.view (ProviderProvider / ReadGroupContextBuilder, ERP-134). Resultat :
// la cle `ribs` est TOTALEMENT ABSENTE du detail pour un user sans accounting.view
// (ex. Commerciale), au meme titre que les scalaires comptables — evite la fuite
// IBAN/BIC (piege n°4 M1).
/** @return Collection<int, ProviderRib> */
#[Groups(['provider:read:accounting'])]
public function getRibs(): Collection
{
return $this->ribs;
}
public function addRib(ProviderRib $rib): static
{
if (!$this->ribs->contains($rib)) {
$this->ribs->add($rib);
$rib->setProvider($this);
}
return $this;
}
public function removeRib(ProviderRib $rib): static
{
if ($this->ribs->removeElement($rib) && $rib->getProvider() === $this) {
$rib->setProvider(null);
}
return $this;
}
// Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
// exposerait la cle "archived" (strip du prefixe "is" sur les getters) et
// droppait silencieusement la cle du JSON (piege n°3 du M1).
#[Groups(['provider:read'])]
#[SerializedName('isArchived')]
public function isArchived(): bool
{
return $this->isArchived;
}
public function setIsArchived(bool $isArchived): static
{
$this->isArchived = $isArchived;
return $this;
}
public function getArchivedAt(): ?DateTimeImmutable
{
return $this->archivedAt;
}
public function setArchivedAt(?DateTimeImmutable $archivedAt): static
{
$this->archivedAt = $archivedAt;
return $this;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
}
@@ -0,0 +1,370 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Adresse d'un prestataire (1:n) — onglet Adresse. Version SIMPLIFIEE de
* SupplierAddress : PAS de address_type (PROSPECT/DEPART/RENDU), PAS de bennes,
* PAS de triage_provider (champs specifiques fournisseur). Champs : country /
* postal_code / city / street / street_complement + M2M sites / contacts /
* categories.
*
* Relations M2M :
* - sites : SiteInterface (module Sites) via resolve_target_entities — au moins
* un site obligatoire (RG-3.05, Assert\Count). Site n'a pas de `code`.
* - contacts : ProviderContact (meme module).
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
*
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
* maillon (a)).
*
* Sous-ressource API (ERP-135, spec § 4.5) :
* - POST /api/providers/{providerId}/addresses : creation rattachee au prestataire
* parent (Link toProperty 'provider'), security technique.providers.manage.
* - PATCH / DELETE /api/provider_addresses/{id} : security technique.providers.manage.
* - GET /api/provider_addresses/{id} : lecture unitaire (security view) — la lecture
* courante reste via le parent. Pas de GET collection autonome.
* Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les
* contraintes de l'entite (jouees avant le processor).
*
* Audite (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('technique.providers.view')",
// site:read + category:read : embarquent les Site / Category lies
// (maillon (c)) plutot que des IRI nus dans le retour.
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/addresses',
uriVariables: [
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ProviderAddress ... WHERE provider = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ProviderAddressProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:addresses']],
processor: ProviderAddressProcessor::class,
),
new Patch(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:addresses']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'provider_address')]
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{
use TimestampableBlamableTrait;
/**
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur une
* adresse prestataire. S'appuie sur CategoryInterface::getCategoryTypeCodes()
* (pas d'import du module Catalog — regle ABSOLUE n°1).
*/
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'addresses')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Provider $provider = null;
#[ORM\Column(length: 80, options: ['default' => 'France'])]
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private string $country = 'France';
// RG-3.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
// Le Regex borne deja la longueur (<= 5) : pas de Length redondant (whitelist
// ERP-107).
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')]
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $postalCode = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $city = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $streetComplement = null;
// Ordre d'affichage de l'adresse (gere serveur, non expose au M3).
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
// RG-3.05 : au moins un site rattache a chaque adresse.
/** @var Collection<int, SiteInterface> */
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
#[ORM\JoinTable(name: 'provider_address_site')]
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private Collection $sites;
/** @var Collection<int, ProviderContact> */
#[ORM\ManyToMany(targetEntity: ProviderContact::class)]
#[ORM\JoinTable(name: 'provider_address_contact')]
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'provider_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private Collection $contacts;
// RG-3.09 : au moins une categorie de type PRESTATAIRE par adresse (le type est
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'provider_address_category')]
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['provider:item:read', 'provider:write:addresses'])]
private Collection $categories;
public function __construct()
{
$this->sites = new ArrayCollection();
$this->contacts = new ArrayCollection();
$this->categories = new ArrayCollection();
}
/**
* RG-3.09 : toute categorie posee sur une adresse prestataire doit etre de
* type PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
->atPath('categories')
->addViolation()
;
return;
}
}
}
public function getId(): ?int
{
return $this->id;
}
public function getProvider(): ?Provider
{
return $this->provider;
}
public function setProvider(?Provider $provider): static
{
$this->provider = $provider;
return $this;
}
public function getCountry(): string
{
return $this->country;
}
public function setCountry(string $country): static
{
$this->country = $country;
return $this;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function setPostalCode(?string $postalCode): static
{
$this->postalCode = $postalCode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getStreetComplement(): ?string
{
return $this->streetComplement;
}
public function setStreetComplement(?string $streetComplement): static
{
$this->streetComplement = $streetComplement;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
/** @return Collection<int, SiteInterface> */
public function getSites(): Collection
{
return $this->sites;
}
public function addSite(SiteInterface $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
}
return $this;
}
public function removeSite(SiteInterface $site): static
{
$this->sites->removeElement($site);
return $this;
}
/** @return Collection<int, ProviderContact> */
public function getContacts(): Collection
{
return $this->contacts;
}
public function addContact(ProviderContact $contact): static
{
if (!$this->contacts->contains($contact)) {
$this->contacts->add($contact);
}
return $this;
}
public function removeContact(ProviderContact $contact): static
{
$this->contacts->removeElement($contact);
return $this;
}
/** @return Collection<int, CategoryInterface> */
public function getCategories(): Collection
{
return $this->categories;
}
public function addCategory(CategoryInterface $category): static
{
if (!$this->categories->contains($category)) {
$this->categories->add($category);
}
return $this;
}
public function removeCategory(CategoryInterface $category): static
{
$this->categories->removeElement($category);
return $this;
}
}
@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Contact d'un prestataire (1:n) — onglet Contacts. Un bloc est valide des qu'au
* moins un champ est rempli (RG-3.04) : garantie portee par un CHECK BDD
* (chk_provider_contact_name) + le ProviderContactProcessor (ERP-135) ; l'entite
* reste permissive (tous les champs nullable).
*
* Embarque sous `provider.contacts` au detail (groupe provider:item:read,
* maillon (a) du contrat de serialisation). Maximum 2 telephones
* (phonePrimary + phoneSecondary).
*
* Sous-ressource API (ERP-135, spec § 4.5) :
* - POST /api/providers/{providerId}/contacts : creation rattachee au prestataire
* parent (Link toProperty 'provider'), security technique.providers.manage.
* - PATCH / DELETE /api/provider_contacts/{id} : security technique.providers.manage.
* Le DELETE est physique et libre (pas de garde « dernier contact » au M3 —
* RG-3.12 front-driven, la collection peut rester vide cote back).
* - GET /api/provider_contacts/{id} : lecture unitaire (security view) — la lecture
* courante reste via le parent (le prestataire embarque ses contacts). Pas de GET
* collection autonome.
* Tout passe par le ProviderContactProcessor (normalisation RG-3.11, RG-3.04).
*
* Audite (#[Auditable]) + Timestampable / Blamable (pattern Shared standard).
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('technique.providers.view')",
normalizationContext: ['groups' => ['provider:item:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/contacts',
uriVariables: [
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ProviderContact ... WHERE provider = :id)
// et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ProviderContactProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read']],
denormalizationContext: ['groups' => ['provider:write:contacts']],
processor: ProviderContactProcessor::class,
),
new Patch(
security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read']],
denormalizationContext: ['groups' => ['provider:write:contacts']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'provider_contact')]
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:item:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'contacts')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Provider $provider = null;
// RG-3.04 : au moins un champ du contact renseigne (CHECK BDD + Processor). Les
// champs restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $jobTitle = null;
// Pas de validation de format telephone (saisie libre), mais une Assert\Length
// calee sur la colonne VARCHAR(20) evite l'erreur Postgres (500 non rattachee au
// champ) au profit d'une 422 propre (ERP-107).
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180, nullable: true)]
#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')]
#[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $email = null;
// Ordre d'affichage du contact (gere serveur, non expose au M3).
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getProvider(): ?Provider
{
return $this->provider;
}
public function setProvider(?Provider $provider): static
{
$this->provider = $provider;
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getJobTitle(): ?string
{
return $this->jobTitle;
}
public function setJobTitle(?string $jobTitle): static
{
$this->jobTitle = $jobTitle;
return $this;
}
public function getPhonePrimary(): ?string
{
return $this->phonePrimary;
}
public function setPhonePrimary(?string $phonePrimary): static
{
$this->phonePrimary = $phonePrimary;
return $this;
}
public function getPhoneSecondary(): ?string
{
return $this->phoneSecondary;
}
public function setPhoneSecondary(?string $phoneSecondary): static
{
$this->phoneSecondary = $phoneSecondary;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
/**
* Contrat des sous-ressources d'un prestataire (Contact, Adresse, RIB) : chacune
* appartient a un Provider parent. Permet au provider decore
* ProviderSubResourceItemProvider d'appliquer le cloisonnement par site du parent
* (§ 2.13 / RG-3.17) de maniere uniforme, sans connaitre le type concret.
*/
interface ProviderOwnedInterface
{
public function getProvider(): ?Provider;
}
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Coordonnees bancaires d'un prestataire (1:n) — onglet Comptabilite. Au moins un
* RIB est obligatoire si le type de reglement est LCR (RG-3.08, verifie au
* ProviderRibProcessor : refus du DELETE du dernier RIB sous LCR — ERP-135).
*
* Embarque sous `provider.ribs` UNIQUEMENT si l'user a accounting.view : le
* read-group est `provider:read:accounting`, retire du contexte par le
* ProviderProvider sinon (gating par omission de cle — evite la fuite IBAN/BIC,
* piege n°4 du M1). Aucun #[AuditIgnore] sur iban/bic : l'audit etant admin-only,
* la tracabilite RIB est conservee (decision M1 reportee, § 2.7).
*
* Sous-ressource API (ERP-135, spec § 4.5) — gating comptable renforce :
* - POST /api/providers/{providerId}/ribs : creation rattachee au prestataire
* parent (Link toProperty 'provider'), security technique.providers.accounting.manage.
* - PATCH / DELETE /api/provider_ribs/{id} : security technique.providers.accounting.manage.
* Le DELETE refuse la suppression du dernier RIB sous LCR (RG-3.08, 409).
* - GET /api/provider_ribs/{id} : lecture unitaire, security
* technique.providers.accounting.view (donnees bancaires sensibles). Pas de GET
* collection autonome.
* Tout passe par le ProviderRibProcessor (RG-3.08 sur DELETE).
*
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle
* banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite
* (#[Auditable]) + Timestampable / Blamable.
*/
#[ApiResource(
operations: [
new Get(
security: "is_granted('technique.providers.accounting.view')",
normalizationContext: ['groups' => ['provider:read:accounting']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
),
new Post(
uriTemplate: '/providers/{providerId}/ribs',
uriVariables: [
'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'),
],
// read:false : pas de stade lecture du parent. Le Link toProperty
// resoudrait l'enfant (SELECT ProviderRib ... WHERE provider = :id) et
// casse en NonUniqueResult des >= 2 enfants. Le parent est rattache
// manuellement par ProviderRibProcessor::linkParent (404 si absent).
read: false,
security: "is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read:accounting']],
denormalizationContext: ['groups' => ['provider:write:accounting']],
processor: ProviderRibProcessor::class,
),
new Patch(
security: "is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read:accounting']],
denormalizationContext: ['groups' => ['provider:write:accounting']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class,
),
new Delete(
security: "is_granted('technique.providers.accounting.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class,
),
],
)]
#[ORM\Entity]
#[ORM\Table(name: 'provider_rib')]
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
#[Auditable]
class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['provider:read:accounting'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Provider::class, inversedBy: 'ribs')]
#[ORM\JoinColumn(name: 'provider_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Provider $provider = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le libellé du RIB est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $label = null;
// Bic/Iban bornent deja le format (et donc la longueur) : pas de Length redondant
// calee sur la colonne (auto-exempte du miroir ERP-107). ibanPropertyPath :
// controle croise — le pays du BIC (positions 5-6) doit correspondre au pays de
// l'IBAN (positions 1-2). Violation portee sur `bic`.
#[ORM\Column(length: 20)]
#[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')]
#[Assert\Bic(
message: 'Le BIC n\'est pas valide.',
ibanPropertyPath: 'iban',
ibanMessage: 'Le BIC ne correspond pas au pays de l\'IBAN.',
)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $bic = null;
#[ORM\Column(length: 34)]
#[Assert\NotBlank(message: 'L\'IBAN est obligatoire.', normalizer: 'trim')]
#[Assert\Iban(message: 'L\'IBAN n\'est pas valide.')]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $iban = null;
// Ordre d'affichage du RIB (gere serveur, non expose au M3).
#[ORM\Column(options: ['default' => 0])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getProvider(): ?Provider
{
return $this->provider;
}
public function setProvider(?Provider $provider): static
{
$this->provider = $provider;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getBic(): ?string
{
return $this->bic;
}
public function setBic(string $bic): static
{
$this->bic = $bic;
return $this;
}
public function getIban(): ?string
{
return $this->iban;
}
public function setIban(string $iban): static
{
$this->iban = $iban;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Repository;
use App\Module\Technique\Domain\Entity\Provider;
use Doctrine\ORM\QueryBuilder;
interface ProviderRepositoryInterface
{
public function findById(int $id): ?Provider;
public function save(Provider $provider): void;
/**
* Restreint un QueryBuilder de liste aux prestataires rattaches au site donne
* (relation DIRECTE provider.sites). Sert le cloisonnement par site pilote par
* l'utilisateur (RG-3.17, § 2.13) : le ProviderProvider resout le site courant
* (CurrentSiteProvider) puis appelle cette methode quand l'user n'a pas
* `sites.bypass_scope`. Decouple ainsi la DECISION (Provider, qui connait
* l'user) du DQL (repository, qui ne connait que l'id de site).
*
* Sous-requete IN (et non JOIN sur la M2M) pour ne pas perturber le
* DISTINCT / ORDER BY / pagination du QueryBuilder de selection — meme parti
* pris que les filtres ?categoryCode / ?siteId. Applique AVANT la pagination
* (le COUNT du Paginator reflete alors le perimetre de l'user).
*/
public function applySiteScope(QueryBuilder $qb, int $siteId): void;
/**
* Construit un QueryBuilder de liste pour le repertoire prestataires.
* - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16).
* - Archivage (RG-3.16) :
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
* - sinon (defaut) -> uniquement les actifs (is_archived = false).
* $archivedOnly a la priorite sur $includeArchived.
* - Tri par defaut : companyName ASC (RG-3.16).
* - $search : recherche fuzzy insensible a la casse sur companyName + les
* contacts lies (firstName / lastName / email) via sous-requete.
* Metacaracteres LIKE echappes. Ignore si null/vide.
* - $categoryCodes : restreint aux prestataires possedant au moins une
* categorie dont le code est dans la liste (OR). Liste vide = pas de filtre.
* - $siteIds : restreint aux prestataires rattaches a l'un des sites donnes
* (OR — RG-3.03, relation DIRECTE provider.sites). Liste vide = pas de filtre.
*
* Filtrage centralise ICI (et non dans le provider/controller) pour que la
* liste paginee et l'export partagent strictement la meme logique de selection
* (miroir M2).
*
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
* l'hydratation des collections affichees est deleguee a
* {@see self::hydrateListCollections()} pour ne pas imposer le cout d'un
* produit cartesien aux chemins non pagines (§ 2.12, cf. M1/ERP-100, M2).
*
* NB : le cloisonnement par site pilote par l'utilisateur (RG-3.17, § 2.13) est
* applique en AMONT par le ProviderProvider (ERP-134), pas par ce QueryBuilder
* (qui ne connait pas l'user courant).
*
* @param list<string> $categoryCodes
* @param list<int> $siteIds
*/
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
): QueryBuilder;
/**
* Hydrate en lot les collections affichees par le repertoire (categories puis
* sites — relation DIRECTE provider.sites, RG-3.03) sur un jeu de prestataires
* DEJA charges, via l'identity map Doctrine (memes instances). A appeler apres
* une selection bornee (page courante ou jeu d'export) pour eviter le N+1 a la
* serialisation, sans imposer de fetch-join au QueryBuilder de selection
* (anti N+1, § 2.12).
*
* Charge les categories et les sites en DEUX requetes distinctes (et non un
* double fetch-join) pour ne pas multiplier categories x sites en un seul
* produit cartesien.
*
* @param list<Provider> $providers
*/
public function hydrateListCollections(array $providers): void;
/**
* Hydrate en lot la collection `contacts` sur un jeu de prestataires DEJA
* charges (memes instances via l'identity map). Reservee aux chemins qui ont
* besoin du contact principal (export) : la LISTE paginee n'embarque pas les
* contacts (§ 2.12), d'ou une methode dediee plutot qu'une passe supplementaire
* dans {@see self::hydrateListCollections()}.
*
* @param list<Provider> $providers
*/
public function hydrateContacts(array $providers): void;
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\State\SerializerContextBuilderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpFoundation\Request;
/**
* Decore le context builder de serialisation d'API Platform pour ajouter
* DYNAMIQUEMENT le groupe de lecture `provider:read:accounting` sur les
* ressources Provider, uniquement si l'utilisateur courant a la permission
* `technique.providers.accounting.view` (cf. spec-back M3 § 2.9 / § 4.1 /
* § 4.2). Jumeau de SupplierReadGroupContextBuilder (M2).
*
* Pourquoi un context builder et pas le Provider : un Provider retourne des
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
* de normalisation est construit ici, en amont du serializer — c'est le point
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
* l'utilisateur. Realise l'intention « gating du groupe accounting » de la spec
* (le groupe n'est jamais pose par defaut sur l'operation : il est AJOUTE ici si
* la permission est presente — resultat identique au « retrait » decrit en spec).
*
* S'applique aux operations de LECTURE (normalization) sur Provider : liste ET
* detail. Sans la permission, les champs comptables (siren, accountNumber,
* tvaMode, nTva, paymentDelay, paymentType, bank) ET les RIB embarques (groupe
* provider:read:accounting porte par getRibs()) ne sont jamais serialises — la
* cle est totalement absente du JSON (gating par omission, parade bug #4 M1).
*
* Priorite de decoration -20 : on s'empile APRES les decorateurs Commercial
* (ClientReadGroupContextBuilder priorite 0, SupplierReadGroupContextBuilder
* priorite -10) sur le meme service `api_platform.serializer.context_builder`.
* Chaque decorateur passe la main pour toute ressource autre que la sienne :
* l'ordre de chainage n'a donc aucun effet fonctionnel, la priorite explicite ne
* sert qu'a lever l'ambiguite de plusieurs decorateurs sur un meme service.
*/
#[AsDecorator(decorates: 'api_platform.serializer.context_builder', priority: -20)]
final readonly class ProviderReadGroupContextBuilder implements SerializerContextBuilderInterface
{
public function __construct(
#[AutowireDecorated]
private SerializerContextBuilderInterface $decorated,
private Security $security,
) {}
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
// Uniquement en lecture, sur la ressource Provider, avec la permission.
if (!$normalization) {
return $context;
}
if (Provider::class !== ($context['resource_class'] ?? null)) {
return $context;
}
if (!$this->security->isGranted('technique.providers.accounting.view')) {
return $context;
}
$groups = $context['groups'] ?? [];
if (!in_array('provider:read:accounting', $groups, true)) {
$groups[] = 'provider:read:accounting';
}
$context['groups'] = $groups;
return $context;
}
}
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Adresse d'un prestataire (M3,
* spec-back § 4.5). Jumeau du SupplierAddressProcessor (M2), recentre sur le
* perimetre ERP-135, AVEC une garde supplementaire propre au M3 : le
* cloisonnement d'ECRITURE des sites de l'adresse (§ 2.13).
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent puis cloisonnement des
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
* garanties en amont par des contraintes sur l'entite, jouees par API Platform
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
* Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
* ProviderAddress::validateCategoryType).
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* La security de l'operation (technique.providers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut.
*
* @implements ProcessorInterface<ProviderAddress, null|ProviderAddress>
*/
final class ProviderAddressProcessor implements ProcessorInterface
{
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderAddress) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->guardSiteScope($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache l'adresse au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/addresses) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ProviderAddress $address, array $uriVariables): void
{
if (null !== $address->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration). Distinct du guardSiteScope ci-dessous, qui cloisonne
// les sites ATTACHES a l'adresse (et non l'acces au prestataire parent).
$this->scopeChecker->assertInScope($provider);
$address->setProvider($provider);
}
/**
* RG-3.05 / § 2.13 (cloisonnement d'ECRITURE — decision Matthieu 11/06) : un
* user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse que des
* sites figurant dans ses propres `user_site`. Tout site hors perimetre -> 422
* sur `sites` (propertyPath consommable inline, convention ERP-101). Un user
* `bypass_scope` (Admin) peut attacher n'importe quel site. Miroir de
* ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse.
*
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
* sites obligatoires RG-3.05) ou PATCH portant la cle `sites`. Un PATCH qui ne
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
* pose). La validation porte sur l'ETAT RESULTANT (address.getSites()).
*/
private function guardSiteScope(ProviderAddress $address): void
{
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
return;
}
// sites non soumis sur un PATCH : rien a cloisonner.
if ($this->em->contains($address) && !in_array('sites', $this->payloadKeys(), true)) {
return;
}
$allowedSiteIds = $this->currentUserSiteIds();
foreach ($address->getSites() as $site) {
if (!$site instanceof SiteInterface) {
continue;
}
if (!in_array($site->getId(), $allowedSiteIds, true)) {
$this->throwSitesViolation($address);
}
}
}
/**
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
* Vide si pas d'user authentifie (cas defensif : la security d'operation
* garantit deja l'authentification).
*
* @return list<int>
*/
private function currentUserSiteIds(): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
$ids = [];
foreach ($user->getSites() as $site) {
if ($site instanceof SiteInterface && null !== $site->getId()) {
$ids[] = $site->getId();
}
}
return $ids;
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
*
* @return never
*/
private function throwSitesViolation(ProviderAddress $address): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
null,
[],
$address,
'sites',
null,
));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Contact d'un prestataire (M3,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-135.
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent, normalisation serveur
* (RG-3.11 : prenom/nom Title Case, telephones reduits aux chiffres, email
* lowercase) via le ProviderFieldNormalizer partage, puis validation RG-3.04
* (au moins un champ parmi prenom / nom / telephone principal / email) avant
* persistance.
* - DELETE : aucune garde « dernier contact » au M3 — la collection peut rester
* vide cote back (RG-3.12 front-driven, spec § 4.5). Suppression physique directe.
*
* La security de l'operation (technique.providers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
* (Assert\Email, Assert\Length...).
*
* @implements ProcessorInterface<ProviderContact, null|ProviderContact>
*/
final class ProviderContactProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderContact) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->validateName($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le contact au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/contacts). La relation n'est pas peuplee
* automatiquement par le Link sur une operation d'ecriture : on resout le
* parent depuis l'uri variable. Sur PATCH (entite existante), le prestataire
* est deja present -> no-op.
*/
private function linkParent(ProviderContact $contact, array $uriVariables): void
{
if (null !== $contact->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration, coherent avec le detail Provider garde en 404).
$this->scopeChecker->assertInScope($provider);
$contact->setProvider($provider);
}
/**
* Normalisation serveur (RG-3.11). Toutes les methodes du normalizer sont
* null-safe : une chaine vide apres trim devient null.
*/
private function normalize(ProviderContact $contact): void
{
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
}
/**
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
* nom / fonction / telephone principal / email est renseigne (double garde avec
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
* ramenees a null et ne suffisent pas a valider le bloc.
*/
private function validateName(ProviderContact $contact): void
{
if (null === $contact->getFirstName()
&& null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
null,
[],
$contact,
'firstName',
null,
));
throw new ValidationException($violations);
}
}
}
@@ -0,0 +1,560 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use JsonException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture du repertoire prestataires (M3). Cf. spec-back M3 § 4.3 /
* § 4.4 + RG-3.10 / RG-3.13 / RG-3.14 / RG-3.15 / RG-3.17. Jumeau du
* SupplierProcessor (M2), avec deux differences structurantes (§ 3.1) :
* - PAS d'onglet Information (aucun champ description / competitors / ...) ni de
* validation de completude comptable -> le prestataire est minimal ;
* - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au
* CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans
* `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`.
*
* Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La
* security d'operation du PATCH est elargie a `manage` OU `accounting.manage`
* pour laisser entrer le role Compta ; ce processor re-gate alors finement :
* - champ comptable modifie dans le payload -> exige accounting.manage (403) ;
* - champ main (companyName / categories / sites) modifie -> exige manage
* (guardManage, 403) : empeche Compta d'editer un autre onglet ;
* - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et
* interdit toute autre modification dans la meme requete (RG-3.13, 422).
* 2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache
* hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`.
* 3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName).
* 4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de
* restauration).
*
* Les RG inter-champs RG-3.07 (Virement -> banque), RG-3.08 (LCR -> >= 1 RIB) et
* RG-3.09 (categorie de type PRESTATAIRE) sont portees par des Assert\Callback +
* ->atPath() sur les entites Provider / ProviderAddress (jouees par API Platform
* AVANT ce processor), pour que chaque 422 porte un propertyPath consommable par
* extractApiViolations (mapping inline, pas un toast — convention ERP-101). Le 409
* sur DELETE du dernier RIB en LCR (volet ecriture de RG-3.08) est porte par le
* ProviderRibProcessor (ERP-135).
*
* @implements ProcessorInterface<Provider, Provider>
*/
final class ProviderProcessor implements ProcessorInterface
{
/** Champs de l'onglet principal (groupe provider:write:main). */
private const array MAIN_FIELDS = [
'companyName', 'categories', 'sites',
];
/** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */
private const array ACCOUNTING_FIELDS = [
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
'paymentType', 'bank',
];
/** Champ d'archivage (groupe provider:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_MANAGE = 'technique.providers.manage';
private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage';
private const string PERM_ARCHIVE = 'technique.providers.archive';
private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope';
/**
* Memoisation du dernier corps de requete decode, clos par le contenu brut
* (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete,
* on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune
* fuite entre requetes sur ce service partage.
*/
private ?string $decodedContent = null;
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
private array $decodedPayloadKeys = [];
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly ProviderFieldNormalizer $normalizer,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Provider) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// Reinitialisation de la memoisation du payload : le service est partage
// (stateful), on repart du corps de LA requete courante.
$this->decodedContent = null;
$this->decodedPayloadKeys = [];
$writableKeys = $this->writablePayloadKeys();
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
$this->guardAccounting($data);
$this->guardSiteScope($data);
$this->normalize($data);
// guardManage apres normalize : la comparaison « change vs etat persiste »
// des champs texte (companyName) se fait sur des valeurs normalisees des
// deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Le seul index unique partiel est uq_provider_company_name_active
// (LOWER(company_name) parmi non-archives/non-deletes — § 2.6).
if ($isArchiveRequest && false === $data->isArchived()) {
// RG-3.14 : restauration en conflit avec un homonyme actif.
throw new ConflictHttpException(
'Restauration impossible : un autre prestataire a pris le nom entre-temps.',
$e,
);
}
// RG-3.10 : doublon de nom de societe.
throw new ConflictHttpException(
sprintf('Un prestataire nommé "%s" existe déjà.', (string) $data->getCompanyName()),
$e,
);
}
}
/**
* RG-3.13 / RG-3.14 : si le payload bascule reellement isArchived, exige la
* permission archive (403), interdit toute autre modification (422) et
* pose/retire archivedAt. Retourne true si la requete est une requete
* d'archivage. Restreint a la mise a jour d'un prestataire existant ET au seul
* cas ou isArchived change vraiment (cf. SupplierProcessor).
*
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
*/
private function guardArchive(Provider $data, array $writableKeys): bool
{
// POST / entite non geree : l'archivage est une action de mise a jour.
if (!$this->em->contains($data)) {
return false;
}
// isArchived inchange par rapport a l'etat persiste : pas une requete
// d'archivage (cas du PATCH representation complete).
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
return false;
}
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
self::ARCHIVE_FIELD,
self::PERM_ARCHIVE,
));
}
// RG-3.13 : une requete d'archivage ne modifie aucun autre champ ecrivable.
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
throw new UnprocessableEntityHttpException(
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
);
}
// RG-3.13 (true -> now) / RG-3.14 (false -> null).
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
return true;
}
/**
* RG-3.15 : la modification effective d'un champ comptable exige
* accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de
* filtrage silencieux). On ne gate que si un champ change reellement par
* rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables
* inchanges ne declenche pas de 403 parasite). Le message precise le premier
* champ fautif.
*/
private function guardAccounting(Provider $data): void
{
$changed = $this->changedAccountingFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_ACCOUNTING_MANAGE,
));
}
}
/**
* § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet
* principal : companyName / categories / sites) exige
* `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du
* payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le
* role Compta — qui entre dans le PATCH via `accounting.manage` (security
* d'operation elargie) — d'editer autre chose que l'onglet Comptabilite.
*
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
* deja gardee par la security d'operation `manage`.
*/
private function guardManage(Provider $data): void
{
if (!$this->em->contains($data)) {
return;
}
$changed = $this->changedBusinessFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_MANAGE,
));
}
}
/**
* RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE — § 2.13) : un user SANS
* `sites.bypass_scope` ne peut attacher au prestataire que des sites figurant
* dans ses propres `user_site`. Tout site hors perimetre -> 422 sur `sites`
* (propertyPath consommable inline, convention ERP-101). Un user `bypass_scope`
* (Admin auto) peut attacher n'importe quel site.
*
* Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user
* sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site
* hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400
* anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement
* AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref`
* (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne
* doit rattacher que ses propres sites), et une defense en profondeur sinon.
*
* Ne joue que si `sites` est effectivement soumis : POST (entite non geree,
* sites obligatoires RG-3.03) ou PATCH portant la cle `sites`. Un PATCH qui ne
* touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur
* pose). La validation porte sur l'ETAT RESULTANT (data.getSites()).
*/
private function guardSiteScope(Provider $data): void
{
if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) {
return;
}
// sites non soumis sur un PATCH : rien a cloisonner.
if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) {
return;
}
$allowedSiteIds = $this->currentUserSiteIds();
foreach ($data->getSites() as $site) {
if (!$site instanceof SiteInterface) {
continue;
}
if (!in_array($site->getId(), $allowedSiteIds, true)) {
$this->throwSitesViolation($data);
}
}
}
/**
* Identifiants des sites rattaches a l'utilisateur courant (`user_site`).
* Vide si pas d'user authentifie (cas defensif : la security d'operation
* garantit deja l'authentification).
*
* @return list<int>
*/
private function currentUserSiteIds(): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return [];
}
$ids = [];
foreach ($user->getSites() as $site) {
if ($site instanceof SiteInterface && null !== $site->getId()) {
$ids[] = $site->getId();
}
}
return $ids;
}
/**
* Champs « metier » (onglet principal : companyName / categories / sites) dont
* la valeur courante differe de l'etat persiste. Scalaires compares par valeur ;
* collections M2M (categories / sites) comparees par ensemble d'identifiants
* (cf. collectionChanged) — la simple presence dans le payload ne suffit pas,
* sous peine de 403 parasite sur un PATCH representation complete.
*
* @return list<string>
*/
private function changedBusinessFields(Provider $data): array
{
$changed = [];
if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) {
$changed[] = 'companyName';
}
if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) {
$changed[] = 'categories';
}
if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) {
$changed[] = 'sites';
}
return $changed;
}
/**
* Vrai si une collection M2M (`categories` ou `sites`) differe reellement de
* l'etat persiste. Ces collections ne sont pas tracees par
* getOriginalEntityData : on compare par identifiants (independamment de
* l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat
* courant (apres application du payload). Symetrique des scalaires : seul un
* changement effectif compte, pas la simple presence dans le payload.
*
* - POST / entite non geree : fournir la collection est un acte metier
* (branche defensive, guardManage ne s'execute que sur entite geree).
* - cle absente du payload (PATCH partiel) : aucun changement.
*
* @param array<int, object> $current
*/
private function collectionChanged(Provider $data, string $field, array $current): bool
{
if (!$this->em->contains($data)) {
return true;
}
if (!in_array($field, $this->payloadKeys(), true)) {
return false;
}
$collection = 'categories' === $field ? $data->getCategories() : $data->getSites();
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat
// persiste comparable, on se rabat sur la presence payload.
if (!$collection instanceof PersistentCollection) {
return true;
}
return $this->idSet($current) !== $this->idSet($collection->getSnapshot());
}
/**
* Ensemble trie des identifiants d'une liste d'entites — pour une comparaison
* par valeur independante de l'ordre.
*
* @param array<int, object> $entities
*
* @return list<mixed>
*/
private function idSet(array $entities): array
{
$ids = array_map(
static fn (object $entity): mixed => method_exists($entity, 'getId')
? $entity->getId()
: spl_object_id($entity),
array_values($entities),
);
sort($ids);
return $ids;
}
/**
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
* identite d'objet : l'identity map Doctrine renvoie la meme instance tant que
* la reference est inchangee.
*
* @return list<string>
*/
private function changedAccountingFields(Provider $data): array
{
$changed = [];
foreach (self::ACCOUNTING_FIELDS as $field) {
$newValue = match ($field) {
'siren' => $data->getSiren(),
'accountNumber' => $data->getAccountNumber(),
'tvaMode' => $data->getTvaMode(),
'nTva' => $data->getNTva(),
'paymentDelay' => $data->getPaymentDelay(),
'paymentType' => $data->getPaymentType(),
'bank' => $data->getBank(),
};
if ($this->fieldChanged($data, $field, $newValue)) {
$changed[] = $field;
}
}
return $changed;
}
/**
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
* non-null est alors un changement.
*/
private function fieldChanged(Provider $data, string $field, mixed $newValue): bool
{
$original = $this->originalData($data);
return $newValue !== ($original[$field] ?? null);
}
/**
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
* application du payload). Vide pour une entite non geree (POST).
*
* @return array<string, mixed>
*/
private function originalData(Provider $data): array
{
if (!$this->em->contains($data)) {
return [];
}
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
}
/**
* Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est
* porte par le Provider (les champs de contact sont normalises par le processor
* de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est
* touche que si une valeur est presente, pour ne jamais ecraser l'existant lors
* d'un PATCH partiel.
*/
private function normalize(Provider $data): void
{
if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
}
}
/**
* Cles ecrivables effectivement presentes dans le payload : on retire les cles
* JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture
* connu. Base du 422 d'archivage (RG-3.13).
*
* @return list<string>
*/
private function writablePayloadKeys(): array
{
$writable = array_merge(
self::MAIN_FIELDS,
self::ACCOUNTING_FIELDS,
[self::ARCHIVE_FIELD],
);
return array_values(array_intersect($this->payloadKeys(), $writable));
}
/**
* Cles de premier niveau effectivement envoyees par le client (payload JSON
* brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies.
*
* @return list<string>
*/
private function payloadKeys(): array
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return [];
}
$content = $request->getContent();
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
if ($content === $this->decodedContent) {
return $this->decodedPayloadKeys;
}
$this->decodedContent = $content;
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
return $this->decodedPayloadKeys;
}
/**
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function extractPayloadKeys(string $content): array
{
if ('' === $content) {
return [];
}
try {
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return [];
}
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
}
/**
* Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que
* les contraintes Symfony, consommable inline par extractApiViolations (ERP-101).
*
* @return never
*/
private function throwSitesViolation(Provider $root): void
{
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Vous ne pouvez rattacher que des sites auxquels vous avez accès.',
null,
[],
$root,
'sites',
null,
));
throw new ValidationException($violations);
}
}
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Processor d'ecriture de la sous-ressource RIB d'un prestataire (M3, spec-back
* § 4.5). Jumeau du SupplierRibProcessor (M2), recentre sur le perimetre ERP-135.
*
* Sequence :
* - POST / PATCH : rattachement au prestataire parent. Aucune normalisation
* specifique ; la validite de l'IBAN et du BIC est garantie par Assert\Iban /
* Assert\Bic sur l'entite (jouees en amont par API Platform). Aucun
* #[AuditIgnore] sur iban/bic : la tracabilite comptable est volontaire
* (decision M1/M2 reportee, spec § 2.7).
* - DELETE : RG-3.08 — si le prestataire est en reglement LCR, la suppression de
* son DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
*
* La security de l'operation (technique.providers.accounting.manage) est appliquee
* par API Platform en amont : un utilisateur sans cette permission recoit 403 sur
* POST/PATCH/DELETE avant d'atteindre ce processor — c'est le niveau de gating
* renforce des donnees bancaires (distinct de manage, spec § 4.5).
*
* @implements ProcessorInterface<ProviderRib, null|ProviderRib>
*/
final class ProviderRibProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ProviderRib) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
$this->guardLastRibDeletionUnderLcr($data);
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le RIB au prestataire parent de la sous-ressource POST
* (/providers/{providerId}/ribs) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ProviderRib $rib, array $uriVariables): void
{
if (null !== $rib->getProvider()) {
return;
}
$providerId = $uriVariables['providerId'] ?? null;
if (null === $providerId) {
return;
}
$provider = $providerId instanceof Provider
? $providerId
: $this->em->getRepository(Provider::class)->find($providerId);
// read:false sur le POST : sans stade lecture, un parent introuvable n'est
// plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la
// contrainte provider_id NOT NULL).
if (!$provider instanceof Provider) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration, coherent avec le detail Provider garde en 404).
$this->scopeChecker->assertInScope($provider);
$rib->setProvider($provider);
}
/**
* RG-3.08 : un prestataire dont le type de reglement est LCR doit conserver au
* moins un RIB. La collection inclut le RIB en cours de suppression : un
* effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
* type de reglement, les RIBs sont optionnels (suppression libre).
*/
private function guardLastRibDeletionUnderLcr(ProviderRib $rib): void
{
$provider = $rib->getProvider();
if (null === $provider) {
return;
}
if ('LCR' === $provider->getPaymentType()?->getCode() && $provider->getRibs()->count() <= 1) {
throw new ConflictHttpException(
'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
);
}
}
}
@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider du repertoire prestataires (M3). Cf. spec-back M3 § 4.1 / § 4.2 +
* RG-3.16 / RG-3.17. Jumeau du SupplierProvider (M2), augmente du cloisonnement
* par site pilote par l'utilisateur (§ 2.13).
*
* Collection (GET /api/providers) :
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
* (deleted_at IS NOT NULL) — RG-3.16 ;
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
* exclus au M3) — RG-3.16 ;
* - tri par defaut companyName ASC — RG-3.16 ;
* - filtres ?search=... (fuzzy companyName + contacts lies : firstName /
* lastName / email), ?categoryCode=<code> (prestataires ayant >= 1 categorie
* de ce code, repetable) et ?siteId=<id> (prestataires rattaches a ce site
* via la relation DIRECTE provider.sites, repetable) ;
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
* ?pagination=false pour alimenter un <select> sans pagination.
*
* Cloisonnement par site (RG-3.17, § 2.13) — applique ICI (le QueryBuilder du
* repository ne connait pas l'user courant) :
* - si l'user N'A PAS `sites.bypass_scope` ET que CurrentSiteProvider::get()
* retourne un site -> la liste est restreinte aux prestataires dont
* provider.sites contient le currentSite (repository::applySiteScope), AVANT
* pagination : totalItems reflete le perimetre de l'user ;
* - le DETAIL (Get / provider de PATCH) d'un prestataire hors perimetre renvoie
* 404 (null) — ne pas reveler l'existence d'une ligne hors site ;
* - user `bypass_scope` (Admin auto, profils consolidation) -> aucun filtre ;
* - currentSite = null (module Sites off / user sans site) -> no-op lecture
* (aligne site-aware.md § 5).
*
* Item (GET /api/providers/{id} + provider de PATCH) :
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
* M3) ; les archives restent consultables/restaurables en detail ;
* - 404 si hors perimetre site (cloisonnement, cf. ci-dessus).
*
* Le filtrage des champs comptables en lecture (groupe provider:read:accounting)
* n'est PAS fait ici mais dans ProviderReadGroupContextBuilder : un Provider
* retourne des donnees mais ne peut pas influencer les groupes de serialisation.
*
* @implements ProviderInterface<Provider>
*/
final class ProviderProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly Pagination $pagination,
// Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
// source UNIQUE partagee avec le provider decore des sous-ressources
// (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
// eviter tout drift entre ces points d'application.
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
{
if ($operation instanceof CollectionOperationInterface) {
return $this->provideCollection($operation, $context);
}
return $this->provideItem($uriVariables);
}
/**
* @param array<string, mixed> $context
*
* @return list<Provider>|Paginator<Provider>
*/
private function provideCollection(Operation $operation, array $context): array|Paginator
{
$filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
$search = $filters['search'] ?? null;
// categoryCode accepte un code unique (?categoryCode=NETTOYAGE, selects)
// OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
$siteIds = $this->readIntList($filters['siteId'] ?? []);
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
$qb = $this->repository->createListQueryBuilder(
$includeArchived,
is_string($search) ? $search : null,
$categoryCodes,
$siteIds,
$archivedOnly,
);
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
$scopeSite = $this->scopeChecker->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
// Echappatoire ?pagination=false : collection complete sans Paginator
// (regle n°13 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Provider> $providers */
$providers = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (§ 2.12) : evite le
// N+1 si la serialisation touche categories/sites, sans cartesien.
$this->repository->hydrateListCollections($providers);
return $providers;
}
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
// Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
// puis on hydrate ses collections en lot (memes entites managees).
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
$this->repository->hydrateListCollections(iterator_to_array($paginator));
return $paginator;
}
/**
* @param array<string, mixed> $uriVariables
*/
private function provideItem(array $uriVariables): ?Provider
{
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$provider = $this->repository->findById((int) $id);
if (null === $provider) {
return null;
}
// Soft-delete : jamais expose au M3 (HP-M4) — 404 via retour null.
// Les archives restent visibles en detail (consultation + restauration).
if (null !== $provider->getDeletedAt()) {
return null;
}
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
// currentSite null (delegue au ProviderSiteScopeChecker).
if (!$this->scopeChecker->isInScope($provider)) {
return null;
}
return $provider;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/
private function readBool(mixed $raw): bool
{
if (is_bool($raw)) {
return $raw;
}
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines. Tolere un code unique (string)
* ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
* valeur unique ou une liste (?key[]=1&key[]=2).
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Technique\Domain\Entity\ProviderOwnedInterface;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider d'item des sous-ressources d'un prestataire (Contact / Adresse / RIB).
* Decore le provider Doctrine par defaut et applique le cloisonnement par site du
* PARENT (§ 2.13 / RG-3.17) sur Get / Patch / Delete.
*
* Sans ce garde, un user cloisonne pourrait lire / editer / supprimer une
* sous-ressource d'un prestataire hors de son site : le detail Provider est bien
* garde en 404 (ProviderProvider), mais les sous-ressources ne passent pas par lui
* (provider Doctrine par defaut, et SiteScopedQueryExtension ne filtre que les
* resources SiteAwareInterface — ce que ces entites ne sont pas). Le RIB est
* particulierement sensible (IBAN / BIC).
*
* Hors perimetre -> retour null -> 404 (anti-enumeration, coherent avec le detail
* Provider). La decision de scope est deleguee a ProviderSiteScopeChecker (source
* unique partagee avec le ProviderProvider et les processors).
*
* @implements ProviderInterface<ProviderOwnedInterface>
*/
final class ProviderSubResourceItemProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
private readonly ProviderInterface $itemProvider,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
{
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
if ($entity instanceof ProviderOwnedInterface) {
$parent = $entity->getProvider();
if (null === $parent || !$this->scopeChecker->isInScope($parent)) {
return null;
}
}
return $entity;
}
}
@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Controller;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du repertoire prestataires (M3, spec-back § 4.6). Jumeau du
* `SupplierExportController` (M2, module Commercial), augmente du cloisonnement
* par site pilote par l'utilisateur (§ 2.13).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est
* OBLIGATOIRE sur la route : sans cela API Platform capterait
* `/api/providers/export.xlsx` comme l'item `GET /api/providers/{id}.{_format}`
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des prestataires (memes filtres que
* `GET /api/providers`, via {@see ProviderRepositoryInterface::createListQueryBuilder()}),
* cloisonnement par site, et mapping metier des colonnes.
*
* Cloisonnement par site (RG-3.17, § 2.13) : replique la logique du
* {@see ProviderProvider}
* — un user sans `sites.bypass_scope` et possedant un currentSite n'exporte que
* les prestataires rattaches a ce site (relation DIRECTE provider.sites). Le
* QueryBuilder ne connait pas l'user : la decision est prise ICI, le DQL dans le
* repository (applySiteScope).
*
* Colonnes de contact : alimentees par le CONTACT PRINCIPAL du prestataire — le
* ProviderContact de plus petit `position` (decision D2, spec § 4.6).
*
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
* `technique.providers.accounting.view` (gating identique a la lecture, § 2.9).
*/
#[AsController]
final class ProviderExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly Security $security,
// Outillage site-aware (cf. ProviderProvider) : resout le site courant pour
// appliquer le cloisonnement RG-3.17 a l'export comme a la liste.
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
#[Route('/api/providers/export.xlsx', name: 'technique_providers_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('technique.providers.view')]
public function __invoke(Request $request): Response
{
// Memes filtres d'archivage que la vue liste (ProviderProvider) pour que
// l'export reflete exactement ce que l'utilisateur voit a l'ecran :
// - includeArchived : inclut les archives en plus des actifs ;
// - archivedOnly : restreint aux seules archives (prioritaire, cf.
// createListQueryBuilder).
$includeArchived = $this->readBool($request->query->get('includeArchived'));
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
$search = $request->query->getString('search') ?: null;
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
// ne pas lever d'exception sur une valeur scalaire.
$query = $request->query->all();
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
$siteIds = $this->readIntList($query['siteId'] ?? []);
$qb = $this->repository
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
;
// Cloisonnement par site (RG-3.17, § 2.13) AVANT materialisation : restreint
// au currentSite pour un user non-bypass (s'intersecte avec un eventuel
// ?siteId du client). No-op pour bypass_scope ou currentSite null.
$scopeSite = $this->siteScopeOrNull();
if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
}
/** @var list<Provider> $providers */
$providers = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (§ 2.12) : le QB de
// selection ne fetch-join pas les to-many. On remplit categories + sites en
// lot (colonnes « Catégories » / « Sites »), puis les contacts (colonnes du
// contact principal) — chacune en requetes IN bornees, anti N+1.
$this->repository->hydrateListCollections($providers);
$this->repository->hydrateContacts($providers);
$withSiren = $this->security->isGranted('technique.providers.accounting.view');
$binary = $this->exporter->export(
'Répertoire prestataires',
$this->buildHeaders($withSiren),
$this->buildRows($providers, $withSiren),
);
return $this->buildResponse($binary);
}
/**
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
* / user sans currentSite). Miroir de ProviderProvider::siteScopeOrNull().
*/
private function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Colonnes de l'export (spec § 4.6). SIREN inseree avant la date de creation,
* uniquement si l'utilisateur a accounting.view.
*
* @return list<string>
*/
private function buildHeaders(bool $withSiren): array
{
$headers = [
'Nom prestataire',
'Contact principal',
'Téléphone principal',
'Téléphone secondaire',
'Email',
'Catégories',
'Sites',
];
if ($withSiren) {
$headers[] = 'SIREN';
}
$headers[] = 'Date de création';
return $headers;
}
/**
* @param list<Provider> $providers
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $providers, bool $withSiren): iterable
{
foreach ($providers as $provider) {
$contact = $this->principalContact($provider);
$row = [
$provider->getCompanyName(),
null !== $contact ? $this->formatContactName($contact) : '',
$contact?->getPhonePrimary() ?? '',
$contact?->getPhoneSecondary() ?? '',
$contact?->getEmail() ?? '',
$this->formatCategories($provider),
$this->formatSites($provider),
];
if ($withSiren) {
$row[] = $provider->getSiren();
}
$row[] = $provider->getCreatedAt()?->format('d/m/Y');
yield $row;
}
}
/**
* Contact principal du prestataire : le ProviderContact de plus petit
* `position` (decision D2, spec § 4.6). Null si le prestataire n'a aucun
* contact (les colonnes contact restent vides).
*/
private function principalContact(Provider $provider): ?ProviderContact
{
$contacts = $provider->getContacts()->toArray();
if ([] === $contacts) {
return null;
}
usort(
$contacts,
static fn (ProviderContact $a, ProviderContact $b): int => $a->getPosition() <=> $b->getPosition(),
);
return $contacts[0];
}
/**
* Libelle du contact principal « Nom Prénom » (spec § 4.6). Les deux parties
* sont optionnelles (RG-3.04 : au moins l'une des deux), d'ou le trim final.
*/
private function formatContactName(ProviderContact $contact): string
{
return trim(sprintf('%s %s', $contact->getLastName() ?? '', $contact->getFirstName() ?? ''));
}
/**
* Libelles des categories du prestataire, dedupliques, tries, joints par
* virgule.
*/
private function formatCategories(Provider $provider): string
{
$names = [];
foreach ($provider->getCategories() as $category) {
// @var CategoryInterface $category
$name = $category->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Sites du prestataire (relation DIRECTE provider.sites, RG-3.03 — contrairement
* au fournisseur M2 dont les sites sont portes par les adresses). La colonne
* « Sites » agrege l'union distincte des sites rattaches.
*/
private function formatSites(Provider $provider): string
{
$names = [];
foreach ($provider->getSites() as $site) {
// @var SiteInterface $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* @param array<string, true> $names ensemble de libelles (cles)
*/
private function joinSorted(array $names): string
{
$list = array_keys($names);
sort($list);
return implode(', ', $list);
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('repertoire-prestataires-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
* Aligne sur ProviderProvider pour un comportement identique a la liste.
*/
private function readBool(mixed $raw): bool
{
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
}
/**
* Normalise un filtre en liste de chaines (valeur unique ou liste).
* Aligne sur ProviderProvider pour un comportement identique a la liste.
*
* @return list<string>
*/
private function readStringList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if (is_string($value) && '' !== trim($value)) {
$out[] = trim($value);
}
}
return $out;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
* ou liste). Aligne sur ProviderProvider.
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\DataFixtures;
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Commercial\Infrastructure\DataFixtures\CommercialReferentialFixtures;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/test du module Technique : prestataires de demonstration couvrant
* les cas metier RG-3.xx du repertoire prestataires (M3), jumelles des fixtures
* fournisseurs (M2). Theme : prestations techniques (maintenance, nettoyage,
* transport).
*
* Cas pivots couverts (§ 8.4) :
* - prestataire COMPLET : >= 1 site sur le formulaire principal (RG-3.03), >= 1
* contact, >= 1 adresse multi-sites (RG-3.05), comptabilite + RIB ;
* - reglement LCR avec RIB (RG-3.08) ; reglement VIREMENT avec banque (RG-3.07) ;
* - 1 prestataire archive (isArchived + archivedAt) pour l'exclusion de la liste
* (RG-3.16) ;
* - prestataires repartis sur des sites DIFFERENTS (86 / 17 / 82) pour exercer le
* cloisonnement par site (RG-3.17) ;
* - mono et multi-categories de type PRESTATAIRE (RG-3.09).
*
* Resolution inter-modules conforme a la regle n°1 (pas d'import de logique) :
* - categories resolues via le contrat Shared CategoryInterface ;
* - sites resolus via le contrat Shared SiteProviderInterface.
*
* Normalisation : valeurs fournies BRUTES, normalisees par ProviderFieldNormalizer
* avant persist, exactement comme le ferait le ProviderProcessor via l'API
* (companyName UPPERCASE, first/last Capitalize, telephones chiffres seuls, emails
* lowercase — RG-3.11).
*
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
* partiel uq_provider_company_name_active). Un prestataire deja present n'est pas
* reconstruit (sous-collections non redupliquees). Rejouable sans doublon.
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`, la
* fixture ne charge rien : les tests seedent et nettoient leurs propres
* prestataires et comptent sur une table `provider` vierge. Meme garde-fou que
* SupplierFixtures / CategoryFixtures.
*/
class ProviderFixtures extends Fixture implements DependentFixtureInterface
{
/**
* Type de categorie exige pour un prestataire et ses adresses (RG-3.09).
* Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
*/
private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
/** Cache des categories resolues par nom. */
private array $categoryCache = [];
/** Cache des sites resolus par nom. */
private array $siteCache = [];
/** ObjectManager courant, capture en debut de load. */
private ObjectManager $manager;
public function __construct(
private readonly ProviderFieldNormalizer $normalizer,
private readonly SiteProviderInterface $siteProvider,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [
CategoryFixtures::class,
SitesFixtures::class,
CommercialReferentialFixtures::class,
];
}
public function load(ObjectManager $manager): void
{
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
$this->manager = $manager;
// === Prestataire COMPLET — VIREMENT + banque (RG-3.07), compta + RIB, ===
// === multi-sites sur le formulaire principal ET sur l'adresse. ===
[$maintenance, $isNew] = $this->ensureProvider($manager, 'Maintenance Pro SAS', ['Maintenance industrielle'], ['Chatellerault', 'Saint-Jean']);
if ($isNew) {
$maintenance->setSiren('841611054');
$maintenance->setAccountNumber('P0001');
$maintenance->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$maintenance->setNTva('FR12841611054');
$maintenance->setPaymentDelay($this->paymentDelay($manager, 'J30'));
$maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$maintenance->setBank($this->bank($manager, 'SG'));
$this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr');
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']);
$this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
}
// === LCR avec RIB (RG-3.08) — site Pommevic ===
[$nettoyage, $isNew] = $this->ensureProvider($manager, 'Nettoyage Sud-Ouest', ['Nettoyage'], ['Pommevic']);
if ($isNew) {
$nettoyage->setSiren('775680459');
$nettoyage->setTvaMode($this->tvaMode($manager, 'FRANCE_VENTES'));
$nettoyage->setPaymentDelay($this->paymentDelay($manager, 'J15'));
$nettoyage->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($nettoyage, 'Sophie', 'Marchand', 'Directrice', '05 56 10 20 30', '06 11 22 33 44', 'sophie.marchand@nettoyage-so.fr', 0);
$this->addContact($nettoyage, 'Marc', 'Girard', 'Chef d\'equipe', '05 56 10 20 31', null, 'marc.girard@nettoyage-so.fr', 1);
$this->addAddress($nettoyage, ['Pommevic'], '82400', 'Pommevic', '8 route des Prestations');
$this->addRib($nettoyage, 'Compte principal', 'BNPAFRPPXXX', 'FR7630006000011234567890189', 0);
}
// === Multi-categories PRESTATAIRE + reglement CHEQUE (sans banque ni RIB) ===
[$transport, $isNew] = $this->ensureProvider($manager, 'Transport Express Atlantique', ['Transport', 'Maintenance industrielle'], ['Saint-Jean']);
if ($isNew) {
$transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
$transport->setPaymentType($this->paymentType($manager, 'CHEQUE'));
$this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr');
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']);
}
// === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 ===
[$petit, $isNew] = $this->ensureProvider($manager, 'Atelier Soudure Locale', ['Maintenance industrielle'], ['Chatellerault']);
if ($isNew) {
$this->addContact($petit, null, 'Caron', 'Gerant', '05 49 81 82 83', null, 'contact@atelier-soudure.fr');
$this->addAddress($petit, ['Chatellerault'], '86100', 'Châtellerault', '6 chemin de l\'Atelier');
}
// === Prestataire archive (RG-3.16) ===
[$ancien, $isNew] = $this->ensureProvider($manager, 'Ancien Prestataire Ferme', ['Nettoyage'], ['Chatellerault'], isArchived: true);
if ($isNew) {
$this->addContact($ancien, null, 'Lambert', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancien-prestataire.fr');
$this->addAddress($ancien, ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée');
}
$manager->flush();
}
/**
* Cree un prestataire (base normalisee + categories PRESTATAIRE + sites directs)
* s'il n'existe pas, sinon retourne l'existant. Retourne [Provider, isNew] :
* isNew=false bloque la reconstruction des sous-collections (idempotence).
*
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
* @param list<string> $siteNames sites du formulaire principal (RG-3.03, >= 1)
*
* @return array{0: Provider, 1: bool}
*/
private function ensureProvider(
ObjectManager $manager,
string $companyName,
array $categoryNames,
array $siteNames,
bool $isArchived = false,
): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
$existing = $manager->getRepository(Provider::class)->findOneBy(['companyName' => $normalizedName]);
if ($existing instanceof Provider) {
return [$existing, false];
}
$provider = new Provider();
$provider->setCompanyName($normalizedName);
foreach ($categoryNames as $categoryName) {
$provider->addCategory($this->category($manager, $categoryName));
}
foreach ($siteNames as $siteName) {
$provider->addSite($this->site($siteName));
}
if ($isArchived) {
$provider->setIsArchived(true);
$provider->setArchivedAt(new DateTimeImmutable());
}
$manager->persist($provider);
return [$provider, true];
}
/**
* Ajoute un contact normalise au prestataire (cascade persist via
* Provider.contacts). Au moins un champ est rempli (RG-3.04).
*/
private function addContact(
Provider $provider,
?string $firstName,
?string $lastName,
?string $jobTitle,
?string $phonePrimary,
?string $phoneSecondary,
?string $email,
int $position = 0,
): void {
$contact = new ProviderContact();
$contact->setProvider($provider);
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
$contact->setJobTitle($jobTitle);
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$contact->setEmail($this->normalizer->normalizeEmail($email));
$contact->setPosition($position);
$provider->addContact($contact);
}
/**
* Ajoute une adresse au prestataire (cascade persist via Provider.addresses).
* Adresse simplifiee M3 : PAS de addressType / bennes / triageProvider. Au
* moins un site est rattache (RG-3.05) ; categories d'adresse de type
* PRESTATAIRE (RG-3.09).
*
* @param list<string> $siteNames au moins un site (RG-3.05)
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
*/
private function addAddress(
Provider $provider,
array $siteNames,
string $postalCode,
string $city,
string $street,
?string $streetComplement = null,
array $categoryNames = [],
int $position = 0,
): void {
$address = new ProviderAddress();
$address->setProvider($provider);
$address->setCountry('France');
$address->setPostalCode($postalCode);
$address->setCity($city);
$address->setStreet($street);
$address->setStreetComplement($streetComplement);
$address->setPosition($position);
foreach ($siteNames as $siteName) {
$address->addSite($this->site($siteName));
}
foreach ($categoryNames as $categoryName) {
$address->addCategory($this->category($this->manager, $categoryName));
}
$provider->addAddress($address);
}
/**
* Ajoute un RIB au prestataire (cascade persist via Provider.ribs).
*/
private function addRib(Provider $provider, string $label, string $bic, string $iban, int $position = 0): void
{
$rib = new ProviderRib();
$rib->setProvider($provider);
$rib->setLabel($label);
$rib->setBic($bic);
$rib->setIban($iban);
$rib->setPosition($position);
$provider->addRib($rib);
}
/**
* Resout une categorie par son nom via le contrat Shared CategoryInterface,
* sans importer le module Catalog (regle n°1). Verifie le type PRESTATAIRE
* (RG-3.09). Mise en cache par nom.
*/
private function category(ObjectManager $manager, string $name): CategoryInterface
{
if (isset($this->categoryCache[$name])) {
return $this->categoryCache[$name];
}
$candidates = $manager->getRepository(CategoryInterface::class)->findBy([
'name' => $name,
'deletedAt' => null,
]);
foreach ($candidates as $candidate) {
if ($candidate instanceof CategoryInterface
&& in_array(self::PROVIDER_CATEGORY_TYPE_CODE, $candidate->getCategoryTypeCodes(), true)) {
return $this->categoryCache[$name] = $candidate;
}
}
throw new RuntimeException(sprintf(
'Categorie PRESTATAIRE "%s" introuvable : CategoryFixtures doit tourner avant ProviderFixtures.',
$name,
));
}
/**
* Resout un site par son nom via le contrat Shared SiteProviderInterface, sans
* importer le module Sites (regle n°1). Mise en cache par nom.
*/
private function site(string $name): SiteInterface
{
if (isset($this->siteCache[$name])) {
return $this->siteCache[$name];
}
$site = $this->siteProvider->findByName($name);
if (!$site instanceof SiteInterface) {
throw new RuntimeException(sprintf(
'Site "%s" introuvable : SitesFixtures doit tourner avant ProviderFixtures.',
$name,
));
}
return $this->siteCache[$name] = $site;
}
private function tvaMode(ObjectManager $manager, string $code): TvaMode
{
$mode = $manager->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
if (!$mode instanceof TvaMode) {
throw new RuntimeException(sprintf(
'TvaMode "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $mode;
}
private function paymentDelay(ObjectManager $manager, string $code): PaymentDelay
{
$delay = $manager->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
if (!$delay instanceof PaymentDelay) {
throw new RuntimeException(sprintf(
'PaymentDelay "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $delay;
}
private function paymentType(ObjectManager $manager, string $code): PaymentType
{
$type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
if (!$type instanceof PaymentType) {
throw new RuntimeException(sprintf(
'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $type;
}
private function bank(ObjectManager $manager, string $code): Bank
{
$bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
if (!$bank instanceof Bank) {
throw new RuntimeException(sprintf(
'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant ProviderFixtures.',
$code,
));
}
return $bank;
}
}
@@ -0,0 +1,287 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Doctrine;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Provider>
*/
class DoctrineProviderRepository extends ServiceEntityRepository implements ProviderRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Provider::class);
}
public function findById(int $id): ?Provider
{
return $this->find($id);
}
public function save(Provider $provider): void
{
$this->getEntityManager()->persist($provider);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(
bool $includeArchived = false,
?string $search = null,
array $categoryCodes = [],
array $siteIds = [],
bool $archivedOnly = false,
): QueryBuilder {
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
// L'hydratation des collections affichees (Catégories / Site(s)) est
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
// imposer un produit cartesien aux chemins non pagines (export,
// ?pagination=false) — § 2.12 (cf. M1/ERP-100, M2).
$qb = $this->createQueryBuilder('p')
->andWhere('p.deletedAt IS NULL')
->orderBy('p.companyName', 'ASC')
;
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived.
if ($archivedOnly) {
$qb->andWhere('p.isArchived = true');
} elseif (!$includeArchived) {
$qb->andWhere('p.isArchived = false');
}
$this->applySearch($qb, $search);
$this->applyCategoryCodes($qb, $categoryCodes);
$this->applySiteIds($qb, $siteIds);
return $qb;
}
public function hydrateListCollections(array $providers): void
{
$ids = $this->collectIds($providers);
if ([] === $ids) {
return;
}
// 1re passe : categories (colonne « Catégories »). Produit p x cat seul.
$this->createQueryBuilder('p')
->leftJoin('p.categories', 'cat')->addSelect('cat')
->where('p.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
// 2e passe : sites (colonne « Site(s) »). SPECIFICITE M3 : les sites sont
// portes DIRECTEMENT par le prestataire (provider.sites, RG-3.03), pas via
// les adresses comme au M2 — d'ou un simple join p -> site (pas d'imbrication
// addr -> site). Separer des categories casse le cartesien cat x site.
$this->createQueryBuilder('p')
->leftJoin('p.sites', 'site')->addSelect('site')
->where('p.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
}
public function applySiteScope(QueryBuilder $qb, int $siteId): void
{
// Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires
// dont provider.sites contient le site donne. Sous-requete IN (alias p5
// distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du
// QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds.
// Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour
// que les deux clauses puissent coexister (intersection) sans collision.
$sub = $this->getEntityManager()->createQueryBuilder()
->select('p5.id')
->from(Provider::class, 'p5')
->join('p5.sites', 'site5')
->where('site5.id = :scopeSiteId')
;
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
->setParameter('scopeSiteId', $siteId)
;
}
public function hydrateContacts(array $providers): void
{
$ids = $this->collectIds($providers);
if ([] === $ids) {
return;
}
// Une seule requete IN bornee : remplit la collection `contacts` des MEMES
// instances Provider (identity map). Tri par position pour que le « contact
// principal » (plus petit position) soit deterministe a l'export.
$this->createQueryBuilder('p')
->leftJoin('p.contacts', 'pc')->addSelect('pc')
->where('p.id IN (:ids)')->setParameter('ids', $ids)
->orderBy('pc.position', 'ASC')
->getQuery()
->getResult()
;
}
/**
* Recherche fuzzy insensible a la casse sur companyName ET sur les contacts
* lies (firstName / lastName / email) — miroir M2. Les deux criteres sont unis
* par OR : un prestataire matche si son nom de societe OU l'un de ses contacts
* matche. Le critere contact passe par une sous-requete IN (plutot qu'un JOIN
* sur la collection) pour ne pas perturber le DISTINCT / ORDER BY / pagination
* principal. Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
* litteraux.
*/
private function applySearch(QueryBuilder $qb, ?string $search): void
{
if (null === $search || '' === trim($search)) {
return;
}
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$contactSub = $this->getEntityManager()->createQueryBuilder()
->select('p2.id')
->from(Provider::class, 'p2')
->join('p2.contacts', 'pc2')
->where('LOWER(pc2.firstName) LIKE :search')
->orWhere('LOWER(pc2.lastName) LIKE :search')
->orWhere('LOWER(pc2.email) LIKE :search')
;
$qb->andWhere(
$qb->expr()->orX(
'LOWER(p.companyName) LIKE :search',
$qb->expr()->in('p.id', $contactSub->getDQL()),
),
)->setParameter('search', $pattern);
}
/**
* Restreint aux prestataires possedant au moins une categorie dont le code
* figure dans la liste (OR). Alimente le filtre « Catégories » du drawer.
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
* perturber le DISTINCT / ORDER BY principal.
*
* @param list<string> $categoryCodes
*/
private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void
{
$codes = $this->normalizeStringList($categoryCodes);
if ([] === $codes) {
return;
}
$sub = $this->getEntityManager()->createQueryBuilder()
->select('p3.id')
->from(Provider::class, 'p3')
->join('p3.categories', 'cat3')
->where('cat3.code IN (:categoryCodes)')
;
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
->setParameter('categoryCodes', $codes)
;
}
/**
* Restreint aux prestataires rattaches a l'un des sites donnes (OR). SPECIFICITE
* M3 : les sites sont portes DIRECTEMENT par le prestataire (provider.sites,
* RG-3.03), d'ou une sous-requete sur p.sites (et non sur les adresses comme au
* M2). Sous-requete IN pour ne pas perturber le tri/pagination principal.
*
* @param list<int> $siteIds
*/
private function applySiteIds(QueryBuilder $qb, array $siteIds): void
{
$ids = $this->normalizeIntList($siteIds);
if ([] === $ids) {
return;
}
$sub = $this->getEntityManager()->createQueryBuilder()
->select('p4.id')
->from(Provider::class, 'p4')
->join('p4.sites', 'site4')
->where('site4.id IN (:siteIds)')
;
$qb->andWhere($qb->expr()->in('p.id', $sub->getDQL()))
->setParameter('siteIds', $ids)
;
}
/**
* Extrait les identifiants non nuls d'un jeu de prestataires (entites managees).
* Les requetes d'hydratation renvoient les MEMES instances Provider (identity
* map), dont les collections sont alors remplies — anti N+1 a la serialisation.
*
* @param list<Provider> $providers
*
* @return list<int>
*/
private function collectIds(array $providers): array
{
$ids = [];
foreach ($providers as $provider) {
$id = $provider->getId();
if (null !== $id) {
$ids[] = $id;
}
}
return $ids;
}
/**
* Nettoie une liste de chaines : trim, retrait des vides, reindexation.
* Defensive : tolere des elements scalaires non-string (cast) et ignore le
* reste sans lever de TypeError, le contrat etant de normaliser une entree
* potentiellement brute (query params).
*
* @param array<mixed> $values
*
* @return list<string>
*/
private function normalizeStringList(array $values): array
{
$out = [];
foreach ($values as $value) {
if (is_string($value) || is_int($value) || is_float($value)) {
$trimmed = trim((string) $value);
if ('' !== $trimmed) {
$out[] = $trimmed;
}
}
}
return $out;
}
/**
* Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation.
* Defensive (cf. normalizeStringList) : accepte des entiers ou des chaines
* numeriques ('1', '2') sans TypeError, ignore le reste.
*
* @param array<mixed> $values
*
* @return list<int>
*/
private function normalizeIntList(array $values): array
{
$out = [];
foreach ($values as $value) {
if (is_numeric($value) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Security;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Decision centralisee du cloisonnement par site des prestataires (§ 2.13 /
* RG-3.17). Source UNIQUE partagee par le ProviderProvider (liste + detail), le
* provider decore des sous-ressources (ProviderSubResourceItemProvider) et les
* processors d'ecriture des sous-ressources — afin d'eviter tout drift entre ces
* points d'application.
*
* Regle : un user SANS `sites.bypass_scope` ET avec un site courant ne voit /
* n'opere que sur les prestataires rattaches (relation directe provider.sites) a
* son site courant. `bypass_scope` (Admin inclus via isAdmin) ou absence de site
* courant (module Sites off / user sans currentSite) -> aucun cloisonnement
* (no-op, aligne site-aware.md § 5).
*/
final class ProviderSiteScopeChecker
{
public function __construct(
private readonly Security $security,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
/**
* Site de cloisonnement a appliquer, ou null si aucun cloisonnement
* (`bypass_scope`, ou pas de site courant resolu).
*/
public function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Vrai si le prestataire est dans le perimetre site de l'user courant — ou si
* aucun cloisonnement ne s'applique.
*/
public function isInScope(Provider $provider): bool
{
$scopeSite = $this->siteScopeOrNull();
if (null === $scopeSite) {
return true;
}
return $this->providerHasSite($provider, (int) $scopeSite->getId());
}
/**
* Leve un 404 si le prestataire est hors perimetre (anti-enumeration : ne pas
* reveler l'existence d'une ligne hors site). No-op si dans le perimetre.
*/
public function assertInScope(Provider $provider): void
{
if (!$this->isInScope($provider)) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
}
/**
* Vrai si le prestataire est rattache (relation directe provider.sites) au site
* d'id donne. Comparaison en memoire sur l'entite deja chargee.
*/
private function providerHasSite(Provider $provider, int $siteId): bool
{
foreach ($provider->getSites() as $site) {
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
return true;
}
}
return false;
}
}
@@ -361,6 +361,91 @@ final class ColumnCommentsCatalog
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
] + self::timestampableBlamableComments(),
// Tables provider* (M3 Technique) — ajoutees au ticket entites (ERP-133),
// comme l a fait supplier (ERP-86) apres sa migration (ERP-85). En test,
// `schema:update --force` recree ces tables depuis le mapping ORM (sans
// COMMENT) ; `app:apply-column-comments` les repose depuis ce catalogue.
'provider' => [
'_table' => 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).',
'id' => 'Identifiant interne auto-incremente.',
'company_name' => 'Raison sociale du prestataire (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_provider_company_name_active, RG-3.10).',
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).',
'account_number' => 'Onglet Comptabilite : numero de compte comptable du prestataire.',
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.',
'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.',
'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque si VIREMENT) et RG-3.08 (RIB).',
'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.',
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.',
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.',
'deleted_at' => 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.',
] + self::timestampableBlamableComments(),
'provider_category' => [
'_table' => 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).',
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).',
],
'provider_site' => [
'_table' => 'Jointure M2M provider <-> site (Sites) — sites du prestataire, selecteur du formulaire principal, au moins un obligatoire (RG-3.03). Sert le cloisonnement par site (§ 2.13).',
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.',
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).',
],
'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).',
'id' => 'Identifiant interne auto-incremente.',
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
'email' => 'Email du contact (lowercase serveur).',
'position' => 'Ordre d affichage du contact dans la liste du prestataire (croissant).',
] + self::timestampableBlamableComments(),
'provider_address' => [
'_table' => 'Adresses d un prestataire (1:n) — >= 1 site rattache (RG-3.05). SANS address_type / bennes / triage_provider (specifiques fournisseur).',
'id' => 'Identifiant interne auto-incremente.',
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire de l adresse.',
'country' => 'Pays de l adresse — defaut France.',
'postal_code' => 'Code postal (4-5 chiffres attendus) — declenche l autocompletion ville via l API BAN cote front (RG-3.06).',
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front.',
'street' => 'Numero et voie de l adresse.',
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
'position' => 'Ordre d affichage de l adresse dans la liste du prestataire (croissant).',
] + self::timestampableBlamableComments(),
'provider_address_site' => [
'_table' => 'Jointure M2M provider_address <-> site (Sites) — sites rattaches a l adresse (>= 1 obligatoire, RG-3.05).',
'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.',
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
],
'provider_address_contact' => [
'_table' => 'Jointure M2M provider_address <-> provider_contact — contacts associes a une adresse.',
'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.',
'provider_contact_id' => 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
],
'provider_address_category' => [
'_table' => 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).',
'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).',
],
'provider_rib' => [
'_table' => 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).',
'id' => 'Identifiant interne auto-incremente.',
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du RIB.',
'label' => 'Libelle du RIB (ex: compte principal).',
'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).',
'iban' => 'IBAN du compte (≤ 34 caracteres).',
'position' => 'Ordre d affichage du RIB dans la liste du prestataire (croissant).',
] + self::timestampableBlamableComments(),
];
}
@@ -54,6 +54,8 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
'ClientAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
// Idem cote fournisseur (meme Regex CP).
'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
// Idem cote prestataire (meme Regex CP — M3 Technique).
'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
// Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20).
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
@@ -0,0 +1,489 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Base des tests fonctionnels du repertoire prestataires (M3 — module Technique).
* Jumelle de la base fournisseurs (M2), recentree sur le perimetre ERP-134
* (Provider + Processor + cloisonnement site).
*
* Donnees (RETEX M1/M2 — pas de fixtures globales pour les tests) : chaque test
* seede ses prestataires en base via les helpers ci-dessous, puis le tearDown les
* purge. Les 3 sites (Chatellerault 86 / Saint-Jean 17 / Pommevic 82) sont seedes
* par SitesFixtures (make test-db-setup) ; on les recupere par code postal.
*
* Categories : `providerCategory('NETTOYAGE')` fetch-or-create une categorie de
* type PRESTATAIRE (requis par RG-3.09). Pour fabriquer une categorie d'un AUTRE
* type (test de rejet RG-3.09), utiliser `foreignCategory()`.
*
* Cleanup : tearDown purge prestataires AVANT categories/users (provider_category
* et provider_site sont ON DELETE CASCADE cote provider — le DELETE DQL sur
* Provider libere categories et sites pour les purges suivantes).
*
* @internal
*/
abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
protected const string TEST_CATEGORY_PREFIX = 'test_prov_cat_';
/** Codes postaux des 3 sites fixtures (cf. SitesFixtures). */
protected const string SITE_86 = '86100'; // Chatellerault
protected const string SITE_17 = '17400'; // Saint-Jean
protected const string SITE_82 = '82400'; // Pommevic
/** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
protected const string VALID_BIC = 'BNPAFRPPXXX';
/** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
protected function tearDown(): void
{
$em = $this->getEm();
$em->createQuery('DELETE FROM '.Provider::class)->execute();
$em->createQuery('DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix')
->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute()
;
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix')
->setParameter('prefix', 'test_%')->execute()
;
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix')
->setParameter('prefix', 'test_%')->execute()
;
parent::tearDown();
}
protected function createAdminClient(): Client
{
return $this->authenticatedClient('admin', 'admin');
}
/**
* Recupere (ou cree) le type PRESTATAIRE. Idempotent (unicite category_type.code).
*/
protected function providerCategoryType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
if (null !== $existing) {
return $existing;
}
$type = new CategoryType();
$type->setCode('PRESTATAIRE');
$type->setLabel('Prestataire');
$em->persist($type);
$em->flush();
return $type;
}
/**
* Fetch-or-create une categorie de type PRESTATAIRE par code (defaut NETTOYAGE).
* Idempotent (lookup par code, aligne sur l'index unique partiel uq_category_code)
* et auto-suffisant. Nom prefixe -> purge par tearDown.
*/
protected function providerCategory(string $code = 'NETTOYAGE'): Category
{
$em = $this->getEm();
$existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
if (null !== $existing) {
return $existing;
}
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.strtolower($code));
$category->setCode($code);
$category->addCategoryType($this->providerCategoryType());
$em->persist($category);
$em->flush();
return $category;
}
/**
* Cree une categorie d'un type DIFFERENT de PRESTATAIRE (pour tester le rejet
* RG-3.09). Code unique pour ne pas collisionner avec une categorie existante.
*/
protected function foreignCategory(): Category
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$type = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'CLIENT']);
if (null === $type) {
$type = new CategoryType();
$type->setCode('CLIENT');
$type->setLabel('Client');
$em->persist($type);
}
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.'foreign_'.$suffix);
$category->setCode('FOREIGN_'.strtoupper($suffix));
$category->addCategoryType($type);
$em->persist($category);
$em->flush();
return $category;
}
/**
* Recupere un site fixture par code postal (cf. SitesFixtures). Echoue
* explicitement si absent (fixtures non chargees / module Sites off).
*/
protected function site(string $postalCode): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['postalCode' => $postalCode]);
self::assertNotNull(
$site,
sprintf('Site fixture "%s" introuvable : SitesFixtures charge (make test-db-setup) ?', $postalCode),
);
return $site;
}
/**
* Seede directement un Provider minimal (sans passer par l'API), pour les tests
* de liste / archivage / cloisonnement. Nom stocke en MAJUSCULES pour refleter
* l'etat normalise (RG-3.11) qu'aurait produit le ProviderProcessor. Porte une
* categorie PRESTATAIRE + les sites donnes (par code postal).
*
* @param list<string> $sitePostalCodes codes postaux des sites a rattacher
*/
protected function seedProvider(
string $companyName,
array $sitePostalCodes = [self::SITE_86],
bool $isArchived = false,
string $categoryCode = 'NETTOYAGE',
?string $siren = null,
): Provider {
$em = $this->getEm();
$provider = new Provider();
$provider->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$provider->addCategory($this->providerCategory($categoryCode));
foreach ($sitePostalCodes as $postalCode) {
$provider->addSite($this->site($postalCode));
}
if (null !== $siren) {
$provider->setSiren($siren);
}
$provider->setIsArchived($isArchived);
if ($isArchived) {
$provider->setArchivedAt(new DateTimeImmutable());
}
$em->persist($provider);
$em->flush();
return $provider;
}
/**
* Payload minimal valide du formulaire principal (companyName + 1 categorie
* PRESTATAIRE + sites donnes). Categorie NETTOYAGE par defaut.
*
* @param list<string> $sitePostalCodes
*
* @return array<string, mixed>
*/
protected function validMainPayload(string $companyName, array $sitePostalCodes = [self::SITE_86]): array
{
$siteIris = array_map(fn (string $pc): string => '/api/sites/'.$this->site($pc)->getId(), $sitePostalCodes);
return [
'companyName' => $companyName,
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
'sites' => $siteIris,
];
}
/**
* Cree un utilisateur non-admin CLOISONNE : porte les permissions donnees via
* un role jetable, rattache aux seuls sites donnes (par code postal), avec un
* currentSite positionne. N'a PAS `sites.bypass_scope` (sauf si fourni dans
* $permissionCodes) -> sujet ideal des tests de cloisonnement (RG-3.17).
*
* Contrairement a createUserWithPermissions() (parent, qui attache TOUS les
* sites et ne pose pas de currentSite), ce helper controle finement le
* perimetre site de l'user.
*
* @param list<string> $permissionCodes
* @param list<string> $sitePostalCodes sites a rattacher (user_site)
*
* @return array{username: string, password: string}
*/
protected function createScopedUser(
array $permissionCodes,
array $sitePostalCodes,
?string $currentSitePostalCode = null,
): array {
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'test_scoped_'.$suffix;
$password = 'testpass';
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
foreach ($permissionCodes as $code) {
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $code]);
self::assertNotNull($permission, sprintf('Permission "%s" introuvable (app:sync-permissions ?).', $code));
$role->addPermission($permission);
}
$em->persist($role);
$user = new User();
$user->setUsername($username);
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, $password));
$user->addRbacRole($role);
foreach ($sitePostalCodes as $postalCode) {
$user->addSite($this->site($postalCode));
}
if (null !== $currentSitePostalCode) {
$user->setCurrentSite($this->site($currentSitePostalCode));
}
$em->persist($user);
$em->flush();
$em->clear();
return ['username' => $username, 'password' => $password];
}
/**
* Ajoute un contact a un prestataire deja persiste (seed direct).
*/
protected function addContact(
Provider $provider,
?string $firstName = 'Marie',
?string $lastName = 'Martin',
?string $phonePrimary = null,
?string $email = null,
int $position = 0,
): ProviderContact {
$contact = new ProviderContact();
$contact->setProvider($provider);
$contact->setFirstName($firstName);
$contact->setLastName($lastName);
$contact->setPhonePrimary($phonePrimary);
$contact->setEmail($email);
$contact->setPosition($position);
$provider->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
return $contact;
}
/**
* Ajoute un RIB a un prestataire deja persiste (seed direct).
*/
protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib
{
$rib = new ProviderRib();
$rib->setProvider($provider);
$rib->setLabel($label);
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$provider->addRib($rib);
$this->getEm()->persist($rib);
$this->getEm()->flush();
return $rib;
}
/**
* Seede un prestataire COMPLET (sans passer par l'API — validations applicatives
* non rejouees mais CHECK BDD respectes) : bloc comptable non nul (SIREN + refs),
* >= 1 RIB, >= 2 sites en relation DIRECTE (formulaire principal, RG-3.03), >= 1
* adresse multi-sites (>= 2 sites) avec >= 1 categorie PRESTATAIRE et >= 1 contact,
* >= 1 contact et >= 1 categorie sur le prestataire. Socle du contrat de
* serialisation et de la DoD (§ 4.0.bis), jumeau de seedCompleteSupplier (M2)
* mais SANS onglet Information (absent au M3) et AVEC sites directs sur le
* prestataire (NOUVEAU M3 — la liste affiche provider.sites, pas un agregat
* d'adresses).
*
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
* coherent avec le RIB seede ; RG-3.08)
*/
protected function seedCompleteProvider(string $companyName, string $paymentTypeCode = 'LCR'): Provider
{
$em = $this->getEm();
// Nom unique parmi les actifs (index partiel uq_provider_company_name_active).
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$provider = new Provider();
$provider->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$provider->addCategory($this->providerCategory('NETTOYAGE'));
// Bloc comptable non nul (gating par omission cote sans accounting.view).
$provider->setSiren('987654321');
$provider->setAccountNumber('P0001');
$provider->setNTva('FR00987654321');
$provider->setTvaMode($this->tvaMode('FRANCE_VENTES'));
$provider->setPaymentDelay($this->paymentDelay('J30'));
$provider->setPaymentType($this->paymentType($paymentTypeCode));
if ('VIREMENT' === $paymentTypeCode) {
$provider->setBank($this->bank('SG'));
}
// >= 2 sites fixtures : relation DIRECTE provider.sites (RG-3.03) pour la
// LISTE + reutilises sur l'adresse multi-sites pour le DETAIL.
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
foreach ($sites as $site) {
$provider->addSite($site);
}
$em->persist($provider);
$contact = new ProviderContact();
$contact->setProvider($provider);
$contact->setFirstName('Marie');
$contact->setLastName('Martin');
$contact->setJobTitle('Responsable');
$contact->setPhonePrimary('0612345678');
$contact->setEmail('marie.martin@seed.test');
$provider->addContact($contact);
$em->persist($contact);
// Adresse simplifiee M3 (PAS de addressType / bennes / triageProvider).
$address = new ProviderAddress();
$address->setProvider($provider);
$address->setCountry('France');
$address->setPostalCode('86000');
$address->setCity('Poitiers');
$address->setStreet('12 rue des Acacias');
foreach ($sites as $site) {
$address->addSite($site);
}
$address->addCategory($this->providerCategory('NETTOYAGE'));
$address->addContact($contact);
$provider->addAddress($address);
$em->persist($address);
$rib = new ProviderRib();
$rib->setProvider($provider);
$rib->setLabel('Compte principal');
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$provider->addRib($rib);
$em->persist($rib);
$em->flush();
return $provider;
}
/**
* Recupere un mode de TVA seede (CommercialReferentialFixtures) par code (ex.
* FRANCE_VENTES). Echoue explicitement si absent (fixtures non chargees).
*/
protected function tvaMode(string $code): TvaMode
{
$tvaMode = $this->getEm()->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$tvaMode,
sprintf('Mode de TVA "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $tvaMode;
}
/**
* Recupere un delai de reglement seede (CommercialReferentialFixtures) par code
* (ex. J30). Echoue explicitement si absent (fixtures non chargees).
*/
protected function paymentDelay(string $code): PaymentDelay
{
$paymentDelay = $this->getEm()->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$paymentDelay,
sprintf('Delai de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $paymentDelay;
}
/**
* Recupere un type de reglement seede (CommercialReferentialFixtures) par code
* (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees).
*/
protected function paymentType(string $code): PaymentType
{
$paymentType = $this->getEm()->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$paymentType,
sprintf('Type de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $paymentType;
}
/**
* Recupere une banque seedee (CommercialReferentialFixtures) par code (ex. SG).
* Echoue explicitement si absente (fixtures non chargees).
*/
protected function bank(string $code): Bank
{
$bank = $this->getEm()->getRepository(Bank::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$bank,
sprintf('Banque "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $bank;
}
/**
* Indexe les violations d'un corps 422 par propertyPath (assert ciblee).
*
* @param array<string, mixed> $body corps decode (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
* de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
* propertyPath de la violation (consommable par extractApiViolations cote front,
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires
* comptables (spec M3 § 3.1).
*
* @internal
*/
final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
{
// === RG-3.07 : Virement impose une banque ===
public function testVirementWithoutBankReturns422OnBankPath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Virement No Bank');
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId()],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('bank', $this->violationsByPath($response->toArray(false)));
}
public function testVirementWithBankReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Virement With Bank');
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId(),
'bank' => '/api/banks/'.$this->bank('SG')->getId(),
],
]);
self::assertResponseStatusCodeSame(200);
}
// === RG-3.08 : LCR impose au moins un RIB (volet ecriture du formulaire) ===
public function testLcrWithoutRibReturns422OnPaymentTypePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Lcr No Rib');
$response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(422);
// Miroir client : violation portee sur `paymentType` (select « Type de
// règlement »), les RIB n'ayant pas de champ de formulaire pour l'ancrer.
self::assertArrayHasKey('paymentType', $this->violationsByPath($response->toArray(false)));
}
public function testLcrWithRibReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Lcr With Rib');
$this->addRib($seed);
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(200);
}
// violationsByPath() : helper mutualise dans AbstractProviderApiTestCase.
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests fonctionnels du formulaire principal prestataire (POST + PATCH) — ERP-134.
* Couvre : creation (RG-3.03 sites obligatoires, RG-3.09 type categorie),
* normalisation companyName (RG-3.11), 409 doublon (RG-3.10).
*
* @internal
*/
final class ProviderApiTest extends AbstractProviderApiTestCase
{
public function testPostMainCreatesProvider(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Maintenance Pro', [self::SITE_86]),
]);
self::assertSame(201, $response->getStatusCode());
$body = $response->toArray();
// RG-3.11 : companyName normalise en MAJUSCULES.
self::assertSame('MAINTENANCE PRO', $body['companyName']);
self::assertArrayHasKey('id', $body);
// sites embarque (relation directe, site:read) avec name/postalCode.
self::assertCount(1, $body['sites']);
self::assertSame('86100', $body['sites'][0]['postalCode']);
}
public function testPostWithoutSiteIsRejected(): void
{
$client = $this->createAdminClient();
$payload = $this->validMainPayload('Sans Site', [self::SITE_86]);
$payload['sites'] = [];
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
]);
// RG-3.03 : au moins un site obligatoire.
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
public function testPostWithoutCategoryIsRejected(): void
{
$client = $this->createAdminClient();
$payload = $this->validMainPayload('Sans Categorie', [self::SITE_86]);
$payload['categories'] = [];
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
]);
// RG-3.09 : au moins une categorie obligatoire.
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
public function testPostWithForeignCategoryTypeIsRejected(): void
{
$client = $this->createAdminClient();
$foreign = $this->foreignCategory();
$payload = $this->validMainPayload('Mauvais Type', [self::SITE_86]);
$payload['categories'] = ['/api/categories/'.$foreign->getId()];
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
]);
// RG-3.09 : categorie hors type PRESTATAIRE -> 422 sur `categories`.
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
public function testDuplicateCompanyNameReturns409(): void
{
$this->seedProvider('Doublon Sarl', [self::SITE_86]);
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
// Casse differente : l'unicite est insensible a la casse (LOWER).
'json' => $this->validMainPayload('doublon sarl', [self::SITE_86]),
]);
// RG-3.10 : doublon de nom (case-insensitive) -> 409.
self::assertSame(409, $response->getStatusCode());
}
public function testSameNameAfterArchiveIsAllowed(): void
{
// Index partiel : l'unicite ignore les archives -> reutilisation du nom OK.
$this->seedProvider('Recyclage Express', [self::SITE_86], isArchived: true);
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Recyclage Express', [self::SITE_86]),
]);
self::assertSame(201, $response->getStatusCode());
}
}
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use Doctrine\DBAL\Connection;
/**
* Tests Audit du repertoire prestataires (M3, spec § 6). Jumeau du
* {@see \App\Tests\Module\Commercial\Api\SupplierAuditTest} (M2). Couvre :
* - POST / PATCH / archivage -> ligne audit_log entity_type='technique.Provider'
* avec l'action et le diff attendus ;
* - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles
* DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1/M2) ;
* - M2M `sites` : un PATCH du formulaire principal qui modifie les sites trace la
* relation many-to-many (audit M2M automatique, § 2.7).
*
* @internal
*/
final class ProviderAuditTest extends AbstractProviderApiTestCase
{
private const string PROVIDER_TYPE = 'technique.Provider';
private const string RIB_TYPE = 'technique.ProviderRib';
private ?Connection $auditConnection = null;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
}
protected function tearDown(): void
{
if (null !== $this->auditConnection) {
$this->auditConnection->close();
}
parent::tearDown();
}
public function testPostProviderIsAudited(): void
{
$admin = $this->createAdminClient();
$payload = $this->validMainPayload('Audit Created Co', [self::SITE_86]);
$created = $admin->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::PROVIDER_TYPE, (string) $created['id'], 'create'),
'Un audit_log "create" doit etre genere pour le prestataire.',
);
}
public function testPatchProviderIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Audit Patch Co', [self::SITE_86]);
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Audit Patch Renamed'],
]);
self::assertResponseStatusCodeSame(200);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::PROVIDER_TYPE, (string) $seed->getId(), 'update'),
'Un audit_log "update" doit etre genere pour le PATCH.',
);
}
public function testArchiveProviderIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Audit Archive Co', [self::SITE_86]);
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update');
self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.');
}
public function testPatchSitesIsAuditedAsManyToMany(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Audit Sites Co', [self::SITE_86]);
// PATCH du formulaire principal : on passe a 2 sites (86 + 17). L'audit M2M
// automatique (§ 2.7) doit tracer la relation `sites` dans le diff.
$admin->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['sites' => [
'/api/sites/'.$this->site(self::SITE_86)->getId(),
'/api/sites/'.$this->site(self::SITE_17)->getId(),
]],
]);
self::assertResponseStatusCodeSame(200);
$changes = $this->latestChanges(self::PROVIDER_TYPE, (string) $seed->getId(), 'update');
self::assertArrayHasKey('sites', $changes, 'La modification M2M des sites doit etre tracee.');
}
public function testRibCreateAuditIncludesIbanAndBic(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedProvider('Rib Audit Host', [self::SITE_86]);
$rib = $admin->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte audite',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
$changes = $this->latestChanges(self::RIB_TYPE, (string) $rib['id'], 'create');
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertSame(self::VALID_IBAN, $changes['iban']);
self::assertSame(self::VALID_BIC, $changes['bic']);
}
/**
* Decode le `changes` (diff) de la derniere ligne audit_log correspondante.
*
* @return array<string, mixed>
*/
private function latestChanges(string $type, string $id, string $action): array
{
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
['type' => $type, 'id' => $id, 'action' => $action],
);
self::assertGreaterThanOrEqual(1, count($rows), sprintf('Un audit_log "%s" doit exister pour %s#%s.', $action, $type, $id));
return json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
}
private function countAudit(string $type, string $id, string $action): int
{
return (int) $this->auditConnection->fetchOne(
'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action',
['type' => $type, 'id' => $id, 'action' => $action],
);
}
}
@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX du repertoire prestataires (M3, § 4.6).
* Jumeau du {@see \App\Tests\Module\Commercial\Api\SupplierExportControllerTest}
* (M2), augmente du cloisonnement par site (§ 2.13, propre au M3).
*
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
* archives par defaut, respect du filtre ?search, peuplement des colonnes contact
* principal / categories / sites (relation directe provider.sites), gating de la
* colonne SIREN selon technique.providers.accounting.view (admin ET user minimal a
* permission explicite), dedup (prestataire multi-categories rendu sur une seule
* ligne), cloisonnement par site (un user cloisonne n'exporte que son site), 403
* sans technique.providers.view, 401 anonyme.
*
* @internal
*/
final class ProviderExportControllerTest extends AbstractProviderApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/providers/export.xlsx';
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Export Alpha');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertStringContainsString('attachment; filename="repertoire-prestataires-', $disposition);
self::assertMatchesRegularExpression(
'/filename="repertoire-prestataires-\d{8}\.xlsx"/',
$disposition,
);
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
$grid = $this->gridFromResponse($response->getContent());
$headers = $grid[0];
self::assertSame('Nom prestataire', $headers[0]);
self::assertContains('Contact principal', $headers);
self::assertContains('Téléphone principal', $headers);
self::assertContains('Téléphone secondaire', $headers);
self::assertContains('Email', $headers);
self::assertContains('Catégories', $headers);
self::assertContains('Sites', $headers);
self::assertContains('Date de création', $headers);
}
public function testExportExcludesArchivedByDefault(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Active One');
$this->seedProvider('Archived One', [self::SITE_86], true);
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('ACTIVE ONE', $names);
self::assertNotContains('ARCHIVED ONE', $names);
}
public function testExportRespectsSearchFilter(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Searchable Alpha');
$this->seedProvider('Other Beta');
$names = $this->companyNames(
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
);
self::assertContains('SEARCHABLE ALPHA', $names);
self::assertNotContains('OTHER BETA', $names);
}
/**
* Les colonnes contact sont alimentees par le CONTACT PRINCIPAL : le contact
* de plus petit `position` (decision D2, § 4.6). On seede deux contacts en
* ordre de position inverse pour garantir que c'est bien le principal (et non
* le premier insere) qui alimente la ligne.
*/
public function testExportUsesPrincipalContactColumns(): void
{
$client = $this->createAdminClient();
$provider = $this->seedProvider('Contact Co');
// position 1 (secondaire) insere en premier...
$this->addContact($provider, 'Bob', 'Secondaire', '0600000001', 'bob@contact.co', 1);
// ...position 0 (principal) insere ensuite : c'est lui qui doit gagner.
$principal = $this->addContact($provider, 'Alice', 'Principal', '0612345678', 'alice@contact.co', 0);
// Le telephone secondaire n'est pas porte par le helper de base : on le pose
// directement sur le contact principal pour alimenter la colonne dediee.
$principal->setPhoneSecondary('0698765432');
$this->getEm()->flush();
$row = $this->rowFor($client->request('GET', self::EXPORT_URL)->getContent(), 'CONTACT CO');
self::assertNotNull($row, 'Ligne « CONTACT CO » introuvable dans l\'export.');
self::assertSame('Principal Alice', $row[1]);
self::assertSame('0612345678', $row[2]);
self::assertSame('0698765432', $row[3]);
self::assertSame('alice@contact.co', $row[4]);
}
/**
* Colonnes « Catégories » et « Sites » : un oubli d'hydratation les rendrait
* vides sans erreur (cf. ERP-100 cote client). Le site est porte EN DIRECT par
* le prestataire (RG-3.03), contrairement au fournisseur M2 (via l'adresse).
*/
public function testExportPopulatesCategoryAndSiteColumns(): void
{
$client = $this->createAdminClient();
$this->seedProvider('Hydrate Co', [self::SITE_86], false, 'NETTOYAGE');
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie PRESTATAIRE (getName()).
// Derive du helper de base (idempotent) plutot que de hardcoder le prefixe.
self::assertStringContainsString((string) $this->providerCategory('NETTOYAGE')->getName(), $flat);
// Colonne « Sites » : site rattache en direct au prestataire (RG-3.03).
self::assertStringContainsString((string) $this->site(self::SITE_86)->getName(), $flat);
}
public function testSirenColumnPresentWithAccountingView(): void
{
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
$client = $this->createAdminClient();
$this->seedProvider('Siren Co', [self::SITE_86], false, 'NETTOYAGE', '123456789');
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('123456789', $this->flatten($grid));
}
public function testSirenColumnAbsentWithoutAccountingView(): void
{
// Seed via admin, puis relecture par un user qui n'a QUE providers.view.
$this->createAdminClient();
$this->seedProvider('No Siren Co', [self::SITE_86], false, 'NETTOYAGE', '987654321');
$creds = $this->createUserWithPermission('technique.providers.view');
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertNotContains('SIREN', $grid[0]);
self::assertStringNotContainsString('987654321', $this->flatten($grid));
}
/**
* Gating SIREN prouve via une permission EXPLICITE (et non le bypass admin) :
* un user minimal portant uniquement technique.providers.view +
* technique.providers.accounting.view voit bien la colonne SIREN et sa valeur.
* Complement de testSirenColumnPresentWithAccountingView (admin), qui ne prouve
* pas que accounting.view SEULE suffit (l'admin bypasse le RBAC). Le pendant
* negatif est couvert par testSirenColumnAbsentWithoutAccountingView.
*/
public function testSirenColumnPresentForMinimalUserWithAccountingView(): void
{
// Seed via admin, puis relecture par un user non-admin a 2 permissions.
$this->createAdminClient();
$this->seedProvider('Gated Siren Co', [self::SITE_86], false, 'NETTOYAGE', '456789123');
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.accounting.view',
]);
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('SIREN', $grid[0]);
self::assertStringContainsString('456789123', $this->flatten($grid));
}
/**
* Dedup : un prestataire portant >= 2 categories PRESTATAIRE est multiplie par
* la jointure (selection/hydratation des collections) ; l'export doit le rendre
* sur UNE SEULE ligne. On seede un prestataire a 2 categories et on assert qu'il
* n'apparait qu'une fois dans la colonne « Nom prestataire ».
*/
public function testExportDeduplicatesProviderWithMultipleCategories(): void
{
$client = $this->createAdminClient();
$provider = $this->seedProvider('Multi Cat Co', [self::SITE_86], false, 'NETTOYAGE');
// 2e categorie PRESTATAIRE sur le meme prestataire.
$provider->addCategory($this->providerCategory('SECURITE'));
$this->getEm()->flush();
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
$occurrences = count(array_filter($names, static fn (string $name): bool => 'MULTI CAT CO' === $name));
self::assertSame(
1,
$occurrences,
'Un prestataire multi-categories doit apparaitre sur une seule ligne (dedup).',
);
}
/**
* Cloisonnement par site (RG-3.17, § 2.13) : un user non-bypass cloisonne sur
* le site 86 n'exporte QUE les prestataires rattaches au site 86 — les
* prestataires des sites 17 / 82 sont exclus, comme dans la liste. Pendant
* export de ProviderSiteScopeTest::testListIsScopedToCurrentSiteForNonBypassUser.
*/
public function testExportIsScopedToCurrentSiteForNonBypassUser(): void
{
// Pre-requis : module Sites actif (sinon currentSite = null, cloisonnement
// no-op et ce test perd son sens).
$this->skipIfSitesModuleDisabled();
$this->createAdminClient();
$this->seedProvider('Presta Site 86', [self::SITE_86]);
$this->seedProvider('Presta Site 17', [self::SITE_17]);
$this->seedProvider('Presta Site 82', [self::SITE_82]);
$creds = $this->createScopedUser(
['technique.providers.view'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('PRESTA SITE 86', $names);
self::assertNotContains('PRESTA SITE 17', $names);
self::assertNotContains('PRESTA SITE 82', $names);
}
public function testForbiddenWithoutProvidersViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Extrait la colonne « Nom prestataire » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function companyNames(string $binary): array
{
$grid = $this->gridFromResponse($binary);
$rows = array_slice($grid, 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
/**
* Renvoie la ligne de donnees dont la 1re colonne (nom) vaut $companyName.
*
* @return null|array<int, mixed>
*/
private function rowFor(string $binary, string $companyName): ?array
{
foreach (array_slice($this->gridFromResponse($binary), 1) as $row) {
if ((string) ($row[0] ?? '') === $companyName) {
return $row;
}
}
return null;
}
/**
* Aplatit toute la grille en une chaine, pour les assertions de presence.
*
* @param array<int, array<int, mixed>> $grid
*/
private function flatten(array $grid): string
{
return implode('|', array_map(
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
$grid,
));
}
}
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests de la liste paginee /api/providers (ProviderProvider) — ERP-134.
* Couvre : envelope Hydra, tri companyName ASC, exclusion des archives,
* ?includeArchived (RG-3.16). Joue en admin (bypass_scope -> pas de cloisonnement).
*
* @internal
*/
final class ProviderListTest extends AbstractProviderApiTestCase
{
public function testListReturnsHydraEnvelopeSortedByName(): void
{
$this->seedProvider('Zeta Services', [self::SITE_86]);
$this->seedProvider('Alpha Nettoyage', [self::SITE_86]);
$this->seedProvider('Mu Maintenance', [self::SITE_86]);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/providers', [
'headers' => ['Accept' => self::LD],
]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
// Envelope Hydra : totalItems present + member.
self::assertSame(3, $body['totalItems']);
$names = array_column($body['member'], 'companyName');
// Tri companyName ASC (RG-3.16) — noms normalises en MAJUSCULES.
self::assertSame(['ALPHA NETTOYAGE', 'MU MAINTENANCE', 'ZETA SERVICES'], $names);
}
public function testListExcludesArchivedByDefault(): void
{
$this->seedProvider('Actif Sas', [self::SITE_86]);
$this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/providers', [
'headers' => ['Accept' => self::LD],
]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
self::assertSame(1, $body['totalItems']);
self::assertSame('ACTIF SAS', $body['member'][0]['companyName']);
}
public function testListIncludeArchivedReintegratesArchived(): void
{
$this->seedProvider('Actif Sas', [self::SITE_86]);
$this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/providers?includeArchived=true', [
'headers' => ['Accept' => self::LD],
]);
self::assertSame(200, $response->getStatusCode());
self::assertSame(2, $response->toArray()['totalItems']);
}
public function testListFiltersBySiteIdViaDirectRelation(): void
{
$this->seedProvider('Site 86 Only', [self::SITE_86]);
$this->seedProvider('Site 17 Only', [self::SITE_17]);
$client = $this->createAdminClient();
$site17 = $this->site(self::SITE_17);
$response = $client->request('GET', '/api/providers?siteId='.$site17->getId(), [
'headers' => ['Accept' => self::LD],
]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
self::assertSame(1, $body['totalItems']);
self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']);
}
public function testPaginationDisabledReturnsFullCollection(): void
{
$token = $this->token();
for ($i = 0; $i < 3; ++$i) {
$this->seedProvider($token.' Item'.$i, [self::SITE_86]);
}
$client = $this->createAdminClient();
// ?pagination=false : echappatoire pour alimenter un <select> (regle n°13).
$data = $client->request('GET', '/api/providers?search='.$token.'&pagination=false', [
'headers' => ['Accept' => self::LD],
])->toArray();
self::assertArrayHasKey('member', $data);
self::assertCount(3, $data['member']);
}
/**
* Anti N+1 (§ 2.12) : le nombre de requetes SQL de la liste ne doit PAS croitre
* avec le nombre de prestataires. On mesure pour N=2 puis N=4 (memes relations
* embarquees : categories + sites directs + adresses.sites) et on exige un
* compte IDENTIQUE — preuve que l'hydratation est batchee (WHERE IN) et non par
* ligne.
*/
public function testListQueryCountDoesNotGrowWithRowCount(): void
{
$this->skipIfSitesModuleDisabled();
$token = $this->token();
$this->seedCompleteProvider($token.' A');
$this->seedCompleteProvider($token.' B');
$countFor2 = $this->countListQueries($token);
$this->seedCompleteProvider($token.' C');
$this->seedCompleteProvider($token.' D');
$countFor4 = $this->countListQueries($token);
self::assertSame(
$countFor2,
$countFor4,
sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
);
}
/**
* Filtre ?typeCode= (cree au M2, reutilise au M3) : GET /api/categories?typeCode=
* PRESTATAIRE ne renvoie QUE les categories de type PRESTATAIRE — prerequis des
* multi-selects Categorie du prestataire (DoD § 4.7).
*/
public function testCategoriesTypeCodeFilterReturnsOnlyPrestataire(): void
{
$prestataire = $this->providerCategory('NETTOYAGE');
$foreign = $this->foreignCategory();
$client = $this->createAdminClient();
$data = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE&pagination=false', [
'headers' => ['Accept' => self::LD],
])->toArray();
$ids = array_column($data['member'], 'id');
self::assertContains($prestataire->getId(), $ids, 'La categorie PRESTATAIRE doit etre presente.');
self::assertNotContains($foreign->getId(), $ids, 'Une categorie d\'un autre type doit etre filtree.');
}
/**
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
* debug Doctrine. Le holder est remis a zero juste avant la requete pour isoler
* ses requetes (hors login).
*/
private function countListQueries(string $token): int
{
$http = $this->createAdminClient();
$holder = self::getContainer()->get('doctrine.debug_data_holder');
$holder->reset();
$http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]]);
$data = $holder->getData();
return count($data['default'] ?? []);
}
private function token(): string
{
return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
}
}
@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire prestataires par role metier (spec-back M3
* § 2.9 + § 2.13, ERP-138). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, le gating des champs comptables en
* lecture (omission de cle) et le cloisonnement par site de l'Usine.
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente —
* pas de mock de role. Jumeau de SupplierRBACMatrixTest (M2), avec la difference
* structurante du M3 : l'Usine n'est plus « 403 partout » mais possede
* `technique.providers.view` en lecture seule, CLOISONNEE a son site courant
* (pas de `sites.bypass_scope`).
*
* Matrice § 2.9 (ERP-138) — rappel :
* - bureau : providers.view + manage (ni accounting, ni archive) + bypass_scope
* - compta : providers.view + accounting.view + accounting.manage (PAS manage) + bypass_scope
* - commerciale : providers.view + manage (PAS accounting) + bypass_scope
* - usine : providers.view seul, SANS bypass_scope (cloisonne a son site)
* - archive : admin seul (aucun role metier)
*
* @internal
*/
final class ProviderRBACMatrixTest extends AbstractProviderApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 2.9 +
// comptes demo). Exerce aussi le chemin de code prod.
self::bootKernel();
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$exit = $application->run(
new ArrayInput([
'command' => 'app:seed-rbac',
'--with-demo-users' => true,
'--password' => self::PWD,
]),
new NullOutput(),
);
self::assertSame(
0,
$exit,
'app:seed-rbac a echoue : les permissions technique.providers.* sont-elles synchronisees (app:sync-permissions) ?',
);
self::ensureKernelShutdown();
}
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedProvider('Bureau Cible');
$client = $this->authAs('bureau');
// view
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK (bypass_scope -> peut attacher le site 86)
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Bureau Cree'),
]);
self::assertResponseStatusCodeSame(201);
// manage : edition onglet principal OK
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Bureau Renomme'],
]);
self::assertResponseStatusCodeSame(200);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauDetailHasNoAccountingFields(): void
{
// Bureau a view mais PAS accounting.view : les champs comptables sont
// ABSENTS du JSON (gating par omission, pas null).
$provider = $this->seedProvider('Bureau Gating Co', [self::SITE_86], siren: '123456789');
$client = $this->authAs('bureau');
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testComptaCanEditAccountingOnly(): void
{
$seed = $this->seedProvider('Compta Cible');
$client = $this->authAs('compta');
// view
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Compta Post'),
]);
self::assertResponseStatusCodeSame(403);
// accounting.manage : edition onglet Comptabilite OK
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(200);
// PAS manage : edition onglet principal refusee (mode strict RG-3.15)
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Compta Renomme'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testComptaDetailHasAccountingFields(): void
{
// Compta a accounting.view : siren + ribs presents dans le JSON.
$provider = $this->seedProvider('Compta View Co', [self::SITE_86], siren: '987654321');
$this->addRib($provider);
$client = $this->authAs('compta');
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $data);
self::assertSame('987654321', $data['siren']);
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
}
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedProvider('Commerciale Cible');
$client = $this->authAs('commerciale');
// view
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Cree'),
]);
self::assertResponseStatusCodeSame(201);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testCommercialeDetailHasNoAccountingFields(): void
{
$provider = $this->seedProvider('Commerciale Gating Co', [self::SITE_86], siren: '123456789');
$client = $this->authAs('commerciale');
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testUsineHasReadOnlyAccessScopedToItsSite(): void
{
// Usine a view (lecture seule), SANS manage / accounting / archive, et
// SANS bypass_scope -> cloisonnee a son site courant (Chatellerault,
// site 86, pose par ensureDemoUsers).
$inScope = $this->seedProvider('Usine InScope', [self::SITE_86]);
$client = $this->authAs('usine');
// view : liste OK (pas un 403 comme au M2)
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// view : detail d'un prestataire de SON site OK
$client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Usine Post'),
]);
self::assertResponseStatusCodeSame(403);
// PAS manage : edition onglet principal refusee
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Renomme Par Usine'],
]);
self::assertResponseStatusCodeSame(403);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testUsineCannotSeeProviderOutOfItsSite(): void
{
// Cloisonnement § 2.13 : un prestataire hors du site courant de l'Usine
// (site 17, l'Usine est sur le site 86) -> 404 (ne pas reveler la ligne).
$outOfScope = $this->seedProvider('Usine OutOfScope', [self::SITE_17]);
$client = $this->authAs('usine');
$client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(404);
}
private function authAs(string $role): Client
{
return $this->authenticatedClient($role, self::PWD);
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests du gating comptabilite + mode strict par groupe (ProviderProcessor /
* ProviderReadGroupContextBuilder) — ERP-134.
*
* Couvre : gating accounting PAR OMISSION (siren/ribs absents sans accounting.view,
* bug #4 M1), mode strict RG-3.15 (403 sur tout le payload), gating archive (RG-3.13).
*
* Les users sont crees via createUserWithPermissions() (parent) : rattaches a TOUS
* les sites SANS currentSite -> CurrentSiteProvider::get() = null -> aucun
* cloisonnement, on isole ainsi le comportement RBAC du comportement site.
*
* @internal
*/
final class ProviderRbacGatingTest extends AbstractProviderApiTestCase
{
public function testAccountingFieldsOmittedWithoutAccountingView(): void
{
$provider = $this->seedProvider('Compta Masquee', [self::SITE_86], siren: '123456789');
$id = $provider->getId();
// Profil type Commerciale : view + manage SANS accounting.view.
$creds = $this->createUserWithPermissions(['technique.providers.view']);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
// Gating par omission : scalaires comptables ET ribs totalement absents.
self::assertArrayNotHasKey('siren', $body);
self::assertArrayNotHasKey('ribs', $body);
// isArchived reste expose (bug #3 M1 : la cle ne doit pas etre droppee).
self::assertArrayHasKey('isArchived', $body);
}
public function testAccountingFieldsPresentWithAccountingView(): void
{
$provider = $this->seedProvider('Compta Visible', [self::SITE_86], siren: '987654321');
$id = $provider->getId();
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.accounting.view',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
self::assertSame('987654321', $body['siren']);
// La cle ribs apparait (collection vide ici, mais presente).
self::assertArrayHasKey('ribs', $body);
}
public function testStrictModeRejectsMixedGroupsForManageOnlyUser(): void
{
$provider = $this->seedProvider('Strict Cible', [self::SITE_86]);
$id = $provider->getId();
// Profil type Bureau : manage SANS accounting.manage.
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.manage',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Renomme', 'siren' => '111222333'],
]);
// RG-3.15 : payload melangeant main + accounting sans accounting.manage
// -> 403 sur tout le payload (mode strict, pas de filtrage silencieux).
self::assertSame(403, $response->getStatusCode());
// Aucun champ n'a ete persiste (rollback du mode strict).
$this->getEm()->clear();
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
self::assertSame('STRICT CIBLE', $reloaded->getCompanyName());
self::assertNull($reloaded->getSiren());
}
public function testAccountingOnlyUserCanPatchAccountingButNotMain(): void
{
$provider = $this->seedProvider('Compta Editrice', [self::SITE_86]);
$id = $provider->getId();
// Profil type Compta : accounting.view + accounting.manage SANS manage.
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
// PATCH accounting -> 200.
$ok = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '555666777'],
]);
self::assertSame(200, $ok->getStatusCode());
// PATCH main (companyName) -> 403 (pas de permission manage).
$ko = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Interdit'],
]);
self::assertSame(403, $ko->getStatusCode());
}
public function testArchiveRequiresArchivePermission(): void
{
$provider = $this->seedProvider('A Archiver', [self::SITE_86]);
$id = $provider->getId();
// Bureau (manage) sans archive -> 403.
$creds = $this->createUserWithPermissions([
'technique.providers.view',
'technique.providers.manage',
]);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
// RG-3.13 : l'archivage exige technique.providers.archive.
self::assertSame(403, $response->getStatusCode());
}
public function testAdminCanArchiveAndSetsArchivedAt(): void
{
$provider = $this->seedProvider('Archivable', [self::SITE_86]);
$id = $provider->getId();
$client = $this->createAdminClient();
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertSame(200, $response->getStatusCode());
$this->getEm()->clear();
$reloaded = $this->getEm()->getRepository(Provider::class)->find($id);
self::assertTrue($reloaded->isArchived());
self::assertNotNull($reloaded->getArchivedAt());
}
public function testRestoreWithNameConflictReturns409(): void
{
// Un prestataire archive porte un nom qu'un prestataire ACTIF a repris
// entre-temps (autorise par l'index partiel : l'archive n'y figure pas).
$archived = $this->seedProvider('Conflit Co', [self::SITE_86], isArchived: true);
$this->seedProvider('Conflit Co', [self::SITE_86]); // actif, meme nom normalise
$client = $this->createAdminClient();
$response = $client->request('PATCH', '/api/providers/'.$archived->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
// RG-3.14 : restaurer ferait deux actifs homonymes -> 409 (pas de 500 SQL).
self::assertSame(409, $response->getStatusCode());
}
}
@@ -0,0 +1,365 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire prestataires
* (M3, spec-back § 4.0 / § 4.0.bis). Jumeau du
* {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest} (M2),
* il reverifie sur le JSON REEL les pieges silencieux herites du M1/M2 :
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> cle `ribs`
* ABSENTE pour un profil type Commerciale (gating par omission).
* - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
* -> isArchived present dans le detail.
* - #1 : categories embarquees sans code/name -> code + name presents en LISTE
* ET DETAIL (provider ET adresse).
* - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
* (relation DIRECTE provider.sites — RG-3.03) ET DETAIL (addresses[].sites[]).
* - ERP-92 : refs comptables (tvaMode/paymentDelay/paymentType/bank) embarquees
* {id, code, label} et non IRI nu (le groupe provider:read:accounting doit
* etre porte par les entites partagees — fix ERP-139, sinon IRI nu).
* Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives exclus).
*
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
* annotations. Toute regression de groupe de serialisation casse ici.
*
* @internal
*/
final class ProviderSerializationContractTest extends AbstractProviderApiTestCase
{
// === #4 — Gating des RIB par accounting.view ===
public function testRibsPresentForAdminWithAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Rib Admin Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
self::assertSame('Compte principal', $data['ribs'][0]['label']);
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
}
public function testRibsAbsentForUserWithoutAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Rib Commerciale Co');
// Profil type Commerciale : technique.providers.view SANS accounting.view.
// createUserWithPermissions n'attache pas de currentSite -> pas de
// cloisonnement, on isole le gating comptable du comportement site.
$creds = $this->createUserWithPermissions(['technique.providers.view']);
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// La cle `ribs` est ABSENTE (pas null) : le groupe provider:read:accounting
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
// fuite IBAN/BIC (piege n°4 du M1).
self::assertArrayNotHasKey('ribs', $data);
}
// === #4.bis — Gating par OMISSION des scalaires comptables ===
public function testAccountingScalarsGatedByOmission(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Compta Gating Co');
$id = $provider->getId();
// Admin : scalaires comptables presents.
$admin = $this->createAdminClient();
$adminData = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $adminData);
self::assertSame('987654321', $adminData['siren']);
self::assertArrayHasKey('accountNumber', $adminData);
self::assertArrayHasKey('paymentType', $adminData);
// Sans accounting.view : scalaires comptables ABSENTS (omission, pas null).
$creds = $this->createUserWithPermissions(['technique.providers.view']);
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
// === ERP-139 — Refs comptables embarquees {id, code, label} et non IRI nu ===
public function testAccountingReferentialsEmbedIdCodeLabel(): void
{
$this->skipIfSitesModuleDisabled();
// Reglement VIREMENT -> banque renseignee : on couvre les 4 referentiels.
$provider = $this->seedCompleteProvider('Refs Embed Co', 'VIREMENT');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Avant fix ERP-139 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
// car les entites partagees ne portaient que client:/supplier:read:accounting,
// pas provider:read:accounting. Apres fix : objet {id, code, label} embarque
// (le front consultation/edition affiche le libelle sans fetch — § 4.0.bis).
foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
self::assertArrayHasKey('id', $data[$ref]);
self::assertArrayHasKey('label', $data[$ref]);
self::assertNotSame('', (string) $data[$ref]['label']);
}
// paymentType embarque aussi son code (logique front VIREMENT/LCR).
self::assertArrayHasKey('code', $data['paymentType']);
self::assertSame('VIREMENT', $data['paymentType']['code']);
}
// === #3 — Booleen isArchived present dans le JSON ===
public function testProviderIsArchivedBooleanIsPresentInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Bool Archived Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// isArchived expose via Groups + SerializedName('isArchived') sur le getter :
// sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
self::assertArrayHasKey('isArchived', $data);
self::assertFalse($data['isArchived']);
}
// === #1 — Embed code/name des Category (liste ET detail) ===
public function testCategoriesEmbedCodeAndNameInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Embed Cat Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['categories']);
$category = $data['categories'][0];
// Avant correctif : seuls @id/@type (category:read absent du contexte).
// Apres : code + name embarques.
self::assertArrayHasKey('code', $category);
self::assertArrayHasKey('name', $category);
self::assertSame('NETTOYAGE', $category['code']);
// Categories d'adresse aussi (category:read dans le contexte du detail).
self::assertArrayHasKey('categories', $data['addresses'][0]);
self::assertNotEmpty($data['addresses'][0]['categories']);
self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
}
public function testCategoriesEmbedCodeAndNameInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
$provider = $this->seedCompleteProvider($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $provider->getId());
self::assertNotNull($row, 'Le prestataire seede doit apparaitre dans la liste filtree.');
self::assertNotEmpty($row['categories']);
self::assertArrayHasKey('code', $row['categories'][0]);
self::assertArrayHasKey('name', $row['categories'][0]);
self::assertSame('NETTOYAGE', $row['categories'][0]['code']);
}
// === #2 — Embed name/postalCode des Site (liste via relation directe + detail) ===
public function testSitesEmbedNameAndPostalCodeInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
$provider = $this->seedCompleteProvider($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $provider->getId());
self::assertNotNull($row);
// sites en relation DIRECTE provider.sites (RG-3.03) : objet Site entier
// (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
self::assertArrayHasKey('sites', $row);
self::assertGreaterThanOrEqual(2, count($row['sites']));
self::assertArrayHasKey('name', $row['sites'][0]);
self::assertArrayHasKey('postalCode', $row['sites'][0]);
self::assertNotSame('', (string) $row['sites'][0]['name']);
}
public function testSitesEmbedNameAndPostalCodeInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Site Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Sites du formulaire principal (relation directe).
self::assertArrayHasKey('sites', $data);
self::assertGreaterThanOrEqual(2, count($data['sites']));
self::assertArrayHasKey('name', $data['sites'][0]);
self::assertArrayHasKey('postalCode', $data['sites'][0]);
// Sites de l'adresse (addresses[].sites[]).
$address = $data['addresses'][0];
self::assertArrayHasKey('sites', $address);
self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
self::assertArrayHasKey('name', $address['sites'][0]);
self::assertArrayHasKey('postalCode', $address['sites'][0]);
self::assertNotSame('', (string) $address['sites'][0]['name']);
}
// === Detail : sous-collections embarquees ===
public function testDetailEmbedsContactsAddressesRibs(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('Embed Subres Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['contacts']);
self::assertSame('Marie', $data['contacts'][0]['firstName']);
self::assertSame('Martin', $data['contacts'][0]['lastName']);
self::assertArrayHasKey('email', $data['contacts'][0]);
self::assertNotEmpty($data['addresses']);
// M3 : adresse simplifiee, PAS de addressType.
self::assertArrayNotHasKey('addressType', $data['addresses'][0]);
self::assertSame('Poitiers', $data['addresses'][0]['city']);
self::assertNotEmpty($data['ribs']);
}
// === refonte-contact V0.2 : pas de contact inline sur le prestataire ===
public function testProviderHasNoInlineContactFields(): void
{
$this->skipIfSitesModuleDisabled();
$provider = $this->seedCompleteProvider('No Inline Contact Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du prestataire.', $key));
}
}
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
{
$this->skipIfSitesModuleDisabled();
$http = $this->createAdminClient();
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
$this->seedProvider($token.' Active', [self::SITE_86]);
$this->seedProvider($token.' Archived', [self::SITE_86], isArchived: true);
// Liste par defaut filtree sur le token : enveloppe member/totalItems sans
// prefixe hydra:, archive EXCLU du totalItems (RG-3.16).
$default = $http->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $default);
self::assertArrayHasKey('totalItems', $default);
self::assertArrayNotHasKey('hydra:member', $default);
self::assertArrayNotHasKey('hydra:totalItems', $default);
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
// includeArchived : l'archive reintegre le total.
$all = $http->request('GET', '/api/providers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems']);
// `view` (PartialCollectionView) sans prefixe hydra:.
$paged = $http->request('GET', '/api/providers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('view', $paged);
self::assertArrayNotHasKey('hydra:view', $paged);
}
/**
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
* detail sans accounting.view) pour les coller dans la spec avant de lancer les
* tickets front. Le test asserte la forme ; si la variable d'env
* PROVIDER_DOD_DUMP est positionnee, il ecrit aussi les 3 corps formates sous
* /tmp pour copie.
*/
public function testDodReferenceJsonShape(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
$provider = $this->seedCompleteProvider($token);
$id = (int) $provider->getId();
$admin = $this->createAdminClient();
$list = $admin->request('GET', '/api/providers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$detailAdmin = $admin->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$creds = $this->createUserWithPermissions(['technique.providers.view']);
$restricted = $this->authenticatedClient($creds['username'], $creds['password']);
$detailRestricted = $restricted->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
// Forme minimale attendue (la DoD valide que tout champ front est present).
self::assertArrayHasKey('member', $list);
self::assertArrayHasKey('siren', $detailAdmin);
self::assertArrayHasKey('ribs', $detailAdmin);
self::assertArrayNotHasKey('siren', $detailRestricted);
self::assertArrayNotHasKey('ribs', $detailRestricted);
if (false !== getenv('PROVIDER_DOD_DUMP')) {
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/provider-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/provider-dod-detail-admin.json', json_encode($detailAdmin, $flags));
file_put_contents('/tmp/provider-dod-detail-restricted.json', json_encode($detailRestricted, $flags));
}
}
/**
* Retrouve un membre de la collection par son id (liste filtree).
*
* @param array<string, mixed> $collection
*
* @return array<string, mixed>|null
*/
private function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
/**
* Tests du cloisonnement par site pilote par l'utilisateur (RG-3.17 / § 2.13) —
* ERP-134. Couvre la LECTURE (liste filtree avant pagination + totalItems, detail
* 404 hors perimetre, bypass voit tout) et l'ECRITURE (sites hors user_site -> 422).
*
* Cloisonnement pilote par l'USER (pas le role) : on cree des users non-admin SANS
* `sites.bypass_scope`, rattaches a un site precis avec un currentSite. L'admin
* (isAdmin -> bypass total) sert de temoin « voit tout ».
*
* @internal
*/
final class ProviderSiteScopeTest extends AbstractProviderApiTestCase
{
protected function setUp(): void
{
parent::setUp();
// Pre-requis : le module Sites doit etre actif (sinon currentSite = null,
// cloisonnement no-op et ces tests perdent leur sens).
$this->skipIfSitesModuleDisabled();
}
public function testListIsScopedToCurrentSiteForNonBypassUser(): void
{
$this->seedProvider('Presta Site 86', [self::SITE_86]);
$this->seedProvider('Presta Site 17', [self::SITE_17]);
$this->seedProvider('Presta Site 82', [self::SITE_82]);
$creds = $this->createScopedUser(
['technique.providers.view'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
$body = $response->toArray();
// totalItems reflete le PERIMETRE de l'user (filtre avant pagination).
self::assertSame(1, $body['totalItems']);
self::assertSame('PRESTA SITE 86', $body['member'][0]['companyName']);
}
public function testDetailOutOfScopeReturns404(): void
{
$inScope = $this->seedProvider('Dans Perimetre', [self::SITE_86]);
$outOfScope = $this->seedProvider('Hors Perimetre', [self::SITE_17]);
$creds = $this->createScopedUser(
['technique.providers.view'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
// In-scope -> 200.
$ok = $client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $ok->getStatusCode());
// Out-of-scope -> 404 (ne pas reveler l'existence hors perimetre).
$ko = $client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertSame(404, $ko->getStatusCode());
}
public function testBypassUserSeesAllSites(): void
{
$this->seedProvider('Presta Site 86', [self::SITE_86]);
$this->seedProvider('Presta Site 17', [self::SITE_17]);
$this->seedProvider('Presta Site 82', [self::SITE_82]);
// Admin = bypass total.
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
self::assertSame(3, $response->toArray()['totalItems']);
}
public function testWriteOutOfScopeSiteRejectedAtIriResolution(): void
{
// User non-bypass / non-read_ref : la resolution de l'IRI du site hors
// perimetre echoue en amont (SiteCollectionScopedExtension : item Site
// « introuvable ») -> 400 anti-enumeration, avant le ProviderProcessor.
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Hors Scope Sas', [self::SITE_17]),
]);
self::assertSame(400, $response->getStatusCode());
}
public function testWriteOutOfScopeSiteRejectedByProcessorGuard(): void
{
// User `sites.read_ref` : peut RESOUDRE n'importe quel site (referentiel
// transverse) mais n'opere que sur ses user_site. La garde guardSiteScope
// du ProviderProcessor est alors l'enforcement autoritaire de RG-3.17
// -> 422 sur `sites` (mappable inline, ERP-101).
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Hors Scope Guard', [self::SITE_17]),
]);
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
public function testWriteAllowsSiteWithinUserScope(): void
{
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
// Site 86 = un des user_site -> 201.
$response = $client->request('POST', '/api/providers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Dans Scope Sas', [self::SITE_86]),
]);
self::assertSame(201, $response->getStatusCode());
}
public function testPatchAddingOutOfScopeSiteIsRejected(): void
{
$provider = $this->seedProvider('Patch Sites', [self::SITE_86]);
$id = $provider->getId();
// read_ref pour pouvoir resoudre l'IRI du site 17 (sinon 400 en amont) et
// exercer la garde guardSiteScope sur le PATCH.
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$site86 = $this->site(self::SITE_86)->getId();
$site17 = $this->site(self::SITE_17)->getId();
$response = $client->request('PATCH', '/api/providers/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['sites' => ['/api/sites/'.$site86, '/api/sites/'.$site17]],
]);
// RG-3.17 : ajouter un site hors user_site -> 422 (garde Processor).
self::assertSame(422, $response->getStatusCode());
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
}
@@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
* (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
* le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`),
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
* garde « dernier contact ») et le gating selon permission (Contacts/Adresses =
* manage, RIB = accounting.manage). Jumeau de SupplierSubResourceApiTest.
*
* @internal
*/
final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
{
protected function setUp(): void
{
parent::setUp();
// seedProvider exige >= 1 site (RG-3.03) : le module Sites doit etre actif.
$this->skipIfSitesModuleDisabled();
}
// === Contacts (security: technique.providers.manage) ===
public function testPostContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Host');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// RG-3.11 : prenom/nom Title Case, telephone chiffres seuls, email lowercase.
self::assertSame('Jean', $data['firstName']);
self::assertSame('Dupont', $data['lastName']);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
}
/**
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
*/
public function testPostContactWithOnlyJobTitleReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact JobTitle Only');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Directeur', $data['jobTitle']);
}
/**
* RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK
* chk_provider_contact_name) est rejete avant la base -> 422 rattachee a
* firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider.
*/
public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Field');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => ' '],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
}
public function testPostContactOnMissingProviderReturns404(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/providers/999999/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Orphan'],
]);
self::assertResponseStatusCodeSame(404);
}
public function testPatchContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Patch');
$contact = $this->addContact($seed, 'Marie', 'Martin');
$data = $client->request('PATCH', '/api/provider_contacts/'.$contact->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['lastName' => 'durand'],
])->toArray();
self::assertResponseStatusCodeSame(200);
// Normalisation aussi sur PATCH : "durand" -> "Durand".
self::assertSame('Durand', $data['lastName']);
}
public function testDeleteLastContactReturns204(): void
{
// M3 : pas de garde « dernier contact » (RG-3.12 front-driven) — la
// suppression du dernier contact est libre (204).
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Solo');
$contact = $this->addContact($seed, 'Unique', 'Contact');
$client->request('DELETE', '/api/provider_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(204);
}
public function testContactWriteWithoutManageReturns403(): void
{
// Un user sans permission technique.providers.manage -> 403 sur la sous-ressource.
$seed = $this->seedProvider('Contact Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['firstName' => 'Nope'],
]);
self::assertResponseStatusCodeSame(403);
}
// === Adresses (security: technique.providers.manage) ===
public function testPostAddressWithValidPayloadReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Host');
$category = $this->providerCategory('NETTOYAGE');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Châtellerault', $data['city']);
}
public function testPostAddressWithoutSiteReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address No Site');
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [],
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
],
]);
// RG-3.05 (Assert\Count min 1 sur sites).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithInvalidPostalCodeReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Bad CP');
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '123',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
],
]);
// RG-3.06 (Assert\Regex ^[0-9]{4,5}$).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithNonPrestataireCategoryReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Bad Cat');
$foreign = $this->foreignCategory(); // type CLIENT -> interdite (RG-3.09).
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$foreign->getId()],
],
]);
// RG-3.09 -> 422 rattachee a categories.
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
public function testDeleteAddressReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Delete');
$category = $this->providerCategory('NETTOYAGE');
$created = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
$client->request('DELETE', $created['@id']);
self::assertResponseStatusCodeSame(204);
}
public function testAddressWriteWithoutManageReturns403(): void
{
$seed = $this->seedProvider('Address Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
],
]);
self::assertResponseStatusCodeSame(403);
}
/**
* § 2.13 / RG-3.05 (cloisonnement d'ECRITURE sur l'adresse) : un user non-bypass
* `sites.read_ref` (qui peut resoudre n'importe quel IRI de site, sinon 400 en
* amont) ne peut attacher a l'adresse que ses propres user_site. Site hors
* perimetre -> 422 sur `sites` (garde ProviderAddressProcessor).
*/
public function testPostAddressWithOutOfScopeSiteReturns422OnSitesPath(): void
{
$seed = $this->seedProvider('Address Scope', [self::SITE_86]);
$category = $this->providerCategory('NETTOYAGE');
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '17400',
'city' => 'Saint-Jean-d\'Angély',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_17)->getId()], // hors user_site
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
// === RIBs (security: technique.providers.accounting.manage) ===
public function testPostRibByAdminReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Host');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte principal',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Compte principal', $data['label']);
}
public function testPostRibWithInvalidIbanReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Bad Iban');
$client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
]);
self::assertResponseStatusCodeSame(422);
}
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422 sur `bic`.
*/
public function testPostRibWithBicIbanCountryMismatchReturns422OnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Pays Mismatch');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath);
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
public function testDeleteRibNonLcrReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Non LCR');
$rib = $this->addRib($seed);
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(204);
}
public function testDeleteLastRibUnderLcrReturns409(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib LCR Solo');
$rib = $this->addRib($seed);
// Passe le prestataire en LCR (seed direct).
$em = $this->getEm();
$managed = $em->getRepository(Provider::class)->find($seed->getId());
$managed->setPaymentType($this->paymentType('LCR'));
$em->flush();
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
// RG-3.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
self::assertResponseStatusCodeSame(409);
}
public function testRibWriteWithoutAccountingManageReturns403(): void
{
// Un user portant seulement technique.providers.manage (sans accounting.manage)
// ne peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
$seed = $this->seedProvider('Rib Forbidden');
$rib = $this->addRib($seed);
$creds = $this->createUserWithPermission('technique.providers.manage');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(403);
$http->request('PATCH', '/api/provider_ribs/'.$rib->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['label' => 'Y'],
]);
self::assertResponseStatusCodeSame(403);
$http->request('DELETE', '/api/provider_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(403);
}
}
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
/**
* Tests du cloisonnement par site des SOUS-RESSOURCES d'un prestataire (Contacts /
* Adresses / RIB) — § 2.13 / RG-3.17. Complement de ProviderSiteScopeTest (qui ne
* couvrait que le Provider lui-meme).
*
* Sans garde dedie, un user cloisonne pouvait lire / editer / supprimer une
* sous-ressource d'un prestataire HORS de son site (le detail Provider est garde en
* 404, mais les sous-ressources passent par le provider Doctrine par defaut, non
* cloisonne — et SiteScopedQueryExtension ne filtre que les SiteAwareInterface).
* Le RIB est particulierement sensible (IBAN / BIC).
*
* Garde pose par ProviderSubResourceItemProvider (Get/Patch/Delete -> 404 hors
* perimetre) + ProviderSiteScopeChecker::assertInScope dans les processors (POST
* sur parent hors perimetre -> 404). Decision de scope partagee (source unique).
*
* @internal
*/
final class ProviderSubResourceSiteScopeTest extends AbstractProviderApiTestCase
{
/** Permissions completes pour exercer view + manage + accounting sur tous les chemins. */
private const array FULL_PERMS = [
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
];
protected function setUp(): void
{
parent::setUp();
$this->skipIfSitesModuleDisabled();
}
public function testGetContactOutOfScopeReturns404ButInScope200(): void
{
$inScope = $this->seedProvider('Presta In Scope', [self::SITE_86]);
$inContactId = $this->addContact($inScope, 'Marie', 'Martin')->getId();
$outScope = $this->seedProvider('Presta Out Scope', [self::SITE_17]);
$outContactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
$client = $this->scopedClient();
$ok = $client->request('GET', '/api/provider_contacts/'.$inContactId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $ok->getStatusCode());
// Hors perimetre : 404 (ne pas reveler l'existence du contact d'un autre site).
$ko = $client->request('GET', '/api/provider_contacts/'.$outContactId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(404, $ko->getStatusCode());
}
public function testGetRibOutOfScopeReturns404(): void
{
// RIB = donnee bancaire sensible (IBAN/BIC) : le cas le plus critique.
$outScope = $this->seedProvider('Presta Out Rib', [self::SITE_17]);
$ribId = $this->addRib($outScope)->getId();
$client = $this->scopedClient();
$response = $client->request('GET', '/api/provider_ribs/'.$ribId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(404, $response->getStatusCode());
}
public function testPatchRibOutOfScopeReturns404(): void
{
$outScope = $this->seedProvider('Presta Patch Rib', [self::SITE_17]);
$ribId = $this->addRib($outScope)->getId();
$client = $this->scopedClient();
$response = $client->request('PATCH', '/api/provider_ribs/'.$ribId, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['label' => 'Hacked'],
]);
self::assertSame(404, $response->getStatusCode());
}
public function testDeleteContactOutOfScopeReturns404(): void
{
$outScope = $this->seedProvider('Presta Del Contact', [self::SITE_17]);
$contactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
$client = $this->scopedClient();
$response = $client->request('DELETE', '/api/provider_contacts/'.$contactId);
self::assertSame(404, $response->getStatusCode());
}
public function testPostContactOnOutOfScopeProviderReturns404(): void
{
$outScope = $this->seedProvider('Presta Post Contact', [self::SITE_17]);
$id = $outScope->getId();
$client = $this->scopedClient();
$response = $client->request('POST', '/api/providers/'.$id.'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Intrus'],
]);
self::assertSame(404, $response->getStatusCode());
}
public function testPostRibOnOutOfScopeProviderReturns404(): void
{
$outScope = $this->seedProvider('Presta Post Rib', [self::SITE_17]);
$id = $outScope->getId();
$client = $this->scopedClient();
$response = $client->request('POST', '/api/providers/'.$id.'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'label' => 'Intrus',
'iban' => self::VALID_IBAN,
'bic' => self::VALID_BIC,
],
]);
self::assertSame(404, $response->getStatusCode());
}
public function testBypassUserReachesSubResourceOnAnySite(): void
{
// Temoin : l'admin (bypass total) lit bien un contact hors « son » site.
$outScope = $this->seedProvider('Presta Admin Reach', [self::SITE_17]);
$contactId = $this->addContact($outScope, 'Marie', 'Martin')->getId();
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/provider_contacts/'.$contactId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
}
/**
* Client authentifie comme un user NON-bypass rattache au seul site 86 (avec
* currentSite 86) — sujet des tests de cloisonnement des sous-ressources.
*/
private function scopedClient(): Client
{
$creds = $this->createScopedUser(
self::FULL_PERMS,
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
return $this->authenticatedClient($creds['username'], $creds['password']);
}
}