diff --git a/config/modules.php b/config/modules.php index e55d2ed..aad4939 100644 --- a/config/modules.php +++ b/config/modules.php @@ -5,6 +5,7 @@ use App\Module\Catalog\CatalogModule; use App\Module\Commercial\CommercialModule; use App\Module\Core\CoreModule; use App\Module\Sites\SitesModule; +use App\Module\Technique\TechniqueModule; use App\Module\Transport\TransportModule; return [ @@ -12,5 +13,6 @@ return [ CommercialModule::class, SitesModule::class, CatalogModule::class, + TechniqueModule::class, TransportModule::class, ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 5aeb29c..a6a4377 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -80,6 +80,16 @@ doctrine: dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity' prefix: 'App\Module\Commercial\Domain\Entity' alias: Commercial + # Mapping inconditionnel du module Technique (meme logique que Commercial) : + # les tables prestataires (provider + sous-collections + jointures M2M) + # creees par la migration M3 (Version20260612100000) doivent etre connues + # de l'ORM. L'activation fonctionnelle passe par config/modules.php. + Technique: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Module/Technique/Domain/Entity' + prefix: 'App\Module\Technique\Domain\Entity' + alias: Technique controller_resolver: auto_mapping: false diff --git a/config/sidebar.php b/config/sidebar.php index 9be6d4a..5d5ccb3 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -61,6 +61,23 @@ return [ ], ], ], + // Section "Technique" (M3, ERP-138) : pole distinct du Commercial, porte le + // repertoire prestataires. L'item est gate par `technique.providers.view` ; + // la section disparait automatiquement (SidebarProvider) si le module + // `technique` est desactive ou si l'user n'a pas la permission. + [ + 'label' => 'sidebar.technique.section', + 'icon' => 'mdi:account-convert-outline', + 'items' => [ + [ + 'label' => 'sidebar.technique.providers', + 'to' => '/providers', + 'icon' => 'mdi:account-wrench-outline', + 'module' => 'technique', + 'permission' => 'technique.providers.view', + ], + ], + ], // Section "Administration" : regroupe toutes les pages de configuration // applicative (RBAC, users, sites, audit log). // diff --git a/config/version.yaml b/config/version.yaml index 03c8d72..ea4b1d5 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.110' + app.version: '0.1.120' diff --git a/docs/specs/M3-prestataires/spec-back.md b/docs/specs/M3-prestataires/spec-back.md new file mode 100644 index 0000000..7d9825c --- /dev/null +++ b/docs/specs/M3-prestataires/spec-back.md @@ -0,0 +1,1099 @@ +--- +# === 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) — Spec back initiale, miroir M2 (fournisseurs). +# Alignement refonte-contact (pas de contact inline sur le formulaire principal). +# Différences M3 : pas d'onglet Information ; site sur le formulaire principal (provider_site) ; +# adresse simple (pas de type/bennes/triage) ; nouveau pôle Technique. + +# === LIENS === +spec_front: ./spec-front.md +maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-42090&p=f&m=dev" + +# === LIEN LESSTIME === +lesstime_taskgroup_id: 29 # M3 — Répertoire prestataires (projet STARSEED #6) +lesstime_project_id: 6 +statut_global: en_dev + +# === DÉPENDANCES AMONT === +depend_de: + - M2-suppliers # pattern jumeau Supplier* répliqué en Provider* ; référentiels compta partagés + - M1-clients # référentiels comptables (TvaMode/PaymentDelay/PaymentType/Bank) + filtre ?typeCode= (créé au M2) + - M0-categories # Category + CategoryType (étendu par seed M3 : type PRESTATAIRE) + - Sites # SitesModule + 3 sites seedés (86 / 17 / 82) déjà en place + - Core # User, Role, Permission, Audit, JWT déjà en place + - Shared # TimestampableBlamableTrait + Subscriber (ERP-52) +--- + +# Spec back — Module 3 : Répertoire prestataires + +## 1. Contexte + +Cette spec **complète et précise** la [spec front V0.2](./spec-front.md) (`M3-reportoire-prestataires.docx` du 04/06/2026) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion, tests, hors-périmètre. + +**Module cible** : **nouveau module `Technique`** (`src/Module/Technique/`). Le prestataire est le **jumeau du fournisseur** (`Provider` / `ProviderContact` / `ProviderAddress` / `ProviderRib`), construit sur le pattern éprouvé M1/M2. Voir § 2.1 pour la justification du module séparé et la consommation des référentiels comptables. + +**Dépendances déjà en place sur `develop`** (héritées M1/M2) : +- `Commercial` → référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (entités lecture seule, déjà seedées — **partagées sans duplication**, consommées en relation ORM). +- `Catalog` (M0) → `Category` + `CategoryType` + **filtre `?typeCode=` opérationnel** (créé au M2). Le M3 ajoute le type `PRESTATAIRE`. +- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82). +- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52). +- `Core` → User, Role, Permission, Audit, JWT. + +> **RETEX obligatoire** : lire [`../_RETEX-M1-pour-M2.md`](../_RETEX-M1-pour-M2.md) AVANT de coder. ~80 % des frictions M1 venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M3. + +## 2. Décisions d'archi + +### 2.1 Module — Nouveau module `Technique`, entités jumelles de `Supplier` + +> **⚠️ Décision à confirmer (Matthieu, 11/06/2026)** : le docx place le répertoire prestataires dans un **Module « Technique »**, confirmé comme **pôle distinct du Commercial**. On crée donc un **nouveau module back `Technique`** : +> - `src/Module/Technique/TechniqueModule.php` : `ID = 'technique'`, `LABEL = 'Technique'`, `REQUIRED = false`, méthode `permissions()` (cf. § 5.1). +> - Activation : ajouter `TechniqueModule::class` dans `config/modules.php`. +> - Front : layer Nuxt `frontend/modules/technique/` (auto-détecté) + nouvelle **section sidebar « Technique »** dans `config/sidebar.php`. + +Le prestataire M3 **réplique à l'identique** le pattern `Supplier*` du M2 sous `Provider*` (tables dédiées, pas de table polymorphe partagée — clients / fournisseurs / prestataires divergent fonctionnellement, l'isolation prime). + +**Référentiels comptables & Category — consommation cross-module (relation ORM partagée, PAS d'import de logique)** : `Provider` référence `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (module Commercial) et `Category` / `Site` (modules Catalog / Sites) via des **relations ORM** (ManyToOne / ManyToMany), **exactement comme `Supplier` (Commercial) référence déjà `Site` (Sites) et `Category` (Catalog)**. Ce sont des **données de référence partagées**, pas de la logique inter-module : aucun service / repository d'un autre module n'est appelé. La règle ABSOLUE n°1 (« ne jamais importer d'un module à un autre — passer par Shared/Contract ») vise les **dépendances de logique métier** ; le projet a déjà acté (M1/M2) que la **référence ORM à une entité de référence partagée** est tolérée et documentée comme telle. + +> **Décision Matthieu (11/06) : on fait « comme supplier »** — consommation ORM partagée des référentiels comptables (zéro refacto, zéro duplication). La remontée dans `Shared` (isolation stricte) reste une option future non retenue au M3 (tracé HP-M4-2). + +### 2.2 IDs entier auto-increment Postgres natif + +Cohérent avec M0/M1/M2 et l'ensemble Starseed. Pas d'UUID, pas de ULID. PK en `INT GENERATED BY DEFAULT AS IDENTITY` (style aligné M1/M2), horodatages en `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`). + +### 2.3 Référentiels comptables — réutilisation M1/M2 (zéro duplication) + +Les 4 tables `tva_mode` / `payment_delay` / `payment_type` / `bank` (+ entités lecture seule et seeds) sont **celles du M1**. Le M3 ne crée **aucune** nouvelle table de référentiel comptable : `provider.tva_mode_id`, `provider.payment_delay_id`, `provider.payment_type_id`, `provider.bank_id` pointent vers les mêmes tables. + +Endpoints : `GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent déjà. **Évolution M3** : élargir leur `security` pour autoriser **aussi** les rôles prestataires (cf. § 4.7). Les codes pivots `VIREMENT` (RG-3.07) et `LCR` (RG-3.08) existent déjà dans `payment_types`. + +### 2.4 Catégories — nouveau `CategoryType` `PRESTATAIRE` + +Le multi-select « Catégorie » du prestataire (formulaire principal ET adresse) référence des `Category` rattachées à un **nouveau `CategoryType` de code `PRESTATAIRE`** (label « Prestataire »), seedé par le M3. On assume des **types distincts** (`CLIENT` / `FOURNISSEUR` / `PRESTATAIRE`) — chacun avec sa taxonomie. + +> **Bonne nouvelle vs M2** : le **filtre `?typeCode=` a été implémenté au M2** sur `/api/categories` (module Catalog). Le M3 n'a donc **plus à le créer** : il suffit de **seeder le type `PRESTATAIRE`** + ses catégories (migration `ON CONFLICT` pour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M2 § 3.2). **À vérifier sur le JSON réel** que `GET /api/categories?typeCode=PRESTATAIRE` filtre bien (DoD de la spec). + +> **Forme réelle de `Category`** : expose `code` **et `name`** (PAS `label`) sous `category:read`, plus `categoryType{ id, code, label }`. Le **libellé affiché front = `category.name`**. Les M2M `provider_category` / `provider_address_category` ne contraignent que des `Category` de type `PRESTATAIRE` (RG-3.09). + +### 2.5 Archive vs soft delete — deux mécanismes distincts (identique M1/M2) + +| Mécanisme | Colonne | Visibilité défaut | Restauration | Utilisateur | +|---|---|---|---|---| +| **Archive** (fonctionnel) | `is_archived` (bool, default false) + `archived_at` | masqué | Oui (toggle UI) | **Admin seul** via `technique.providers.archive` | +| **Soft delete** (technique) | `deleted_at` (timestamptz nullable) | masqué | HP M4+ | Aucun rôle au M3 (HP) | + +Conséquences (miroir M2) : +- `DELETE /api/providers/{id}` **non exposé** au M3 (404 si appelé). +- `GET /api/providers?includeArchived=true` permet de voir les archivés (permission `technique.providers.view`). +- PATCH `{ "isArchived": true }` archive ; PATCH `{ "isArchived": false }` restaure. +- L'unicité métier ignore les archivés ET les soft-deletés (cf. § 2.6). + +### 2.6 Unicité partielle Postgres — nom de société + +> **Décision à confirmer (alignée Q4 M1 / § 2.6 M2)** : l'unicité métier porte **uniquement sur le nom de prestataire** (`company_name`). Le SIREN et l'email principal ne sont **pas** uniques. + +Index unique partiel (`WHERE is_archived = FALSE AND deleted_at IS NULL`) sur `LOWER(company_name)`. Doublon → `409 Conflict` géré par le `ProviderProcessor`. + +### 2.7 Audit & traces temporelles + +Pattern Starseed standard, miroir M1/M2 : +- `#[Auditable]` sur `Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib`. +- **Tous les champs auditables** (pas d'`#[AuditIgnore]`) — y compris `ProviderRib.iban` et `ProviderRib.bic` (audit admin-only côté Starseed → traçabilité comptable). +- Audit M2M automatique sur `provider.categories` et `provider.sites` (`{categories: {added:[...], removed:[...]}}`). +- **Libellés i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.technique_provider`, `audit.entity.technique_providercontact`, `audit.entity.technique_provideraddress`, `audit.entity.technique_providerrib` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`). + +### 2.8 Timestampable + Blamable + +`Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib` implémentent `TimestampableInterface` + `BlamableInterface` et utilisent `TimestampableBlamableTrait`. Migration : 4 colonnes par table (`created_at`/`updated_at` NOT NULL, `created_by`/`updated_by` nullable `ON DELETE SET NULL`) + commentaires via le helper `addStandardTimestampableBlamableComments($schema, '')`. + +### 2.9 Permissions RBAC — granularité (5 permissions, identique M2) + +| Permission | Admin | Bureau | Compta | Commerciale | Usine | +|---|---|---|---|---|---| +| `technique.providers.view` | ✅ | ✅ | ✅ | ✅ (sauf compta) | ✅ (cloisonné par site — § 2.13) | +| `technique.providers.manage` | ✅ | ✅ | ❌ | ✅ | ❌ | +| `technique.providers.accounting.view` | ✅ | ❌ | ✅ | ❌ | ❌ | +| `technique.providers.accounting.manage` | ✅ | ❌ | ✅ | ❌ | ❌ | +| `technique.providers.archive` | ✅ | ❌ | ❌ | ❌ | ❌ | + +Notes (miroir M2) : +- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un prestataire existant. Pas de création (pas de `manage` global). +- **Commerciale** : `view` + `manage` mais **pas** `accounting.view` → onglet Comptabilité masqué (front) et filtré (back) via le `ProviderReadGroupContextBuilder` (gating **par ajout** de groupe `provider:read:accounting`, jamais par retrait). Sans la permission, scalaires compta + `ribs` ne sont jamais sérialisés. +- **Bureau** : `view` + `manage` (tout sauf Comptabilité). +- **Usine** : `view` (lecture seule, pas de `manage`), **cloisonné par site** — voir § 2.13. +- **⚠️ Le « Tout » vs « son site uniquement » de la colonne Consultation du docx n'est PAS porté par le rôle** : c'est un **cloisonnement par site piloté par l'utilisateur** (décision Matthieu, 11/06). Tout user voit par défaut les prestataires de **son site courant** ; les profils qui doivent voir **tous les sites** (Admin, et selon besoin Bureau/Compta/Commerciale) l'obtiennent via la permission `sites.bypass_scope` (Admin l'a par bypass total). Mécanique complète en § 2.13. + +### 2.10 Validation incrémentale par onglet (workflow front-driven, identique M2) + +`Provider` créé en BDD **dès validation du formulaire principal** via `POST /api/providers`. Onglets suivants → **PATCH partiels** avec groupes de sérialisation dédiés : + +- `provider:write:main` — formulaire principal (POST + PATCH) : `companyName`, `categories`, `sites` +- `provider:write:contacts` — onglet Contact (sous-ressource `provider_contact`) +- `provider:write:addresses` — onglet Adresse (sous-ressource `provider_address`) +- `provider:write:accounting` — onglet Comptabilité (security séparée) +- `provider:write:archive` — toggle archive (security `technique.providers.archive`) + +**Pas de groupe `provider:write:information`** (pas d'onglet Information au M3). **Pas de state machine** côté back (pas de `status = draft|active`). + +### 2.11 Normalisation serveur des entrées texte (identique M1/M2) + +`ProviderFieldNormalizer` (miroir `SupplierFieldNormalizer`), service interne appelé par les Processors avant validation : + +```php +final class ProviderFieldNormalizer +{ + public function normalizeCompanyName(?string $v): ?string // mb_strtoupper(trim) + public function normalizePersonName(?string $v): ?string // mb_convert_case TITLE + public function normalizeEmail(?string $v): ?string // mb_strtolower(trim) + public function normalizePhone(?string $v): ?string // preg_replace('/\D+/', '') +} +``` + +Le formatage `XX XX XX XX XX` est fait à l'affichage front. Le back stocke `0612345678` (chiffres seuls). + +### 2.12 Liste : embed catégories + sites + hydratation anti-N+1 (cohérence M1/M2) + +La **liste** `GET /api/providers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme M1/M2. + +> **Différence M3 (importante)** : au M2, `sites[]` de la liste était l'**agrégat dédoublonné des adresses** (`Supplier::getSites()`). Au M3, le **prestataire porte directement des sites** (formulaire principal — RG-3.03, M2M `provider_site`). La colonne « Site » de la liste affiche donc **`provider.sites` (relation directe)**, pas un agrégat d'adresses. Plus simple et plus performant. + +Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS les to-many dans la requête de liste (filtres + tri seulement) ; `hydrateListCollections()` remplit `categories` puis `sites` (relation directe) via des requêtes `IN` bornées séparées sur les mêmes instances (identity map), pour éviter le produit cartésien sur les chemins non paginés (export, `?pagination=false`). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité. + +### 2.13 Cloisonnement par site — visibilité pilotée par l'utilisateur (DÉCISION M3) + +> **Décision Matthieu (11/06/2026)** : la visibilité des prestataires est **cloisonnée par site, automatiquement, côté back, en fonction de l'utilisateur** — **pas du rôle**. Un user a un (ou des) site(s) (`user_site`, + un `currentSite` actif). Il ne voit que les prestataires **rattachés à son site**. Les profils qui doivent voir tous les sites passent par `sites.bypass_scope` (Admin l'a par bypass total). Le rôle « Usine » n'est qu'un cas particulier de cette règle générale. + +**Réutilisation de l'infra Sites existante** (`docs/modules/site-aware.md`) : `CurrentSiteProvider` (site courant de l'user), permission `sites.bypass_scope` (voit tous les sites — Admin automatique), users ↔ sites via M2M `user_site`. + +**⚠️ Pourquoi PAS `SiteAwareInterface` standard** : le pattern opt-in `SiteAwareInterface` + `SiteScopedQueryExtension` est **mono-site** (`site_id INT NOT NULL`, ManyToOne unique, filtre `x.site = :currentSite`). Or le prestataire est **multi-site** (M2M `provider_site`, ≥ 1 — RG-3.03). Le pattern standard ne s'applique donc pas tel quel. On câble un **filtre de cloisonnement custom multi-site** (cas explicitement renvoyé au module par `site-aware.md § 6.1 / § 6.2`), qui réutilise `CurrentSiteProvider` + `sites.bypass_scope` : + +- **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter). +- **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre). +- **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses). +- **Cohérence sous-ressources** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site. + +> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle). + +> **Index** : `idx_provider_site_site` sur `provider_site(site_id)` (déjà prévu § 3.2) sert le filtre `WHERE site = :currentSite`. + +## 3. Modèle de données + +### 3.1 Diagramme + +``` ++----------------------+ +--------------------------+ +-----------------+ +| provider |--n:m-->| provider_category |<--n:m--| category | +| | +--------------------------+ | type=PRESTATAIRE| +| id (PK) | +-----------------+ +| company_name |--n:m-->| provider_site |<--n:m--| site (Sites) | +| is_archived | +--------------------------+ | (RG-3.03) | +| archived_at | +-----------------+ +| deleted_at | +--------------------------+ +| -- Comptabilité -- |--1:n-->| provider_contact | +| siren / account_num | +--------------------------+ +| tva_mode_id | +-----------------+ +| n_tva | +--------------------------+ | tva_mode (M1) | +| payment_delay_id |--1:n-->| provider_address | | payment_* (M1) | +| payment_type_id | +--------------------------+ | bank (M1) | +| bank_id (nullable) | | (PAS de address_type) +-----------------+ ++----------------------+ +--n:m--> site + +--n:m--> provider_contact + +--------------------------+ +--n:m--> category (PRESTATAIRE) + | provider_rib | + +--------------------------+ + label / bic / iban +``` + +**Particularités M3 (différences vs `supplier`)** : +- **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 + comptabilité. +- **`provider.sites` (M2M `provider_site`)** : sélecteur de site **sur le formulaire principal** (RG-3.03, ≥ 1). NOUVEAU vs supplier (qui n'avait des sites que sur l'adresse). +- **`provider_address` simplifiée** : **pas** de `address_type`, **pas** de `bennes`, **pas** de `triage_provider`. Champs : sites[], street, street_complement, postal_code, city, country, categories[], contacts[]. +- Les référentiels comptables (`tva_mode`...) **ne sont pas recréés** — FK vers les tables M1. + +### 3.2 Migration Doctrine — SQL Postgres + +Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (à dater par le dev). + +> **Même justification qu'au M1/M2** : la migration crée un schéma avec **FK cross-module** (`user`, `category`, `site`, FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (`make db-reset`) — exception racine de la règle ABSOLUE n°11. Le seed du `CategoryType PRESTATAIRE` se fait **en deux endroits** (migration `ON CONFLICT` pour la prod + fixture idempotente en dev/test). + +> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par le helper. Le SQL ci-dessous est *illustratif* (style aligné M1/M2 : `INT GENERATED BY DEFAULT AS IDENTITY`, `TIMESTAMP(0) WITHOUT TIME ZONE`). + +```sql +-- ===================================================================== +-- Seed taxonomie : nouveau type PRESTATAIRE (référentiels comptables = M1, non recréés) +-- ===================================================================== +INSERT INTO category_type (code, label) VALUES ('PRESTATAIRE', 'Prestataire') + ON CONFLICT (code) DO NOTHING; + +-- ===================================================================== +-- Table principale `provider` +-- ===================================================================== +CREATE TABLE provider ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + -- Formulaire principal + company_name VARCHAR(180) NOT NULL, + -- (PAS d'onglet Information — aucun champ description/competitors/founded/employees/...) + -- Onglet Comptabilité (FK référentiels M1 — partagés) + siren VARCHAR(20), + account_number VARCHAR(40), + tva_mode_id INT REFERENCES tva_mode(id) ON DELETE RESTRICT, + n_tva VARCHAR(40), + payment_delay_id INT REFERENCES payment_delay(id) ON DELETE RESTRICT, + payment_type_id INT REFERENCES payment_type(id) ON DELETE RESTRICT, + bank_id INT REFERENCES bank(id) ON DELETE RESTRICT, + -- Archive (exposé M3) + is_archived BOOLEAN NOT NULL DEFAULT FALSE, + archived_at TIMESTAMP(0) WITHOUT TIME ZONE, + -- Soft delete (préparé, non exposé au M3) + deleted_at TIMESTAMP(0) WITHOUT TIME ZONE, + -- Timestampable + Blamable + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL +); + +CREATE INDEX idx_provider_is_archived ON provider(is_archived); +CREATE INDEX idx_provider_deleted_at ON provider(deleted_at); +CREATE INDEX idx_provider_created_by ON provider(created_by); +CREATE INDEX idx_provider_updated_by ON provider(updated_by); + +-- Unicité métier (partielle : ignore archives + soft-delete) — nom de société uniquement (cf. § 2.6) +CREATE UNIQUE INDEX uq_provider_company_name_active + ON provider (LOWER(company_name)) + WHERE is_archived = FALSE AND deleted_at IS NULL; + +-- ===================================================================== +-- M2M provider ↔ category (catégories de type PRESTATAIRE — RG-3.09) +-- ===================================================================== +CREATE TABLE provider_category ( + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, + PRIMARY KEY (provider_id, category_id) +); +CREATE INDEX idx_provider_category_category ON provider_category(category_id); + +-- ===================================================================== +-- M2M provider ↔ site (sélecteur de site du FORMULAIRE PRINCIPAL — RG-3.03) +-- ===================================================================== +CREATE TABLE provider_site ( + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT, + PRIMARY KEY (provider_id, site_id) +); +CREATE INDEX idx_provider_site_site ON provider_site(site_id); + +-- ===================================================================== +-- Sous-collection : Contacts (1:n) +-- ===================================================================== +CREATE TABLE provider_contact ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + first_name VARCHAR(120), + last_name VARCHAR(120), + job_title VARCHAR(120), + phone_primary VARCHAR(20), + phone_secondary VARCHAR(20), + email VARCHAR(180), + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL, + -- RG-3.04 : au moins 1 champ rempli (garanti côté Processor ; le CHECK ci-dessous + -- couvre le cas « nom OU prénom » comme garde-fou minimal aligné M2) + CONSTRAINT chk_provider_contact_name + CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL) +); +CREATE INDEX idx_provider_contact_provider ON provider_contact(provider_id); + +-- ===================================================================== +-- Sous-collection : Adresses (1:n) — PAS de address_type / bennes / triage +-- ===================================================================== +CREATE TABLE provider_address ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + country VARCHAR(80) NOT NULL DEFAULT 'France', + postal_code VARCHAR(20) NOT NULL, + city VARCHAR(120) NOT NULL, + street VARCHAR(255) NOT NULL, + street_complement VARCHAR(255), + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL +); +CREATE INDEX idx_provider_address_provider ON provider_address(provider_id); + +-- M2M provider_address ↔ site (RG-3.05 : ≥ 1 site) +CREATE TABLE provider_address_site ( + provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE, + site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT, + PRIMARY KEY (provider_address_id, site_id) +); + +-- M2M provider_address ↔ provider_contact +CREATE TABLE provider_address_contact ( + provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE, + provider_contact_id INT NOT NULL REFERENCES provider_contact(id) ON DELETE CASCADE, + PRIMARY KEY (provider_address_id, provider_contact_id) +); + +-- M2M provider_address ↔ category (catégorie d'adresse, type PRESTATAIRE — RG-3.09) +CREATE TABLE provider_address_category ( + provider_address_id INT NOT NULL REFERENCES provider_address(id) ON DELETE CASCADE, + category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, + PRIMARY KEY (provider_address_id, category_id) +); + +-- ===================================================================== +-- Sous-collection : RIB (1:n) +-- ===================================================================== +CREATE TABLE provider_rib ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + provider_id INT NOT NULL REFERENCES provider(id) ON DELETE CASCADE, + label VARCHAR(120) NOT NULL, + bic VARCHAR(20) NOT NULL, + iban VARCHAR(34) NOT NULL, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL +); +CREATE INDEX idx_provider_rib_provider ON provider_rib(provider_id); +``` + +### 3.2.bis Commentaires SQL obligatoires (échantillon) + +```php +$this->addSql("COMMENT ON TABLE provider IS 'Répertoire prestataires (M3 Technique) — entités archivables.'"); +$this->addSql("COMMENT ON COLUMN provider.company_name IS 'Raison sociale du prestataire — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-3.10).'"); +$this->addSql("COMMENT ON COLUMN provider.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque) et RG-3.08 (RIB).'"); +$this->addSql("COMMENT ON COLUMN provider.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-3.07), null sinon.'"); +$this->addSql("COMMENT ON COLUMN provider.siren IS 'SIREN du prestataire (9 chiffres). Non unique (cf. RG-3.10). Saisi à l''onglet Comptabilité.'"); +// provider_site (M2M) : commenter via COMMENT ON TABLE +$this->addSql("COMMENT ON TABLE provider_site IS 'Sites rattachés au prestataire (sélecteur du formulaire principal — RG-3.03, ≥ 1).'"); +$this->addSql("COMMENT ON COLUMN provider_address.postal_code IS 'Code postal — déclenche l''autocomplétion ville via l''API BAN (RG-3.06).'"); +// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12) +$this->addStandardTimestampableBlamableComments($schema, 'provider'); +$this->addStandardTimestampableBlamableComments($schema, 'provider_contact'); +$this->addStandardTimestampableBlamableComments($schema, 'provider_address'); +$this->addStandardTimestampableBlamableComments($schema, 'provider_rib'); +``` + +### 3.3 Entité `Provider` — squelette (extrait) + +Miroir de `Supplier` (cf. [`../M2-suppliers/spec-back.md § 3.3`](../M2-suppliers/spec-back.md)), **amputé de l'onglet Information** et **augmenté de `sites` (relation directe)**. + +```php + ['provider:read', 'category:read', 'site:read', 'default:read']], + provider: ProviderProvider::class, + ), + new Get( + security: "is_granted('technique.providers.view')", + // Détail embarque sous-collections (contacts, addresses, ribs) + relations imbriquées. + // provider:read:accounting AJOUTÉ dynamiquement par le ReadGroupContextBuilder si accounting.view. + 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', 'default:read']], + denormalizationContext: ['groups' => ['provider:write:main']], + processor: ProviderProcessor::class, + ), + new Patch( + // Security élargie : manage OU accounting.manage (Compta édite la compta sans manage global). + security: "is_granted('technique.providers.manage') or is_granted('technique.providers.accounting.manage')", + normalizationContext: ['groups' => ['provider: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')] +#[Auditable] +class Provider implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + #[Groups(['provider:read'])] + private ?int $id = null; + + #[ORM\Column(length: 180)] + #[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')] + #[Assert\Length(min: 2, max: 180, normalizer: 'trim')] + #[Groups(['provider:read', 'provider:write:main'])] + private ?string $companyName = null; + + /** @var Collection Catégories de type PRESTATAIRE (RG-3.09) */ + #[ORM\ManyToMany(targetEntity: Category::class)] + #[ORM\JoinTable(name: 'provider_category')] + #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] + #[Groups(['provider:read', 'provider:write:main'])] + private Collection $categories; + + /** @var Collection Sites du prestataire — sélecteur du formulaire principal (RG-3.03) */ + #[ORM\ManyToMany(targetEntity: Site::class)] + #[ORM\JoinTable(name: 'provider_site')] + #[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')] + #[Groups(['provider:read', 'provider:write:main'])] + private Collection $sites; + + // === Onglet Comptabilité (lecture/écriture conditionnées par permission — cf. M2) === + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?string $siren = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?string $accountNumber = null; + + #[ORM\ManyToOne(targetEntity: TvaMode::class)] + #[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?TvaMode $tvaMode = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?string $nTva = null; + + #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] + #[ORM\JoinColumn(name: 'payment_delay_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', 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', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['provider:read:accounting', 'provider:write:accounting'])] + private ?Bank $bank = null; + + // === Sous-collections — EMBARQUÉES dans le DÉTAIL (RETEX M1 §2) === + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['provider:item:read'])] + private Collection $contacts; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['provider:item:read'])] + private Collection $addresses; + + /** @var Collection RIB embarqués dans le groupe COMPTA (gated par le Provider) */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['provider:read:accounting'])] + private Collection $ribs; + + // === Archive / Soft delete === + #[ORM\Column(name: 'is_archived', options: ['default' => false])] + private bool $isArchived = false; + + // ⚠ PIÈGE BOOLÉEN (bug #3 M1) : #[Groups] + #[SerializedName('isArchived')] SUR LE GETTER, + // sinon Symfony strip "is" → attribut "archived" → clé droppée. À tester sur JSON réel. + #[Groups(['provider:read', 'provider:write:archive'])] + #[SerializedName('isArchived')] + public function isArchived(): bool + { + return $this->isArchived; + } + // ... archivedAt, getters/setters, __construct (ArrayCollection) ... +} +``` + +### 3.4 Squelettes des autres entités + +Même pattern que les jumelles `Supplier*` (`#[Auditable]`, `TimestampableBlamableTrait`, FK `provider_id`). **Chaque propriété affichée porte un read-group** (RETEX M1 §1 maillon (a)) : + +**`ProviderContact`** — propriétés dans `['provider:item:read', 'provider:write:contacts']` : +`firstName`, `lastName`, `jobTitle`, `phonePrimary`, `phoneSecondary`, `email`, `id`. Embed sous `provider.contacts` au détail ; éditables via la sous-ressource. **Max 2 téléphones** (`phonePrimary` + `phoneSecondary`). + +**`ProviderAddress`** — propriétés dans `['provider:item:read', 'provider:write:addresses']` : +`country`, `postalCode`, `city`, `street`, `streetComplement`, `id`. **PAS** de `addressType` / `bennes` / `triageProvider`. Relations imbriquées (maillon (c) — read-groups dans le contexte du `Get` racine) : +- M2M `sites` → `#[Groups(['provider:item:read'])]` ; `Site` expose `id`/`name`/`postalCode`/`city`/`color` en `site:read` (**pas de `code`**) (`Assert\Count(min:1)` — RG-3.05). +- M2M `contacts` → `#[Groups(['provider:item:read'])]` ; embarque des `ProviderContact`. +- M2M `categories` → `#[Groups(['provider:item:read'])]` ; `Category` (id/code/name, type PRESTATAIRE — RG-3.09). + +**`ProviderRib`** — propriétés dans `['provider:read:accounting', 'provider:write:accounting']` : +`label`, `bic`, `iban`, `id`. Embed sous `provider.ribs` **uniquement** si l'user a `accounting.view`. Aucun `#[AuditIgnore]` sur `iban`/`bic`. + +> ⚠ `Site` / `Category` / référentiels comptables appartiennent à d'autres modules — on consomme leurs read-groups (`site:read`, `category:read`, `provider:read:accounting` pour les réfs compta), **pas de logique inter-module** (§ 2.1). + +## 4. API REST (API Platform) + +### 4.0 Contrat de sérialisation (RETEX M1 — section critique) + +> **Leçon M1/M2** : ~80 % des frictions venaient du contrat de sérialisation. Pour **chaque champ affiché** (liste OU détail), les **3 maillons** doivent être prouvés : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent. + +**Contexte par opération** : + +| Opération | `normalizationContext` (groupes) | +|---|---| +| `GetCollection` (liste) | `provider:read` + `category:read` + `site:read` + `default:read` | +| `Get` (détail) | `provider:read` + `provider:item:read` + `provider:read:accounting`¹ + `category:read` + `site:read` + `default:read` | + +¹ `provider:read:accounting` retiré par le `ProviderProvider` / `ProviderReadGroupContextBuilder` si l'user n'a pas `technique.providers.accounting.view`. + +**LISTE — champ datatable → maillons** : + +| Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) | +|---|---|---|---| +| Nom | `companyName` ∈ `provider:read` | ✅ | — | +| Catégories | `categories` ∈ `provider:read` (embed) | ✅ | `category:read` ✅ (code/**name**) | +| Site | `sites` ∈ `provider:read` (embed, relation **directe** — RG-3.03) | ✅ | `site:read` ✅ (**name**/postalCode, pas de code) | +| Dernière activité | `updatedAt` ∈ `provider:read` | ✅ | — | + +**DÉTAIL — champ → maillons** : + +| Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) | +|---|---|---|---| +| Scalaires principaux | `provider:read` | ✅ | — | +| `categories[]` (id/code/name) | `categories` ∈ `provider:read` | ✅ | `category:read` ✅ | +| `sites[]` (formulaire principal) | `sites` ∈ `provider:read` | ✅ | `site:read` ✅ | +| `contacts[]` (5 champs) | `contacts` ∈ `provider:item:read` | ✅ | propriétés `ProviderContact` ∈ `provider:item:read` ✅ | +| `addresses[]` (scalaires) | `addresses` ∈ `provider:item:read` | ✅ | propriétés `ProviderAddress` ∈ `provider:item:read` ✅ | +| `addresses[].sites[]` | `sites` ∈ `provider:item:read` | ✅ | `site:read` ✅ | +| `addresses[].categories[]` | `categories` ∈ `provider:item:read` | ✅ | `category:read` ✅ | +| `addresses[].contacts[]` | `contacts` ∈ `provider:item:read` | ✅ | propriétés `ProviderContact` ∈ `provider:item:read` ✅ | +| Scalaires Comptabilité | `provider:read:accounting` | ✅ (gated) | réfs (`tvaMode`…) id+label ∈ `provider:read:accounting` | +| `ribs[]` (label/bic/iban) | `ribs` ∈ `provider:read:accounting` | ✅ (gated) | — | + +### 4.0.bis Réponses JSON de référence (DoD — à CAPTURER sur l'API réelle) + +> **Definition of Done** (miroir ERP-92 du M2) : avant de démarrer les écrans front, **capturer les réponses RÉELLES** via un test PHPUnit (`ProviderSerializationContractTest`, prestataire complet seedé) et les coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. **Ne jamais déclarer un champ « embarqué » sans l'avoir vu dans un JSON réel** (règle anti-régression M2). +> +> **2 pièges hérités M1/M2 à re-tester sur le M3** : +> 1. Réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) : doivent sortir en **objet `{id, code, label}`**, pas en IRI nu → vérifier que les entités partagées portent bien le groupe `provider:read:accounting` (sinon les annoter, comme le fix ERP-92 l'a fait pour `supplier:read:accounting`). +> 2. Gating compta par **omission de clé** : pour un user sans `accounting.view`, les clés `siren`/`tvaMode`/`ribs`/… sont **absentes** (pas `null`). + +> **✅ Capturé sur l'API réelle (ERP-139)** via `ProviderSerializationContractTest::testDodReferenceJsonShape` (`PROVIDER_DOD_DUMP=1`). Les `id`/`companyName`/noms de catégorie ci-dessous proviennent du prestataire seedé par le test (`seedCompleteProvider`) ; la **forme** (clés, embed, gating) est le contrat réel à respecter côté front. + +`GET /api/providers` (liste, ADMIN avec `accounting.view` — un membre, capture réelle) : +```json +{ + "@context": "/api/contexts/Provider", + "@id": "/api/providers", + "@type": "Collection", + "totalItems": 1, + "member": [ + { + "@id": "/api/providers/572", + "@type": "Provider", + "id": 572, + "companyName": "DOD21AADC 0E3CCE", + "categories": [ + { + "@type": "Category", + "@id": "/api/categories/3006", + "id": 3006, + "name": "test_prov_cat_nettoyage", + "code": "NETTOYAGE", + "categoryTypes": [ + {"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00" + } + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "siren": "987654321", + "accountNumber": "P0001", + "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00987654321", + "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"}, + "ribs": [ + {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00", + "isArchived": false + } + ], + "view": {"@id": "/api/providers?search=DoD21aadc", "@type": "PartialCollectionView"} +} +``` + +> Les `sites[]` de la liste sont la **relation directe** `provider.sites` (formulaire principal — RG-3.03), objet `Site` entier (pas un IRI nu). Les catégories embarquent `code` + `name`. Les prestataires archivés sont **exclus** du `totalItems` (RG-3.16). Pour un profil **sans `accounting.view`** (ex. Commerciale), `siren`/`accountNumber`/`tvaMode`/`nTva`/`paymentDelay`/`paymentType`/`bank`/`ribs` **disparaissent** de chaque membre (gating par omission — cf. détail restreint ci-dessous). + +`GET /api/providers/{id}` (détail — user **avec** `accounting.view`, capture réelle) : +```json +{ + "@context": "/api/contexts/Provider", + "@id": "/api/providers/572", + "@type": "Provider", + "id": 572, + "companyName": "DOD21AADC 0E3CCE", + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "siren": "987654321", + "accountNumber": "P0001", + "tvaMode": {"@id": "/api/tva_modes/13", "@type": "TvaMode", "id": 13, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00987654321", + "paymentDelay": {"@id": "/api/payment_delays/8", "@type": "PaymentDelay", "id": 8, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/10", "@type": "PaymentType", "id": 10, "code": "LCR", "label": "LCR"}, + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "addresses": [ + { + "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35, + "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00" + } + ], + "ribs": [ + {"@id": "/api/provider_ribs/60", "@type": "ProviderRib", "id": 60, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00", + "isArchived": false +} +``` + +`GET /api/providers/{id}` (même prestataire, user **sans** `accounting.view` — capture réelle) : +```json +{ + "@context": "/api/contexts/Provider", + "@id": "/api/providers/572", + "@type": "Provider", + "id": 572, + "companyName": "DOD21AADC 0E3CCE", + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "addresses": [ + { + "@id": "/api/provider_addresses/35", "@type": "ProviderAddress", "id": 35, + "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", + "sites": [ + {"@type": "Site", "@id": "/api/sites/28", "id": 28, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/29", "id": 29, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "createdAt": "2026-06-12T10:51:22+02:00", "updatedAt": "2026-06-12T10:51:22+02:00", "fullAddress": "Z i\n17400 Fontenet"} + ], + "contacts": [ + {"@id": "/api/provider_contacts/50", "@type": "ProviderContact", "id": 50, "firstName": "Marie", "lastName": "Martin", "jobTitle": "Responsable", "phonePrimary": "0612345678", "email": "marie.martin@seed.test", "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "categories": [ + {"@type": "Category", "@id": "/api/categories/3006", "id": 3006, "name": "test_prov_cat_nettoyage", "code": "NETTOYAGE", "categoryTypes": [{"@id": "/api/category_types/586", "@type": "CategoryType", "id": 586, "code": "PRESTATAIRE", "label": "Prestataire"}], "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00"} + ], + "createdAt": "2026-06-12T15:17:29+02:00", "updatedAt": "2026-06-12T15:17:29+02:00" + } + ], + "createdAt": "2026-06-12T15:17:29+02:00", + "updatedAt": "2026-06-12T15:17:29+02:00", + "isArchived": false +} +``` + +> **Gating par omission confirmé sur le JSON réel** : pour un user **sans** `accounting.view`, les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank` **et `ribs`** sont **absentes** (pas `null`). `isArchived`, `contacts[]`, `addresses[]` (avec `sites[]`/`contacts[]`/`categories[]`) restent exposés. Vérifié par `ProviderSerializationContractTest::testRibsAbsentForUserWithoutAccountingView` + `testAccountingScalarsGatedByOmission`. +> +> **Réfs comptables = objets embarqués `{id, code, label}`** (pas IRI nu) : le fix ERP-139 a ajouté `provider:read:accounting` sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` (réplique du fix ERP-92 du M2). Vérifié par `testAccountingReferentialsEmbedIdCodeLabel`. + +### 4.1 `GET /api/providers` — Liste + +- **Security** : `is_granted('technique.providers.view')` +- **Query params** (alimentent le panneau « Filtrer ») : + - `includeArchived=true|false` (default `false`) + - `categoryCode=` (filtre les prestataires ayant ≥ 1 `Category` de ce code ; répétable) + - `siteId=` (filtre via la relation **directe** `provider.sites` ; répétable) — *NB : au M3 le site est porté par le prestataire, le filtre joint `provider_site` (pas les adresses).* + - `search=` (fuzzy sur `companyName` + contacts liés `provider_contact` (firstName / lastName / email) via LEFT JOIN groupé par `provider.id`) +- **Tri par défaut** : `companyName ASC` +- **Cloisonnement par site (§ 2.13)** : si l'user **n'a pas** `sites.bypass_scope`, la liste est filtrée sur les prestataires dont `provider.sites` contient le `currentSite` (RG-3.17). Transparent pour le client (pas de query param). +- **Pagination** : standard Starseed (règle ABSOLUE n°13) — Hydra, 10/page, `?pagination=false` pour les selects. `ProviderProvider` branché sur `ApiPlatform\Doctrine\Orm\Paginator`. ⚠️ Le filtre de cloisonnement s'applique **avant** la pagination (le `totalItems` reflète le périmètre de l'user). +- **Anti N+1 (§ 2.12)** : hydratation des `categories` + `sites` via requêtes `IN` bornées séparées (pas de fetch-join combiné). +- **Codes** : `200` / `401` / `403` + +### 4.2 `GET /api/providers/{id}` — Détail + +- **Security** : `is_granted('technique.providers.view')` +- **Comportement** : prestataire + contacts + adresses + RIBs. Champs `provider:read:accounting` inclus seulement si `technique.providers.accounting.view`. +- **Cloisonnement par site (§ 2.13)** : un user sans `sites.bypass_scope` qui demande un prestataire **hors de son site courant** → **404** (ne pas révéler l'existence hors périmètre — RG-3.17). +- **Codes** : `200` / `404` / `401` / `403` + +### 4.3 `POST /api/providers` — Création (formulaire principal) + +- **Security** : `is_granted('technique.providers.manage')` +- **Body** (groupe `provider:write:main`) : +```json +{ + "companyName": "MAINTENANCE PRO SAS", + "categories": ["/api/categories/300"], + "sites": ["/api/sites/87"] +} +``` +- **Réponse 201** : le prestataire créé avec son `id`. Le front enchaîne les PATCH par onglet. +- **Codes** : + - `201` / `400` / `401` / `403` + - `409 Conflict` si doublon de nom (`companyName` — RG-3.10). SIREN/email non uniques. + - `422` : catégories vides (RG-3.09) ; sites vides (RG-3.03) ; catégorie hors type PRESTATAIRE (RG-3.09). + +### 4.4 `PATCH /api/providers/{id}` — Modification + +- **Security base** : `is_granted('technique.providers.manage')` +- **Security additionnelle** (dans le `ProviderProcessor`) : + - payload contenant un champ `provider:write:accounting` → exige `technique.providers.accounting.manage` + - payload contenant `isArchived` → exige `technique.providers.archive` + - **mode strict** (RG-3.15) : payload mélangeant des groupes hors permissions → 403 sur tout le payload. +- **Body** : merge-patch+json, champs modifiés uniquement. +- **Codes** : `200` / `400` / `401` / `403` / `404` / `409` / `422` + +### 4.5 Sous-ressources + +**Contacts** : `POST /api/providers/{id}/contacts`, `PATCH /api/provider_contacts/{id}`, `DELETE /api/provider_contacts/{id}`. +- **Security** : `is_granted('technique.providers.manage')` +- **RG-3.12** : au moins 1 bloc Contact valide pour finaliser l'onglet côté front. Côté back, la collection peut rester vide (pas de state machine). + +**Adresses** : `POST /api/providers/{id}/addresses`, `PATCH /api/provider_addresses/{id}`, `DELETE /api/provider_addresses/{id}`. +- **Security** : `is_granted('technique.providers.manage')` +- Validations : ≥ 1 site (RG-3.05) ; catégories de type PRESTATAIRE uniquement (RG-3.09) ; `postalCode` matche `^[0-9]{4,5}$` (RG-3.06). + +**RIBs** : `POST /api/providers/{id}/ribs`, `PATCH /api/provider_ribs/{id}`, `DELETE /api/provider_ribs/{id}`. +- **Security** : `is_granted('technique.providers.accounting.manage')` +- **RG-3.08** : si `paymentType.code = LCR`, suppression du dernier RIB → 409. + +### 4.6 `GET /api/providers/export.xlsx` — Export + +- **Security** : `is_granted('technique.providers.view')` +- **Comportement** : XLSX des prestataires **affichés** (mêmes filtres que la liste, non archivés par défaut). +- Colonnes : Nom prestataire, Contact principal (Nom + Prénom), Téléphone principal, Téléphone secondaire, Email, Catégories (CSV), Sites (CSV), SIREN (omis si pas `accounting.view`), Date de création. _(Colonnes contact alimentées depuis le contact principal `provider_contact` de plus petit `position`.)_ +- **Implémentation** : controller custom `ProviderExportController` avec `#[Route(priority: 1)]` (règle ABSOLUE — conflit API Platform `{id}`). Lib : PhpSpreadsheet (déjà présente). +- **Réponse 200** : `Content-Disposition: attachment; filename="repertoire-prestataires-{YYYYMMDD}.xlsx"` + +### 4.7 Référentiels (réutilisés M1/M2 — évolution security) + +`GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent. **Évolution M3** : élargir leur `security` pour autoriser aussi les rôles prestataires, p.ex. `... or is_granted('technique.providers.view')`. Tri `position ASC` puis `label ASC`. Pas d'écriture exposée (HP). + +`GET /api/categories?typeCode=PRESTATAIRE` alimente les multi-selects Catégorie (prestataire + adresse). ✅ **Le filtre `?typeCode=` existe** (créé au M2) — il suffit de **seeder le type `PRESTATAIRE`** + ses catégories. **À vérifier** que le filtre fonctionne pour ce nouveau type (DoD). + +## 5. Autorisation + +### 5.1 Déclaration des permissions + +Créer `TechniqueModule::permissions()` : + +```php +['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'], +``` + +Synchronisation : `php bin/console app:sync-permissions`. + +### 5.2 Mapping rôles MALIO ↔ permissions + +Cf. § 2.9 (matrice détaillée — identique à la matrice M2 transposée sur `technique.providers`) + § 2.13 (cloisonnement par site via `sites.bypass_scope`). **Attribution `sites.bypass_scope` par défaut** : Admin (auto) + Bureau + Compta + Commerciale ; **Usine non** (cloisonnée à son site). + +### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8) + +1. **`config/sidebar.php`** — **nouvelle section « Technique »** + item : +```php +[ + 'key' => 'technique', + 'label' => 'sidebar.technique.section', + 'items' => [ + [ + 'label' => 'sidebar.technique.providers', + 'to' => '/providers', + 'icon' => 'mdi:account-wrench-outline', + 'module' => 'technique', + 'permission' => 'technique.providers.view', + ], + ], +], +``` + +2. **`frontend/tests/e2e/_fixtures/personas.ts`** — étendre les personas existants : + - Admin : `view` + `manage` + `accounting.view` + `accounting.manage` + `archive` + - Bureau : `view` + `manage` + - Compta : `view` + `accounting.view` + `accounting.manage` + - Commerciale : `view` + `manage` + `sites.bypass_scope` + - Bureau / Compta : + `sites.bypass_scope` (voient tous les sites) + - Usine : `view` **sans** `sites.bypass_scope` → cloisonné à son site (§ 2.13). Persona avec un `currentSite` positionné pour tester le filtre. + +3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back des mêmes personas. + +> ⚠ Les 3 sources doivent être touchées dans le **même commit** (sinon drift / test cassé). + +### 5.4 Vérification front + +- `usePermissions()` filtre l'item sidebar et masque l'onglet Comptabilité (`technique.providers.accounting.view`). +- Bouton « Archiver » visible si `technique.providers.archive` (Admin seul). + +## 6. Audit & dates + +- `Provider`, `ProviderContact`, `ProviderAddress`, `ProviderRib` : `#[Auditable]`, tous champs audités (y compris `iban`/`bic`). +- Audit M2M automatique sur `provider.categories` et `provider.sites`. +- Timestampable + Blamable : pattern Shared standard (§ 2.8). +- Libellés i18n `audit.entity.technique_*` (§ 2.7). + +## 7. Règles de gestion (RG) + +> Les RG-3.03 → RG-3.08 reprennent le docx source. RG-3.01 / RG-3.02 sont **supprimées** (refonte-contact). Les RG-3.09 → RG-3.16 sont des **précisions back** (miroir M2) explicitement marquées. + +### Formulaire principal + +- ~~**RG-3.01**~~ _(SUPPRIMÉE — refonte-contact, 11/06)_ : le contact principal inline (Nom OU Prénom) est retiré du formulaire principal. Garantie « au moins un contact nommé » portée par **RG-3.04** + **RG-3.12** sur `ProviderContact`. +- ~~**RG-3.02**~~ _(SUPPRIMÉE du formulaire principal — refonte-contact)_ : plus de téléphones inline sur le formulaire principal. Le « maximum 2 téléphones » reste applicable aux blocs `ProviderContact` (`phonePrimary` + `phoneSecondary`). +- **RG-3.03** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur le **formulaire principal** pour valider la création. `Assert\Count(min: 1)` sur `provider.sites` (M2M `provider_site`). **Spécificité M3** (le fournisseur n'avait pas de site sur le formulaire principal). **Écriture cloisonnée (§ 2.13)** : un user sans `sites.bypass_scope` ne peut choisir que des sites de ses `user_site` (sinon 422). + +### Onglet Contact + +- **RG-3.04** : Un bloc Contact est valide dès qu'**au moins 1 champ** est rempli (Nom, Prénom, Fonction, Téléphone ou Email). CHECK BDD `chk_provider_contact_name` (garde-fou minimal). Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a aucun champ rempli. + +### Onglet Adresse + +- **RG-3.05** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur **chaque adresse**. `Assert\Count(min: 1)` sur `providerAddress.sites` (M2M `provider_address_site`). +- **RG-3.06** : `city` préremplie depuis `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr), appel **direct front** via `useAddressAutocomplete()` (réutilisé M1/M2). Si plusieurs villes correspondent → choix dans le select. Cas dégradé (API down) : Ville en texte libre + toast. Validation serveur : `postalCode` matche `^[0-9]{4,5}$` ; pas de contrôle strict de cohérence CP/Ville. + +### Onglet Comptabilité + +- **RG-3.07** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'` (options SG / CIC / CA). Validation server-side dans le `ProviderProcessor` : `payment_type = VIREMENT` et `bank IS NULL` → 422. +- **RG-3.08** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si `paymentType.code = 'LCR'` : + - `paymentType = LCR` ET `provider.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ». + - DELETE du dernier RIB d'un prestataire en LCR → 409. + - Autres types : RIBs optionnels (0..n). + +### Précisions back (miroir M2) + +- **RG-3.09** _(précision back)_ : les `Category` posées sur `provider.categories` ET sur `provider_address.categories` doivent être de **type `PRESTATAIRE`**. Toute catégorie d'un autre type → **422** (`categories: "Type de catégorie non autorisé (PRESTATAIRE attendu)."`). Front : multi-selects alimentés par `GET /api/categories?typeCode=PRESTATAIRE`. +- **RG-3.10** _(précision back)_ : `companyName` unique (case-insensitive) parmi les prestataires non archivés ET non soft-deletés (index partiel `uq_provider_company_name_active`). Doublon → 409 « Un prestataire nommé "{companyName}" existe déjà. » SIREN et email **non** uniques (§ 2.6). +- **RG-3.11** _(normalisation serveur)_ : `companyName` **UPPERCASE** ; `firstName`/`lastName` (sur `ProviderContact`) **Capitalize** ; téléphones **chiffres uniquement** ; `email` **lowercase**. Formatage `XX XX XX XX XX` à l'affichage front. +- **RG-3.12** _(front-driven)_ : au moins 1 bloc Contact valide pour finaliser l'onglet (cf. RG-3.04). Pas de test back. +- **RG-3.13** _(archivage)_ : PATCH `{ "isArchived": true }` exige `technique.providers.archive` (**Admin seul**). Pose `isArchived = true` + `archivedAt = now()`. Aucun autre champ dans la même requête. +- **RG-3.14** _(restauration)_ : PATCH `{ "isArchived": false }` exige la même permission. Pose `isArchived = false` + `archivedAt = null`. Conflit d'unicité (un autre prestataire actif a pris le nom) → 409. +- **RG-3.15** _(PATCH mix de groupes, mode strict)_ : un PATCH mélangeant plusieurs groupes alors que l'user n'a pas toutes les permissions → **403 sur tout le payload** (pas de filtrage silencieux). Le front ne doit jamais envoyer de champ hors-permission. +- **RG-3.16** _(liste / tri)_ : `GET /api/providers` exclut par défaut archivés (`is_archived = TRUE`) + soft-deletés (`deleted_at IS NOT NULL`). `?includeArchived=true` inclut les archivés (pas les soft-deletés). Tri par défaut `companyName ASC`. +- **RG-3.17** _(cloisonnement par site — § 2.13)_ : un user **sans** `sites.bypass_scope` ne voit (liste + détail) que les prestataires dont `provider.sites` contient son `currentSite`. Liste : filtrée avant pagination (`totalItems` = périmètre user). Détail hors périmètre → **404**. Users `bypass_scope` (Admin auto) → tous sites. Cloisonnement **piloté par l'utilisateur, pas par le rôle**. + +## 8. Tests à automatiser + +### 8.1 Cas à couvrir (back — PHPUnit) + +- [ ] **RG-3.03** : POST prestataire sans site → 422 ; avec ≥ 1 site → 201 +- [ ] **RG-3.04** : POST contact totalement vide → 422 (CHECK) ; 1 champ rempli → 200 +- [ ] **RG-3.05** : POST adresse sans aucun site → 422 +- [ ] **RG-3.06** : POST adresse `postalCode` invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict) +- [ ] **RG-3.07** : POST Comptabilité `paymentType=VIREMENT` sans `bank` → 422 ; avec `bank` → 200 +- [ ] **RG-3.08** : POST `paymentType=LCR` sans RIB → 422 ; DELETE du dernier RIB en LCR → 409 +- [ ] **RG-3.09** : POST `categories` avec une `Category` de type ≠ PRESTATAIRE → 422 (sur provider ET sur provider_address) +- [ ] **RG-3.10** : POST `companyName` déjà pris → 409 ; même nom après archivage de l'ancien → 201 ; SIREN/email dupliqués → 201 +- [ ] **RG-3.11** : POST `companyName="maintenance pro"` → persiste `"MAINTENANCE PRO"` ; normalisation `firstName`/`phonePrimary`/`email` testée via un bloc `ProviderContact` +- [ ] **RG-3.13/14** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; restauration en conflit de nom → 409 +- [ ] **RG-3.15** : Bureau PATCH `{companyName, siren}` → 403 sur tout le payload (strict) +- [ ] **RG-3.16** : GET liste sans flag → exclut archivés ; `?includeArchived=true` → inclut ; tri `companyName ASC` +- [ ] **RBAC** : Bureau / Commerciale / Compta / Usine sur chaque permission (matrice § 2.9) — 200/403 selon le verbe +- [ ] **🔴 Cloisonnement par site (RG-3.17 / § 2.13)** : user **sans** `bypass_scope`, `currentSite = 86` → la liste ne contient QUE les prestataires rattachés au site 86 (assertion sur `member` + `totalItems`) ; GET détail d'un prestataire site 17 → **404** ; user `bypass_scope` (admin) → voit tous les sites ; **écriture cloisonnée** : POST/PATCH par un user non-bypass avec un site hors de ses `user_site` (formulaire principal OU adresse) → 422 ; avec uniquement ses propres sites → 201/200 +- [ ] **Compta** : GET prestataire retourne les champs accounting ; PATCH accounting → 200 ; PATCH contacts/adresses → 403 ; POST création → 403 +- [ ] **Commerciale** : GET prestataire **sans** les champs accounting ; onglet Comptabilité masqué +- [ ] **🔴 Gating RIB (bug #4 M1)** : GET détail en tant que Commerciale → la clé `ribs` est **ABSENTE** (assertion sur le corps JSON) +- [ ] **🔴 Sérialisation booléen (bug #3 M1)** : GET détail expose bien la clé `isArchived` dans le JSON réel +- [ ] **Embed relations (bugs #1/#2 M1)** : GET **liste ET détail** → `categories[].code` + `.name` présents ; `sites[]` (relation directe) exposent `name` + `postalCode` (objet Site entier, PAS un IRI nu) ; `addresses[].sites[]` au détail +- [ ] **Filtre typeCode** : `GET /api/categories?typeCode=PRESTATAIRE` ne renvoie QUE les catégories de type PRESTATAIRE +- [ ] **Anti N+1 liste (§ 2.12)** : sur `GET /api/providers` avec N prestataires, nombre de requêtes SQL constant +- [ ] **Audit** : POST + PATCH + archive → audit_log `entity_type='Provider'`, `changes` correct ; iban/bic présents dans le diff ; M2M `sites`/`categories` tracés +- [ ] **Pagination** (règle n°13) : enveloppe Hydra (`totalItems` / `view`) ; `?pagination=false` renvoie tout +- [ ] **Migration** : `make db-reset` → schéma OK ; namespace racine ; CategoryType PRESTATAIRE présent APRÈS db-reset (fixture idempotente) ; index partiel `uq_provider_company_name_active` présent ; **toutes les colonnes ont un `COMMENT ON COLUMN`** (`ColumnsHaveSqlCommentTest` vert) +- [ ] **i18n audit** : `audit.entity.technique_provider`… présents (`AuditableEntitiesHaveI18nLabelTest` vert) + +### 8.2 Cas à couvrir (front — Vitest) + +- [ ] `usePaginatedList({url:'/providers'})` : exclusion archivés par défaut, envelope Hydra +- [ ] `useProviderForm()` : workflow par onglet (validation incrémentale, PATCH partiel) — **sans onglet Information** +- [ ] `useAddressAutocomplete()` : réutilisation M1/M2 (nominal + dégradé) — pas de nouveau test si déjà couvert +- [ ] Sélecteur de site formulaire principal (RG-3.03) : ≥ 1 requis +- [ ] `` : `` + « + Ajouter » → `/providers/new` +- [ ] Permissions : Compta accède à `/providers/{id}` mais onglet Comptabilité éditable seul ; Commerciale ne voit pas l'onglet Comptabilité +- [ ] `useFormErrors` : mapping 422 inline par champ (formulaire principal + blocs) + +### 8.3 Tests E2E + +**Non prévus au M3** (règle ABSOLUE n°7). Extension des personas existants pour ajouter les permissions `technique.providers.*` — cf. § 5.3. + +### 8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec) + +`ProviderFixtures` idempotent couvrant tous les cas des RG : +- Catégories de type PRESTATAIRE seedées (au moins « Maintenance industrielle », « Nettoyage », « Transport »). +- ≥ 1 prestataire **complet** (≥ 1 site sur le formulaire principal, ≥ 1 contact, ≥ 1 adresse multi-sites, comptabilité + RIB). +- 1 prestataire **en LCR avec RIB** (RG-3.08) et 1 **en VIREMENT avec banque** (RG-3.07). +- 1 prestataire **archivé** (vérifier exclusion liste + restauration). +- Réutiliser les comptes de rôles démo (`bureau`, `compta`, `commerciale`, `usine`, `admin`). + +> Idempotence obligatoire (le purger Doctrine vide `category`/`category_type` au `db-reset`). Le `CategoryType PRESTATAIRE` est seedé **en migration ET en fixture**. + +### 8.5 Checklist RETEX (à cocher avant « spec prête ») + +- [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0) +- [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only** +- [x] **Réponses JSON RÉELLES** capturées (§ 4.0.bis) — liste + détail (avec/sans `accounting.view`) collés depuis `ProviderSerializationContractTest` (ERP-139) +- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-3.15) +- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit + i18n, routes à plat : rappelés +- [x] Réutilisations M1/M2 identifiées (référentiels compta partagés, taxonomie code/type, filtre `?typeCode=`, `usePaginatedList`, blocs, archive, normalisation, `useAddressAutocomplete`) +- [x] Seed/fixtures démo planifiés (§ 8.4) +- [x] **Décisions tranchées (Matthieu, 11/06)** : module `Technique` (§ 2.1) ✅ ; référentiels comptables « comme supplier » (ORM partagée) ✅ ; cloisonnement par site piloté user via `sites.bypass_scope` (§ 2.13 / RG-3.17) ✅ ; unicité nom seul (§ 2.6) ✅ + +## 9. Hors-périmètre (HP) + +- **HP-M4-2** : **Remontée des référentiels comptables dans `Shared`** (ou module neutre) si isolation stricte souhaitée (cf. § 2.1). _NB : décision M3 = consommation ORM partagée, comme `Supplier` (validée Matthieu, 11/06)._ +- _**(ex-HP-M4-1 — DÉSORMAIS DANS LE PÉRIMÈTRE M3)**_ : le **cloisonnement par site** (visibilité prestataires selon le site de l'utilisateur) est implémenté au M3 — cf. § 2.13 + RG-3.17. Le « bypass multi-sites » passe par `sites.bypass_scope`. +- **HP-M4-3** : **DELETE / soft delete d'un prestataire** (colonne `deleted_at` préparée, non exposée au M3). +- **HP-M4-4** : **CRUD admin des référentiels comptables** (`TvaMode` / `PaymentDelay` / `PaymentType` / `Bank`) — partagés, seed seulement. +- **HP-M4-5** : **CRUD admin de `CategoryType`** (le M3 seed seulement le type PRESTATAIRE). +- **HP-M4-6** : **Onglet Rapports** (front placeholder « À venir » ; aucun modèle ni API back). +- **HP-M4-7** : **Onglet Échanges** (placeholder « À venir »). +- **HP-M4-8** : **Validation IBAN/BIC stricte** (au M3, `Assert\Iban` / `Assert\Bic` standard sur `ProviderRib`). +- **HP-M4-9** : **Validation SIREN stricte** (Luhn) — au M3, `Assert\Length(9)` + `Assert\Regex('/^\d{9}$/')`. +- **HP-M4-10** : **Référencement entrant** (modules futurs ajoutant une FK `provider_id` : interventions, maintenance, etc.). +- **HP-M4-11** : **Export CSV** (XLSX uniquement au M3). +- **HP-M4-12** : **Liaison Prestataire ↔ Fournisseur / Client** (un même tiers multi-rôles). Au M3, entités strictement séparées. + +## 10. Liens & dépendances + +### Liens + +- Spec front : [`./spec-front.md`](./spec-front.md) +- Spec M2 fournisseurs (pattern de référence direct) : [`../M2-suppliers/spec-back.md`](../M2-suppliers/spec-back.md) +- Spec M1 clients : [`../M1-clients/spec-back.md`](../M1-clients/spec-back.md) +- RETEX sérialisation : [`../_RETEX-M1-pour-M2.md`](../_RETEX-M1-pour-M2.md) +- Doc audit-log : [`../../audit-log.md`](../../audit-log.md) +- Site-aware (périmètre Usine) : [`../../modules/site-aware.md`](../../modules/site-aware.md) +- BAN api : `https://adresse.data.gouv.fr/api-doc/adresse` +- Trace fonctionnelle : `M3-reportoire-prestataires.docx` (V0.2) / `M3-reportoire-prestataires-V01.pdf` (V0.1, obsolète) + +### Dépendances amont (déjà en place dans Starseed) + +- Module `Commercial` : référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (**partagés**, relation ORM) +- Module `Catalog` (M0) : `Category` + `CategoryType` + **filtre `?typeCode=`** (créé au M2) (+ seed type PRESTATAIRE au M3) +- Module `Sites` : `Site` (3 sites 86/17/82) — M2M `provider_site` + `provider_address_site` +- Module `Core` : `User`, `Role`, `Permission`, `Audit`, JWT +- `Shared` : `TimestampableBlamableTrait` + `Subscriber` +- API Platform 4 + Doctrine ORM + PostgreSQL 16 + PhpSpreadsheet (export) + +### Specs futures qui dépendent du M3 + +- **M-Interventions / Maintenance** : FK `provider_id`. + +--- + +## 📦 Tickets Lesstime (à découper) + +**TaskGroup Lesstime** : à créer — `M3 — Répertoire prestataires` (projet `ERP / Starseed`, projectId=6). + +Ordre indicatif (back avant front, migration en tête) : +0. **Module `Technique` + Taxonomie PRESTATAIRE** — créer `TechniqueModule` (ID/LABEL/REQUIRED/permissions) + activer dans `config/modules.php` + layer front `modules/technique/` ; seed `CategoryType PRESTATAIRE` (migration `ON CONFLICT` + fixture idempotente) + catégories prestataires ; **vérifier** que le filtre `?typeCode=PRESTATAIRE` fonctionne. Prérequis du multi-select Catégorie. +1. **Migration BDD M3** (tables provider + provider_site + sous-collections + M2M + index partiel + COMMENT ON COLUMN) +2. **Entités + Repositories** (Provider, ProviderContact, ProviderAddress, ProviderRib) + **hydratation liste** (categories, sites — § 2.12) +3. **Provider + Processor** (ProviderProvider paginé, ProviderProcessor — normalisation, archivage, accounting conditionnel, mode strict, gating) + **filtre de cloisonnement par site** (§ 2.13 / RG-3.17 : `ProviderSiteScopeExtension` réutilisant `CurrentSiteProvider` + `sites.bypass_scope` ; liste filtrée, détail 404 hors périmètre) +4. **Sous-ressources** (ProviderContactProcessor, ProviderAddressProcessor, ProviderRibProcessor) +5. **Validators** (contrôle catégorie type PRESTATAIRE, RG-3.07/3.08, ≥1 site formulaire principal RG-3.03) +6. **Export XLSX** (ProviderExportController, priority:1) +7. **RBAC** : `TechniqueModule::permissions()` + sync 3 sources + tests personas +8. **Tests PHPUnit** : matrice RG-3.03 → RG-3.16 (§ 8.1) + capture JSON réel (§ 4.0.bis) +9. **Front : page Répertoire** (`/providers`) + `usePaginatedList` +10. **Front : page Création** (`/providers/new`) + `useProviderForm` (sans onglet Information) +11. **Front : page Consultation** (`/providers/{id}`) + onglets placeholder « À venir » (Rapports / Échanges) +12. **Front : page Modification** (`/providers/{id}/edit`) +13. **i18n + Sidebar** (section `sidebar.technique.section` + `sidebar.technique.providers` + permission, traductions, libellés audit) + +### Actions manuelles dans Lesstime (Matthieu) + +1. Créer le TaskGroup `M3 — Répertoire prestataires` (projet ERP / Starseed, projectId=6). +2. Créer les ~14 tickets ci-dessus (ticket 0 module+taxonomie inclus) avec dépendances séquentielles. +3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel. + +### ✅ Décisions tranchées (Matthieu, 11/06/2026) + +1. **Module `Technique`** (§ 2.1) — nouveau module back + section sidebar « Technique ». ✅ +2. **Référentiels comptables** — « comme supplier » : consommation ORM partagée (pas de remontée dans `Shared`). ✅ +3. **Cloisonnement par site** (§ 2.13 / RG-3.17) — visibilité pilotée par l'**utilisateur** (son `currentSite`), automatique côté back ; bypass multi-sites via `sites.bypass_scope` (Admin auto + Bureau/Compta/Commerciale ; **Usine cloisonnée**). Indépendant du rôle. ✅ +4. **Unicité = nom seul** (§ 2.6). ✅ + +5. **Écriture cloisonnée** (§ 2.13 / RG-3.03 / RG-3.05) — un user non-bypass ne peut attacher que **les sites dont il dispose** (`user_site`), formulaire principal ET adresses ; site hors périmètre → 422. ✅ + +### ⚠️ Point de raffinement à confirmer (non bloquant) + +- **Attribution `sites.bypass_scope`** : confirmer la liste des profils « voient tous les sites » (défaut : Admin + Bureau + Compta + Commerciale ; Usine non). diff --git a/docs/specs/M3-prestataires/spec-front.md b/docs/specs/M3-prestataires/spec-front.md new file mode 100644 index 0000000..bc6720b --- /dev/null +++ b/docs/specs/M3-prestataires/spec-front.md @@ -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) | `` | `?search=` | +| **Catégorie** | `` (multi, type PRESTATAIRE) | `?categoryCode=` | +| **Site** | `` (86 / 17 / 82) | `?siteId=` | +| **Inclure les archivés** | `` | `?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 : `` branché sur `usePaginatedList({ 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)** | `` | Oui | RG-3.11 (UPPERCASE serveur) ; RG-3.10 (unicité) | +| **Catégorie** | `` (multi) | Oui | `Category` de **type PRESTATAIRE** via `GET /api/categories?typeCode=PRESTATAIRE` (RG-3.09). Libellé affiché = `category.name`. | +| **Sélecteur de site** | `` (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 » (``) → 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** | `` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) | +| **Prénom** | `` | Conditionnel | RG-3.04 + RG-3.11 (Capitalize) | +| **Fonction** | `` | Non | — | +| **Téléphone** (x1, +1 possible, **max 2**) | `` | Non | RG-3.11 (format) ; max 2 téléphones par contact | +| **Email** | `` 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** | `` (86 / 17 / 82) | Oui | RG-3.05 — ≥ 1 site. Stocke des IDs de Site (M2M `provider_address_site`). | +| **Adresse** | `` (saisie assistée) | Oui | RG-3.06 — autocomplete BAN | +| **Adresse complémentaire** | `` | Non | — | +| **Code postal** | `` (saisie assistée) | Oui | RG-3.06 — déclenche autocomplete ville (BAN) | +| **Ville** | `` (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** | `` (préremplie « France ») | Oui | — | +| **Catégories** | `` (multi) | Oui | Catégories de type PRESTATAIRE (RG-3.09) | +| **Contact** | `` (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** | `` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. [`spec-back.md § 2.6`](./spec-back.md)) | +| **Numéro de compte** | `` | Oui | — | +| **Mode de TVA** | `` | Oui | Liste depuis `/api/tva_modes` (référentiel partagé M1) | +| **N° de TVA** | `` | Oui | — | +| **Délai de règlement** | `` | Oui | Liste depuis `/api/payment_delays` | +| **Type de règlement** | `` | Oui | Liste depuis `/api/payment_types` | +| **Banque** | `` | 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é** | `` | Oui (si LCR) | RG-3.08 | +| **BIC** | `` | Oui (si LCR) | RG-3.08 | +| **IBAN** | `` | 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** : `` (+ `usePaginatedList`) +- **Input texte** : `` +- **Select simple** : `` (Pays, Ville, référentiels comptables) +- **Select multi (cases à cocher)** : `` (Catégorie, Sites, Contacts rattachés) +- **Bouton** : ``, `` +- **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 : `` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1/M2). + +## Composables & appels API + +- `usePaginatedList({ 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 `` 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). diff --git a/docs/specs/_RETEX-M1-pour-M2.md b/docs/specs/_RETEX-M1-pour-M2.md new file mode 100644 index 0000000..3d62c81 --- /dev/null +++ b/docs/specs/_RETEX-M1-pour-M2.md @@ -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([':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** : `//{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/` (liste) ET `GET /api//{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. diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index cd6a8ad..7e1bb0f 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -30,6 +30,10 @@ "clients": "Répertoire clients", "suppliers": "Répertoire fournisseurs" }, + "technique": { + "section": "Technique", + "providers": "Répertoire prestataires" + }, "core": { "roles": "Gestion des rôles", "users": "Utilisateurs", @@ -362,6 +366,130 @@ } } }, + "technique": { + "providers": { + "title": "Répertoire prestataires", + "add": "Ajouter", + "export": "Exporter", + "empty": "Aucun prestataire pour l'instant.", + "column": { + "companyName": "Nom", + "categories": "Catégories", + "sites": "Site", + "lastActivity": "Dernière activité" + }, + "filters": { + "title": "Filtres", + "search": "Recherche", + "categories": "Catégories", + "sites": "Sites", + "status": "Statut", + "includeArchived": "Inclure les archivés", + "apply": "Voir les résultats", + "reset": "Réinitialiser" + }, + "tab": { + "contact": "Contact", + "contacts": "Contacts", + "address": "Adresse", + "reports": "Rapports", + "exchanges": "Échanges", + "accounting": "Comptabilité" + }, + "action": { + "edit": "Modifier", + "archive": "Archiver", + "restore": "Restaurer" + }, + "consultation": { + "title": "Fiche prestataire", + "back": "Retour au répertoire", + "loading": "Chargement…", + "notFound": "Prestataire introuvable.", + "confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.", + "confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif." + }, + "edit": { + "title": "Modifier le prestataire", + "back": "Retour à la fiche", + "loading": "Chargement…", + "notFound": "Prestataire introuvable.", + "save": "Enregistrer" + }, + "form": { + "title": "Ajouter un prestataire", + "back": "Précédent", + "submit": "Valider", + "duplicateCompany": "Un prestataire portant ce nom de société existe déjà.", + "main": { + "companyName": "Nom du prestataire (Entreprise)", + "categories": "Catégorie", + "sites": "Site" + }, + "errors": { + "nameRequired": "Le nom du prestataire est obligatoire.", + "siteRequired": "Sélectionnez au moins un site.", + "categoryRequired": "Sélectionnez au moins une catégorie." + }, + "contact": { + "lastName": "Nom", + "firstName": "Prénom", + "jobTitle": "Fonction", + "email": "Email", + "phonePrimary": "Téléphone", + "phoneSecondary": "Téléphone (2)", + "addPhone": "Ajouter un numéro", + "remove": "Supprimer le contact", + "add": "Nouveau contact" + }, + "address": { + "sites": "Sites", + "categories": "Catégorie", + "contacts": "Contact(s) rattaché(s)", + "country": "Pays", + "postalCode": "Code postal", + "city": "Ville", + "street": "Adresse", + "streetNotFound": "Adresse introuvable ? Saisissez-la directement.", + "streetComplement": "Adresse complémentaire", + "remove": "Supprimer l'adresse", + "add": "Nouvelle adresse", + "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." + }, + "accounting": { + "siren": "SIREN", + "accountNumber": "Numéro de compte", + "tvaMode": "Mode de TVA", + "nTva": "N° de TVA", + "paymentDelay": "Délai de règlement", + "paymentType": "Type de règlement", + "bank": "Banque", + "ribLabel": "Libellé", + "ribBic": "BIC", + "ribIban": "IBAN", + "addRib": "Ajouter un RIB", + "removeRib": "Supprimer le RIB" + }, + "confirmDelete": { + "title": "Confirmer la suppression", + "cancel": "Annuler", + "confirm": "Supprimer", + "contact": "Supprimer ce contact ?", + "address": "Supprimer cette adresse ?", + "rib": "Supprimer ce RIB ?" + } + }, + "toast": { + "error": "Une erreur est survenue. Réessayez.", + "exportError": "L'export du répertoire prestataires a échoué. Réessayez.", + "createSuccess": "Prestataire créé avec succès", + "updateSuccess": "Prestataire mis à jour avec succès", + "addComplete": "Prestataire ajouté", + "archiveSuccess": "Prestataire archivé avec succès", + "restoreSuccess": "Prestataire restauré avec succès" + } + } + }, "auth": { "login": "Connexion", "logout": "Deconnexion", @@ -416,7 +544,11 @@ "commercial_supplier": "Fournisseur", "commercial_supplieraddress": "Adresse fournisseur", "commercial_suppliercontact": "Contact fournisseur", - "commercial_supplierrib": "RIB fournisseur" + "commercial_supplierrib": "RIB fournisseur", + "technique_provider": "Prestataire", + "technique_provideraddress": "Adresse prestataire", + "technique_providercontact": "Contact prestataire", + "technique_providerrib": "RIB prestataire" }, "empty": "Aucune activité enregistrée", "no_results": "Aucun résultat pour ces filtres", diff --git a/frontend/modules/technique/components/ProviderAddressBlock.vue b/frontend/modules/technique/components/ProviderAddressBlock.vue new file mode 100644 index 0000000..3d37def --- /dev/null +++ b/frontend/modules/technique/components/ProviderAddressBlock.vue @@ -0,0 +1,269 @@ + + + diff --git a/frontend/modules/technique/components/ProviderContactBlock.vue b/frontend/modules/technique/components/ProviderContactBlock.vue new file mode 100644 index 0000000..0db2702 --- /dev/null +++ b/frontend/modules/technique/components/ProviderContactBlock.vue @@ -0,0 +1,108 @@ + + + diff --git a/frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts b/frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts new file mode 100644 index 0000000..6159a15 --- /dev/null +++ b/frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, ref, computed } from 'vue' +import { emptyProviderAddress } from '~/modules/technique/types/providerForm' +import ProviderAddressBlock from '../ProviderAddressBlock.vue' + +// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2. +const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({ + searchCityMock: vi.fn(), + searchAddressMock: vi.fn(), +})) +vi.mock('~/shared/composables/useAddressAutocomplete', () => ({ + useAddressAutocomplete: () => ({ + searchCity: searchCityMock, + searchAddress: searchAddressMock, + }), +})) + +// Auto-imports Nuxt/Vue utilises sans import explicite par le composant. +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('ref', ref) +vi.stubGlobal('computed', computed) + +// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate. +const MalioInputAutocompleteStub = defineComponent({ + name: 'MalioInputAutocomplete', + props: { + modelValue: { type: [String, Number, null], default: undefined }, + options: { type: Array as () => { value: string | number, label: string }[], default: () => [] }, + loading: { type: Boolean, default: false }, + minSearchLength: { type: Number, default: 0 }, + label: { type: String, default: '' }, + readonly: { type: Boolean, default: false }, + allowCreate: { type: Boolean, default: false }, + }, + emits: ['update:modelValue', 'search', 'select'], + setup(props) { + return () => h('div', { + 'data-testid': 'addr-autocomplete', + 'data-options': JSON.stringify(props.options.map(o => o.value)), + }) + }, +}) + +function mountBlock(overrides: Record = {}, errors?: Record) { + return mount(ProviderAddressBlock, { + props: { + modelValue: { ...emptyProviderAddress(), ...overrides }, + categoryOptions: [], + siteOptions: [], + contactOptions: [], + countryOptions: [], + ...(errors ? { errors } : {}), + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioSelect: true, + MalioSelectCheckbox: true, + MalioInputText: true, + MalioInputAutocomplete: MalioInputAutocompleteStub, + }, + }, + }) +} + +describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => { + it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => { + const wrapper = mountBlock() + // Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3. + expect(wrapper.find('malio-input-number-stub').exists()).toBe(false) + expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false) + // Aucun select ne porte le label « type d'adresse ». + const hasAddressType = wrapper.findAll('malio-select-stub').some( + el => el.attributes('label') === 'technique.providers.form.address.addressType', + ) + expect(hasAddressType).toBe(false) + }) +}) + +describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => { + it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => { + const wrapper = mountBlock({}, { + sites: 'Au moins un site est obligatoire.', + categories: 'Au moins une catégorie est obligatoire.', + }) + const checkboxes = wrapper.findAll('malio-select-checkbox-stub') + const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites') + const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories') + + expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.') + expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.') + }) + + it('affiche l\'erreur serveur sur le code postal', () => { + const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' }) + const field = wrapper.findAll('malio-input-text-stub').find( + el => el.attributes('label') === 'technique.providers.form.address.postalCode', + ) + expect(field?.attributes('error')).toBe('Code postal invalide.') + }) +}) + +describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => { + beforeEach(() => { + searchCityMock.mockReset() + searchAddressMock.mockReset() + }) + + it('n\'appelle pas la BAN en deca de 3 caracteres', async () => { + const wrapper = mountBlock() + wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab') + await flushPromises() + expect(searchAddressMock).not.toHaveBeenCalled() + }) + + it('relance la recherche apres une erreur (pas de bascule definitive)', async () => { + searchAddressMock + .mockRejectedValueOnce(new Error('BAN indisponible')) + .mockResolvedValueOnce([ + { label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' }, + ]) + const wrapper = mountBlock() + const auto = wrapper.findComponent(MalioInputAutocompleteStub) + + auto.vm.$emit('search', 'rue du test') + await flushPromises() + auto.vm.$emit('search', 'rue du teste') + await flushPromises() + + expect(searchAddressMock).toHaveBeenCalledTimes(2) + }) + + it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => { + searchAddressMock.mockRejectedValue(new Error('BAN indisponible')) + const wrapper = mountBlock() + const auto = wrapper.findComponent(MalioInputAutocompleteStub) + + auto.vm.$emit('search', 'rue du test') + await flushPromises() + auto.vm.$emit('search', 'rue du teste') + await flushPromises() + + expect(wrapper.emitted('degraded')).toHaveLength(1) + }) + + it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => { + const wrapper = mountBlock() + expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true) + }) + + it('inclut la rue courante dans les options meme sans recherche BAN', () => { + const wrapper = mountBlock({ street: '1 rue du Test' }) + const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]') + expect(values).toContain('1 rue du Test') + }) +}) diff --git a/frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts b/frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts new file mode 100644 index 0000000..928a2d7 --- /dev/null +++ b/frontend/modules/technique/components/__tests__/ProviderContactBlock.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, ref, computed } from 'vue' +import { emptyProviderContact } from '~/modules/technique/types/providerForm' +import ProviderContactBlock from '../ProviderContactBlock.vue' + +// Auto-imports Nuxt/Vue utilises sans import explicite par le composant. +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('ref', ref) +vi.stubGlobal('computed', computed) + +/** Stub d'un champ Malio qui re-expose la prop `error` recue dans un data-* attribut. */ +function errorProbe(testid: string) { + return defineComponent({ + name: `Probe-${testid}`, + props: { + modelValue: { type: [String, Number, null], default: undefined }, + error: { type: String, default: '' }, + label: { type: String, default: '' }, + readonly: { type: Boolean, default: false }, + }, + setup(props) { + return () => h('div', { 'data-testid': testid, 'data-error': props.error }) + }, + }) +} + +function mountBlock(errors?: Record) { + return mount(ProviderContactBlock, { + props: { + modelValue: emptyProviderContact(), + ...(errors ? { errors } : {}), + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioInputPhone: true, + MalioInputText: errorProbe('contact-text'), + MalioInputEmail: errorProbe('contact-email'), + }, + }, + }) +} + +describe('ProviderContactBlock — mapping erreur par champ (ERP-101)', () => { + it('affiche l\'erreur serveur sur le champ email via la prop errors', () => { + const wrapper = mountBlock({ email: 'L\'adresse email n\'est pas valide.' }) + expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('L\'adresse email n\'est pas valide.') + }) + + it('laisse les champs sans erreur quand errors est absent', () => { + const wrapper = mountBlock() + expect(wrapper.find('[data-testid="contact-email"]').attributes('data-error')).toBe('') + }) +}) diff --git a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts new file mode 100644 index 0000000..a0ab65b --- /dev/null +++ b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts @@ -0,0 +1,653 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141). + * + * `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et + * l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la + * creation : + * - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie + * -> POST bloque, erreurs inline, aucun appel reseau. + * - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json + * + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact + + * reaffichage du nom normalise. + * - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName. + * - 422 -> mapping inline par champ (propertyPath). + * - Onglets : « Comptabilite » present uniquement avec accounting.view ; + * completeTab deverrouille/avance et signale le dernier onglet. + */ + +const mockPost = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) +// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite). +const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false })) + +vi.stubGlobal('useApi', () => ({ + get: vi.fn(), + post: mockPost, + put: vi.fn(), + patch: mockPatch, + delete: vi.fn(), +})) +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('useToast', () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), +})) +vi.stubGlobal('usePermissions', () => ({ + can: (perm: string) => { + if (perm === 'technique.providers.accounting.view') return permState.accountingView + if (perm === 'technique.providers.accounting.manage') return permState.accountingManage + return true + }, +})) + +const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm') +const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm') +type ProviderForm = ReturnType + +const SITE_86 = '/api/sites/1' +const CAT_MAINT = '/api/categories/7' + +/** Accede a un bloc contact (cast : sous noUncheckedIndexedAccess l'index est optionnel). */ +function contactAt(form: ProviderForm, index = 0) { + return form.contacts.value[index] ?? emptyProviderContact() +} + +/** Accede a un bloc adresse (idem). */ +function addressAt(form: ProviderForm, index = 0) { + return form.addresses.value[index] ?? emptyProviderAddress() +} + +describe('useProviderForm', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = false + permState.accountingManage = false + }) + + it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => { + const form = useProviderForm() + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired') + expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired') + expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired') + expect(form.mainLocked.value).toBe(false) + }) + + it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => { + const form = useProviderForm() + form.main.companyName = 'Maintenance Pro' + form.main.siteIris = [SITE_86] + + await form.submitMain() + + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.sites).toBeUndefined() + expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired') + }) + + it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => { + mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' }) + const form = useProviderForm() + form.main.companyName = 'Maintenance Pro' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const created = await form.submitMain() + + expect(created).toBe(true) + expect(mockPost).toHaveBeenCalledTimes(1) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/providers') + expect(body).toEqual({ + companyName: 'Maintenance Pro', + categories: [CAT_MAINT], + sites: [SITE_86], + }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + + expect(form.providerId.value).toBe(42) + // RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur. + expect(form.main.companyName).toBe('MAINTENANCE PRO') + expect(form.mainLocked.value).toBe(true) + expect(form.activeTab.value).toBe('contact') + expect(form.unlockedIndex.value).toBe(0) + }) + + it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => { + const form = useProviderForm() + form.main.companyName = ' ' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired') + }) + + it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => { + mockPost.mockRejectedValueOnce({ response: { status: 409 } }) + const form = useProviderForm() + form.main.companyName = 'Doublon' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany') + expect(form.mainLocked.value).toBe(false) + }) + + it('422 : mappe les violations serveur inline par champ', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] }, + }, + }) + const form = useProviderForm() + form.main.companyName = 'X' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.') + }) + + it('onglet Comptabilite : absent sans accounting.view, present avec', () => { + expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address']) + expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting']) + + permState.accountingView = true + const form = useProviderForm() + expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting']) + }) + + it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => { + const form = useProviderForm() + + // Contact -> Adresse (pas le dernier). + expect(form.completeTab('contact')).toBe(false) + expect(form.isValidated('contact')).toBe(true) + expect(form.activeTab.value).toBe('address') + expect(form.unlockedIndex.value).toBe(1) + + // Adresse = dernier onglet remplissable (sans accounting.view) -> true. + expect(form.completeTab('address')).toBe(true) + expect(form.isValidated('address')).toBe(true) + }) + + it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => { + const form = useProviderForm() + + await form.patchProvider({ siren: '123456789' }) + expect(mockPatch).not.toHaveBeenCalled() + + mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' }) + form.main.companyName = 'Acme' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + await form.submitMain() + + await form.patchProvider({ siren: '123456789' }) + expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false }) + }) +}) + +describe('useProviderForm — onglet Contact (ERP-142)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = false + permState.accountingManage = false + }) + + /** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */ + function createdForm() { + const form = useProviderForm() + form.providerId.value = 7 + return form + } + + it('RG-3.04 : « + Nouveau contact » desactive tant que le dernier bloc est vide', () => { + const form = createdForm() + expect(form.canAddContact.value).toBe(false) + + // addContact est un no-op tant que le bloc est vide. + form.addContact() + expect(form.contacts.value).toHaveLength(1) + + contactAt(form).lastName = 'Doe' + expect(form.canAddContact.value).toBe(true) + form.addContact() + expect(form.contacts.value).toHaveLength(2) + }) + + it('removeContact retire le bloc et son erreur de ligne', () => { + const form = createdForm() + contactAt(form).lastName = 'Doe' + form.addContact() + form.contactErrors.value = [{}, { lastName: 'x' }] + + form.removeContact(1) + expect(form.contacts.value).toHaveLength(1) + expect(form.contactErrors.value).toHaveLength(1) + }) + + it('submitContacts : POST des nouveaux, capture id + IRI, finalise l\'onglet', async () => { + mockPost.mockResolvedValueOnce({ '@id': '/api/provider_contacts/55', id: 55 }) + const form = createdForm() + contactAt(form).lastName = 'Doe' + + const ok = await form.submitContacts(vi.fn()) + + expect(ok).toBe(true) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/providers/7/contacts') + expect(body).toMatchObject({ lastName: 'Doe' }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + expect(contactAt(form).id).toBe(55) + expect(contactAt(form).iri).toBe('/api/provider_contacts/55') + expect(form.isValidated('contact')).toBe(true) + }) + + it('submitContacts : PATCH des contacts existants sur /provider_contacts/{id}', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + contactAt(form).id = 55 + contactAt(form).lastName = 'Doe' + + await form.submitContacts(vi.fn()) + + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith('/provider_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false }) + }) + + it('RG-3.12 : onglet vide -> soumet l\'amorce pour declencher la 422 firstName inline', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] }, + }, + }) + const form = createdForm() + + const ok = await form.submitContacts(vi.fn()) + + expect(ok).toBe(false) + expect(mockPost).toHaveBeenCalledTimes(1) + expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.') + expect(form.isValidated('contact')).toBe(false) + }) + + it('mappe les erreurs 422 PAR LIGNE (le bloc 2 echoue, le bloc 1 passe)', async () => { + mockPost + .mockResolvedValueOnce({ '@id': '/api/provider_contacts/1', id: 1 }) + .mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'email', message: 'L\'adresse email n\'est pas valide.' }] }, + }, + }) + const form = createdForm() + contactAt(form).lastName = 'Doe' + form.addContact() + contactAt(form, 1).email = 'invalide' + + const ok = await form.submitContacts(vi.fn()) + + expect(ok).toBe(false) + expect(form.contactErrors.value[0]).toBeUndefined() + expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.') + }) +}) + +describe('useProviderForm — onglet Adresse (ERP-143)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = false + permState.accountingManage = false + }) + + /** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */ + function createdForm() { + const form = useProviderForm() + form.providerId.value = 7 + return form + } + + /** Remplit un bloc adresse valide (site + categorie + scalaires requis). */ + function fillValidAddress(form: ProviderForm, index = 0): void { + const a = addressAt(form, index) + a.siteIris = [SITE_86] + a.categoryIris = [CAT_MAINT] + a.postalCode = '86100' + a.city = 'Châtellerault' + a.street = '1 rue du Test' + } + + it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => { + const form = createdForm() + expect(form.canAddAddress.value).toBe(false) + + // no-op tant que l'adresse n'est pas valide. + form.addAddress() + expect(form.addresses.value).toHaveLength(1) + + addressAt(form).siteIris = [SITE_86] + expect(form.canAddAddress.value).toBe(false) // categorie manquante + addressAt(form).categoryIris = [CAT_MAINT] + expect(form.canAddAddress.value).toBe(true) + form.addAddress() + expect(form.addresses.value).toHaveLength(2) + }) + + it('removeAddress retire le bloc et son erreur de ligne', () => { + const form = createdForm() + fillValidAddress(form) + form.addAddress() + form.addressErrors.value = [{}, { city: 'x' }] + + form.removeAddress(1) + expect(form.addresses.value).toHaveLength(1) + expect(form.addressErrors.value).toHaveLength(1) + }) + + it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => { + mockPost.mockResolvedValueOnce({ id: 88 }) + const form = createdForm() + fillValidAddress(form) + + const ok = await form.submitAddresses(vi.fn()) + + expect(ok).toBe(true) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/providers/7/addresses') + expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + expect(addressAt(form).id).toBe(88) + expect(form.isValidated('address')).toBe(true) + }) + + it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + fillValidAddress(form) + addressAt(form).id = 88 + + await form.submitAddresses(vi.fn()) + + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false }) + }) + + it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] }, + }, + }) + const form = createdForm() + fillValidAddress(form) + + const ok = await form.submitAddresses(vi.fn()) + + expect(ok).toBe(false) + expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.') + expect(form.isValidated('address')).toBe(false) + }) +}) + +describe('useProviderForm — onglet Comptabilite (ERP-144)', () => { + const TVA = '/api/tva_modes/1' + const DELAY = '/api/payment_delays/1' + const TYPE = '/api/payment_types/3' + const BANK = '/api/banks/2' + + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = true + permState.accountingManage = true + }) + + /** Prestataire cree, onglet Comptabilite editable (view + manage). */ + function createdForm() { + const form = useProviderForm() + form.providerId.value = 7 + return form + } + + /** Remplit les scalaires comptables communs. */ + function fillScalars(form: ProviderForm): void { + form.accounting.siren = '123456789' + form.accounting.accountNumber = '4010' + form.accounting.tvaModeIri = TVA + form.accounting.nTva = 'FR123' + form.accounting.paymentDelayIri = DELAY + form.accounting.paymentTypeIri = TYPE + } + + it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => { + permState.accountingManage = false + const form = createdForm() + expect(form.accountingReadonly.value).toBe(true) + + permState.accountingManage = true + const form2 = createdForm() + expect(form2.accountingReadonly.value).toBe(false) + }) + + it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => { + const form = createdForm() + form.accounting.bankIri = BANK + + // Type VIREMENT -> banque requise, conservee. + form.setPaymentType(TYPE, true, false) + expect(form.accounting.bankIri).toBe(BANK) + + // Type non-VIREMENT -> banque videe (sans objet). + form.setPaymentType(TYPE, false, false) + expect(form.accounting.bankIri).toBeNull() + }) + + it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => { + const form = createdForm() + expect(form.ribs.value).toHaveLength(0) + + form.setPaymentType(TYPE, false, true) + expect(form.ribs.value).toHaveLength(1) + }) + + it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => { + const form = createdForm() + form.setPaymentType(TYPE, false, true) + expect(form.canAddRib.value).toBe(false) + + const rib = form.ribs.value[0] + if (rib) { + rib.label = 'Compte' + rib.bic = 'BNPAFRPP' + rib.iban = 'FR76...' + } + expect(form.canAddRib.value).toBe(true) + }) + + it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + fillScalars(form) + form.accounting.bankIri = BANK + + const ok = await form.submitAccounting(true, false, vi.fn()) + + expect(ok).toBe(true) + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith( + '/providers/7', + expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }), + { toast: false }, + ) + expect(form.isValidated('accounting')).toBe(true) + }) + + it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + fillScalars(form) + form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false) + + await form.submitAccounting(false, false, vi.fn()) + + const body = mockPatch.mock.calls[0]?.[1] as Record + expect(body.bank).toBeNull() + }) + + it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => { + mockPost.mockResolvedValueOnce({ id: 50 }) + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + fillScalars(form) + form.setPaymentType(TYPE, false, true) + const rib = form.ribs.value[0] + if (rib) { + rib.label = 'Compte' + rib.bic = 'BNPAFRPP' + rib.iban = 'FR76...' + } + + const ok = await form.submitAccounting(false, true, vi.fn()) + + expect(ok).toBe(true) + expect(mockPost).toHaveBeenCalledWith( + '/providers/7/ribs', + expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }), + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + expect(form.ribs.value[0]?.id).toBe(50) + // Le PATCH des scalaires intervient APRES la creation du RIB. + expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false }) + }) + + it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => { + mockPatch.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] }, + }, + }) + const form = createdForm() + fillScalars(form) + + const ok = await form.submitAccounting(true, false, vi.fn()) + + expect(ok).toBe(false) + expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.') + expect(form.isValidated('accounting')).toBe(false) + }) + + it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] }, + }, + }) + const form = createdForm() + fillScalars(form) + form.setPaymentType(TYPE, false, true) + const rib = form.ribs.value[0] + if (rib) { + rib.label = 'Compte' + rib.bic = 'BNPAFRPP' + } + + const ok = await form.submitAccounting(false, true, vi.fn()) + + expect(ok).toBe(false) + expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.') + expect(mockPatch).not.toHaveBeenCalled() + }) +}) + +describe('useProviderForm — modification (ERP-145)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = false + permState.accountingManage = false + }) + + it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => { + const form = useProviderForm() + form.editMode.value = true + form.activeTab.value = 'contact' + + expect(form.completeTab('contact')).toBe(false) + expect(form.isValidated('contact')).toBe(false) + expect(form.activeTab.value).toBe('contact') + }) + + it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => { + mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' }) + const form = useProviderForm() + form.providerId.value = 7 + form.main.companyName = 'Maintenance Pro' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const ok = await form.updateMain() + + expect(ok).toBe(true) + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith( + '/providers/7', + { companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] }, + { toast: false }, + ) + // Reaffiche le nom normalise renvoye par le serveur. + expect(form.main.companyName).toBe('MAINTENANCE PRO') + }) + + it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => { + const form = useProviderForm() + form.providerId.value = 7 + form.main.companyName = 'X' + form.main.categoryIris = [CAT_MAINT] + + const ok = await form.updateMain() + + expect(ok).toBe(false) + expect(mockPatch).not.toHaveBeenCalled() + expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired') + }) + + it('updateMain : 409 doublon -> erreur inline sur companyName', async () => { + mockPatch.mockRejectedValueOnce({ response: { status: 409 } }) + const form = useProviderForm() + form.providerId.value = 7 + form.main.companyName = 'Doublon' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const ok = await form.updateMain() + + expect(ok).toBe(false) + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany') + }) +}) diff --git a/frontend/modules/technique/composables/__tests__/useProvidersRepository.test.ts b/frontend/modules/technique/composables/__tests__/useProvidersRepository.test.ts new file mode 100644 index 0000000..2bbf646 --- /dev/null +++ b/frontend/modules/technique/composables/__tests__/useProvidersRepository.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useProvidersRepository, type Provider } from '../useProvidersRepository' + +const mockApiGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ get: mockApiGet })) + +/** + * Tests du repertoire prestataires (ERP-140). + * + * `useProvidersRepository` est une fine enveloppe de `usePaginatedList` + * sur `/providers`. Les invariants generiques de pagination sont deja couverts + * par `usePaginatedList.test.ts` ; on verifie ici le CONTRAT propre au repertoire : + * - la ressource ciblee est bien `/providers` + * - l'enveloppe Hydra (member / totalItems) est consommee + * - le header `Accept: application/ld+json` est envoye (sinon API Platform 4 + * renvoie un tableau plat sans pagination) + * - EXCLUSION DES ARCHIVES PAR DEFAUT : aucun `includeArchived` n'est envoye + * tant que l'utilisateur ne coche pas le filtre (le back masque alors les + * archives) ; le filtre `includeArchived` est bien transmis une fois applique. + */ +describe('useProvidersRepository', () => { + beforeEach(() => { + mockApiGet.mockReset() + }) + + /** Une page de prestataires Hydra, avec categories[] et sites[] embarques. */ + const PAGE: Provider[] = [ + { + id: 1, + companyName: 'ACME MAINTENANCE', + categories: [{ code: 'MAINTENANCE_INDUSTRIELLE', name: 'Maintenance industrielle' }], + sites: [{ id: 4, name: 'Chatellerault', color: '#056CF2' }], + updatedAt: '2026-06-15T08:12:01+02:00', + isArchived: false, + }, + ] + + it('cible /providers, consomme l\'enveloppe Hydra et envoie l\'Accept ld+json', async () => { + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + const repo = useProvidersRepository() + + await repo.fetch() + + expect(mockApiGet).toHaveBeenCalledTimes(1) + const [url, query, opts] = mockApiGet.mock.calls[0] + expect(url).toBe('/providers') + expect(query).toMatchObject({ page: 1, itemsPerPage: 10 }) + expect(opts).toMatchObject({ + toast: false, + headers: { Accept: 'application/ld+json' }, + }) + expect(repo.items.value).toEqual(PAGE) + expect(repo.totalItems.value).toBe(1) + }) + + it('exclut les archives par defaut : aucun includeArchived au premier fetch', async () => { + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + const repo = useProvidersRepository() + + await repo.fetch() + + const query = mockApiGet.mock.calls[0][1] as Record + expect(query.includeArchived).toBeUndefined() + }) + + it('transmet includeArchived une fois le filtre applique (retour page 1)', async () => { + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + const repo = useProvidersRepository() + await repo.fetch() + + mockApiGet.mockResolvedValueOnce({ member: PAGE, totalItems: 1 }) + await repo.setFilters({ includeArchived: true }) + + expect(repo.currentPage.value).toBe(1) + const query = mockApiGet.mock.calls.at(-1)?.[1] as Record + expect(query.includeArchived).toBe(true) + }) +}) diff --git a/frontend/modules/technique/composables/useProvider.ts b/frontend/modules/technique/composables/useProvider.ts new file mode 100644 index 0000000..db795e2 --- /dev/null +++ b/frontend/modules/technique/composables/useProvider.ts @@ -0,0 +1,70 @@ +import { ref } from 'vue' +import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail' + +/** + * Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation / + * Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via + * `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs + * sous `provider:item:read` / `provider:read:accounting`) — une SEULE requete + * peuple les deux ecrans (embed borne, pas de N+1). + * + * L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra + * complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage). + * + * Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration + * (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant, + * qui decide du toast a afficher. + */ +export function useProvider(id: number | string) { + const api = useApi() + + const provider = ref(null) + const loading = ref(false) + const error = ref(false) + + /** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */ + function fetchDetail(): Promise { + return api.get( + `/providers/${id}`, + {}, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + } + + /** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */ + async function load(): Promise { + loading.value = true + error.value = false + try { + provider.value = await fetchDetail() + } + catch { + error.value = true + provider.value = null + } + finally { + loading.value = false + } + } + + /** + * Bascule l'archivage (PATCH `isArchived` SEUL — groupe provider:write:archive ; + * tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH + * ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles + * comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est + * propagee a l'appelant AVANT le rechargement. + */ + async function setArchived(isArchived: boolean): Promise { + await api.patch(`/providers/${id}`, { isArchived }, { toast: false }) + provider.value = await fetchDetail() + } + + return { + provider, + loading, + error, + load, + archive: () => setArchived(true), + restore: () => setArchived(false), + } +} diff --git a/frontend/modules/technique/composables/useProviderForm.ts b/frontend/modules/technique/composables/useProviderForm.ts new file mode 100644 index 0000000..34e7e91 --- /dev/null +++ b/frontend/modules/technique/composables/useProviderForm.ts @@ -0,0 +1,612 @@ +import { computed, reactive, ref, type Ref } from 'vue' +import { useFormErrors } from '~/shared/composables/useFormErrors' +import { mapViolationsToRecord } from '~/shared/utils/api' +import { + emptyProviderAccounting, + emptyProviderAddress, + emptyProviderContact, + emptyProviderMain, + emptyProviderRib, + type ProviderAccountingDraft, + type ProviderAddressFormDraft, + type ProviderAddressResponse, + type ProviderContactFormDraft, + type ProviderContactResponse, + type ProviderMainDraft, + type ProviderMainResponse, + type ProviderRibFormDraft, + type ProviderRibResponse, +} from '~/modules/technique/types/providerForm' +import { + buildProviderContactPayload, + isProviderContactBlank, + isProviderContactNamed, +} from '~/modules/technique/utils/forms/providerContact' +import { + buildProviderAddressPayload, + isProviderAddressValid, +} from '~/modules/technique/utils/forms/providerAddress' +import { + buildProviderAccountingPayload, + buildProviderRibPayload, + isRibBlank, + isRibComplete, +} from '~/modules/technique/utils/forms/providerAccounting' + +/** + * Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) — + * miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en + * composable. + * + * Particularites M3 (cf. spec-front § « Ecran Ajouter ») : + * - PAS d'onglet « Information » : le formulaire principal est minimal (Nom + + * Categorie + Site). + * - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe + * `provider.sites`). + * - Creation incrementale par onglets (Contact · Adresse · Comptabilite) : + * POST principal puis PATCH partiels par groupe de serialisation + * (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets + * arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et + * l'orchestration des onglets. + * + * Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL. + */ + +/** + * Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ; + * « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification. + * L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir + * (`technique.providers.accounting.view` — Admin, Compta). + */ +export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] { + return canAccountingView + ? ['contact', 'address', 'accounting'] + : ['contact', 'address'] +} + +export function useProviderForm() { + const api = useApi() + const { t } = useI18n() + const toast = useToast() + const { can } = usePermissions() + + // Erreurs de validation par champ (ERP-101) du formulaire principal. + const mainErrors = useFormErrors() + + // ── Etat du prestataire cree ──────────────────────────────────────────── + const providerId = ref(null) + const mainLocked = ref(false) + const mainSubmitting = ref(false) + const tabSubmitting = ref(false) + + // ── Formulaire principal ────────────────────────────────────────────────── + const main = reactive(emptyProviderMain()) + + // ── Onglets : ordre + gating progressif ─────────────────────────────────── + const canAccountingView = computed(() => can('technique.providers.accounting.view')) + const canAccountingManage = computed(() => can('technique.providers.accounting.manage')) + const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value)) + + // Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree). + const unlockedIndex = ref(-1) + const activeTab = ref('contact') + // Onglets valides (passent en lecture seule). + const validated = reactive>({}) + // Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de + // bascule automatique d'onglet a la validation (cf. completeTab). + const editMode = ref(false) + + function isValidated(key: string): boolean { + return validated[key] === true + } + + function tabIndex(key: string): number { + return tabKeys.value.indexOf(key) + } + + /** + * Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09 + * (>= 1 categorie). Pose les erreurs inline et retourne false si invalide. + * Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un + * aller-retour inutile et porte la garantie RG-3.03 cote front. + */ + function validateMainFront(): boolean { + let valid = true + if (!main.companyName?.trim()) { + mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired')) + valid = false + } + if (main.siteIris.length === 0) { + mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired')) + valid = false + } + if (main.categoryIris.length === 0) { + mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired')) + valid = false + } + return valid + } + + /** + * Payload du POST principal (groupe `provider:write:main`). `companyName` est + * omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur + * le champ plutot qu'une erreur de type. Les relations M2M partent en IRI. + */ + function buildMainPayload(): Record { + const payload: Record = { + categories: [...main.categoryIris], + sites: [...main.siteIris], + } + if (main.companyName?.trim()) { + payload.companyName = main.companyName + } + return payload + } + + /** + * POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09, + * puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er + * onglet et bascule sur « Contact ». Retourne true si cree, false sinon. + */ + async function submitMain(): Promise { + if (mainSubmitting.value) return false + mainErrors.clearErrors() + if (!validateMainFront()) return false + + mainSubmitting.value = true + try { + const created = await api.post('/providers', buildMainPayload(), { + headers: { Accept: 'application/ld+json' }, + toast: false, + }) + + providerId.value = created.id + // Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11). + main.companyName = created.companyName ?? main.companyName + + mainLocked.value = true + unlockedIndex.value = 0 + activeTab.value = tabKeys.value[0] ?? 'contact' + toast.success({ title: t('technique.providers.toast.createSuccess') }) + return true + } + catch (error) { + // 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ; + // 422 → mapping inline par champ ; autre → toast de fallback (ERP-101). + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 409) { + const message = t('technique.providers.form.duplicateCompany') + mainErrors.setError('companyName', message) + toast.error({ title: t('technique.providers.toast.error'), message }) + } + else { + mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) + } + return false + } + finally { + mainSubmitting.value = false + } + } + + /** + * PATCH partiel du prestataire (mode strict : un seul groupe de serialisation + * par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires + * (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources + * (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas. + */ + async function patchProvider(payload: Record): Promise { + if (providerId.value === null) return + await api.patch(`/providers/${providerId.value}`, payload, { toast: false }) + } + + /** + * MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe + * provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09, + * 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la + * difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la + * navigation est libre en modification). Retourne true si le PATCH a reussi. + */ + async function updateMain(): Promise { + if (providerId.value === null || mainSubmitting.value) return false + mainErrors.clearErrors() + if (!validateMainFront()) return false + + mainSubmitting.value = true + try { + const updated = await api.patch( + `/providers/${providerId.value}`, + buildMainPayload(), + { toast: false }, + ) + main.companyName = updated.companyName ?? main.companyName + return true + } + catch (error) { + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 409) { + const message = t('technique.providers.form.duplicateCompany') + mainErrors.setError('companyName', message) + toast.error({ title: t('technique.providers.toast.error'), message }) + } + else { + mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) + } + return false + } + finally { + mainSubmitting.value = false + } + } + + /** + * Marque un onglet valide (passe en lecture seule), deverrouille et avance a + * l'onglet suivant. Retourne true si c'etait le dernier onglet du flux + * (creation terminee), false sinon. + */ + function completeTab(key: string): boolean { + // En modification : navigation libre, l'onglet reste editable apres validation. + if (editMode.value) { + return false + } + validated[key] = true + const index = tabIndex(key) + const next = tabKeys.value[index + 1] + if (next === undefined) { + return true + } + unlockedIndex.value = Math.max(unlockedIndex.value, index + 1) + activeTab.value = next + return false + } + + /** + * Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX : + * on n'arrete pas au premier bloc en echec (decision ERP-101). Reinitialise la + * cible, tente chaque ligne via `saveRow`, mappe les 422 inline ou delegue le + * fallback a `onUnmappedError`. `shouldSkip` ignore les amorces vides. Retourne + * true si au moins un bloc a echoue. Miroir de `useSupplierFormErrors.submitRows`. + */ + async function submitRows( + rows: T[], + target: Ref[]>, + saveRow: (row: T, index: number) => Promise, + onUnmappedError: (error: unknown, index: number) => void, + shouldSkip?: (row: T, index: number) => boolean, + ): Promise { + target.value = [] + let hasError = false + for (let index = 0; index < rows.length; index++) { + const row = rows[index] as T + if (shouldSkip?.(row, index)) { + continue + } + try { + await saveRow(row, index) + } + catch (error) { + const response = (error as { response?: { status?: number, _data?: unknown } })?.response + const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {} + if (Object.keys(mapped).length > 0) { + target.value[index] = mapped + } + else { + onUnmappedError(error, index) + } + hasError = true + } + } + return hasError + } + + // ── Onglet Contact (ERP-142) ────────────────────────────────────────────── + const contacts = ref([emptyProviderContact()]) + // Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows. + const contactErrors = ref[]>([]) + + // « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU + // prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas). + const canAddContact = computed(() => { + const last = contacts.value[contacts.value.length - 1] + return last !== undefined && isProviderContactNamed(last) + }) + + function addContact(): void { + if (canAddContact.value) { + contacts.value.push(emptyProviderContact()) + } + } + + function removeContact(index: number): void { + contacts.value.splice(index, 1) + contactErrors.value.splice(index, 1) + } + + /** + * Valide l'onglet Contact : POST des nouveaux contacts sur + * /providers/{id}/contacts, PATCH des existants sur /provider_contacts/{id} + * (sous-ressource, groupe provider:write:contacts). RG-3.12 : au moins un bloc + * valide. Si l'onglet ne contient QUE des amorces vides, on les soumet pour + * declencher la 422 RG-3.04 inline (sur `firstName`) plutot que de finaliser un + * onglet vide. Retourne true si l'onglet a ete valide (avance/termine). + */ + async function submitContacts(onError: (error: unknown) => void): Promise { + if (providerId.value === null || tabSubmitting.value) { + return false + } + tabSubmitting.value = true + try { + const hasSubmittable = contacts.value.some(c => c.id !== null || !isProviderContactBlank(c)) + const hasError = await submitRows( + contacts.value, + contactErrors, + async (contact) => { + const body = buildProviderContactPayload(contact) + if (contact.id === null) { + const created = await api.post( + `/providers/${providerId.value}/contacts`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + contact.id = created.id + contact.iri = created['@id'] ?? null + } + else { + await api.patch(`/provider_contacts/${contact.id}`, body, { toast: false }) + } + }, + onError, + contact => hasSubmittable && contact.id === null && isProviderContactBlank(contact), + ) + if (hasError) { + return false + } + completeTab('contact') + return true + } + finally { + tabSubmitting.value = false + } + } + + // ── Onglet Adresse (ERP-143) ────────────────────────────────────────────── + const addresses = ref([emptyProviderAddress()]) + // Erreurs 422 par ligne (alignees sur l'index du v-for). + const addressErrors = ref[]>([]) + + // « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas + // au moins un site ET une categorie (RG-3.05 / RG-3.09). + const canAddAddress = computed(() => { + const last = addresses.value[addresses.value.length - 1] + return last !== undefined && isProviderAddressValid(last) + }) + + function addAddress(): void { + if (canAddAddress.value) { + addresses.value.push(emptyProviderAddress()) + } + } + + function removeAddress(index: number): void { + addresses.value.splice(index, 1) + addressErrors.value.splice(index, 1) + } + + /** + * Valide l'onglet Adresse : POST des nouvelles adresses sur + * /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id} + * (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par + * ligne. Retourne true si l'onglet a ete valide (avance/termine). + */ + async function submitAddresses(onError: (error: unknown) => void): Promise { + if (providerId.value === null || tabSubmitting.value) { + return false + } + tabSubmitting.value = true + try { + const hasError = await submitRows( + addresses.value, + addressErrors, + async (address) => { + const body = buildProviderAddressPayload(address) + if (address.id === null) { + const created = await api.post( + `/providers/${providerId.value}/addresses`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + address.id = created.id + } + else { + await api.patch(`/provider_addresses/${address.id}`, body, { toast: false }) + } + }, + onError, + ) + if (hasError) { + return false + } + completeTab('address') + return true + } + finally { + tabSubmitting.value = false + } + } + + // ── Onglet Comptabilite (ERP-144) ───────────────────────────────────────── + const accounting = reactive(emptyProviderAccounting()) + const ribs = ref([]) + const accountingErrors = useFormErrors() + // Erreurs 422 par ligne de RIB (alignees sur l'index du v-for). + const ribErrors = ref[]>([]) + + // L'onglet est editable seulement avec accounting.manage (sinon lecture seule). + const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value) + + /** + * Met a jour le type de reglement (IRI) en propageant ses RG inter-champs : + * - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ; + * - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on + * purge les erreurs de RIB (les blocs sont conserves mais non persistes). + * `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a + * partir du code resolu via les referentiels. + */ + function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void { + accounting.paymentTypeIri = iri + if (!isBankRequired) { + accounting.bankIri = null + } + if (isRibRequired) { + if (ribs.value.length === 0) { + ribs.value.push(emptyProviderRib()) + } + } + else { + ribErrors.value = [] + } + } + + // « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08). + const canAddRib = computed(() => { + const last = ribs.value[ribs.value.length - 1] + return last !== undefined && isRibComplete(last) + }) + + function addRib(): void { + if (canAddRib.value) { + ribs.value.push(emptyProviderRib()) + } + } + + function removeRib(index: number): void { + ribs.value.splice(index, 1) + ribErrors.value.splice(index, 1) + // Garde au moins un bloc RIB visible (sous LCR). + if (ribs.value.length === 0) { + ribs.value.push(emptyProviderRib()) + } + } + + /** + * Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord + * (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister + * AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting, + * banque envoyee seulement si VIREMENT — RG-3.07). Erreurs RIB par ligne ; + * erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete + * valide. + */ + async function submitAccounting( + isBankRequired: boolean, + isRibRequired: boolean, + onRibError: (error: unknown) => void, + ): Promise { + if (providerId.value === null || tabSubmitting.value) { + return false + } + tabSubmitting.value = true + accountingErrors.clearErrors() + try { + // 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee + // s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli) + // on la soumet pour declencher la 422 NotBlank inline. + if (isRibRequired) { + const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r)) + const ribHasError = await submitRows( + ribs.value, + ribErrors, + async (rib) => { + const body = buildProviderRibPayload(rib) + if (rib.id === null) { + const created = await api.post( + `/providers/${providerId.value}/ribs`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + rib.id = created.id + } + else { + await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false }) + } + }, + onRibError, + rib => hasSubmittableRib && rib.id === null && isRibBlank(rib), + ) + if (ribHasError) { + return false + } + } + + // 2) PATCH des scalaires comptables (erreurs inline sur leurs champs). + try { + await api.patch( + `/providers/${providerId.value}`, + buildProviderAccountingPayload(accounting, isBankRequired), + { toast: false }, + ) + } + catch (error) { + accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) + return false + } + + completeTab('accounting') + return true + } + finally { + tabSubmitting.value = false + } + } + + return { + // etat + main, + providerId, + mainLocked, + mainSubmitting, + tabSubmitting, + mainErrors, + // onglets + canAccountingView, + canAccountingManage, + tabKeys, + activeTab, + unlockedIndex, + validated, + editMode, + isValidated, + // contacts + contacts, + contactErrors, + canAddContact, + addContact, + removeContact, + submitContacts, + // adresses + addresses, + addressErrors, + canAddAddress, + addAddress, + removeAddress, + submitAddresses, + // comptabilite + accounting, + ribs, + accountingErrors, + ribErrors, + accountingReadonly, + setPaymentType, + canAddRib, + addRib, + removeRib, + submitAccounting, + // actions + validateMainFront, + buildMainPayload, + submitMain, + updateMain, + patchProvider, + completeTab, + submitRows, + } +} diff --git a/frontend/modules/technique/composables/useProviderReferentials.ts b/frontend/modules/technique/composables/useProviderReferentials.ts new file mode 100644 index 0000000..3b56d8c --- /dev/null +++ b/frontend/modules/technique/composables/useProviderReferentials.ts @@ -0,0 +1,136 @@ +import { ref } from 'vue' + +/** + * Charge les referentiels (listes courtes) alimentant les selects du formulaire + * principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) : + * categories (type PRESTATAIRE) et sites (86 / 17 / 82). + * + * Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire + * principal) seuls categories + sites sont necessaires. Les referentiels + * comptables (modes de TVA, delais/types de reglement, banques) seront charges + * par l'onglet Comptabilite (ERP-144). + * + * Toutes les collections sont recuperees en entier via l'echappatoire prevue + * `?pagination=false` (referentiels de quelques entrees), avec l'en-tete + * `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe + * Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle + * quelle dans le payload POST (relations M2M). + * + * Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un + * echec (permission manquante, reseau) laisse simplement la liste vide. + * + * Etat 100 % local a l'instance (refs) — aucune persistance URL. + */ + +/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */ +export interface RefOption { + value: string + label: string +} + +/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */ +export interface PaymentTypeOption extends RefOption { + code: string +} + +interface HydraMember { + '@id': string +} + +interface ReferentialMember extends HydraMember { + code: string + label: string +} + +interface CategoryMember extends HydraMember { + code: string + name: string +} + +interface SiteMember extends HydraMember { + name: string + postalCode: string +} + +interface CountryMember extends HydraMember { + code: string + name: string +} + +const LD_JSON_HEADERS = { Accept: 'application/ld+json' } + +export function useProviderReferentials() { + const api = useApi() + + const categories = ref([]) + const sites = ref([]) + const countries = ref([]) + // Referentiels comptables (charges a la demande via loadAccounting). + const tvaModes = ref([]) + const paymentDelays = ref([]) + const paymentTypes = ref([]) + const banks = ref([]) + + /** Recupere une collection complete (pagination desactivee) en Hydra. */ + async function fetchAll( + url: string, + query: Record = {}, + ): Promise { + const res = await api.get<{ member?: T[] }>( + url, + { pagination: 'false', ...query }, + { headers: LD_JSON_HEADERS, toast: false }, + ) + return res.member ?? [] + } + + /** Charge en parallele les referentiels du formulaire principal (categories + sites). */ + async function loadMain(): Promise { + await Promise.allSettled([ + // RG-3.09 : un prestataire ne porte que des categories de type + // PRESTATAIRE -> filtre cote API. Libelle affiche = `name`. + fetchAll('/categories', { typeCode: 'PRESTATAIRE' }) + .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }), + // Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres + // du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ». + fetchAll('/sites') + .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }), + // Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke + // `country` en chaine libre, « France »...). value === label. Aligne sur + // les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse. + fetchAll('/countries') + .then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }), + ]) + } + + /** + * Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele + * uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient + * (allSettled) : un referentiel en echec reste vide. + */ + async function loadAccounting(): Promise { + await Promise.allSettled([ + fetchAll('/tva_modes') + .then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }), + fetchAll('/payment_delays') + .then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }), + // Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR). + fetchAll('/payment_types') + .then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }), + fetchAll('/banks') + .then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }), + ]) + } + + return { + categories, + sites, + countries, + tvaModes, + paymentDelays, + paymentTypes, + banks, + loadMain, + loadAccounting, + } +} diff --git a/frontend/modules/technique/composables/useProvidersRepository.ts b/frontend/modules/technique/composables/useProvidersRepository.ts new file mode 100644 index 0000000..de3ff19 --- /dev/null +++ b/frontend/modules/technique/composables/useProvidersRepository.ts @@ -0,0 +1,62 @@ +import { usePaginatedList } from '~/shared/composables/usePaginatedList' + +/** + * Site Starseed rattache DIRECTEMENT au prestataire (M2M `provider_site`, + * RG-3.03), tel qu'embarque en LISTE (groupe site:read) pour la colonne « Site » + * du Repertoire (badges colores). + * + * Difference M3 vs M2 : au M2 les sites venaient de l'agregat dedoublonne des + * adresses (`Supplier::getSites()`) ; ici c'est une relation directe portee par + * le formulaire principal (cf. spec-back M3 § 2.12). + */ +export interface ProviderSite { + id: number + name: string + color: string +} + +/** + * Categorie (type PRESTATAIRE) rattachee au prestataire, embarquee en LISTE + * (groupe category:read). La colonne « Catégories » affiche le `name` (cohérence + * M1/M2 — libellé = `name`, pas `code`). + */ +export interface ProviderCategory { + code: string + name: string +} + +/** + * Vue MINIMALE d'un prestataire pour le Repertoire (datatable). Volontairement + * partielle : seuls les champs des colonnes + l'id (navigation) sont types ici. + * Le detail complet (onglets) est hors perimetre de cet ecran (ERP-140). + */ +export interface Provider { + id: number + companyName: string + categories: ProviderCategory[] + sites: ProviderSite[] + /** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */ + updatedAt: string | null + isArchived: boolean +} + +/** + * Repertoire prestataires (ERP-140) — simple enveloppe de `usePaginatedList` + * sur la ressource `/providers` (pagination serveur obligatoire ; jamais de + * chargement integral en memoire). Miroir de `useSuppliersRepository` (M2). + * + * Les filtres (recherche, categories, sites, inclusion des archives) sont pilotes + * par la page via `setFilters` du composable partage — la remise en page 1 est + * garantie. Par defaut, aucun `includeArchived` n'est envoye : le back masque + * donc les prestataires archives (exclusion par defaut, spec-back § 2.11). + * + * Le cloisonnement par site est applique AUTOMATIQUEMENT cote back (§ 2.13) en + * fonction de l'utilisateur — rien a filtrer cote front. + * + * Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau + * est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de + * `usePaginatedList`. Aucun reset au logout a gerer. + */ +export function useProvidersRepository() { + return usePaginatedList({ url: '/providers' }) +} diff --git a/frontend/modules/technique/nuxt.config.ts b/frontend/modules/technique/nuxt.config.ts new file mode 100644 index 0000000..268da7f --- /dev/null +++ b/frontend/modules/technique/nuxt.config.ts @@ -0,0 +1 @@ +export default defineNuxtConfig({}) diff --git a/frontend/modules/technique/pages/providers/[id]/edit.vue b/frontend/modules/technique/pages/providers/[id]/edit.vue new file mode 100644 index 0000000..1340e21 --- /dev/null +++ b/frontend/modules/technique/pages/providers/[id]/edit.vue @@ -0,0 +1,538 @@ + + + diff --git a/frontend/modules/technique/pages/providers/[id]/index.vue b/frontend/modules/technique/pages/providers/[id]/index.vue new file mode 100644 index 0000000..fb6bfb4 --- /dev/null +++ b/frontend/modules/technique/pages/providers/[id]/index.vue @@ -0,0 +1,308 @@ + + + diff --git a/frontend/modules/technique/pages/providers/index.vue b/frontend/modules/technique/pages/providers/index.vue new file mode 100644 index 0000000..cfedde4 --- /dev/null +++ b/frontend/modules/technique/pages/providers/index.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/frontend/modules/technique/pages/providers/new.vue b/frontend/modules/technique/pages/providers/new.vue new file mode 100644 index 0000000..5b9862d --- /dev/null +++ b/frontend/modules/technique/pages/providers/new.vue @@ -0,0 +1,530 @@ + + + diff --git a/frontend/modules/technique/types/providerForm.ts b/frontend/modules/technique/types/providerForm.ts new file mode 100644 index 0000000..2a4f1b7 --- /dev/null +++ b/frontend/modules/technique/types/providerForm.ts @@ -0,0 +1,177 @@ +/** + * Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique). + * + * Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet + * Information, et porte en plus un selecteur de site SUR le formulaire principal + * (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse). + * + * Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des + * DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet + * Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent. + * + * Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`), + * envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 : + * `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`). + */ + +/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */ +export interface ProviderMainDraft { + /** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */ + companyName: string | null + /** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */ + categoryIris: string[] + /** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */ + siteIris: string[] +} + +/** Fabrique un formulaire principal vierge. */ +export function emptyProviderMain(): ProviderMainDraft { + return { + companyName: null, + categoryIris: [], + siteIris: [], + } +} + +/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */ +export interface ProviderMainResponse { + id: number + /** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */ + companyName: string | null +} + +/** + * Un contact du prestataire (onglet Contact, ERP-142). Miroir de + * `SupplierContactFormDraft` (M2). Tous les champs sont nullable cote ORM ; la + * validite (RG-3.04) tient a la presence d'AU MOINS un champ rempli parmi + * prenom / nom / fonction / telephone principal / email (cf. back). + */ +export interface ProviderContactFormDraft { + /** Id serveur une fois le contact cree (null tant que non persiste). */ + id: number | null + /** IRI Hydra du contact cree — servira au rattachement M2M cote adresse (ERP-143). */ + iri: string | null + firstName: string | null + lastName: string | null + jobTitle: string | null + phonePrimary: string | null + phoneSecondary: string | null + email: string | null + /** UI : le 2e numero a ete revele via le bouton « + » (max 2 telephones). */ + hasSecondaryPhone: boolean +} + +/** Fabrique un contact vierge. */ +export function emptyProviderContact(): ProviderContactFormDraft { + return { + id: null, + iri: null, + firstName: null, + lastName: null, + jobTitle: null, + phonePrimary: null, + phoneSecondary: null, + email: null, + hasSecondaryPhone: false, + } +} + +/** Reponse du POST /providers/{id}/contacts (groupe provider:item:read + IRI Hydra). */ +export interface ProviderContactResponse { + '@id'?: string + id: number +} + +/** + * Une adresse du prestataire (onglet Adresse, ERP-143). Version SIMPLIFIEE de + * `SupplierAddressFormDraft` (M2) : PAS de type d'adresse (Prospect/Depart/Rendu), + * PAS de bennes, PAS de prestation de triage. Champs postaux + M2M sites / + * categories / contacts (par IRI). + */ +export interface ProviderAddressFormDraft { + /** Id serveur une fois l'adresse creee (null tant que non persistee). */ + id: number | null + /** Pays (chaine libre, defaut « France »). */ + country: string + postalCode: string | null + city: string | null + street: string | null + streetComplement: string | null + /** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */ + categoryIris: string[] + /** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */ + siteIris: string[] + /** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */ + contactIris: string[] +} + +/** Fabrique une adresse vierge (France presaisi). */ +export function emptyProviderAddress(): ProviderAddressFormDraft { + return { + id: null, + country: 'France', + postalCode: null, + city: null, + street: null, + streetComplement: null, + categoryIris: [], + siteIris: [], + contactIris: [], + } +} + +/** Reponse du POST /providers/{id}/addresses (id suffisant pour le suivi cote front). */ +export interface ProviderAddressResponse { + id: number +} + +/** + * Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`). + * Relations (TVA / delai / type de reglement / banque) portees par leur IRI. + */ +export interface ProviderAccountingDraft { + siren: string | null + accountNumber: string | null + tvaModeIri: string | null + nTva: string | null + paymentDelayIri: string | null + paymentTypeIri: string | null + /** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */ + bankIri: string | null +} + +/** Fabrique un onglet Comptabilite vierge. */ +export function emptyProviderAccounting(): ProviderAccountingDraft { + return { + siren: null, + accountNumber: null, + tvaModeIri: null, + nTva: null, + paymentDelayIri: null, + paymentTypeIri: null, + bankIri: null, + } +} + +/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */ +export interface ProviderRibFormDraft { + id: number | null + label: string | null + bic: string | null + iban: string | null +} + +/** Fabrique un RIB vierge. */ +export function emptyProviderRib(): ProviderRibFormDraft { + return { + id: null, + label: null, + bic: null, + iban: null, + } +} + +/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */ +export interface ProviderRibResponse { + id: number +} diff --git a/frontend/modules/technique/utils/forms/__tests__/providerAccounting.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerAccounting.spec.ts new file mode 100644 index 0000000..fc9915c --- /dev/null +++ b/frontend/modules/technique/utils/forms/__tests__/providerAccounting.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { + buildProviderAccountingPayload, + buildProviderRibPayload, + isBankRequiredForPaymentType, + isRibBlank, + isRibComplete, + isRibRequiredForPaymentType, +} from '../providerAccounting' +import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm' + +/** + * Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs + * RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads. + */ +describe('providerAccounting helpers', () => { + describe('RG-3.07 / RG-3.08 — type de reglement', () => { + it('banque requise uniquement pour VIREMENT', () => { + expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true) + expect(isBankRequiredForPaymentType('LCR')).toBe(false) + expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false) + expect(isBankRequiredForPaymentType(null)).toBe(false) + }) + + it('RIB requis uniquement pour LCR', () => { + expect(isRibRequiredForPaymentType('LCR')).toBe(true) + expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false) + expect(isRibRequiredForPaymentType(null)).toBe(false) + }) + }) + + describe('isRibBlank / isRibComplete', () => { + it('un RIB vierge est vide et incomplet', () => { + expect(isRibBlank(emptyProviderRib())).toBe(true) + expect(isRibComplete(emptyProviderRib())).toBe(false) + }) + + it('un RIB partiel n\'est ni vide ni complet', () => { + const rib = { ...emptyProviderRib(), iban: 'FR76...' } + expect(isRibBlank(rib)).toBe(false) + expect(isRibComplete(rib)).toBe(false) + }) + + it('un RIB avec libelle + BIC + IBAN est complet', () => { + const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' } + expect(isRibComplete(rib)).toBe(true) + }) + }) + + describe('buildProviderAccountingPayload (RG-3.07)', () => { + it('envoie la banque si requise (VIREMENT)', () => { + const payload = buildProviderAccountingPayload({ + ...emptyProviderAccounting(), + paymentTypeIri: '/api/payment_types/3', + bankIri: '/api/banks/2', + }, true) + expect(payload.bank).toBe('/api/banks/2') + expect(payload.paymentType).toBe('/api/payment_types/3') + }) + + it('force la banque a null si non requise (hors VIREMENT)', () => { + const payload = buildProviderAccountingPayload({ + ...emptyProviderAccounting(), + bankIri: '/api/banks/2', + }, false) + expect(payload.bank).toBeNull() + }) + }) + + describe('buildProviderRibPayload', () => { + it('omet les champs requis vides (NotBlank back joue sur le champ)', () => { + const payload = buildProviderRibPayload(emptyProviderRib()) + expect(payload).not.toHaveProperty('label') + expect(payload).not.toHaveProperty('bic') + expect(payload).not.toHaveProperty('iban') + }) + + it('conserve les champs remplis', () => { + const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }) + expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }) + }) + }) +}) diff --git a/frontend/modules/technique/utils/forms/__tests__/providerAddress.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerAddress.spec.ts new file mode 100644 index 0000000..a453a41 --- /dev/null +++ b/frontend/modules/technique/utils/forms/__tests__/providerAddress.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { + buildProviderAddressPayload, + isProviderAddressValid, +} from '../providerAddress' +import { emptyProviderAddress } from '~/modules/technique/types/providerForm' + +/** + * Helpers purs de l'onglet Adresse prestataire (ERP-143). RG-3.05 (>= 1 site) et + * construction du payload de sous-ressource (relations en IRI, requis vides omis, + * pas de type d'adresse / bennes / triage — difference M2). + */ +describe('providerAddress helpers', () => { + const SITE = '/api/sites/1' + const CAT = '/api/categories/7' + + describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => { + it('false sans site', () => { + const address = { ...emptyProviderAddress(), categoryIris: [CAT] } + expect(isProviderAddressValid(address)).toBe(false) + }) + + it('false sans categorie', () => { + const address = { ...emptyProviderAddress(), siteIris: [SITE] } + expect(isProviderAddressValid(address)).toBe(false) + }) + + it('true avec au moins un site ET une categorie', () => { + const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] } + expect(isProviderAddressValid(address)).toBe(true) + }) + }) + + describe('buildProviderAddressPayload', () => { + it('mappe les relations en IRI et n\'embarque PAS type/bennes/triage (difference M2)', () => { + const payload = buildProviderAddressPayload({ + ...emptyProviderAddress(), + postalCode: '86100', + city: 'Châtellerault', + street: '1 rue du Test', + siteIris: [SITE], + categoryIris: [CAT], + contactIris: ['/api/provider_contacts/9'], + }) + expect(payload).toEqual({ + country: 'France', + postalCode: '86100', + city: 'Châtellerault', + street: '1 rue du Test', + streetComplement: null, + categories: [CAT], + sites: [SITE], + contacts: ['/api/provider_contacts/9'], + }) + expect(payload).not.toHaveProperty('addressType') + expect(payload).not.toHaveProperty('bennes') + expect(payload).not.toHaveProperty('triageProvider') + }) + + it('omet les scalaires requis vides (NotBlank back joue sur le champ)', () => { + const payload = buildProviderAddressPayload({ + ...emptyProviderAddress(), + siteIris: [SITE], + categoryIris: [CAT], + }) + expect(payload).not.toHaveProperty('postalCode') + expect(payload).not.toHaveProperty('city') + expect(payload).not.toHaveProperty('street') + // streetComplement n'est PAS requis -> reste present a null. + expect(payload).toHaveProperty('streetComplement', null) + }) + }) +}) diff --git a/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts new file mode 100644 index 0000000..0b8f8a4 --- /dev/null +++ b/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { + buildProviderContactPayload, + hasAtLeastOneFilledContact, + isProviderContactBlank, + isProviderContactNamed, +} from '../providerContact' +import { emptyProviderContact } from '~/modules/technique/types/providerForm' + +/** + * Helpers purs de l'onglet Contact prestataire (ERP-142). On verifie la + * definition de « bloc vide » (RG-3.04, alignee sur le back) et la construction + * du payload de sous-ressource. + */ +describe('providerContact helpers', () => { + describe('isProviderContactBlank (RG-3.04)', () => { + it('un bloc vierge est vide', () => { + expect(isProviderContactBlank(emptyProviderContact())).toBe(true) + }) + + it('un seul champ rempli parmi nom/prenom/fonction/tel/email suffit a le rendre non vide', () => { + for (const field of ['firstName', 'lastName', 'jobTitle', 'phonePrimary', 'email'] as const) { + const contact = { ...emptyProviderContact(), [field]: 'x' } + expect(isProviderContactBlank(contact)).toBe(false) + } + }) + + it('ignore les espaces (trim) — un champ blanc ne compte pas', () => { + expect(isProviderContactBlank({ ...emptyProviderContact(), lastName: ' ' })).toBe(true) + }) + + it('un 2e telephone seul NE suffit PAS (exclu, comme le back)', () => { + const contact = { ...emptyProviderContact(), hasSecondaryPhone: true, phoneSecondary: '0102030405' } + expect(isProviderContactBlank(contact)).toBe(true) + }) + }) + + describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => { + it('vrai avec un prenom seul ou un nom seul', () => { + expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true) + expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true) + }) + + it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => { + expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false) + expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false) + expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false) + }) + }) + + describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => { + it('false si aucun bloc n\'est nomme', () => { + expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false) + }) + + it('true des qu\'un bloc porte un nom ou prenom', () => { + expect(hasAtLeastOneFilledContact([ + emptyProviderContact(), + { ...emptyProviderContact(), lastName: 'Dupont' }, + ])).toBe(true) + }) + }) + + describe('buildProviderContactPayload', () => { + it('mappe les champs et envoie null pour les vides', () => { + const payload = buildProviderContactPayload({ ...emptyProviderContact(), lastName: 'Doe' }) + expect(payload).toEqual({ + firstName: null, + lastName: 'Doe', + jobTitle: null, + phonePrimary: null, + phoneSecondary: null, + email: null, + }) + }) + + it('n\'envoie le 2e telephone que si revele (max 2)', () => { + const masque = buildProviderContactPayload({ + ...emptyProviderContact(), + phoneSecondary: '0102030405', + hasSecondaryPhone: false, + }) + expect(masque.phoneSecondary).toBeNull() + + const revele = buildProviderContactPayload({ + ...emptyProviderContact(), + phoneSecondary: '0102030405', + hasSecondaryPhone: true, + }) + expect(revele.phoneSecondary).toBe('0102030405') + }) + }) +}) diff --git a/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts new file mode 100644 index 0000000..e939f96 --- /dev/null +++ b/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from 'vitest' + +// formatPhoneFR est auto-importe dans le helper via le chemin partage ; on le mocke +// pour un rendu deterministe (la mise en forme exacte est testee ailleurs). +vi.mock('~/shared/utils/phone', () => ({ + formatPhoneFR: (v: string) => `fmt(${v})`, +})) + +const { + canEditProvider, + categoryOptionsOf, + contactOptionsOf, + iriOf, + irisOf, + mapAccountingDraft, + mapAddressToDraft, + mapContactToDraft, + mapRibToDraft, + paymentTypeCodeOf, + referentialOptionOf, + showArchiveAction, + showRestoreAction, + siteOptionsOf, +} = await import('../providerDetail') + +/** + * Helpers purs des ecrans Consultation / Modification (ERP-145) : mapping du + * detail embarque vers les brouillons + regles d'affichage des actions (Modifier / + * Archiver / Restaurer). + */ +describe('providerDetail helpers', () => { + describe('iriOf / irisOf', () => { + it('extrait l\'IRI d\'un objet embarque, d\'un IRI nu, ou null', () => { + expect(iriOf({ '@id': '/api/banks/2' })).toBe('/api/banks/2') + expect(iriOf('/api/banks/2')).toBe('/api/banks/2') + expect(iriOf(null)).toBeNull() + expect(iriOf(undefined)).toBeNull() + }) + + it('extrait les IRI d\'une collection embarquee', () => { + expect(irisOf([{ '@id': '/api/sites/1' }, { '@id': '/api/sites/2' }])).toEqual(['/api/sites/1', '/api/sites/2']) + expect(irisOf(undefined)).toEqual([]) + }) + }) + + describe('mapContactToDraft', () => { + it('mappe les champs, formate les telephones et derive hasSecondaryPhone', () => { + const draft = mapContactToDraft({ + '@id': '/api/provider_contacts/5', + id: 5, + firstName: 'Jean', + lastName: 'Dupont', + phonePrimary: '0102030405', + phoneSecondary: '0607080910', + email: 'jean@x.fr', + }) + expect(draft).toMatchObject({ + id: 5, + iri: '/api/provider_contacts/5', + firstName: 'Jean', + lastName: 'Dupont', + phonePrimary: 'fmt(0102030405)', + phoneSecondary: 'fmt(0607080910)', + email: 'jean@x.fr', + hasSecondaryPhone: true, + }) + }) + + it('hasSecondaryPhone faux sans 2e numero', () => { + const draft = mapContactToDraft({ '@id': '/api/provider_contacts/6', id: 6, lastName: 'Doe' }) + expect(draft.hasSecondaryPhone).toBe(false) + expect(draft.phoneSecondary).toBeNull() + }) + }) + + describe('mapAddressToDraft', () => { + it('extrait les IRI des sites / categories / contacts embarques', () => { + const draft = mapAddressToDraft({ + '@id': '/api/provider_addresses/3', + id: 3, + country: 'France', + postalCode: '86100', + city: 'Châtellerault', + street: '1 rue du Test', + sites: [{ '@id': '/api/sites/1' }], + categories: [{ '@id': '/api/categories/7' }], + contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'], + }) + expect(draft.siteIris).toEqual(['/api/sites/1']) + expect(draft.categoryIris).toEqual(['/api/categories/7']) + expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6']) + expect(draft.id).toBe(3) + }) + }) + + describe('mapAccountingDraft / mapRibToDraft', () => { + it('mappe les scalaires et les IRI des referentiels embarques', () => { + const draft = mapAccountingDraft({ + '@id': '/api/providers/9', + id: 9, + siren: '123456789', + accountNumber: '4010', + nTva: 'FR123', + tvaMode: { '@id': '/api/tva_modes/1', label: 'TVA' }, + paymentType: { '@id': '/api/payment_types/3', code: 'VIREMENT' }, + bank: { '@id': '/api/banks/2' }, + }) + expect(draft.tvaModeIri).toBe('/api/tva_modes/1') + expect(draft.paymentTypeIri).toBe('/api/payment_types/3') + expect(draft.bankIri).toBe('/api/banks/2') + expect(draft.paymentDelayIri).toBeNull() + expect(draft.siren).toBe('123456789') + }) + + it('mappe un RIB embarque', () => { + expect(mapRibToDraft({ '@id': '/api/provider_ribs/1', id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' })) + .toEqual({ id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' }) + }) + }) + + describe('options builders (libelles role-independants depuis l\'embed)', () => { + it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => { + expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }])) + .toEqual([{ value: '/api/categories/7', label: 'Maintenance' }]) + expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }])) + .toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }]) + expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }])) + .toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }]) + }) + + it('referentialOptionOf / paymentTypeCodeOf', () => { + expect(referentialOptionOf({ '@id': '/api/banks/2', label: 'SG' })) + .toEqual([{ value: '/api/banks/2', label: 'SG' }]) + expect(referentialOptionOf(null)).toEqual([]) + expect(referentialOptionOf('/api/banks/2')).toEqual([]) + expect(paymentTypeCodeOf({ '@id': '/api/payment_types/3', code: 'LCR' })).toBe('LCR') + expect(paymentTypeCodeOf(null)).toBeNull() + }) + }) + + describe('actions selon permissions', () => { + /** Fabrique un `can` qui n'autorise que les codes fournis. */ + const canFor = (granted: string[]) => (code: string) => granted.includes(code) + const canAnyFor = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c)) + + it('« Modifier » visible avec manage OU accounting.manage (Compta inclus)', () => { + expect(canEditProvider(canAnyFor(['technique.providers.manage']))).toBe(true) + expect(canEditProvider(canAnyFor(['technique.providers.accounting.manage']))).toBe(true) + expect(canEditProvider(canAnyFor(['technique.providers.view']))).toBe(false) + }) + + it('« Archiver » visible seulement avec archive ET prestataire actif (Admin seul)', () => { + const admin = canFor(['technique.providers.archive']) + const bureau = canFor(['technique.providers.manage']) + expect(showArchiveAction(admin, false)).toBe(true) + expect(showArchiveAction(admin, true)).toBe(false) // deja archive -> Restaurer + expect(showArchiveAction(bureau, false)).toBe(false) // pas la permission archive + }) + + it('« Restaurer » visible seulement avec archive ET prestataire archive', () => { + const admin = canFor(['technique.providers.archive']) + expect(showRestoreAction(admin, true)).toBe(true) + expect(showRestoreAction(admin, false)).toBe(false) + expect(showRestoreAction(canFor([]), true)).toBe(false) + }) + }) +}) diff --git a/frontend/modules/technique/utils/forms/providerAccounting.ts b/frontend/modules/technique/utils/forms/providerAccounting.ts new file mode 100644 index 0000000..4357acf --- /dev/null +++ b/frontend/modules/technique/utils/forms/providerAccounting.ts @@ -0,0 +1,86 @@ +/** + * Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144) — + * miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle + * ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07 + * (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API. + */ + +import type { + ProviderAccountingDraft, + ProviderRibFormDraft, +} from '~/modules/technique/types/providerForm' + +/** Code pivot du type de reglement imposant une banque (RG-3.07). */ +const PAYMENT_TYPE_VIREMENT = 'VIREMENT' +/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */ +const PAYMENT_TYPE_LCR = 'LCR' + +/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */ +const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const + +/** Vrai si une chaine porte au moins un caractere non-espace. */ +function isFilled(value: string | null | undefined): boolean { + return value !== null && value !== undefined && value.trim() !== '' +} + +/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */ +export function isBankRequiredForPaymentType(code: string | null | undefined): boolean { + return code === PAYMENT_TYPE_VIREMENT +} + +/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */ +export function isRibRequiredForPaymentType(code: string | null | undefined): boolean { + return code === PAYMENT_TYPE_LCR +} + +/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */ +export function isRibBlank(rib: ProviderRibFormDraft): boolean { + return ![rib.label, rib.bic, rib.iban].some(isFilled) +} + +/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */ +export function isRibComplete(rib: ProviderRibFormDraft): boolean { + return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban) +} + +/** + * Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations + * sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon + * `null` (le back vide la relation hors VIREMENT). + */ +export function buildProviderAccountingPayload( + accounting: ProviderAccountingDraft, + isBankRequired: boolean, +): Record { + return { + siren: accounting.siren || null, + accountNumber: accounting.accountNumber || null, + tvaMode: accounting.tvaModeIri, + nTva: accounting.nTva || null, + paymentDelay: accounting.paymentDelayIri, + paymentType: accounting.paymentTypeIri, + bank: isBankRequired ? accounting.bankIri : null, + } +} + +/** + * Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les + * champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur + * le champ. + */ +export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record { + const payload: Record = { + label: rib.label, + bic: rib.bic, + iban: rib.iban, + } + + for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) { + const value = payload[key] + if (value === null || value === undefined || value === '') { + delete payload[key] + } + } + + return payload +} diff --git a/frontend/modules/technique/utils/forms/providerAddress.ts b/frontend/modules/technique/utils/forms/providerAddress.ts new file mode 100644 index 0000000..ff1f5ed --- /dev/null +++ b/frontend/modules/technique/utils/forms/providerAddress.ts @@ -0,0 +1,50 @@ +/** + * Helpers purs de l'onglet Adresse prestataire (M3 Technique, ERP-143) — miroir + * SIMPLIFIE de `supplierFormRules`/`supplierEdit` (M2), reimplemente cote module + * Technique (regle ABSOLUE n°1 : pas d'import inter-module). Testables sans Vue. + */ + +import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm' + +/** + * Champs scalaires obligatoires non nullable cote back (NotBlank). A la creation + * (POST), on OMET du payload ceux qui sont vides pour que la 422 porte la + * violation NotBlank propre (sur le champ) plutot qu'une erreur de type. + */ +const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const + +/** + * RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un + * nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les + * scalaires (CP/ville/rue) restent valides par le back (422 inline). + */ +export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean { + return address.siteIris.length >= 1 && address.categoryIris.length >= 1 +} + +/** + * Payload de la sous-ressource addresses (groupe `provider:write:addresses`). + * Relations M2M en IRI. Les scalaires requis vides sont omis a la creation (cf. + * REQUIRED_NON_NULLABLE_KEYS). + */ +export function buildProviderAddressPayload(address: ProviderAddressFormDraft): Record { + const payload: Record = { + country: address.country, + postalCode: address.postalCode || null, + city: address.city || null, + street: address.street || null, + streetComplement: address.streetComplement || null, + categories: [...address.categoryIris], + sites: [...address.siteIris], + contacts: [...address.contactIris], + } + + for (const key of REQUIRED_NON_NULLABLE_KEYS) { + const value = payload[key] + if (value === null || value === undefined || value === '') { + delete payload[key] + } + } + + return payload +} diff --git a/frontend/modules/technique/utils/forms/providerContact.ts b/frontend/modules/technique/utils/forms/providerContact.ts new file mode 100644 index 0000000..41f0074 --- /dev/null +++ b/frontend/modules/technique/utils/forms/providerContact.ts @@ -0,0 +1,66 @@ +/** + * Helpers purs de l'onglet Contact prestataire (M3 Technique, ERP-142) — miroir + * reduit de `supplierFormRules.ts` / `supplierEdit.ts` (M2). Testables sans Vue + * ni API : detection de bloc vide (RG-3.04) et construction du payload de + * sous-ressource contacts. + */ + +import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm' + +/** Vrai si une chaine porte au moins un caractere non-espace. */ +function isFilled(value: string | null | undefined): boolean { + return value !== null && value !== undefined && value.trim() !== '' +} + +/** + * RG-3.04 : un bloc Contact est VIDE tant qu'aucun des champs comptant pour la + * validite n'est rempli — prenom / nom / fonction / telephone principal / email. + * + * `phoneSecondary` est volontairement EXCLU : le back (CHECK + * `chk_provider_contact_name` + `ProviderContactProcessor`) ne le compte pas non + * plus, un bloc ne portant qu'un 2e numero reste invalide. Garder la meme + * definition cote front evite tout drift (un bloc « vide » front == bloc rejete + * back). + */ +export function isProviderContactBlank(contact: ProviderContactFormDraft): boolean { + return ![ + contact.firstName, + contact.lastName, + contact.jobTitle, + contact.phonePrimary, + contact.email, + ].some(isFilled) +} + +/** + * RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom + * — aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de + * contact valide (la fonction / le telephone / l'email seuls ne suffisent pas). + */ +export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean { + return isFilled(contact.firstName) || isFilled(contact.lastName) +} + +/** + * RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un + * contact nomme (prenom ou nom). + */ +export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean { + return contacts.some(isProviderContactNamed) +} + +/** + * Payload de la sous-ressource contacts (groupe `provider:write:contacts`). Les + * chaines vides sont envoyees a null (le serveur normalise/trim de toute facon). + * `phoneSecondary` n'est envoye que si le 2e numero a ete revele (max 2 tel). + */ +export function buildProviderContactPayload(contact: ProviderContactFormDraft): Record { + return { + firstName: contact.firstName || null, + lastName: contact.lastName || null, + jobTitle: contact.jobTitle || null, + phonePrimary: contact.phonePrimary || null, + phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null, + email: contact.email || null, + } +} diff --git a/frontend/modules/technique/utils/forms/providerDetail.ts b/frontend/modules/technique/utils/forms/providerDetail.ts new file mode 100644 index 0000000..36bbd04 --- /dev/null +++ b/frontend/modules/technique/utils/forms/providerDetail.ts @@ -0,0 +1,245 @@ +/** + * Helpers purs des ecrans Consultation / Modification prestataire (M3 Technique, + * ERP-145) — miroir SIMPLIFIE de `supplierConsultation.ts` (M2). Mappent le payload + * `GET /api/providers/{id}` (relations embarquees, cf. groupes `provider:item:read` + * + `provider:read:accounting`) vers les brouillons « plats » partages avec + * `ProviderContactBlock` / `ProviderAddressBlock` et l'onglet Comptabilite. + * + * Ne touchent ni a l'API ni a l'etat reactif (testables unitairement). + * + * Rappels de contrat back (JSON reel fige — ERP-139, spec-back § 4.0.bis) : + * - categories / sites du prestataire et des adresses : OBJETS embarques (avec @id) ; + * - refs comptables (tvaMode/paymentDelay/paymentType/bank) : OBJETS embarques + * `{@id, id, label, (code pour paymentType)}` ; + * - champs nuls OMIS (skip_null_values) → toujours lire avec `?? null` ; + * - champs comptables + `ribs` TOTALEMENT ABSENTS sans permission accounting.view. + * + * Differences M2 : pas de type d'adresse / bennes / triage, pas d'onglet Information. + */ + +import { formatPhoneFR } from '~/shared/utils/phone' +import type { + ProviderAccountingDraft, + ProviderAddressFormDraft, + ProviderContactFormDraft, + ProviderRibFormDraft, +} from '~/modules/technique/types/providerForm' +import type { RefOption } from '~/modules/technique/composables/useProviderReferentials' + +/** Reference Hydra embarquee minimale (@id toujours present). */ +export interface HydraRef { + '@id': string + [key: string]: unknown +} + +/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */ +export type Relation = HydraRef | string | null | undefined + +/** Site embarque (groupe site:read). */ +export interface SiteRead extends HydraRef { + name?: string + postalCode?: string + color?: string +} + +/** Categorie embarquee (groupe category:read). */ +export interface CategoryRead extends HydraRef { + code?: string + name?: string +} + +/** Contact embarque (groupe provider:item:read). */ +export interface ContactRead extends HydraRef { + id: number + firstName?: string | null + lastName?: string | null + jobTitle?: string | null + phonePrimary?: string | null + phoneSecondary?: string | null + email?: string | null +} + +/** Adresse embarquee (groupe provider:item:read) — version simplifiee M3. */ +export interface AddressRead extends HydraRef { + id: number + country?: string | null + postalCode?: string | null + city?: string | null + street?: string | null + streetComplement?: string | null + sites?: SiteRead[] + categories?: CategoryRead[] + // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. + contacts?: Array +} + +/** RIB embarque (groupe provider:read:accounting, present ssi accounting.view). */ +export interface RibRead extends HydraRef { + id: number + label?: string | null + bic?: string | null + iban?: string | null +} + +/** + * Detail d'un prestataire (`GET /api/providers/{id}`). Tous les champs sont + * optionnels : skip_null_values + gating accounting peuvent omettre n'importe + * quelle cle. + */ +export interface ProviderDetail extends HydraRef { + id: number + companyName?: string | null + isArchived?: boolean + categories?: CategoryRead[] + sites?: SiteRead[] + contacts?: ContactRead[] + addresses?: AddressRead[] + ribs?: RibRead[] + // Onglet Comptabilite (present ssi accounting.view) + siren?: string | null + accountNumber?: string | null + nTva?: string | null + tvaMode?: Relation + paymentDelay?: Relation + paymentType?: Relation + bank?: Relation +} + +/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */ +export function iriOf(relation: Relation): string | null { + if (relation === null || relation === undefined) { + return null + } + if (typeof relation === 'string') { + return relation + } + return relation['@id'] ?? null +} + +/** IRI des elements d'une collection embarquee (categories / sites du prestataire). */ +export function irisOf(items: HydraRef[] | undefined): string[] { + return (items ?? []).map(i => i['@id']) +} + +/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */ +export function mapContactToDraft(contact: ContactRead): ProviderContactFormDraft { + const phoneSecondary = contact.phoneSecondary ?? null + return { + id: contact.id, + iri: contact['@id'] ?? null, + firstName: contact.firstName ?? null, + lastName: contact.lastName ?? null, + jobTitle: contact.jobTitle ?? null, + phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null, + phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null, + email: contact.email ?? null, + hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '', + } +} + +/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */ +export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraft { + return { + id: address.id, + country: address.country ?? 'France', + postalCode: address.postalCode ?? null, + city: address.city ?? null, + street: address.street ?? null, + streetComplement: address.streetComplement ?? null, + categoryIris: (address.categories ?? []).map(c => c['@id']), + siteIris: (address.sites ?? []).map(s => s['@id']), + contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), + } +} + +/** Mappe un RIB embarque vers un brouillon. */ +export function mapRibToDraft(rib: RibRead): ProviderRibFormDraft { + return { + id: rib.id, + label: rib.label ?? null, + bic: rib.bic ?? null, + iban: rib.iban ?? null, + } +} + +/** Mappe les champs comptables (scalaires + IRI des referentiels embarques). */ +export function mapAccountingDraft(provider: ProviderDetail): ProviderAccountingDraft { + return { + siren: provider.siren ?? null, + accountNumber: provider.accountNumber ?? null, + nTva: provider.nTva ?? null, + tvaModeIri: iriOf(provider.tvaMode), + paymentDelayIri: iriOf(provider.paymentDelay), + paymentTypeIri: iriOf(provider.paymentType), + bankIri: iriOf(provider.bank), + } +} + +/** + * Options de categories (value=IRI, label=nom) construites depuis l'embed. + * Source role-independante : evite de dependre de `GET /categories` (403 possible + * pour un role metier), qui laisserait les libelles vides en consultation. + */ +export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOption[] { + return (categories ?? []).map(c => ({ + value: c['@id'], + label: c.name ?? c.code ?? c['@id'], + })) +} + +/** Options de sites (value=IRI, label=nom) construites depuis un embed. */ +export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] { + return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] })) +} + +/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */ +export function contactOptionsOf(contacts: ContactRead[] | undefined): RefOption[] { + return (contacts ?? []).map(c => ({ + value: c['@id'], + label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']), + })) +} + +/** + * Liste a une seule option (ou vide) construite depuis un referentiel embarque + * (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en + * lecture seule. Le libelle vient de l'embed, jamais d'un GET de referentiel — + * l'affichage reste correct quel que soit le role. + */ +export function referentialOptionOf(relation: Relation): RefOption[] { + if (!relation || typeof relation === 'string') { + return [] + } + const label = (relation.label as string | undefined) + ?? (relation.name as string | undefined) + ?? relation['@id'] + return [{ value: relation['@id'], label }] +} + +/** Code metier d'un referentiel embarque (PaymentType.code = 'LCR' / 'VIREMENT'), ou null. */ +export function paymentTypeCodeOf(relation: Relation): string | null { + if (!relation || typeof relation === 'string') { + return null + } + return (relation.code as string | undefined) ?? null +} + +/** + * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet — + * `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir + * ouvrir l'edition pour son onglet Comptabilite). Le readonly fin par onglet est + * gere sur l'ecran d'edition. + */ +export function canEditProvider(canAny: (codes: string[]) => boolean): boolean { + return canAny(['technique.providers.manage', 'technique.providers.accounting.manage']) +} + +/** Bouton « Archiver » : permission archive ET prestataire encore actif (Admin seul). */ +export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('technique.providers.archive') && !isArchived +} + +/** Bouton « Restaurer » : permission archive ET prestataire deja archive (Admin seul). */ +export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('technique.providers.archive') && isArchived +} diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index 73afbd4..ededce8 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -84,6 +84,17 @@ export const personas: Record = { 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', 'commercial.suppliers.archive', + // Technique — Repertoire prestataires (M3, ERP-138). Meme logique que + // clients/fournisseurs : mappe sur le persona "tout", pas de nouveau + // persona (regle ABSOLUE n°7). user-full porte deja sites.bypass_scope, + // donc il voit les prestataires de tous les sites (M3 § 2.13). + // technique.providers.view n'ajoute pas de lien dans la section + // Administration, donc expectedAdminLinks reste inchange. + 'technique.providers.view', + 'technique.providers.manage', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + 'technique.providers.archive', ], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], }, diff --git a/makefile b/makefile index f07ca38..8b52e20 100644 --- a/makefile +++ b/makefile @@ -231,6 +231,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/migrations/Version20260612080000.php b/migrations/Version20260612080000.php new file mode 100644 index 0000000..bf4d7cd --- /dev/null +++ b/migrations/Version20260612080000.php @@ -0,0 +1,121 @@ + 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); + } +} diff --git a/migrations/Version20260612100000.php b/migrations/Version20260612100000.php new file mode 100644 index 0000000..16eacd8 --- /dev/null +++ b/migrations/Version20260612100000.php @@ -0,0 +1,451 @@ += 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, + )); + } +} diff --git a/migrations/Version20260615120000.php b/migrations/Version20260615120000.php new file mode 100644 index 0000000..381206b --- /dev/null +++ b/migrations/Version20260615120000.php @@ -0,0 +1,50 @@ +addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name'); + $this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)'); + + $this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name'); + $this->addSql('ALTER TABLE provider_contact ADD 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)'); + + $this->addSql('COMMENT ON TABLE provider_contact IS $_$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->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$'); + } +} diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php index 760cf89..b150a45 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php @@ -17,7 +17,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; * Fixtures dev/test du module Catalog : categories de demonstration rattachees * a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte * 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 * donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29 * (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2). @@ -71,6 +73,11 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface 'Grossiste' => 'GROSSISTE', 'Importateur' => 'IMPORTATEUR', ], + 'PRESTATAIRE' => [ + 'Maintenance industrielle' => 'MAINTENANCE_INDUSTRIELLE', + 'Nettoyage' => 'NETTOYAGE', + 'Transport' => 'TRANSPORT', + ], ]; public function __construct( diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php index b329414..8d5c518 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php @@ -21,6 +21,10 @@ use Doctrine\Persistence\ObjectManager; * taxonomie distincte des fournisseurs (Negociant, Cooperative...). Mirroir de * 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 * 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 @@ -36,12 +40,13 @@ class CategoryTypeFixtures extends Fixture { /** * Source unique des types : code technique => libelle FR. Doit rester aligne - * sur le seed des migrations Version20260602100000 (CLIENT) et - * Version20260605120000 (FOURNISSEUR). + * sur le seed des migrations Version20260602100000 (CLIENT), + * Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE). */ private const TYPES = [ 'CLIENT' => 'Client', 'FOURNISSEUR' => 'Fournisseur', + 'PRESTATAIRE' => 'Prestataire', ]; public function __construct( diff --git a/src/Module/Commercial/Domain/Entity/Bank.php b/src/Module/Commercial/Domain/Entity/Bank.php index 1ee4542..1108cdd 100644 --- a/src/Module/Commercial/Domain/Entity/Bank.php +++ b/src/Module/Commercial/Domain/Entity/Bank.php @@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * `client:read:accounting` permet l'embarquement dans la reponse Client ; - * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -48,15 +49,15 @@ class Bank #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php index 5e8be75..94f55af 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentDelay.php +++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php @@ -21,7 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * `client:read:accounting` permet l'embarquement dans la reponse Client ; - * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -48,15 +49,15 @@ class PaymentDelay #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php index af045c9..564b31a 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentType.php +++ b/src/Module/Commercial/Domain/Entity/PaymentType.php @@ -24,7 +24,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe * `client:read:accounting` permet l'embarquement dans la reponse Client ; - * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -51,15 +52,15 @@ class PaymentType #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php index e28072f..4cd01c1 100644 --- a/src/Module/Commercial/Domain/Entity/TvaMode.php +++ b/src/Module/Commercial/Domain/Entity/TvaMode.php @@ -25,7 +25,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le * groupe `client:read:accounting` permet d'embarquer le mode dans la reponse * d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting` - * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0). + * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0) ; + * `provider:read:accounting` dans la reponse Prestataire (M3, ERP-139 — § 4.0.bis). */ #[ApiResource( operations: [ @@ -55,15 +56,15 @@ class TvaMode #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting', 'provider:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php index 20669e9..fd882a6 100644 --- a/src/Module/Core/Application/Rbac/RbacSeeder.php +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -50,11 +50,19 @@ final class RbacSeeder /** * Definition unique des 4 roles + matrice § 2.7. La cle est le code du role, * `label` le libelle FR affichable, `permissions` la liste des codes RBAC a - * attacher (vide pour usine : aucun acces ; admin n'apparait pas car il - * bypass tout via isAdmin ; `commercial.clients.archive` et - * `commercial.suppliers.archive` ne sont attaches a aucun role metier — + * attacher (admin n'apparait pas car il bypass tout via isAdmin ; + * `commercial.clients.archive`, `commercial.suppliers.archive` et + * `technique.providers.archive` ne sont attaches a aucun role metier — * admin seul). * + * Cloisonnement par site des prestataires (M3 § 2.13) : la permission + * `sites.bypass_scope` est attribuee par defaut a Bureau / Compta / + * Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et + * reste cloisonnee a son site courant. Admin a le bypass total via isAdmin. + * C'est un cloisonnement pilote par user/permission, pas par code de role : + * pour cloisonner Bureau/Commerciale, il suffit de retirer la permission + * ici, aucun autre code a changer. + * * @var array}> */ private const array MATRIX = [ @@ -66,6 +74,11 @@ final class RbacSeeder // Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite). 'commercial.suppliers.view', 'commercial.suppliers.manage', + // Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite). + 'technique.providers.view', + 'technique.providers.manage', + // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. + 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', @@ -82,6 +95,13 @@ final class RbacSeeder 'commercial.suppliers.view', 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', + // Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement + // (pas de manage global -> ne peut pas creer un prestataire). + 'technique.providers.view', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. + 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', @@ -96,14 +116,25 @@ final class RbacSeeder // (onglet Comptabilite masque/filtre pour la Commerciale). 'commercial.suppliers.view', 'commercial.suppliers.manage', + // Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting + // (onglet Comptabilite masque/filtre pour la Commerciale). + 'technique.providers.view', + 'technique.providers.manage', + // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites. + 'sites.bypass_scope', // Lecture des referentiels transverses pour les selects client (ERP-102). 'catalog.categories.read_ref', 'sites.read_ref', ], ], self::ROLE_USINE => [ - 'label' => 'Usine', - 'permissions' => [], + 'label' => 'Usine', + // Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule, + // SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site + // courant. Aucun autre acces metier. + 'permissions' => [ + 'technique.providers.view', + ], ], ]; diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php index 7e7545b..bbef32d 100644 --- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php +++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php @@ -203,6 +203,15 @@ final class SeedE2ECommand extends Command 'commercial.suppliers.accounting.view', 'commercial.suppliers.accounting.manage', 'commercial.suppliers.archive', + // Technique — Repertoire prestataires (M3, ERP-138). Meme + // logique : mappe sur le persona "tout". user-full porte deja + // sites.bypass_scope -> voit les prestataires de tous les + // sites (M3 § 2.13). Miroir de personas.ts. + 'technique.providers.view', + 'technique.providers.manage', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + 'technique.providers.archive', ], ], [ diff --git a/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php new file mode 100644 index 0000000..ea7f6f6 --- /dev/null +++ b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php @@ -0,0 +1,99 @@ + "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; + } +} diff --git a/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php b/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php new file mode 100644 index 0000000..11d39da --- /dev/null +++ b/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php @@ -0,0 +1,79 @@ + valeur courante des champs obligatoires de l'onglet. + $fields = [ + 'siren' => $provider->getSiren(), + 'accountNumber' => $provider->getAccountNumber(), + 'tvaMode' => $provider->getTvaMode(), + 'nTva' => $provider->getNTva(), + 'paymentDelay' => $provider->getPaymentDelay(), + 'paymentType' => $provider->getPaymentType(), + ]; + + $violations = new ConstraintViolationList(); + + foreach ($fields as $property => $value) { + if ($this->isMissing($value)) { + $violations->add(new ConstraintViolation( + 'Ce champ est obligatoire.', + null, + [], + $provider, + $property, + $value, + )); + } + } + + if (count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les + * references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que + * lorsqu'elles valent null. + */ + private function isMissing(mixed $value): bool + { + if (null === $value) { + return true; + } + + return is_string($value) && '' === trim($value); + } +} diff --git a/src/Module/Technique/Domain/Entity/Provider.php b/src/Module/Technique/Domain/Entity/Provider.php new file mode 100644 index 0000000..8c95e8a --- /dev/null +++ b/src/Module/Technique/Domain/Entity/Provider.php @@ -0,0 +1,607 @@ += 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 */ + #[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 */ + #[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 */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $contacts; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'provider', targetEntity: ProviderAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $addresses; + + /** @var Collection */ + #[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 */ + 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 */ + 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 */ + #[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 */ + #[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 */ + #[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; + } +} diff --git a/src/Module/Technique/Domain/Entity/ProviderAddress.php b/src/Module/Technique/Domain/Entity/ProviderAddress.php new file mode 100644 index 0000000..55ef080 --- /dev/null +++ b/src/Module/Technique/Domain/Entity/ProviderAddress.php @@ -0,0 +1,370 @@ + ['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 */ + #[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 */ + #[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 */ + #[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 */ + 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 */ + 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 */ + 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; + } +} diff --git a/src/Module/Technique/Domain/Entity/ProviderContact.php b/src/Module/Technique/Domain/Entity/ProviderContact.php new file mode 100644 index 0000000..9511f6b --- /dev/null +++ b/src/Module/Technique/Domain/Entity/ProviderContact.php @@ -0,0 +1,241 @@ + ['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; + } +} diff --git a/src/Module/Technique/Domain/Entity/ProviderOwnedInterface.php b/src/Module/Technique/Domain/Entity/ProviderOwnedInterface.php new file mode 100644 index 0000000..a6a7303 --- /dev/null +++ b/src/Module/Technique/Domain/Entity/ProviderOwnedInterface.php @@ -0,0 +1,16 @@ + ['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; + } +} diff --git a/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php new file mode 100644 index 0000000..49f5fb1 --- /dev/null +++ b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php @@ -0,0 +1,98 @@ + 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 $categoryCodes + * @param list $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 $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 $providers + */ + public function hydrateContacts(array $providers): void; +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php b/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php new file mode 100644 index 0000000..0ba501f --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php @@ -0,0 +1,76 @@ +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; + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php new file mode 100644 index 0000000..653cf91 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php @@ -0,0 +1,220 @@ += 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 + */ +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 + */ + 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 + */ + 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); + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php new file mode 100644 index 0000000..10674f6 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php @@ -0,0 +1,144 @@ + + */ +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 exige au moins le prenom OU le nom (aligne sur le + * M1/M2 — un contact se materialise par son nom ; fonction / telephone / email + * seuls ne suffisent pas). 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 (les chaines vides sont deja ramenees a null). + */ + private function validateName(ProviderContact $contact): void + { + if (null === $contact->getFirstName() && null === $contact->getLastName()) { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Le prénom ou le nom du contact est obligatoire.', + null, + [], + $contact, + 'firstName', + null, + )); + + throw new ValidationException($violations); + } + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php new file mode 100644 index 0000000..8df9e9b --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php @@ -0,0 +1,590 @@ + 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 + */ +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', + ]; + + /** + * Champs comptables obligatoires a la validation complete de l'onglet + * (spec-front M3 § Onglet Comptabilite — miroir M1/M2). bank est exclu : + * conditionnel (RG-3.07). + */ + private const array ACCOUNTING_REQUIRED_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', + ]; + + /** 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 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, + private readonly ProviderAccountingCompletenessValidator $accountingValidator, + ) {} + + 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); + + // Completude de l'onglet Comptabilite (apres normalize : les chaines vides + // sont deja ramenees a null). Joue uniquement sur une soumission d'onglet. + $this->validateAccountingCompleteness($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 $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 + */ + 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 + */ + 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 $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 $entities + * + * @return list + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + /** + * Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se + * declenche que si TOUS les champs requis sont presents dans le payload + * (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur + * qui leve une 422 listant chaque champ manquant (mapping inline ERP-101). + */ + private function validateAccountingCompleteness(Provider $data): void + { + if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) { + return; + } + + $this->accountingValidator->validate($data); + } + + 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 + */ + 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); + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php new file mode 100644 index 0000000..0c8107f --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php @@ -0,0 +1,120 @@ + + */ +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.', + ); + } + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php new file mode 100644 index 0000000..b5955f9 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php @@ -0,0 +1,223 @@ + (prestataires ayant >= 1 categorie + * de ce code, repetable) et ?siteId= (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 cote front). + if (!$this->pagination->isEnabled($operation, $context)) { + /** @var list $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 $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 + */ + 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 + */ + 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; + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderSubResourceItemProvider.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderSubResourceItemProvider.php new file mode 100644 index 0000000..6814eb6 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderSubResourceItemProvider.php @@ -0,0 +1,52 @@ + 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 + */ +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; + } +} diff --git a/src/Module/Technique/Infrastructure/Controller/ProviderExportController.php b/src/Module/Technique/Infrastructure/Controller/ProviderExportController.php new file mode 100644 index 0000000..4085901 --- /dev/null +++ b/src/Module/Technique/Infrastructure/Controller/ProviderExportController.php @@ -0,0 +1,328 @@ +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 $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 + */ + 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 $providers + * + * @return iterable> + */ + 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 $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 + */ + 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 + */ + 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; + } +} diff --git a/src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php b/src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php new file mode 100644 index 0000000..998ec15 --- /dev/null +++ b/src/Module/Technique/Infrastructure/DataFixtures/ProviderFixtures.php @@ -0,0 +1,393 @@ += 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 + */ + 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 $categoryNames categories de type PRESTATAIRE (RG-3.09) + * @param list $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 $siteNames au moins un site (RG-3.05) + * @param list $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; + } +} diff --git a/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php b/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php new file mode 100644 index 0000000..ac5be4a --- /dev/null +++ b/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php @@ -0,0 +1,287 @@ + + */ +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 $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 $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 $providers + * + * @return list + */ + 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 $values + * + * @return list + */ + 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 $values + * + * @return list + */ + private function normalizeIntList(array $values): array + { + $out = []; + foreach ($values as $value) { + if (is_numeric($value) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } +} diff --git a/src/Module/Technique/Infrastructure/Security/ProviderSiteScopeChecker.php b/src/Module/Technique/Infrastructure/Security/ProviderSiteScopeChecker.php new file mode 100644 index 0000000..8e8f2ce --- /dev/null +++ b/src/Module/Technique/Infrastructure/Security/ProviderSiteScopeChecker.php @@ -0,0 +1,85 @@ + 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; + } +} diff --git a/src/Module/Technique/TechniqueModule.php b/src/Module/Technique/TechniqueModule.php new file mode 100644 index 0000000..a5653cc --- /dev/null +++ b/src/Module/Technique/TechniqueModule.php @@ -0,0 +1,58 @@ + + */ + 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'], + ]; + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index c2e0720..a6f6dee 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -361,6 +361,91 @@ final class ColumnCommentsCatalog 'iban' => 'IBAN du compte (≤ 34 caracteres).', 'position' => 'Ordre d affichage du RIB dans la liste du fournisseur (croissant).', ] + self::timestampableBlamableComments(), + + // Tables provider* (M3 Technique) — ajoutees au ticket entites (ERP-133), + // comme l a fait supplier (ERP-86) apres sa migration (ERP-85). En test, + // `schema:update --force` recree ces tables depuis le mapping ORM (sans + // COMMENT) ; `app:apply-column-comments` les repose depuis ce catalogue. + 'provider' => [ + '_table' => 'Repertoire prestataires (M3 Technique) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M4). Pas d onglet Information (≠ supplier).', + 'id' => 'Identifiant interne auto-incremente.', + 'company_name' => 'Raison sociale du prestataire (stockee en MAJUSCULES). Unique case-insensitive parmi les actifs non archives/non supprimes (uq_provider_company_name_active, RG-3.10).', + 'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-3.10).', + 'account_number' => 'Onglet Comptabilite : numero de compte comptable du prestataire.', + 'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id (referentiel partage M1), ON DELETE RESTRICT.', + 'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.', + 'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id (M1), ON DELETE RESTRICT.', + 'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id (M1), ON DELETE RESTRICT. Pilote RG-3.07 (Banque si VIREMENT) et RG-3.08 (RIB).', + 'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id (M1), ON DELETE RESTRICT. Obligatoire ssi payment_type = VIREMENT (RG-3.07), null sinon.', + 'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission technique.providers.archive.', + 'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration.', + 'deleted_at' => 'Horodatage du soft-delete technique (HP M4) — non expose par l API au M3. Null = ligne active.', + ] + self::timestampableBlamableComments(), + + 'provider_category' => [ + '_table' => 'Jointure M2M provider <-> category (Catalog) — categories de type PRESTATAIRE du prestataire, au moins une obligatoire (RG-3.09).', + 'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur de la categorie.', + 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie de type PRESTATAIRE rattachee au prestataire (RG-3.09).', + ], + + 'provider_site' => [ + '_table' => 'Jointure M2M provider <-> site (Sites) — sites du prestataire, selecteur du formulaire principal, au moins un obligatoire (RG-3.03). Sert le cloisonnement par site (§ 2.13).', + 'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire porteur du site.', + 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache au prestataire (RG-3.03, idx_provider_site_site).', + ], + + 'provider_contact' => [ + '_table' => 'Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (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). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).', + 'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).', + 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).', + '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(), ]; } diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index fb5d338..1e2213f 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -54,6 +54,8 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase 'ClientAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', // Idem cote fournisseur (meme Regex CP). 'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', + // Idem cote prestataire (meme Regex CP — M3 Technique). + 'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', // Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20). 'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.', // Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres. diff --git a/tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php b/tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php new file mode 100644 index 0000000..b1a9b05 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryPrestataireSeedTest.php @@ -0,0 +1,107 @@ +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'); + } +} diff --git a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php new file mode 100644 index 0000000..2992694 --- /dev/null +++ b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php @@ -0,0 +1,489 @@ +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 $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 $sitePostalCodes + * + * @return array + */ + 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 $permissionCodes + * @param list $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 $body corps decode (toArray(false)) + * + * @return array propertyPath => message + */ + protected function violationsByPath(array $body): array + { + $byPath = []; + foreach ($body['violations'] ?? [] as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + + return $byPath; + } +} diff --git a/tests/Module/Technique/Api/ProviderAccountingValidationTest.php b/tests/Module/Technique/Api/ProviderAccountingValidationTest.php new file mode 100644 index 0000000..fde7d3e --- /dev/null +++ b/tests/Module/Technique/Api/ProviderAccountingValidationTest.php @@ -0,0 +1,138 @@ +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); + } + + // === Completude de l'onglet Comptabilite (six scalaires obligatoires) === + + /** + * spec-front M3 § Onglet Comptabilite : a la validation COMPLETE de l'onglet + * (les six champs requis presents dans le payload), chacun vide doit renvoyer + * une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir + * M1/M2 (ProviderAccountingCompletenessValidator). + */ + public function testIncompleteAccountingTabReturns422OnEachField(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Accounting Incomplete'); + + $response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD], + 'json' => [ + 'siren' => null, + 'accountNumber' => null, + 'tvaMode' => null, + 'nTva' => null, + 'paymentDelay' => null, + 'paymentType' => null, + ], + ]); + + self::assertResponseStatusCodeSame(422); + $paths = $this->violationsByPath($response->toArray(false)); + self::assertArrayHasKey('siren', $paths); + self::assertArrayHasKey('accountNumber', $paths); + self::assertArrayHasKey('tvaMode', $paths); + self::assertArrayHasKey('nTva', $paths); + self::assertArrayHasKey('paymentDelay', $paths); + self::assertArrayHasKey('paymentType', $paths); + } + + /** + * Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une + * validation d'onglet : la completude ne se declenche pas (edition ponctuelle + * preservee, cf. validateAccountingCompleteness). + */ + public function testPartialAccountingPatchSkipsCompleteness(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Accounting Partial'); + + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['nTva' => 'FR12345678901'], + ]); + + self::assertResponseStatusCodeSame(200); + } + + // violationsByPath() : helper mutualise dans AbstractProviderApiTestCase. +} diff --git a/tests/Module/Technique/Api/ProviderApiTest.php b/tests/Module/Technique/Api/ProviderApiTest.php new file mode 100644 index 0000000..82c8a14 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderApiTest.php @@ -0,0 +1,115 @@ +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()); + } +} diff --git a/tests/Module/Technique/Api/ProviderAuditTest.php b/tests/Module/Technique/Api/ProviderAuditTest.php new file mode 100644 index 0000000..60ab5ac --- /dev/null +++ b/tests/Module/Technique/Api/ProviderAuditTest.php @@ -0,0 +1,162 @@ + 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 + */ + 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], + ); + } +} diff --git a/tests/Module/Technique/Api/ProviderExportControllerTest.php b/tests/Module/Technique/Api/ProviderExportControllerTest.php new file mode 100644 index 0000000..9c9519e --- /dev/null +++ b/tests/Module/Technique/Api/ProviderExportControllerTest.php @@ -0,0 +1,319 @@ +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> + */ + 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 + */ + 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 + */ + 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> $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, + )); + } +} diff --git a/tests/Module/Technique/Api/ProviderListTest.php b/tests/Module/Technique/Api/ProviderListTest.php new file mode 100644 index 0000000..836cf6e --- /dev/null +++ b/tests/Module/Technique/Api/ProviderListTest.php @@ -0,0 +1,170 @@ + 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