Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee1f344764 | |||
| 3fe0f676f6 | |||
| d5462bcf42 | |||
| 54d8327fa5 | |||
| 09a4b9d464 | |||
| d97b9ce6d0 |
@@ -5,10 +5,12 @@ use App\Module\Catalog\CatalogModule;
|
|||||||
use App\Module\Commercial\CommercialModule;
|
use App\Module\Commercial\CommercialModule;
|
||||||
use App\Module\Core\CoreModule;
|
use App\Module\Core\CoreModule;
|
||||||
use App\Module\Sites\SitesModule;
|
use App\Module\Sites\SitesModule;
|
||||||
|
use App\Module\Technique\TechniqueModule;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
CoreModule::class,
|
CoreModule::class,
|
||||||
CommercialModule::class,
|
CommercialModule::class,
|
||||||
SitesModule::class,
|
SitesModule::class,
|
||||||
CatalogModule::class,
|
CatalogModule::class,
|
||||||
|
TechniqueModule::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ doctrine:
|
|||||||
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
|
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
|
||||||
prefix: 'App\Module\Commercial\Domain\Entity'
|
prefix: 'App\Module\Commercial\Domain\Entity'
|
||||||
alias: Commercial
|
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:
|
controller_resolver:
|
||||||
auto_mapping: false
|
auto_mapping: false
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
// Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le
|
||||||
|
// repertoire prestataires. L'item est gate par `technique.providers.view` ;
|
||||||
|
// la section disparait automatiquement (SidebarProvider) si le module
|
||||||
|
// `technique` est desactive ou si l'user n'a pas la permission.
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.technique.section',
|
||||||
|
'icon' => 'mdi:wrench-outline',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.technique.providers',
|
||||||
|
'to' => '/providers',
|
||||||
|
'icon' => 'mdi:account-wrench-outline',
|
||||||
|
'module' => 'technique',
|
||||||
|
'permission' => 'technique.providers.view',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
// Section "Administration" : regroupe toutes les pages de configuration
|
// Section "Administration" : regroupe toutes les pages de configuration
|
||||||
// applicative (RBAC, users, sites, audit log).
|
// applicative (RBAC, users, sites, audit log).
|
||||||
//
|
//
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.110'
|
app.version: '0.1.114'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M3
|
||||||
|
nom: "Répertoire prestataires"
|
||||||
|
ecran: repertoire-prestataires
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V0.2
|
||||||
|
date_redaction: 2026-06-11
|
||||||
|
# Historique :
|
||||||
|
# V0.2 (2026-06-11) — Restitution Markdown du docx « M3-reportoire-prestataires.docx » (04/06/2026).
|
||||||
|
# Alignement refonte-contact (comme M1/M2) : le contact principal inline du formulaire principal
|
||||||
|
# du PDF V0.1 (Nom contact / Prénom contact / Téléphone + / Email) est RETIRÉ — saisie via
|
||||||
|
# l'onglet Contacts uniquement (décision Matthieu, 11/06 : « oublie le contact inline, comme client »).
|
||||||
|
# RG-3.01 / RG-3.02 (contact inline + max 2 tél sur le formulaire principal) supprimées en conséquence.
|
||||||
|
# V0.1 (PDF) — version fonctionnelle plus ancienne, NON retenue (contact inline sur le formulaire principal).
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev"
|
||||||
|
regles_metier: [RG-3.03, RG-3.04, RG-3.05, RG-3.06, RG-3.07, RG-3.08, RG-3.09, RG-3.10, RG-3.11, RG-3.12, RG-3.13, RG-3.14, RG-3.15, RG-3.16, RG-3.17]
|
||||||
|
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||||
|
lien_spec_back: ./spec-back.md
|
||||||
|
|
||||||
|
# === VALIDATION CLIENT ===
|
||||||
|
client_validation_1:
|
||||||
|
statut: validee
|
||||||
|
date: 2026-05-22
|
||||||
|
version: V0
|
||||||
|
valide_par: "Matthieu (CP MALIO)"
|
||||||
|
client_validation_2:
|
||||||
|
statut: validee
|
||||||
|
date: 2026-06-01
|
||||||
|
version: V0.1
|
||||||
|
valide_par: "Matthieu (CP MALIO)"
|
||||||
|
client_validation_3:
|
||||||
|
statut: a_valider
|
||||||
|
date: 2026-06-04
|
||||||
|
version: V0.2
|
||||||
|
resume: "Module 3 — Répertoire prestataires. Pôle Technique (nouvelle section sidebar). Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Contact / Adresse / Comptabilité (Rapports, Échanges = placeholders 'À venir'). PAS d'onglet Information. Sélecteur de site aussi sur le formulaire principal."
|
||||||
|
trace_archivee: "uploads/M3-reportoire-prestataires.docx (V0.2) + M3-reportoire-prestataires-V01.pdf (V0.1, obsolète)"
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6)
|
||||||
|
lesstime_project_id: 6
|
||||||
|
statut_global: en_dev
|
||||||
|
---
|
||||||
|
|
||||||
|
# Module 3 — Répertoire prestataires (V0.2 front)
|
||||||
|
|
||||||
|
> **Origine** : spec fonctionnelle `M3-reportoire-prestataires.docx` (V0.2 du 04/06/2026 ; historique V0 22/05 → V0.1 01/06). Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié, **sauf** l'alignement refonte-contact (cf. ci-dessous). Toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M3 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md) et au [M2 fournisseurs](../M2-suppliers/spec-front.md).
|
||||||
|
|
||||||
|
> **⚠️ Alignement refonte-contact (décision Matthieu, 11/06/2026)** : le PDF V0.1 portait un **contact principal inline** sur le formulaire principal (Nom du contact / Prénom du contact / Téléphone + bouton + / Email) avec RG-3.01 (Nom OU Prénom) et RG-3.02 (max 2 téléphones). Ce contact inline est **retiré**, exactement comme l'a fait M1/M2 (refonte-contact). Les coordonnées du contact se saisissent **uniquement dans l'onglet Contacts**. **RG-3.01 et RG-3.02 sont donc supprimées du formulaire principal** ; la garantie « au moins un contact nommé » est portée par RG-3.04 + RG-3.12, et le « maximum 2 téléphones » s'applique aux blocs Contact.
|
||||||
|
|
||||||
|
> **⚠️ Décision d'architecture (à confirmer) — pôle « Technique »** : le docx place le répertoire prestataires dans un **Module « Technique »**. Confirmé par Matthieu (11/06) : c'est bien un **nouveau pôle Technique**, distinct du Commercial. Côté front cela se traduit par une **nouvelle section sidebar « Technique »** (route `/providers`). Côté back, voir [`spec-back.md § 2.1`](./spec-back.md) (nouveau module `Technique`, entités jumelles du fournisseur, référentiels comptables consommés en relation ORM partagée).
|
||||||
|
|
||||||
|
## But
|
||||||
|
|
||||||
|
Lister tous les prestataires de l'organisation et accéder rapidement à leurs fiches : consultation, création, modification, archivage. C'est la **porte d'entrée du pôle Technique**.
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
- **Depuis** : menu principal → section **Technique** → entrée « Répertoire prestataires » (route `/providers`).
|
||||||
|
- **Rôles autorisés** (tableau « Rôles & permissions » du docx) :
|
||||||
|
|
||||||
|
| Rôle | Consultation | Création / Modification | Archivage |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||||
|
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
|
||||||
|
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
|
||||||
|
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
|
||||||
|
| **Usine** | ✅ Son site uniquement | — | ❌ |
|
||||||
|
|
||||||
|
> **Notes** :
|
||||||
|
> - RBAC transposée sur `technique.providers.*` (cf. [`spec-back.md § 2.9 / § 5`](./spec-back.md)). Compta édite uniquement l'onglet Comptabilité d'un prestataire existant ; Compta ne peut pas **créer** un prestataire. **L'archivage est réservé à Admin**.
|
||||||
|
> - **Cloisonnement par site (décision 11/06 — DANS LE PÉRIMÈTRE M3)** : « Tout » vs « son site uniquement » n'est **pas porté par le rôle** mais par l'**utilisateur**. Chaque user a un site courant ; **par défaut il ne voit que les prestataires rattachés à son site**. Les profils qui doivent voir tous les sites (Admin, et par défaut Bureau / Compta / Commerciale) ont la permission `sites.bypass_scope` (Admin l'a automatiquement). **Usine** n'a pas le bypass → cloisonnée à son site. Filtrage **automatique côté back** (cf. [`spec-back.md § 2.13`](./spec-back.md)) — aucun filtre à coder côté front.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Page d'entrée du pôle **Technique** (route `/providers`). Titre : « **Répertoire prestataires** ».
|
||||||
|
|
||||||
|
- Affichage principal : un **datatable** listant tous les prestataires **actifs** (les archivés sont masqués par défaut — toggle/filtre dédié).
|
||||||
|
- **Clic sur une ligne** → écran **Consultation prestataire** (page dédiée).
|
||||||
|
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un prestataire**.
|
||||||
|
- **Bouton « Filtrer »** (haut droite) → panneau de filtres (cf. ci-dessous).
|
||||||
|
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des prestataires **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
|
||||||
|
|
||||||
|
### Panneau de filtres (bouton « Filtrer »)
|
||||||
|
|
||||||
|
Réutilise le pattern M1/M2. Filtres branchés sur les query params de `GET /api/providers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||||
|
|
||||||
|
| Filtre | Composant | Query param back |
|
||||||
|
|---|---|---|
|
||||||
|
| **Recherche** (nom entreprise / contact / email) | `<MalioInputText>` | `?search=` |
|
||||||
|
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type PRESTATAIRE) | `?categoryCode=` |
|
||||||
|
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
|
||||||
|
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
|
||||||
|
|
||||||
|
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/providers`.
|
||||||
|
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||||
|
|
||||||
|
## Datatable du Répertoire
|
||||||
|
|
||||||
|
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Provider>({ url: '/providers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes (alignées M2) :
|
||||||
|
|
||||||
|
| Colonne | Source | Tri |
|
||||||
|
|---|---|---|
|
||||||
|
| **Nom** | `provider.companyName` | ASC par défaut |
|
||||||
|
| **Catégories** | `provider.categories[].name` (embarquées en liste — cohérence M1/M2 ; libellé = `name`, pas `label`) | Non |
|
||||||
|
| **Site** | `provider.sites[].name` (sites du prestataire — cf. note ci-dessous) | Non |
|
||||||
|
| **Dernière activité** | `provider.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `provider:read` | Oui |
|
||||||
|
|
||||||
|
> **Source de la colonne « Site »** : le M3 porte un sélecteur de site **sur le formulaire principal** (RG-3.03) — donc `provider.sites[]` est une relation **directe** du prestataire (≠ M2 où les sites venaient de l'agrégat des adresses). La colonne liste affiche ces sites directs. Voir [`spec-back.md § 2.12`](./spec-back.md).
|
||||||
|
> **Clic sur une ligne** → écran Consultation. **Pagination** : standard Starseed 10 / 25 / 50 (défaut 10). Tri serveur `companyName ASC` par défaut.
|
||||||
|
|
||||||
|
## Écran « Ajouter un prestataire »
|
||||||
|
|
||||||
|
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
|
||||||
|
|
||||||
|
**Accès** : bouton « + Ajouter » du Répertoire. **Rôles** : Admin, Bureau.
|
||||||
|
|
||||||
|
**Barre d'onglets en création (3 onglets)** : `Contact` · `Adresse` · `Comptabilité`. Les onglets `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification (placeholders « À venir »).
|
||||||
|
|
||||||
|
> **Différence majeure avec M2 : PAS d'onglet « Information ».** Le M3 n'a aucun champ Description / Concurrent / Date création / Salariés / CA / Dirigeant / Résultat / Volume. Le formulaire principal est minimal (3 champs).
|
||||||
|
|
||||||
|
> **Règle « placeholder par défaut » (convention MALIO)** : tout onglet ou écran que la spec ne détaille pas explicitement (ici **Rapports** et **Échanges**) est livré en **placeholder « À venir »** (frame vide, navigable, pas de validation ni d'API), à l'identique des autres modules (M1/M2). Aucun champ inventé hors spec.
|
||||||
|
|
||||||
|
### Formulaire principal (pré-onglets)
|
||||||
|
|
||||||
|
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/providers`, puis bascule sur l'onglet Contact ; les champs passent en readonly.
|
||||||
|
|
||||||
|
| Champ | Type composant | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nom du prestataire (Entreprise)** | `<MalioInputText>` | Oui | RG-3.11 (UPPERCASE serveur) ; RG-3.10 (unicité) |
|
||||||
|
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type PRESTATAIRE** via `GET /api/categories?typeCode=PRESTATAIRE` (RG-3.09). Libellé affiché = `category.name`. |
|
||||||
|
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.03 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100 / 17400 / 82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M `provider_site`). |
|
||||||
|
|
||||||
|
**Action** : « Valider » (`<MalioButton>`) → POST `/api/providers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Contact ».
|
||||||
|
|
||||||
|
### Onglet « Contact »
|
||||||
|
|
||||||
|
Saisir un ou plusieurs contacts. Au moins un bloc Contact valide est requis (RG-3.12). **(Refonte-contact : pas de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)**
|
||||||
|
|
||||||
|
**Bloc Contact** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Nom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
|
||||||
|
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) |
|
||||||
|
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||||
|
| **Téléphone** (x1, +1 possible, **max 2**) | `<MalioInputText>` | Non | RG-3.11 (format) ; max 2 téléphones par contact |
|
||||||
|
| **Email** | `<MalioInputText>` type email | Non | RG-3.11 (lowercase) |
|
||||||
|
|
||||||
|
**RG-3.04 / RG-3.12** : un bloc Contact est valide dès qu'au moins 1 champ est rempli ; au moins 1 bloc Contact valide pour finaliser l'onglet — l'onglet Contact ne peut pas être validé vide.
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas au moins 1 champ rempli** (RG-3.04).
|
||||||
|
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||||
|
- « Valider » → PATCH `/api/providers/{id}/contacts`.
|
||||||
|
|
||||||
|
### Onglet « Adresse »
|
||||||
|
|
||||||
|
Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
|
||||||
|
|
||||||
|
**Bloc Adresse** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-3.05 — ≥ 1 site. Stocke des IDs de Site (M2M `provider_address_site`). |
|
||||||
|
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — autocomplete BAN |
|
||||||
|
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||||
|
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-3.06 — déclenche autocomplete ville (BAN) |
|
||||||
|
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-3.06 — alimentée par api-adresse.data.gouv.fr suivant le CP ; si plusieurs villes, choix dans le select |
|
||||||
|
| **Pays** | `<MalioSelect>` (préremplie « France ») | Oui | — |
|
||||||
|
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type PRESTATAIRE (RG-3.09) |
|
||||||
|
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||||
|
|
||||||
|
> **Différence avec M2** : l'adresse prestataire n'a **PAS** de Type d'adresse (Prospect/Départ/Rendu), **PAS** de Bennes, **PAS** de Prestation de triage. C'est une adresse « simple » (site + adresse postale + catégories + contacts).
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
|
||||||
|
- « Supprimer » (icône) : modal de confirmation puis suppression.
|
||||||
|
- « Valider » → PATCH `/api/providers/{id}/addresses`.
|
||||||
|
|
||||||
|
### Onglet « Comptabilité »
|
||||||
|
|
||||||
|
⚠ **Accessible aux rôles avec `technique.providers.accounting.view`** (Admin + Compta). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un prestataire (pas de `manage` global).
|
||||||
|
|
||||||
|
**Champs comptables** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. [`spec-back.md § 2.6`](./spec-back.md)) |
|
||||||
|
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||||
|
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel partagé M1) |
|
||||||
|
| **N° de TVA** | `<MalioInputText>` | Oui | — |
|
||||||
|
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
|
||||||
|
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
|
||||||
|
| **Banque** | `<MalioSelect>` | Conditionnel | RG-3.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
|
||||||
|
|
||||||
|
**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-3.08) :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
|
||||||
|
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
|
||||||
|
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-3.08 |
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- « + RIB » : ajoute un bloc.
|
||||||
|
- « Supprimer » (icône) : modal de confirmation.
|
||||||
|
- « Valider » → PATCH `/api/providers/{id}` (groupe `provider:write:accounting`) + sous-ressource RIBs.
|
||||||
|
|
||||||
|
## Écran « Consultation prestataire »
|
||||||
|
|
||||||
|
Tous les champs en **lecture seule**. La page s'ouvre par défaut sur l'onglet **Contacts**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
|
||||||
|
|
||||||
|
- **Flèche retour** (gauche) → revient au Répertoire.
|
||||||
|
- **Bouton « Modifier »** (droite, visible si `technique.providers.manage`) → écran Modification.
|
||||||
|
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `technique.providers.archive`) → modal de confirmation, puis PATCH `/api/providers/{id}` `{ "isArchived": true }`.
|
||||||
|
|
||||||
|
> Un prestataire archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||||
|
|
||||||
|
### Onglets affichés en consultation
|
||||||
|
|
||||||
|
`Contacts` · `Adresse` · `Rapports` · `Échanges` · `Comptabilité`. Navigation **libre** entre onglets (pas de séquence forcée). `Rapports` et `Échanges` = placeholders « À venir ». `Comptabilité` selon permission.
|
||||||
|
|
||||||
|
- **Onglet Contacts** : un bloc par contact, 5 champs en lecture seule (Nom / Prénom / Fonction / Téléphone / Email).
|
||||||
|
- **Onglet Adresse** : un bloc par adresse, en lecture seule (Sélecteur de site / Adresse / Adresse complémentaire / Code postal / Ville / Pays / Catégorie / Contact).
|
||||||
|
- **Onglet Comptabilité** : bloc principal (champs comptables) + un bloc par RIB. Le champ **Banque** n'apparaît que si Type de règlement = Virement (RG-3.07).
|
||||||
|
|
||||||
|
## Écran « Modification prestataire »
|
||||||
|
|
||||||
|
Comportement identique à l'écran Ajouter (mêmes formulaires, mêmes RG-3.03 → RG-3.08) sauf :
|
||||||
|
- **Pas de formulaire principal** réaffiché (champs principaux édités via l'onglet correspondant / pré-remplis).
|
||||||
|
- Les champs sont **pré-remplis** avec les valeurs actuelles du prestataire.
|
||||||
|
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||||
|
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
|
||||||
|
- **Accès** : Admin, Bureau (Compta pour l'onglet Comptabilité uniquement).
|
||||||
|
|
||||||
|
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||||
|
|
||||||
|
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||||
|
- **Input texte** : `<MalioInputText>`
|
||||||
|
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
|
||||||
|
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||||
|
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||||
|
- **Toasts** : standards via `useApi()`
|
||||||
|
- **Validation par champ** : `useFormErrors` (mapping 422 inline — règle frontend obligatoire)
|
||||||
|
|
||||||
|
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||||
|
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2).
|
||||||
|
|
||||||
|
## Composables & appels API
|
||||||
|
|
||||||
|
- `usePaginatedList<Provider>({ url: '/providers' })` — liste paginée (obligatoire). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)).
|
||||||
|
- `useProvider(id)` — charge le détail via `GET /api/providers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Écrans Consultation et Modification peuplés depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1). **DoD avant intégration** : vérifier que le JSON réel contient ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||||
|
- `useProviderForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useSupplierForm()`.
|
||||||
|
- `useAddressAutocomplete()` — **réutilisé du M1/M2** (BAN), pas de réécriture.
|
||||||
|
- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
|
||||||
|
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||||
|
- Filter `formatPhoneFR()` — **réutilisé** pour l'affichage `XX XX XX XX XX`.
|
||||||
|
|
||||||
|
## Règles de formatage et normalisation
|
||||||
|
|
||||||
|
Le serveur normalise systématiquement (RG-3.11 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||||
|
|
||||||
|
| Champ | Normalisation serveur | Affichage front |
|
||||||
|
|---|---|---|
|
||||||
|
| Nom prestataire (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||||
|
| Nom + Prénom contact | Capitalize | identique |
|
||||||
|
| Téléphones (blocs `ProviderContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
|
||||||
|
| Email | lowercase intégral | identique |
|
||||||
|
|
||||||
|
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche.
|
||||||
|
|
||||||
|
## API adresse postale
|
||||||
|
|
||||||
|
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1/M2** (réutilisé tel quel) :
|
||||||
|
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville (RG-3.06 : si plusieurs villes, choix dans le select).
|
||||||
|
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
|
||||||
|
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
|
||||||
|
|
||||||
|
## Différences notables avec le M2 (fournisseurs)
|
||||||
|
|
||||||
|
| Zone | M2 fournisseurs | M3 prestataires |
|
||||||
|
|---|---|---|
|
||||||
|
| Onglet Information | 8 champs (Description … Volume) | **Absent** (aucun champ Information) |
|
||||||
|
| Sélecteur de site sur formulaire principal | Non (sites uniquement via adresses) | **Oui** (RG-3.03 — relation directe `provider.sites`) |
|
||||||
|
| Type d'adresse | Radio Prospect / Départ / Rendu (RG-2.09) | **Absent** |
|
||||||
|
| Bennes / Prestation de triage (adresse) | Présents | **Absents** |
|
||||||
|
| Onglet Transport | Placeholder | **Absent** |
|
||||||
|
| Onglet Statistiques | Placeholder | **Absent** |
|
||||||
|
| Onglets « À venir » | Transport / Stats / Rapports / Échanges | **Rapports / Échanges** uniquement |
|
||||||
|
| Catégories | type `FOURNISSEUR` | **nouveau type `PRESTATAIRE`** |
|
||||||
|
| Pôle / module | Commercial | **Technique** (nouvelle section sidebar + module back) |
|
||||||
|
| Cloisonnement par site | aucun | **Visibilité par site, pilotée par l'utilisateur** (bypass via `sites.bypass_scope`) — § 2.13 |
|
||||||
|
|
||||||
|
## Points résolus côté back
|
||||||
|
|
||||||
|
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Catégorie multi-select | M2M `provider_category`, `Category` de type **PRESTATAIRE** (RG-3.09) |
|
||||||
|
| 2 | Site sur le formulaire principal | M2M `provider_site` (≥ 1 — RG-3.03), distinct de `provider_address_site` (RG-3.05) |
|
||||||
|
| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
|
||||||
|
| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
|
||||||
|
| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Rapports / Échanges) |
|
||||||
|
| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
|
||||||
|
| 7 | Unicité métier | Nom de prestataire uniquement (à valider — § 2.6). SIREN/email non uniques |
|
||||||
|
| 8 | Référentiels comptables | Réutilisés M1/M2 (zéro duplication) ; relation ORM partagée |
|
||||||
|
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1/M2 (RG-3.06) |
|
||||||
|
| 10 | Format export | XLSX uniquement (CSV = HP) |
|
||||||
|
| 11 | Cloisonnement par site (Usine « son site ») | Filtre back automatique par `currentSite` + bypass `sites.bypass_scope` (§ 2.13 / RG-3.17) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Tickets Lesstime
|
||||||
|
|
||||||
|
**TaskGroup Lesstime** : **#29 — M3 — Répertoire prestataires** (projet `ERP / Starseed`, projectId=6) — créé le 11/06/2026, 16 tickets `ERP-131` → `ERP-146`, statut « Prêt à dev », assignés à **Tristan**.
|
||||||
|
|
||||||
|
| # | Ticket | Réf | Tag |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1.1 | Créer module Technique + taxonomie PRESTATAIRE | ERP-131 | Backend |
|
||||||
|
| 1.2 | Migrer le schéma BDD M3 (provider + sous-collections) | ERP-132 | Backend |
|
||||||
|
| 1.3 | Créer entités + repositories Provider* | ERP-133 | Backend |
|
||||||
|
| 1.4 | ProviderProvider + ProviderProcessor + cloisonnement site | ERP-134 | Backend |
|
||||||
|
| 1.5 | Sous-ressources Contacts / Adresses / RIBs | ERP-135 | Backend |
|
||||||
|
| 1.6 | Valider les RG métier server-side (RG-3.03→3.09) | ERP-136 | Backend |
|
||||||
|
| 1.7 | Export XLSX des prestataires | ERP-137 | Backend |
|
||||||
|
| 1.8 | RBAC technique.providers.* (3 sources) | ERP-138 | Backend |
|
||||||
|
| 1.9 | PHPUnit RG-3.x + capture contrat JSON | ERP-139 | Backend |
|
||||||
|
| 1.10 | Page Répertoire (/providers) | ERP-140 | Frontend |
|
||||||
|
| 1.11 | Page Ajouter (/providers/new) + formulaire principal | ERP-141 | Frontend |
|
||||||
|
| 1.12 | Onglet Contact | ERP-142 | Frontend |
|
||||||
|
| 1.13 | Onglet Adresse (autocomplete BAN) | ERP-143 | Frontend |
|
||||||
|
| 1.14 | Onglet Comptabilité + RIB | ERP-144 | Frontend |
|
||||||
|
| 1.15 | Pages Consultation + Modification | ERP-145 | Frontend |
|
||||||
|
| 1.16 | i18n + sidebar Technique + libellés audit | ERP-146 | Frontend |
|
||||||
|
|
||||||
|
> Détail back complet → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# RETEX M1 (Clients) → à appliquer pour M2 (Fournisseurs)
|
||||||
|
|
||||||
|
> But : éviter de reproduire en M2 les erreurs de **contrat de sérialisation** qui ont bloqué M1.
|
||||||
|
> ~80 % des frictions M1 venaient du contrat API (sérialisation / groupes / sous-ressources), **pas** du métier.
|
||||||
|
> À lire AVANT de rédiger `spec-back.md` et `spec-front.md` du M2, et à garder ouvert pendant la rédaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. TL;DR (les 3 erreurs à ne jamais refaire)
|
||||||
|
|
||||||
|
1. **Affirmer qu'un champ est « embarqué » sans vérifier les 3 maillons de sérialisation.** En M1 : `Category.code` annoncé dans `client:read`, détail annoncé embarquant contacts/adresses/ribs → **faux dans le code**. Résultat : colonnes liste vides, onglets détail impossibles à peupler.
|
||||||
|
2. **Livrer des sous-ressources en POST-only** (pas de `GetCollection`, pas d'embed) → le front ne peut pas lister les enfants de l'agrégat.
|
||||||
|
3. **Écrire la spec/les tickets sur une intention, pas sur le contrat réel.** Le docblock `Client` décrivait un embed jamais implémenté.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contrat de sérialisation : les 3 maillons obligatoires
|
||||||
|
|
||||||
|
Pour **chaque champ affiché** (liste OU détail), la spec back doit prouver les trois maillons. Si un seul manque → le champ sort en quasi-IRI (`@id`/`@type` seulement) ou pas du tout.
|
||||||
|
|
||||||
|
| Maillon | Question | Exemple M1 raté |
|
||||||
|
|---|---|---|
|
||||||
|
| (a) Groupe sur la **propriété** | `#[Groups([...])]` contient-il un read-group ? | `Supplier::$addresses` sans groupe → jamais sérialisé |
|
||||||
|
| (b) Groupe dans le **`normalizationContext` de l'opération** | l'opération (`GetCollection`/`Get`) liste-t-elle ce groupe ? | `GetCollection` en `['client:read','default:read']` |
|
||||||
|
| (c) Read-group de l'**entité imbriquée** dans le contexte parent | pour embarquer les champs d'une relation (catégorie, site…), le contexte parent inclut-il `category:read` / `site:read` ? | `Category.code` ∈ `category:read`, absent du contexte client → pas de `code` |
|
||||||
|
|
||||||
|
**Règle de rédaction** : dans `spec-back.md`, faire un tableau « champ → groupe propriété → groupe(s) à ajouter au contexte de chaque opération » pour la liste ET le détail. Inclure explicitement les **relations imbriquées** (ex. catégories d'une adresse, sites d'une adresse).
|
||||||
|
|
||||||
|
## 2. Collections enfant d'un agrégat : décider embed vs GetCollection, et câbler en ENTIER
|
||||||
|
|
||||||
|
Décision à acter dès la spec back pour chaque sous-collection (contacts, adresses, RIB, lignes…) :
|
||||||
|
|
||||||
|
- **Embed dans le détail (recommandé pour un agrégat DDD)** : poser `#[Groups(['<root>:item:read'])]` sur la propriété + ajouter au `normalizationContext` du `Get` racine les read-groups des entités enfant **et** de leurs relations imbriquées. 1 requête, cohérent avec un composable `useX(id)`. Réservé aux ensembles **bornés** (ne viole pas la règle n°13 : elle vise les collections exposées, pas un embed borné d'item).
|
||||||
|
- **GetCollection sous-ressource** : `/<root>/{id}/children` paginé. À réserver aux collections potentiellement volumineuses. Si choisi, **créer l'opération** (pas seulement POST).
|
||||||
|
|
||||||
|
❌ Anti-pattern M1 : sous-ressources avec `POST` + `Get` unitaire seulement → **aucun moyen de lister** (ids non découvrables). Interdit.
|
||||||
|
|
||||||
|
## 3. Vérifier le contrat sur l'API RÉELLE avant d'écrire les tickets front
|
||||||
|
|
||||||
|
Le blocage M1 (codes/sites/sous-collections) aurait été vu en 5 min. À mettre dans la **definition of done de la spec back** :
|
||||||
|
|
||||||
|
> Créer un enregistrement de test, appeler `GET /api/<resource>` (liste) ET `GET /api/<resource>/{id}` (détail), **coller la réponse JSON réelle** dans la spec. Toute donnée affichée par le front doit apparaître dans ce JSON collé.
|
||||||
|
|
||||||
|
## 4. La spec décrit le RÉEL, pas l'intention
|
||||||
|
|
||||||
|
- Bannir les « devrait être embarqué », « est exposé » non vérifiés. Décrire ce qui existe (ou ce qui sera livré dans le ticket, en le marquant clairement « à livrer »).
|
||||||
|
- Si un docblock/commentaire existant contredit le code, le **corriger**, pas le recopier.
|
||||||
|
|
||||||
|
## 5. Réutiliser les acquis M1 (ne pas réinventer)
|
||||||
|
|
||||||
|
- **Taxonomie ERP-78** : si M2 catégorise les fournisseurs, repartir du modèle **type unique + `code` stable** (slug MAJUSCULE auto-généré, NOT NULL, figé, **lecture seule** `category:read`), filtrage métier via `?categoryCode=`. Réutiliser le contrat partagé `CategoryInterface` (pas d'import inter-module).
|
||||||
|
- **Front** : `usePaginatedList` (listes), composants `Malio*`, `useApi()`, `formatPhoneFR`, blocs réutilisables (Contact/Adresse), pattern de blocs dynamiques + modal de confirmation.
|
||||||
|
- **Archive** : flag `is_archived` **distinct** de `deleted_at` (soft delete). Restauration → gérer le 409 homonyme.
|
||||||
|
- **Normalisation = serveur** (UPPERCASE nom société, Capitalize noms, lowercase email, téléphone en chiffres). Le front envoie la saisie, réaffiche la valeur normalisée renvoyée. À documenter dans la spec.
|
||||||
|
- **Gating fin + mode strict PATCH** : PATCH par groupe de sérialisation ; tout champ hors-permission dans le payload = **403 sur l'intégralité** (pas de filtrage silencieux). Spécifier la matrice rôle × onglet.
|
||||||
|
|
||||||
|
## 6. Règles ABSOLUES transverses à rappeler dans la spec M2
|
||||||
|
|
||||||
|
- **Pagination obligatoire** (règle n°13) sur toute `GetCollection` ; échappatoire `?pagination=false` réservée aux selects de référentiels bornés.
|
||||||
|
- **`COMMENT ON COLUMN`** (règle n°12) sur chaque colonne créée/modifiée (sinon `make test` casse). Helper standard pour les colonnes Timestampable/Blamable.
|
||||||
|
- **Timestampable + Blamable** sur toute nouvelle entité métier (4 colonnes + trait) ; garde-fou archi.
|
||||||
|
- **`#[Auditable]`** sur les entités métier ; **`#[AuditIgnore]`** sur les champs sensibles (équivalents BIC/IBAN/secret).
|
||||||
|
- **`declare(strict_types=1);`** partout ; commentaires FR, code EN.
|
||||||
|
- **Routes front à plat** (pas de préfixe module), état tableau **jamais** dans l'URL.
|
||||||
|
- **3 miroirs RBAC** à toucher ensemble : `config/sidebar.php`, `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
|
||||||
|
- **Communication inter-module** uniquement via `Shared/Domain/Contract/` ou domain events — jamais d'import direct.
|
||||||
|
|
||||||
|
## 7. Fixtures & seed dès le départ
|
||||||
|
|
||||||
|
M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour M2 : prévoir dès la spec un seed de fournisseurs démo **couvrant tous les cas des règles métier** (relations, catégories codées, archivés, cas comptables) + comptes de rôles démo, pour vérifier le gating et le golden path sans bricolage.
|
||||||
|
|
||||||
|
## 8. Mini-checklist de relecture de la spec M2 (avant de la déclarer prête)
|
||||||
|
|
||||||
|
- [ ] Chaque champ affiché (liste + détail) a ses 3 maillons de sérialisation documentés (propriété, contexte opération, relations imbriquées).
|
||||||
|
- [ ] Chaque sous-collection a une décision **embed vs GetCollection** explicite et **complètement câblée** (pas de POST-only).
|
||||||
|
- [ ] Réponses JSON réelles (liste + détail) collées dans la spec back.
|
||||||
|
- [ ] Matrice RBAC rôle × écran × onglet + mode strict PATCH spécifiés.
|
||||||
|
- [ ] Pagination, COMMENT ON COLUMN, Timestampable/Blamable, Audit, routes à plat : rappelés.
|
||||||
|
- [ ] Réutilisations M1 identifiées (taxonomie code, usePaginatedList, blocs, archive, normalisation).
|
||||||
|
- [ ] Seed/fixtures démo planifiés.
|
||||||
@@ -30,6 +30,10 @@
|
|||||||
"clients": "Répertoire clients",
|
"clients": "Répertoire clients",
|
||||||
"suppliers": "Répertoire fournisseurs"
|
"suppliers": "Répertoire fournisseurs"
|
||||||
},
|
},
|
||||||
|
"technique": {
|
||||||
|
"section": "Technique",
|
||||||
|
"providers": "Répertoire prestataires"
|
||||||
|
},
|
||||||
"core": {
|
"core": {
|
||||||
"roles": "Gestion des rôles",
|
"roles": "Gestion des rôles",
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
@@ -416,7 +420,11 @@
|
|||||||
"commercial_supplier": "Fournisseur",
|
"commercial_supplier": "Fournisseur",
|
||||||
"commercial_supplieraddress": "Adresse fournisseur",
|
"commercial_supplieraddress": "Adresse fournisseur",
|
||||||
"commercial_suppliercontact": "Contact 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",
|
"empty": "Aucune activité enregistrée",
|
||||||
"no_results": "Aucun résultat pour ces filtres",
|
"no_results": "Aucun résultat pour ces filtres",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineNuxtConfig({})
|
||||||
@@ -84,6 +84,17 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
'commercial.suppliers.accounting.view',
|
'commercial.suppliers.accounting.view',
|
||||||
'commercial.suppliers.accounting.manage',
|
'commercial.suppliers.accounting.manage',
|
||||||
'commercial.suppliers.archive',
|
'commercial.suppliers.archive',
|
||||||
|
// Technique — Repertoire prestataires (M3, ERP-138). Meme logique que
|
||||||
|
// clients/fournisseurs : mappe sur le persona "tout", pas de nouveau
|
||||||
|
// persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope,
|
||||||
|
// donc il voit les prestataires de tous les sites (M3 § 2.13).
|
||||||
|
// technique.providers.view n'ajoute pas de lien dans la section
|
||||||
|
// Administration, donc expectedAdminLinks reste inchange.
|
||||||
|
'technique.providers.view',
|
||||||
|
'technique.providers.manage',
|
||||||
|
'technique.providers.accounting.view',
|
||||||
|
'technique.providers.accounting.manage',
|
||||||
|
'technique.providers.archive',
|
||||||
],
|
],
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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_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_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_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:
|
fixtures:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ArrayParameterType;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M3 (ticket 1.1) — Taxonomie PRESTATAIRE (module Catalog, prerequis du module Technique).
|
||||||
|
*
|
||||||
|
* Contexte : le M3 (repertoire prestataires) a besoin d'une taxonomie distincte
|
||||||
|
* des types CLIENT (M1) et FOURNISSEUR (M2). Decision Matthieu (11/06) : types
|
||||||
|
* distincts CLIENT / FOURNISSEUR / PRESTATAIRE, chacun avec sa taxonomie. Le
|
||||||
|
* multi-select « Categorie » du prestataire (formulaire principal + adresse)
|
||||||
|
* ne reference que des `Category` rattachees au type PRESTATAIRE (RG-3.09).
|
||||||
|
*
|
||||||
|
* Cette migration :
|
||||||
|
* 1. cree le `category_type` PRESTATAIRE (code PRESTATAIRE, label « Prestataire ») ;
|
||||||
|
* 2. seede 3 `Category` de demonstration rattachees a ce type via la jonction
|
||||||
|
* ManyToMany `category_category_type` (modele courant depuis Version20260608120000 ;
|
||||||
|
* la colonne ManyToOne `category.category_type_id` n'existe plus).
|
||||||
|
*
|
||||||
|
* Aucune colonne creee/modifiee -> pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
|
||||||
|
* la migration ne fait que des INSERT de donnees de reference.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||||
|
* avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par FQCN
|
||||||
|
* alphabetique -> une migration `App\Module\...` passerait avant les
|
||||||
|
* `DoctrineMigrations\...` sur base vide, donc avant la creation des tables
|
||||||
|
* `category` / `category_type` / `category_category_type`. Le namespace racine
|
||||||
|
* garantit l'ordre par timestamp.
|
||||||
|
*
|
||||||
|
* Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type,
|
||||||
|
* `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et chaque ligne
|
||||||
|
* de jonction (aligne sur le pattern ERP-84 / Version20260605120000). En prod la
|
||||||
|
* table `category` est vide (aucune fixture metier). En dev/test, le purger
|
||||||
|
* Doctrine vide `category` / `category_type` avant les fixtures qui reproduisent
|
||||||
|
* le meme etat final (CategoryTypeFixtures / CategoryFixtures etendus a PRESTATAIRE).
|
||||||
|
*/
|
||||||
|
final class Version20260612080000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Categories de demonstration du type PRESTATAIRE : nom => code stable. Le
|
||||||
|
* code est la cle metier (slug MAJUSCULE du nom, miroir du
|
||||||
|
* CategoryCodeGenerator) et reste unique parmi les actifs (uq_category_code,
|
||||||
|
* partage avec les codes CLIENT / FOURNISSEUR — aucune collision ici). Le nom
|
||||||
|
* est unique GLOBALEMENT parmi les actifs (uq_category_name_active) : les
|
||||||
|
* libelles ci-dessous n'entrent en collision avec aucune categorie seedee.
|
||||||
|
*/
|
||||||
|
private const array PROVIDER_CATEGORIES = [
|
||||||
|
'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
|
||||||
|
'Nettoyage' => 'NETTOYAGE',
|
||||||
|
'Transport' => 'TRANSPORT',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'M3 1.1 : cree le CategoryType PRESTATAIRE + seed des categories prestataires (Maintenance industrielle, Nettoyage, Transport).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. Type PRESTATAIRE (idempotent via l'index unique uq_category_type_code).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
foreach (self::PROVIDER_CATEGORIES as $name => $code) {
|
||||||
|
// 2a. Categorie sous PRESTATAIRE (si le code est libre parmi les
|
||||||
|
// actifs). created_at/updated_at NOT NULL -> NOW() ; le blame
|
||||||
|
// reste null (seed hors contexte HTTP, libelle « Systeme » cote front).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category (name, code, created_at, updated_at)
|
||||||
|
SELECT :name, :code, NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SQL, ['name' => $name, 'code' => $code]);
|
||||||
|
|
||||||
|
// 2b. Jonction M2M categorie <-> type PRESTATAIRE (modele courant).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_category_type (category_id, category_type_id)
|
||||||
|
SELECT c.id, ct.id
|
||||||
|
FROM category c
|
||||||
|
CROSS JOIN category_type ct
|
||||||
|
WHERE c.code = :code AND c.deleted_at IS NULL
|
||||||
|
AND ct.code = 'PRESTATAIRE'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category_category_type cct
|
||||||
|
WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
|
||||||
|
)
|
||||||
|
SQL, ['code' => $code]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Best-effort : on retire d'abord les categories seedees (par code) — la FK
|
||||||
|
// category_category_type est ON DELETE CASCADE cote category, donc les
|
||||||
|
// lignes de jonction partent avec —, puis le type s'il n'est plus reference.
|
||||||
|
$this->addSql(
|
||||||
|
'DELETE FROM category WHERE code IN (:codes) '
|
||||||
|
."AND id IN (SELECT category_id FROM category_category_type cct "
|
||||||
|
."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'PRESTATAIRE')",
|
||||||
|
['codes' => array_values(self::PROVIDER_CATEGORIES)],
|
||||||
|
['codes' => ArrayParameterType::STRING],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM category_type
|
||||||
|
WHERE code = 'PRESTATAIRE'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category_category_type cct WHERE cct.category_type_id = category_type.id
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|||||||
* Fixtures dev/test du module Catalog : categories de demonstration rattachees
|
* Fixtures dev/test du module Catalog : categories de demonstration rattachees
|
||||||
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
|
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
|
||||||
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
|
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
|
||||||
* (ERP-84 : Negociant, Cooperative...). Chaque categorie porte un `code` stable.
|
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
|
||||||
|
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque
|
||||||
|
* categorie porte un `code` stable.
|
||||||
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
|
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
|
||||||
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
|
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
|
||||||
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
|
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
|
||||||
@@ -71,6 +73,11 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
'Grossiste' => 'GROSSISTE',
|
'Grossiste' => 'GROSSISTE',
|
||||||
'Importateur' => 'IMPORTATEUR',
|
'Importateur' => 'IMPORTATEUR',
|
||||||
],
|
],
|
||||||
|
'PRESTATAIRE' => [
|
||||||
|
'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE',
|
||||||
|
'Nettoyage' => 'NETTOYAGE',
|
||||||
|
'Transport' => 'TRANSPORT',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ use Doctrine\Persistence\ObjectManager;
|
|||||||
* taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de
|
* taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de
|
||||||
* la migration Version20260605120000.
|
* la migration Version20260605120000.
|
||||||
*
|
*
|
||||||
|
* M3 1.1 : ajout du type PRESTATAIRE (code PRESTATAIRE, label « Prestataire »),
|
||||||
|
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
|
||||||
|
* Transport). Mirroir de la migration Version20260612080000.
|
||||||
|
*
|
||||||
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
|
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
|
||||||
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque
|
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque
|
||||||
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
|
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
|
||||||
@@ -36,12 +40,13 @@ class CategoryTypeFixtures extends Fixture
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Source unique des types : code technique => libelle FR. Doit rester aligne
|
* Source unique des types : code technique => libelle FR. Doit rester aligne
|
||||||
* sur le seed des migrations Version20260602100000 (CLIENT) et
|
* sur le seed des migrations Version20260602100000 (CLIENT),
|
||||||
* Version20260605120000 (FOURNISSEUR).
|
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE).
|
||||||
*/
|
*/
|
||||||
private const TYPES = [
|
private const TYPES = [
|
||||||
'CLIENT' => 'Client',
|
'CLIENT' => 'Client',
|
||||||
'FOURNISSEUR' => 'Fournisseur',
|
'FOURNISSEUR' => 'Fournisseur',
|
||||||
|
'PRESTATAIRE' => 'Prestataire',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||||
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
||||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
|
||||||
|
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -48,15 +49,15 @@ class Bank
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||||
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
||||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
|
||||||
|
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -48,15 +49,15 @@ class PaymentDelay
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* Timestampable/Blamable (referentiel statique whiteliste dans
|
* Timestampable/Blamable (referentiel statique whiteliste dans
|
||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe
|
||||||
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
* `client:read:accounting` permet l'embarquement dans la reponse Client ;
|
||||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ;
|
||||||
|
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -51,15 +52,15 @@ class PaymentType
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
||||||
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
||||||
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
|
* d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting`
|
||||||
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0).
|
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0) ;
|
||||||
|
* `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis).
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
@@ -55,15 +56,15 @@ class TvaMode
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 30)]
|
#[ORM\Column(length: 30)]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
|
|||||||
@@ -50,11 +50,19 @@ final class RbacSeeder
|
|||||||
/**
|
/**
|
||||||
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
* Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
|
||||||
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
* `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
|
||||||
* attacher (vide pour usine : aucun acces ; admin n'apparait pas car il
|
* attacher (admin n'apparait pas car il bypass tout via isAdmin ;
|
||||||
* bypass tout via isAdmin ; `commercial.clients.archive` et
|
* `commercial.clients.archive`, `commercial.suppliers.archive` et
|
||||||
* `commercial.suppliers.archive` ne sont attaches a aucun role metier —
|
* `technique.providers.archive` ne sont attaches a aucun role metier —
|
||||||
* admin seul).
|
* admin seul).
|
||||||
*
|
*
|
||||||
|
* Cloisonnement par site des prestataires (M3 § 2.13) : la permission
|
||||||
|
* `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
|
||||||
|
* Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
|
||||||
|
* reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
|
||||||
|
* C'est un cloisonnement pilote par user/permission, pas par code de role :
|
||||||
|
* pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
|
||||||
|
* ici, aucun autre code a changer.
|
||||||
|
*
|
||||||
* @var array<string, array{label: string, permissions: list<string>}>
|
* @var array<string, array{label: string, permissions: list<string>}>
|
||||||
*/
|
*/
|
||||||
private const array MATRIX = [
|
private const array MATRIX = [
|
||||||
@@ -66,6 +74,11 @@ final class RbacSeeder
|
|||||||
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
|
// Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
|
||||||
'commercial.suppliers.view',
|
'commercial.suppliers.view',
|
||||||
'commercial.suppliers.manage',
|
'commercial.suppliers.manage',
|
||||||
|
// Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
|
||||||
|
'technique.providers.view',
|
||||||
|
'technique.providers.manage',
|
||||||
|
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||||
|
'sites.bypass_scope',
|
||||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||||
'catalog.categories.read_ref',
|
'catalog.categories.read_ref',
|
||||||
'sites.read_ref',
|
'sites.read_ref',
|
||||||
@@ -82,6 +95,13 @@ final class RbacSeeder
|
|||||||
'commercial.suppliers.view',
|
'commercial.suppliers.view',
|
||||||
'commercial.suppliers.accounting.view',
|
'commercial.suppliers.accounting.view',
|
||||||
'commercial.suppliers.accounting.manage',
|
'commercial.suppliers.accounting.manage',
|
||||||
|
// Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
|
||||||
|
// (pas de manage global -> ne peut pas creer un prestataire).
|
||||||
|
'technique.providers.view',
|
||||||
|
'technique.providers.accounting.view',
|
||||||
|
'technique.providers.accounting.manage',
|
||||||
|
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||||
|
'sites.bypass_scope',
|
||||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||||
'catalog.categories.read_ref',
|
'catalog.categories.read_ref',
|
||||||
'sites.read_ref',
|
'sites.read_ref',
|
||||||
@@ -96,14 +116,25 @@ final class RbacSeeder
|
|||||||
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||||
'commercial.suppliers.view',
|
'commercial.suppliers.view',
|
||||||
'commercial.suppliers.manage',
|
'commercial.suppliers.manage',
|
||||||
|
// Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
|
||||||
|
// (onglet Comptabilite masque/filtre pour la Commerciale).
|
||||||
|
'technique.providers.view',
|
||||||
|
'technique.providers.manage',
|
||||||
|
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||||
|
'sites.bypass_scope',
|
||||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||||
'catalog.categories.read_ref',
|
'catalog.categories.read_ref',
|
||||||
'sites.read_ref',
|
'sites.read_ref',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
self::ROLE_USINE => [
|
self::ROLE_USINE => [
|
||||||
'label' => 'Usine',
|
'label' => 'Usine',
|
||||||
'permissions' => [],
|
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
|
||||||
|
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
|
||||||
|
// courant. Aucun autre acces metier.
|
||||||
|
'permissions' => [
|
||||||
|
'technique.providers.view',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,15 @@ final class SeedE2ECommand extends Command
|
|||||||
'commercial.suppliers.accounting.view',
|
'commercial.suppliers.accounting.view',
|
||||||
'commercial.suppliers.accounting.manage',
|
'commercial.suppliers.accounting.manage',
|
||||||
'commercial.suppliers.archive',
|
'commercial.suppliers.archive',
|
||||||
|
// Technique — Repertoire prestataires (M3, ERP-138). Meme
|
||||||
|
// logique : mappe sur le persona "tout". user-full porte deja
|
||||||
|
// sites.bypass_scope -> voit les prestataires de tous les
|
||||||
|
// sites (M3 § 2.13). Miroir de personas.ts.
|
||||||
|
'technique.providers.view',
|
||||||
|
'technique.providers.manage',
|
||||||
|
'technique.providers.accounting.view',
|
||||||
|
'technique.providers.accounting.manage',
|
||||||
|
'technique.providers.archive',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+76
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+220
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+560
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Module\Technique\Domain\Entity\ProviderOwnedInterface;
|
||||||
|
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider d'item des sous-ressources d'un prestataire (Contact / Adresse / RIB).
|
||||||
|
* Decore le provider Doctrine par defaut et applique le cloisonnement par site du
|
||||||
|
* PARENT (§ 2.13 / RG-3.17) sur Get / Patch / Delete.
|
||||||
|
*
|
||||||
|
* Sans ce garde, un user cloisonne pourrait lire / editer / supprimer une
|
||||||
|
* sous-ressource d'un prestataire hors de son site : le detail Provider est bien
|
||||||
|
* garde en 404 (ProviderProvider), mais les sous-ressources ne passent pas par lui
|
||||||
|
* (provider Doctrine par defaut, et SiteScopedQueryExtension ne filtre que les
|
||||||
|
* resources SiteAwareInterface — ce que ces entites ne sont pas). Le RIB est
|
||||||
|
* particulierement sensible (IBAN / BIC).
|
||||||
|
*
|
||||||
|
* Hors perimetre -> retour null -> 404 (anti-enumeration, coherent avec le detail
|
||||||
|
* Provider). La decision de scope est deleguee a ProviderSiteScopeChecker (source
|
||||||
|
* unique partagee avec le ProviderProvider et les processors).
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<ProviderOwnedInterface>
|
||||||
|
*/
|
||||||
|
final class ProviderSubResourceItemProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
|
||||||
|
private readonly ProviderInterface $itemProvider,
|
||||||
|
private readonly ProviderSiteScopeChecker $scopeChecker,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
|
||||||
|
{
|
||||||
|
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
|
||||||
|
|
||||||
|
if ($entity instanceof ProviderOwnedInterface) {
|
||||||
|
$parent = $entity->getProvider();
|
||||||
|
if (null === $parent || !$this->scopeChecker->isInScope($parent)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Technique;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module Technique (M3) — pole distinct du Commercial qui porte le repertoire
|
||||||
|
* prestataires (entites Provider* livrees par les tickets suivants du M3).
|
||||||
|
*
|
||||||
|
* Decision Matthieu (11/06/2026) : le repertoire prestataires vit dans un
|
||||||
|
* module a part entiere « Technique » (et non sous Commercial), conformement au
|
||||||
|
* docx source. Ce module est activable/desactivable comme les autres
|
||||||
|
* (cf. config/modules.php), non requis au boot.
|
||||||
|
*
|
||||||
|
* Au ticket 1.1, le module ne porte encore aucune entite : il declare seulement
|
||||||
|
* son identite et son jeu de permissions (cf. spec-back M3 § 2.1 + § 5.1). Le
|
||||||
|
* cablage de la section sidebar « Technique » et l'attribution des permissions
|
||||||
|
* aux roles interviennent avec l'ecran prestataires (tickets ulterieurs).
|
||||||
|
*/
|
||||||
|
final class TechniqueModule
|
||||||
|
{
|
||||||
|
public const string ID = 'technique';
|
||||||
|
public const string LABEL = 'Technique';
|
||||||
|
public const bool REQUIRED = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste declarative des permissions RBAC exposees par le module Technique.
|
||||||
|
*
|
||||||
|
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
|
||||||
|
* qui se charge d'upserter ces entrees dans la table `permission`, de
|
||||||
|
* reactiver les codes precedemment marques orphelins et de marquer comme
|
||||||
|
* orphelins ceux qui ont disparu du code source.
|
||||||
|
*
|
||||||
|
* La cle `module` est auto-injectee par le sync command a partir de
|
||||||
|
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
|
||||||
|
*
|
||||||
|
* Convention de nommage des codes : `module.resource[.sub].action` en
|
||||||
|
* snake_case, le prefixe module devant correspondre exactement a
|
||||||
|
* `self::ID` (verifie par la commande de synchronisation).
|
||||||
|
*
|
||||||
|
* Granularite alignee sur Commercial (les prestataires sont le jumeau des
|
||||||
|
* fournisseurs) : view + manage, plus deux permissions dediees a l'onglet
|
||||||
|
* Comptabilite et une a l'archivage (cf. spec-back M3 § 2.9 + § 5.1).
|
||||||
|
*
|
||||||
|
* @return array<int, array{code: string, label: string}>
|
||||||
|
*/
|
||||||
|
public static function permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['code' => 'technique.providers.view', 'label' => 'Voir les prestataires'],
|
||||||
|
['code' => 'technique.providers.manage', 'label' => 'Créer / modifier les prestataires (hors onglet Comptabilité)'],
|
||||||
|
['code' => 'technique.providers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un prestataire'],
|
||||||
|
['code' => 'technique.providers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un prestataire'],
|
||||||
|
['code' => 'technique.providers.archive', 'label' => 'Archiver / restaurer un prestataire'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -361,6 +361,91 @@ final class ColumnCommentsCatalog
|
|||||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||||
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
|
'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).',
|
||||||
] + self::timestampableBlamableComments(),
|
] + 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.',
|
'ClientAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
||||||
// Idem cote fournisseur (meme Regex CP).
|
// Idem cote fournisseur (meme Regex CP).
|
||||||
'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.',
|
'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).
|
// Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20).
|
||||||
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
|
'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.',
|
||||||
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
|
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du seed de la taxonomie PRESTATAIRE (M3 1.1) cote API.
|
||||||
|
*
|
||||||
|
* Le multi-select « Categorie » du prestataire (formulaire principal + adresse)
|
||||||
|
* consomme `GET /api/categories?typeCode=PRESTATAIRE`. Ce test prouve que :
|
||||||
|
* - le filtre `?typeCode=PRESTATAIRE` ne renvoie QUE les categories du type
|
||||||
|
* PRESTATAIRE (aucune fuite de categorie d'un autre type) ;
|
||||||
|
* - chaque membre renvoye porte bien le type PRESTATAIRE dans `categoryTypes`.
|
||||||
|
*
|
||||||
|
* NB : la base de test est purgee de toute categorie / type entre chaque test
|
||||||
|
* (cf. AbstractCatalogApiTestCase::cleanupCatalogTestData), donc le type et les
|
||||||
|
* categories PRESTATAIRE sont materialises ici (et non lus depuis le seed de la
|
||||||
|
* migration / fixture, qui ne survit pas a la purge). On valide ainsi le contrat
|
||||||
|
* du filtre sur le code reel `PRESTATAIRE`. La presence du seed apres un
|
||||||
|
* `make db-reset` reel est, elle, verifiee par l'idempotence des fixtures.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryPrestataireSeedTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Categories de demonstration seedees par la migration / fixture PRESTATAIRE.
|
||||||
|
*/
|
||||||
|
private const array PROVIDER_CATEGORIES = [
|
||||||
|
'Maintenance industrielle',
|
||||||
|
'Nettoyage',
|
||||||
|
'Transport',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function testTypeCodePrestataireReturnsOnlyProviderCategories(): void
|
||||||
|
{
|
||||||
|
$providerType = $this->getOrCreatePrestataireType();
|
||||||
|
foreach (self::PROVIDER_CATEGORIES as $name) {
|
||||||
|
$this->createCategory($name, $providerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bruit : un type + une categorie d'un autre type ne doivent PAS fuiter.
|
||||||
|
$noiseType = $this->createCategoryType('TEST_FOURNISSEUR', 'Test Fournisseur');
|
||||||
|
$this->createCategory(self::TEST_CATEGORY_PREFIX.'noise', $noiseType);
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE&pagination=false');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$members = $response->toArray()['member'];
|
||||||
|
$names = array_map(static fn (array $m): string => $m['name'], $members);
|
||||||
|
sort($names);
|
||||||
|
|
||||||
|
$expected = self::PROVIDER_CATEGORIES;
|
||||||
|
sort($expected);
|
||||||
|
self::assertSame(
|
||||||
|
$expected,
|
||||||
|
$names,
|
||||||
|
'Le filtre ?typeCode=PRESTATAIRE doit ne renvoyer QUE les categories du type PRESTATAIRE.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chaque categorie remontee doit PORTER le type PRESTATAIRE.
|
||||||
|
foreach ($members as $member) {
|
||||||
|
self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTypeCodePrestataireKeepsHydraPagination(): void
|
||||||
|
{
|
||||||
|
$providerType = $this->getOrCreatePrestataireType();
|
||||||
|
$this->createCategory('Maintenance industrielle', $providerType);
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('GET', '/api/categories?typeCode=PRESTATAIRE');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
self::assertArrayHasKey('totalItems', $data, 'Le filtre ne doit pas casser la pagination Hydra.');
|
||||||
|
self::assertArrayHasKey('member', $data);
|
||||||
|
|
||||||
|
foreach ($data['member'] as $member) {
|
||||||
|
self::assertContains('PRESTATAIRE', array_column($member['categoryTypes'], 'code'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupere le type PRESTATAIRE reel, ou le cree s'il est absent. Le code
|
||||||
|
* `PRESTATAIRE` est seede par CategoryTypeFixtures (present en debut de suite),
|
||||||
|
* mais le cleanup purge tous les `category_type` entre les tests : selon
|
||||||
|
* l'ordre d'execution, le type peut donc exister ou non. Le get-or-create rend
|
||||||
|
* le test robuste sans dependre du seed ni le dupliquer.
|
||||||
|
*/
|
||||||
|
private function getOrCreatePrestataireType(): CategoryType
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
|
||||||
|
|
||||||
|
if ($existing instanceof CategoryType) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createCategoryType('PRESTATAIRE', 'Prestataire');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Technique;
|
||||||
|
|
||||||
|
use App\Module\Technique\TechniqueModule;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests structurels du module Technique (M3) : identite et contrat
|
||||||
|
* `permissions()`.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TechniqueModuleTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testModuleIdentity(): void
|
||||||
|
{
|
||||||
|
self::assertSame('technique', TechniqueModule::ID);
|
||||||
|
self::assertSame('Technique', TechniqueModule::LABEL);
|
||||||
|
self::assertFalse(TechniqueModule::REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPermissionsSetContainsExactlyFiveCodes(): void
|
||||||
|
{
|
||||||
|
// Garde-fou : le jeu de permissions du module est fige par ce test. Si
|
||||||
|
// quelqu'un ajoute / retire une permission sans ajuster la spec (§ 5.1)
|
||||||
|
// ni la matrice RBAC, le test casse explicitement.
|
||||||
|
$codes = array_column(TechniqueModule::permissions(), 'code');
|
||||||
|
sort($codes);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
'technique.providers.accounting.manage',
|
||||||
|
'technique.providers.accounting.view',
|
||||||
|
'technique.providers.archive',
|
||||||
|
'technique.providers.manage',
|
||||||
|
'technique.providers.view',
|
||||||
|
],
|
||||||
|
$codes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEveryPermissionCodeIsPrefixedByModuleId(): void
|
||||||
|
{
|
||||||
|
// Convention de nommage `module.resource[.sub].action` : le prefixe doit
|
||||||
|
// correspondre exactement a l'ID du module (verifie aussi par
|
||||||
|
// app:sync-permissions).
|
||||||
|
foreach (TechniqueModule::permissions() as $permission) {
|
||||||
|
self::assertStringStartsWith(
|
||||||
|
TechniqueModule::ID.'.',
|
||||||
|
$permission['code'],
|
||||||
|
'Chaque code de permission doit etre prefixe par l\'ID du module.',
|
||||||
|
);
|
||||||
|
self::assertNotSame('', $permission['label'], 'Chaque permission doit porter un label.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user