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