diff --git a/config/version.yaml b/config/version.yaml index 9475c88..1bd718d 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.69' + app.version: '0.1.74' diff --git a/docs/specs/M1-clients/cahier-test-back-M1.md b/docs/specs/M1-clients/cahier-test-back-M1.md index be9e2cc..33fe309 100644 --- a/docs/specs/M1-clients/cahier-test-back-M1.md +++ b/docs/specs/M1-clients/cahier-test-back-M1.md @@ -18,8 +18,8 @@ merge de la stack. | RG | Intitulé | Test(s) | Source | |----|----------|---------|--------| -| RG-1.01 | Prénom OU nom obligatoire → 422 | `ClientApiTest::testPostWithoutFirstOrLastNameReturns422` ; `ClientProcessorTest` (unit) | ERP-55 | -| RG-1.02 | phoneSecondary persisté ; max 2 téléphones | `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` ; `::testThirdPhoneFieldIsIgnored` | **ERP-60** | +| ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact | +| ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact | | RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 | | RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** | | RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 | diff --git a/docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.md b/docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.md new file mode 100644 index 0000000..7d61bb4 --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.md @@ -0,0 +1,135 @@ +# M1 · Ticket 1/3 (Backend) — Supprimer le contact inline du `Client` + +## 1. Objectif + +Retirer de l'entité `Client` (et de la table `client`) les **5 champs du contact +principal inline** : `firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`. +La gestion des contacts passe désormais **exclusivement** par la sous-entité +`ClientContact` (onglet « Contacts »), déjà en place et déjà porteuse des mêmes champs. + +Le code M1 est **déjà livré en prod** : ce ticket inclut donc une **migration de données** +(backfill) pour ne perdre aucune information de contact existante avant de supprimer les +colonnes. + +Contexte et justification : voir `README.md` du dossier `refonte-contact`. + +## 2. Périmètre + +### IN + +- Migration Doctrine : **backfill puis suppression** des 5 colonnes de `client`. +- `Client` (entité) : supprimer les 5 propriétés, getters/setters, annotations ORM / + `Assert` / `Groups`. +- `ClientProcessor` : retirer les 5 champs de `MAIN_FIELDS`, `changedBusinessFields()`, + `normalize()` ; supprimer `validateMainContact()` (RG-1.01 — n'a plus d'objet). +- `DoctrineClientRepository::applySearch()` : trancher D1 (recherche) et l'appliquer. +- `ClientExportController` : trancher D2 (colonnes export) et l'appliquer. +- `ClientFixtures` : retirer les 5 paramètres inline de `ensureClient()` ; garantir que + chaque client seedé possède au moins 1 `ClientContact` (déjà géré par `addContact()`). +- Tests PHPUnit : mettre à jour / retirer les cas qui exercent ces 5 champs sur `Client`. + +### OUT + +- Toute modification de `ClientContact` / `ClientContactProcessor` : **inchangés** (c'est la + cible, les champs y restent). `ClientFieldNormalizer` reste tel quel (toujours appelé par + `ClientContactProcessor`). +- Le front (formulaires, vues, types, i18n) → **ticket 2/3**. +- Les specs (`spec-back.md`, `spec-front.md`, cahier de test) → **ticket 3/3**. + +## 3. Fichiers à modifier + +| Fichier | Action | +|---|---| +| `src/Module/Commercial/Domain/Entity/Client.php` | Supprimer props `firstName` (~l.158), `lastName` (~l.163), `phonePrimary` (~l.168), `phoneSecondary` (~l.172), `email` (~l.178) + leurs getters/setters (~l.329-382) + groupes `client:read`/`client:write:main` + `Assert\*`. | +| `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php` | Retirer les 5 clés de `MAIN_FIELDS` (~l.63) ; de `changedBusinessFields()` (~l.277-281) ; les 6 lignes de `normalize()` qui touchent email/phone/first/last/secondary (~l.433-441) ; supprimer `validateMainContact()` (~l.447-456) et son appel. | +| `src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php` | `applySearch()` (~l.110-124) : appliquer **D1**. | +| `src/Module/Commercial/Infrastructure/Controller/ClientExportController.php` | `buildHeaders()` (~l.94-114) + `buildRows()` (~l.121-143) : appliquer **D2**. | +| `src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php` | `ensureClient()` (~l.357-395) : retirer firstName/lastName/phonePrimary/phoneSecondary/email ; conserver `addContact()`. | +| `migrations/Version.php` (NOUVELLE) | Backfill + `DROP COLUMN` (cf. § 4). | +| `tests/Module/Commercial/**` | Voir § 5. | + +## 4. Migration Doctrine — backfill puis suppression + +> Migration **modulaire** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : ce +> n'est PAS une migration d'initialisation, le schéma `client` / `client_contact` existe +> déjà (règle ABSOLUE n°11). + +### `up()` + +1. **Backfill — ne créer un contact que pour les clients qui n'en ont aucun**, afin de ne + pas dupliquer le contact déjà recopié à la création (`prefillFirstContact`) : + + ```sql + INSERT INTO client_contact + (client_id, first_name, last_name, phone_primary, phone_secondary, email, position, created_at, updated_at) + SELECT c.id, c.first_name, c.last_name, c.phone_primary, c.phone_secondary, c.email, 0, NOW(), NOW() + FROM client c + WHERE NOT EXISTS (SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id) + AND (c.first_name IS NOT NULL OR c.last_name IS NOT NULL); + ``` + + > Le `WHERE ... first_name OU last_name IS NOT NULL` respecte le CHECK + > `chk_client_contact_name`. Les rares clients sans nom de contact ET sans contact + > existant ne reçoivent pas de ligne (cas théorique : `phone_primary`/`email` étaient + > `NOT NULL` mais les noms nullables). + +2. **Supprimer les 5 colonnes** : + + ```sql + ALTER TABLE client + DROP COLUMN first_name, + DROP COLUMN last_name, + DROP COLUMN phone_primary, + DROP COLUMN phone_secondary, + DROP COLUMN email; + ``` + + > Pas de `COMMENT ON COLUMN` à poser (on supprime). Vérifier qu'aucun index ne portait + > sur `email` (l'index unique `uq_client_email_active` a déjà été supprimé — décision Q4 / + > RG-1.17, cf. `ClientMigrationTest`). + +### `down()` (best-effort) + +1. Recréer les 5 colonnes (`phone_primary`/`email` en `NOT NULL` impose un défaut transitoire + ou un re-remplissage depuis le contact `position = 0`). +2. Re-remplir depuis `client_contact` (`position = 0`) si possible. +3. Reposer les `COMMENT ON COLUMN` d'origine (textes RG-1.19/1.20/1.21/1.01/1.17 — cf. + `migrations/Version20260601000000.php` l.251-255). + +> `down()` ne peut pas restaurer parfaitement les données (ambiguïté si plusieurs contacts). +> Documenter cette limite dans le docblock de la migration. + +## 5. Tests à mettre à jour + +| Fichier | Action | +|---|---| +| `tests/Module/Commercial/Api/ClientApiTest.php` | Retirer firstName/lastName/phone/email des payloads POST/PATCH `client` et des assertions JSON. | +| `tests/.../ClientFormulaireMainTest.php` | Supprimer les tests RG-1.01 (firstName/lastName) et RG-1.02 (téléphones) **côté Client** — ils basculent côté `ClientContact` (couverts ailleurs). | +| `tests/.../ClientExportControllerTest.php` | Aligner les en-têtes/lignes attendus sur **D2**. | +| `tests/.../ClientMigrationTest.php` | Asserter que les 5 colonnes **n'existent plus** sur `client` ; vérifier le backfill (un client sans contact obtient bien 1 `client_contact`). | +| `tests/.../ClientFieldNormalizerTest.php` | Conserver les tests du normalizer (toujours utilisé par `ClientContact`) ; retirer les cas spécifiques aux champs `Client` s'il y en a. | +| RG-1.01/1.02 (matrice) | Ne plus tester sur `Client` ; vérifier qu'ils restent couverts sur `ClientContact` (RG-1.05). | + +## 6. Décisions à trancher (cf. README § 3) + +- **D1 — recherche** : recommandé = `LEFT JOIN client_contact` (fuzzy sur + `companyName` + contact `first_name`/`last_name`/`email`). Attention au `DISTINCT` / + risque de doublons de lignes si plusieurs contacts matchent (grouper par `client.id`). +- **D2 — export** : recommandé = alimenter les colonnes contact depuis le contact de plus + petit `position` (fetch-join `contacts` pour éviter le N+1). + +## 7. Critères d'acceptation (DoD) + +- [ ] Les colonnes `first_name`, `last_name`, `phone_primary`, `phone_secondary`, `email` + n'existent plus sur la table `client`. +- [ ] La migration est jouable sur une base seedée sans perte de contact (backfill vérifié) + et `down()` documenté comme best-effort. +- [ ] `Client`, `ClientProcessor`, `DoctrineClientRepository`, `ClientExportController`, + `ClientFixtures` ne référencent plus les 5 champs. +- [ ] D1 et D2 implémentées conformément à la décision validée. +- [ ] `ClientContact` / `ClientContactProcessor` / `ClientFieldNormalizer` inchangés. +- [ ] `make test` vert (notamment `tests/Architecture/ColumnsHaveSqlCommentTest` et + `EntitiesAreTimestampableBlamableTest`). +- [ ] `make php-cs-fixer-allow-risky` ne signale rien sur les fichiers touchés. +- [ ] Aucune régression du contrat de sérialisation : capturer le JSON réel de + `GET /api/clients/{id}` et vérifier l'absence des 5 champs (réflexe RETEX M1). diff --git a/docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.prompt.md b/docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.prompt.md new file mode 100644 index 0000000..fc8b201 --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.prompt.md @@ -0,0 +1,58 @@ +# Prompt d'implémentation — M1 · Ticket 1/3 (Backend) + +Tu travailles sur le projet **Starseed** (Symfony 8 / API Platform 4 / Doctrine / PostgreSQL). +Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Commentaires en français, +code en anglais, `declare(strict_types=1);` partout. + +## Mission + +Supprimer le **contact principal inline** de l'entité `Client` : les 5 champs +`firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`. Les contacts sont gérés +uniquement via la sous-entité `ClientContact` (onglet Contacts), déjà en place. Le code est +déjà en prod → migration avec **backfill** avant `DROP`. + +La spec détaillée du ticket est dans `docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.md`. +Lis-la en entier, ainsi que le `README.md` du même dossier (décision + RG impactées + D1/D2). + +## Étapes + +1. **Explorer** : `Client.php`, `ClientProcessor.php`, `DoctrineClientRepository.php`, + `ClientExportController.php`, `ClientFixtures.php`, et `ClientContact.php` (pour confirmer + que la cible porte bien les mêmes champs). +2. **Demander la validation des décisions D1 (recherche) et D2 (export)** avant de coder — + défauts recommandés : D1 = LEFT JOIN sur `client_contact`, D2 = colonnes export depuis le + contact `position` minimal. Ne pas inventer un autre comportement. +3. **Migration** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : backfill + `INSERT INTO client_contact ... WHERE NOT EXISTS(...)` puis `ALTER TABLE client DROP COLUMN ...` + (les 5). `down()` best-effort documenté. Voir le SQL exact dans la spec § 4. +4. **Entité** : retirer les 5 props + getters/setters + `#[ORM\Column]` + `#[Assert\*]` + + `#[Groups(['client:read','client:write:main'])]`. +5. **Processor** : retirer de `MAIN_FIELDS`, `changedBusinessFields()`, `normalize()` ; + supprimer `validateMainContact()` et son appel. +6. **Repository** : `applySearch()` selon D1. +7. **Export** : `buildHeaders()` / `buildRows()` selon D2. +8. **Fixtures** : alléger `ensureClient()` ; garder `addContact()`. +9. **Tests** : mettre à jour `ClientApiTest`, `ClientFormulaireMainTest`, + `ClientExportControllerTest`, `ClientMigrationTest`, `ClientFieldNormalizerTest` + (cf. spec § 5). Ajouter une assertion que le backfill crée bien un contact pour un client + qui n'en avait pas. + +## Garde-fous + +- Ne touche **pas** `ClientContact`, `ClientContactProcessor`, `ClientFieldNormalizer`. +- Respecte les règles ABSOLUES : pagination, `#[Auditable]`, COMMENT ON COLUMN (ici on + supprime → pas de commentaire à poser, mais ne pas casser le garde-fou). +- Les RG-1.01 et RG-1.02 disparaissent **du Client** : leur équivalent (RG-1.05 / RG-1.14) + vit déjà sur `ClientContact`, ne le duplique pas. + +## Vérification finale (obligatoire avant de dire « fini ») + +```bash +make db-reset && make migration-migrate # migration rejouable sur base fraîche +make test # PHPUnit vert +make php-cs-fixer-allow-risky # lint +``` + +Puis capture le JSON réel de `GET /api/clients/{id}` (avec un JWT) et confirme que les 5 +champs ont disparu de la réponse et que `contacts[]` porte bien l'info (réflexe RETEX M1 : +on valide sur le contrat réel, pas sur les annotations). diff --git a/docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.md b/docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.md new file mode 100644 index 0000000..d419fa6 --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.md @@ -0,0 +1,74 @@ +# M1 · Ticket 2/3 (Frontend) — Retirer le bloc contact principal des écrans Client + +## 1. Objectif + +Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des +trois écrans Client — **création**, **consultation**, **modification** — ainsi que des +types, mappeurs, validations et clés i18n associés. La saisie des contacts se fait +désormais uniquement dans l'**onglet « Contacts »** (composant `ClientContactBlock`, déjà +en place et inchangé). + +Dépend du **ticket 1/3 (back)** : l'API ne renvoie/n'accepte plus ces 5 champs sur `client`. +Contexte : voir `README.md` du dossier `refonte-contact`. + +## 2. Périmètre + +### IN — fichiers `frontend/modules/commercial/` + +| Fichier | Action | +|---|---| +| `pages/clients/new.vue` | Supprimer le bloc principal Nom/Prénom/Téléphones/Email (~l.27-63), l'état `main.firstName/lastName/email`, `mainPhones` (~l.445-459), la fonction `prefillFirstContact()` (~l.658-665) et son appel, le mapping payload POST `phonePrimary/phoneSecondary` (~l.513-524). Adapter `isMainValid` (~l.479-493) : la validation principale ne porte plus que sur `companyName` (+ relation/catégories selon RG existantes). L'onglet **Contacts** devient le point de saisie des coordonnées ; garantir au moins un `ClientContactBlock` vide au départ. | +| `pages/clients/[id]/edit.vue` | Supprimer les 5 champs du bloc principal (~l.32-73). `mapMainDraft()` et `buildMainPayload()` ne portent plus ces champs. L'onglet Contacts reste éditable. | +| `pages/clients/[id]/index.vue` | Supprimer l'affichage lecture seule des 5 champs du bloc principal (~l.49-104, partie contact). Conserver l'onglet Contacts (lecture seule). | +| `types/clientForm.ts` | `MainFormDraft` : retirer `firstName`, `lastName`, `email`, `phonePrimary`, `phoneSecondary`, `hasSecondaryPhone`. Garder `ContactFormDraft` (inchangé). | +| `types/clientConsultation.ts` | `ClientDetail` : retirer `firstName/lastName/phonePrimary/phoneSecondary/email` (les commentaires « Contact principal »). Garder `ContactRead`. | +| `utils/clientEdit.ts` | `mapMainDraft()` et `buildMainPayload()` : retirer les 5 champs. Garder `buildContactPayload()`. | +| `utils/clientConsultation.ts` | Retirer toute lecture des 5 champs inline du client (garder `mapContactToDraft`, `contactOptionsOf`). | +| `i18n/locales/fr.json` | Retirer `commercial.clients.form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone`. **Conserver** tout le bloc `commercial.clients.form.contact.*`. Vérifier qu'aucune autre vue ne référence les clés retirées. | +| `**/__tests__/*.spec.ts` | Mettre à jour `clientFormRules.spec.ts`, `clientEdit.spec.ts`, `clientConsultation.spec.ts` (cf. § 4). | + +### OUT + +- `ClientContactBlock.vue`, l'onglet Contacts, `useClient`, la liste/répertoire + (`pages/clients/index.vue` — ses colonnes n'affichent déjà pas le contact inline) : + **inchangés**. +- Le back → ticket 1/3. Les specs → ticket 3/3. + +## 3. Comportement attendu après modification + +- **Création** : le formulaire principal demande l'entreprise (et relation/catégories selon + l'existant), plus de Nom/Prénom/Téléphone/Email inline. L'utilisateur renseigne les + coordonnées dans l'onglet **Contacts**. La création reste valide tant qu'il y a + `companyName` **et** ≥ 1 bloc Contact valide (Nom OU Prénom) — RG-1.05/RG-1.14 inchangées. +- **Consultation** : plus de bloc contact principal ; l'onglet Contacts affiche les + contacts. +- **Modification** : idem ; le PATCH du groupe `client:write:main` n'envoie plus les 5 + champs. + +## 4. Tests Vitest à mettre à jour + +- `clientFormRules.spec.ts` : la validité du « principal » ne dépend plus de + firstName/email/phone ; conserver `isContactNamed()` (RG-1.05) sur les blocs Contacts. +- `clientEdit.spec.ts` : `buildMainPayload()` ne contient plus les 5 champs ; `mapMainDraft()` + non plus. +- `clientConsultation.spec.ts` : retirer les assertions sur les 5 champs inline. + +## 5. Tips & rappels projet + +- `useApi()` obligatoire (jamais `$fetch`/`ofetch`). Composants `Malio*` obligatoires. +- État de tableau jamais dans l'URL (règle inchangée). +- Les valeurs sont **normalisées côté serveur** (Capitalize / chiffres / lowercase) : le + front envoie la saisie et réaffiche la valeur renvoyée — ne pas réintroduire de + normalisation front. +- Ne pas créer de clé i18n orpheline ni laisser de clé `form.main.*` morte. + +## 6. Critères d'acceptation (DoD) + +- [ ] Les 3 écrans n'affichent plus Nom/Prénom/Téléphone/Téléphone 2/Email en bloc principal. +- [ ] Le parcours de création fonctionne avec `companyName` + onglet Contacts (≥ 1 contact). +- [ ] `MainFormDraft` / `ClientDetail` ne déclarent plus les 5 champs ; `mapMainDraft` / + `buildMainPayload` non plus. +- [ ] Aucune clé i18n `form.main.firstName/lastName/email/phone*` restante ni référencée. +- [ ] `make nuxt-test` vert. +- [ ] Vérification visuelle du golden path (`make dev-nuxt`, port 3004) : création → + consultation → modification d'un client sans bloc contact inline. diff --git a/docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.prompt.md b/docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.prompt.md new file mode 100644 index 0000000..55091d0 --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.prompt.md @@ -0,0 +1,47 @@ +# Prompt d'implémentation — M1 · Ticket 2/3 (Frontend) + +Projet **Starseed** (Nuxt 4 / Vue 3 Composition API / TypeScript / @malio/layer-ui). +Lis `CLAUDE.md` et `.claude/rules/frontend.md` avant de coder. Commentaires en français, +code en anglais, 4 espaces d'indentation. + +## Mission + +Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des +écrans Client (création / consultation / modification) et de tout le code associé (types, +mappeurs, validations, i18n). Les contacts restent gérés par l'onglet **Contacts** +(`ClientContactBlock`, inchangé). + +Spec détaillée : `docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.md` (lis-la en +entier + le `README.md` du dossier). Ce ticket dépend du ticket back (l'API ne porte plus +les 5 champs sur `client`). + +## Étapes + +1. Explorer `frontend/modules/commercial/` : `pages/clients/new.vue`, `[id]/edit.vue`, + `[id]/index.vue`, `types/clientForm.ts`, `types/clientConsultation.ts`, + `utils/clientEdit.ts`, `utils/clientConsultation.ts`, `i18n/locales/fr.json`. +2. Supprimer le bloc principal des 3 écrans + l'état réactif `main.firstName/lastName/email`, + `mainPhones`, `prefillFirstContact()`. +3. Adapter `isMainValid` : ne dépend plus que de `companyName` (+ relation/catégories selon + l'existant). La garantie « ≥ 1 contact valide » reste portée par l'onglet Contacts. +4. Nettoyer les types (`MainFormDraft`, `ClientDetail`) et les mappeurs (`mapMainDraft`, + `buildMainPayload`, `clientConsultation`). +5. Retirer les clés i18n `form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone` ; + vérifier par recherche qu'aucune vue ne les utilise plus. **Garder** `form.contact.*`. +6. Mettre à jour les specs Vitest (`clientFormRules`, `clientEdit`, `clientConsultation`). + +## Garde-fous + +- `useApi()` uniquement ; composants `Malio*` uniquement ; pas d'état tableau dans l'URL. +- Ne touche pas `ClientContactBlock.vue`, l'onglet Contacts, ni la liste/répertoire. +- Pas de normalisation front (le serveur normalise). + +## Vérification finale + +```bash +make nuxt-test # Vitest vert +make dev-nuxt # port 3004 — golden path manuel +``` + +Golden path à vérifier dans le navigateur : créer un client (entreprise + 1 contact dans +l'onglet Contacts), le consulter, le modifier — sans aucun bloc contact inline. diff --git a/docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.md b/docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.md new file mode 100644 index 0000000..e74418d --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.md @@ -0,0 +1,51 @@ +# M1 · Ticket 3/3 (Specs) — Acter la suppression du contact inline dans les specs M1 + +## 1. Objectif + +Mettre à jour la **documentation fonctionnelle/technique M1** pour refléter la décision : +le contact principal inline est supprimé du `Client`, les contacts vivent uniquement dans +`ClientContact`. Les specs sont la **source de vérité** du projet (cf. `workflow.md`) : elles +doivent décrire le modèle cible, pas l'ancien. + +> Idéalement réalisé **avant** les tickets 1 et 2 (la spec guide le code), mais peut être +> fait en parallèle. À minima, ne pas merger le code sans aligner la spec. + +## 2. Fichiers à modifier + +| Fichier | Sections concernées | +|---|---| +| `docs/specs/M1-clients/spec-back.md` | § 3.1 diagramme E-R (retirer les 5 colonnes du bloc `client`) ; § 3.2 migration SQL `CREATE TABLE client` (retirer `first_name`/`last_name`/`phone_primary`/`phone_secondary`/`email` + leurs COMMENT) ; § 3.4 squelette entité `Client` (retirer les 5 props) ; § 4.3 exemple payload `POST /api/clients` (retirer les 5 champs) ; § 4.1 filtre `?search=` (refléter D1) ; § 4.6 export (refléter D2) ; § 7 RG (voir § 3 ci-dessous) ; § 8 cahier de tests (déplacer RG-1.01/1.02 vers ClientContact). | +| `docs/specs/M1-clients/spec-front.md` | « Formulaire principal » (l.85-103) : retirer les lignes Nom/Prénom/Téléphone/Téléphone 2/Email ; écrans Consultation / Modification ; règles de formatage. Préciser que les coordonnées se saisissent dans l'onglet Contact. | +| `docs/specs/M1-clients/cahier-test-back-M1.md` | Retirer / requalifier les lignes RG-1.01 et RG-1.02 (désormais couvertes par RG-1.05 sur `ClientContact`). | + +## 3. Traitement des règles de gestion + +- **RG-1.01** (firstName OU lastName obligatoire sur Client) → marquer **supprimée** : + « Remplacée par RG-1.05 (≥ 1 contact valide) + RG-1.14 (≥ 1 bloc Contact). Le contact + principal inline n'existe plus. » +- **RG-1.02** (max 2 téléphones sur Client) → marquer **supprimée du Client** (reste + applicable aux blocs `ClientContact`). +- **RG-1.19 / RG-1.20 / RG-1.21** (normalisation) → préciser que le **scope `Client` + disparaît** ; la normalisation reste sur `ClientContact` (et `ClientAddress.billingEmail` + pour RG-1.21). +- Ne **pas renuméroter** les RG existantes (éviter le drift avec le code/tests) : marquer + « supprimée / requalifiée » en place, avec date et renvoi à la décision. + +## 4. Forme + +- Bumper la version des deux specs (`version: V0` → `V1`) dans le frontmatter, avec une + entrée d'historique : date `2026-06-03`, motif « Suppression du contact inline du Client + (refonte-contact) », auteur. +- Ajouter un encadré « Décision » en tête de la section modèle de données, renvoyant au + `README.md` du dossier `refonte-contact`. +- Conserver le style des specs (sections numérotées, tableaux RG, exemples JSON). + +## 5. Critères d'acceptation (DoD) + +- [ ] `spec-back.md` : aucune mention des 5 colonnes inline dans le modèle `client` + (E-R + SQL + entité + payload) ; RG-1.01/1.02 marquées supprimées ; D1/D2 décrites. +- [ ] `spec-front.md` : le formulaire principal ne liste plus les champs de contact ; + l'onglet Contact est présenté comme seul lieu de saisie des coordonnées. +- [ ] `cahier-test-back-M1.md` : RG-1.01/1.02 retirées/requalifiées. +- [ ] Versions bumpées (V1) + historique daté dans les deux specs. +- [ ] Cohérence vérifiée avec les tickets 1 et 2 (mêmes décisions D1/D2). diff --git a/docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.prompt.md b/docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.prompt.md new file mode 100644 index 0000000..9559787 --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.prompt.md @@ -0,0 +1,38 @@ +# Prompt d'implémentation — M1 · Ticket 3/3 (Specs) + +Projet **Starseed**. Tâche **documentaire** : mettre à jour les specs M1 Clients pour acter +la suppression du contact principal inline du `Client`. Les specs sont la source de vérité ; +elles doivent décrire le modèle cible. + +## Mission + +Modifier `docs/specs/M1-clients/spec-back.md`, `spec-front.md` et `cahier-test-back-M1.md` +pour retirer le contact inline du `Client` (5 champs `firstName/lastName/phonePrimary/ +phoneSecondary/email`) — les contacts vivent uniquement dans `ClientContact`. + +Spec du ticket : `docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.md` (lis-la + le +`README.md` du dossier, qui contient la décision, les RG impactées et les décisions D1/D2). + +## Étapes + +1. Lire les 3 fichiers de specs M1 visés, repérer toutes les occurrences des 5 champs + (diagramme E-R, CREATE TABLE client, squelette entité, payload POST, filtre search, + export, RG, cahier de test). +2. Retirer les 5 colonnes du modèle `client` (E-R + SQL + entité + exemple JSON). +3. Marquer **supprimées** RG-1.01 et RG-1.02 (renvoi à RG-1.05/RG-1.14 sur `ClientContact`), + restreindre le scope de RG-1.19/1.20/1.21 à `ClientContact`. **Ne pas renuméroter** les RG. +4. Refléter les décisions D1 (recherche) et D2 (export) une fois tranchées. +5. Côté `spec-front.md` : retirer les champs de contact du formulaire principal ; présenter + l'onglet Contact comme seul lieu de saisie. +6. Bumper `version: V0 → V1` + ajouter une entrée d'historique datée (2026-06-03). + +## Garde-fous + +- Ne touche pas au code, uniquement aux `.md` de specs. +- Garde le style existant (sections numérotées, tableaux RG, exemples JSON). +- Cohérence stricte avec les tickets 1 (back) et 2 (front) : mêmes décisions D1/D2. + +## Vérification + +Relire les 3 fichiers : plus aucune mention des 5 champs inline dans le modèle `client` ; +RG-1.01/1.02 marquées supprimées ; versions à V1 avec historique. diff --git a/docs/specs/M1-clients/refonte-contact/M2-amendement-tickets.md b/docs/specs/M1-clients/refonte-contact/M2-amendement-tickets.md new file mode 100644 index 0000000..45b0699 --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/M2-amendement-tickets.md @@ -0,0 +1,57 @@ +# Amendement des tickets M2 existants — suppression du contact inline du `Supplier` + +Les 14 tickets M2 (n° 84–97, groupe Lesstime « M2 — Répertoire fournisseurs ») ont été +rédigés sur le modèle initial **avec** contact inline. La décision `refonte-contact` les +amende : `Supplier` ne porte **plus** les 5 champs `firstName/lastName/phonePrimary/ +phoneSecondary/email` ; les contacts vivent uniquement dans `SupplierContact` (onglet +Contacts). Comme M2 n'est pas codé, il suffit de **ne jamais créer** ces colonnes/champs. + +## Bandeau injecté en tête des tickets impactés + +> ⚠️ **AMENDEMENT 2026-06-03 — refonte-contact.** Le contact principal inline est +> **supprimé** du `Supplier` : ne pas créer/saisir les colonnes ni les champs `firstName`, +> `lastName`, `phonePrimary`, `phoneSecondary`, `email` sur l'entité/le formulaire +> `Supplier`. Les contacts sont gérés **uniquement** via `SupplierContact` (onglet +> Contacts). RG-2.01 et RG-2.02 sont supprimées (équivalent assuré par RG-2.04 / RG-2.13). +> RG-2.12 ne s'applique qu'à `companyName` + `SupplierContact`. Décisions transverses +> recherche (D1) et export (D2) : cf. `docs/specs/M1-clients/refonte-contact/README.md`. + +## Tickets à amender + +### Back + +| Ticket | n° | Impact | +|---|---|---| +| migration BDD M2 (supplier + sous-collections) | #85 | Retirer `first_name/last_name/phone_primary/phone_secondary/email` du `CREATE TABLE supplier` et leurs `COMMENT ON COLUMN`. `supplier_contact` inchangé. | +| entités + repositories M2 | #86 | `Supplier` : retirer les 5 props + `Assert\Callback` RG-2.01. `SupplierContact` inchangé. | +| SupplierProvider + SupplierProcessor | #87 | Retirer la validation RG-2.01, la normalisation des champs inline, leur présence dans `MAIN_FIELDS` / changedFields. Recherche selon D1. | +| export XLSX fournisseurs | #91 | Colonnes contact selon D2 (depuis le contact principal, ou supprimées). | +| tests PHPUnit M2 | #92 | RG-2.01/2.02 testées sur `SupplierContact` (pas `Supplier`) ; contrat de sérialisation sans les 5 champs inline sur le supplier. | + +### Front + +| Ticket | n° | Impact | +|---|---|---| +| page Ajouter un fournisseur (`/suppliers/new`) + `useSupplierForm` | #94 | Retirer le bloc contact principal du formulaire + le pré-remplissage du 1er contact. Saisie des coordonnées dans l'onglet Contacts. | +| page Consultation fournisseur (`/suppliers/{id}`) | #95 | Retirer l'affichage du bloc contact principal. | +| page Modification fournisseur (`/suppliers/{id}/edit`) | #96 | Retirer les 5 champs du bloc principal ; payload `supplier:write:main` sans ces champs. | + +### Léger + +| Ticket | n° | Impact | +|---|---|---| +| page Répertoire fournisseurs + datatable | #93 | Recherche « nom / contact / email » selon D1. Datatable : colonnes inchangées (pas de contact inline en colonne). | +| i18n + sidebar fournisseurs | #97 | Ne pas créer les clés i18n `form.main.firstName/lastName/email/phone*` (garder `form.contact.*`). | + +## Tickets NON impactés + +- #84 (taxonomie FOURNISSEUR), #88 (sous-ressources contacts/adresses/ribs — + `SupplierContact` est la cible, inchangé), #89 (validators Information Commerciale / + catégorie / RG-2.07-2.08), #90 (RBAC fournisseurs). + +## Méthode d'amendement + +Pour chaque ticket impacté : **préfixer** la description existante du bandeau ci-dessus +(sans rien supprimer du contenu d'origine), via `mcp__lesstime__update-task` +(`description` = bandeau + description actuelle). La méthode préserve l'historique et reste +réversible (retirer le bandeau). diff --git a/docs/specs/M1-clients/refonte-contact/M2-ticket-specs.md b/docs/specs/M1-clients/refonte-contact/M2-ticket-specs.md new file mode 100644 index 0000000..e525b1f --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/M2-ticket-specs.md @@ -0,0 +1,55 @@ +# M2 · Ticket Specs — Retirer le contact inline du `Supplier` dans les specs M2 + +## 1. Objectif + +Mettre à jour les specs **M2 Fournisseurs** déjà rédigées pour **ne plus inclure** le contact +principal inline sur le `Supplier`. M2 est le jumeau strict de M1 (`Supplier` / +`SupplierContact` / `SupplierAddress` / `SupplierRib`) et n'est **pas encore codé** : il faut +donc corriger la conception **en amont**, pour que les 14 tickets M2 « prêts à dev » soient +implémentés directement sans les 5 colonnes inline. + +> Pendant de M1 ticket 3/3, mais côté M2 : **aucune migration de suppression ni backfill** — +> on retire simplement le contact inline du modèle cible. Contexte : `README.md` du dossier +> `refonte-contact`. + +## 2. Fichiers à modifier + +| Fichier | Sections concernées | +|---|---| +| `docs/specs/M2-suppliers/spec-back.md` | § 3.1 diagramme E-R (l.175-179 : retirer les 5 colonnes du bloc `supplier`) ; § 3.2 `CREATE TABLE supplier` (l.227-231) ; § 3.4 squelette entité `Supplier` (l.496-517 : props + `Assert\Callback` RG-2.01) ; § 4 exemples payload POST/GET (l.782-805, 867-871) ; recherche `?search=` (l.847 : refléter D1) ; export (refléter D2) ; § contrat de sérialisation (l.725, 729) ; § 7 RG (voir § 3). | +| `docs/specs/M2-suppliers/spec-front.md` | « Formulaire principal » (l.105-117 : retirer Nom/Prénom/Téléphone/Téléphone 2/Email) ; onglet « Contact » (l.140-157 : retirer la phrase de pré-remplissage depuis le formulaire principal, l.142) ; écrans Consultation/Modification ; règles de formatage (l.283-285) ; recherche (l.76 : refléter D1). | + +## 3. Traitement des règles de gestion M2 + +- **RG-2.01** (firstName OU lastName obligatoire sur Supplier) → **supprimée** : remplacée + par RG-2.04 (≥ 1 contact valide) + RG-2.13 (≥ 1 bloc Contact). Le contact inline n'existe + plus sur `Supplier`. +- **RG-2.02** (max 2 téléphones sur Supplier) → **supprimée du Supplier** (reste sur + `SupplierContact`). +- **RG-2.12** (normalisation Capitalize / chiffres / lowercase) → restreindre le scope : + s'applique à `companyName` (UPPERCASE) et aux champs de `SupplierContact` ; **plus** aux + champs inline du `Supplier` (qui disparaissent). +- Ne pas renuméroter les RG : marquer « supprimée / requalifiée » en place, avec date. + +## 4. Forme + +- Bumper la version des deux specs M2 + entrée d'historique datée (2026-06-03, motif + « Suppression du contact inline du Supplier — alignement refonte-contact M1 »). +- Encadré « Décision » renvoyant au `README.md` du dossier `refonte-contact`. +- Garder le style des specs M2. + +## 5. Lien avec les tickets M2 existants + +La mise à jour des specs doit être cohérente avec l'**amendement des tickets M2** (voir +`M2-amendement-tickets.md`) : tickets back #85/#86/#87/#91/#92 et front #94/#95/#96 (+ #93/#97 +légers). Specs et tickets décrivent le **même** modèle cible (sans contact inline). + +## 6. Critères d'acceptation (DoD) + +- [ ] `spec-back.md` M2 : aucune mention des 5 colonnes inline dans le modèle `supplier` + (E-R + SQL + entité + payloads + sérialisation) ; RG-2.01/2.02 marquées supprimées ; + D1/D2 décrites. +- [ ] `spec-front.md` M2 : formulaire principal sans champs de contact ; onglet Contact + présenté comme seul lieu de saisie (sans pré-remplissage depuis le principal). +- [ ] Versions bumpées + historique daté. +- [ ] Cohérence avec l'amendement des tickets M2. diff --git a/docs/specs/M1-clients/refonte-contact/M2-ticket-specs.prompt.md b/docs/specs/M1-clients/refonte-contact/M2-ticket-specs.prompt.md new file mode 100644 index 0000000..c35bf21 --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/M2-ticket-specs.prompt.md @@ -0,0 +1,36 @@ +# Prompt d'implémentation — M2 · Ticket Specs + +Projet **Starseed**. Tâche **documentaire**. Mettre à jour les specs M2 Fournisseurs +(`docs/specs/M2-suppliers/spec-back.md` + `spec-front.md`) pour retirer le contact principal +inline du `Supplier` (5 champs `firstName/lastName/phonePrimary/phoneSecondary/email`). + +M2 n'est **pas encore codé** : on corrige la conception en amont, **sans** migration ni +backfill (contrairement à M1). Les contacts vivent uniquement dans `SupplierContact`. + +Spec du ticket : `docs/specs/M1-clients/refonte-contact/M2-ticket-specs.md` (lis-la + le +`README.md` du dossier). + +## Étapes + +1. Lire `spec-back.md` et `spec-front.md` M2 ; repérer toutes les occurrences des 5 champs + (E-R l.175-179, CREATE TABLE supplier l.227-231, entité l.496-517, payloads l.782-805 / + 867-871, sérialisation l.725-729, RG-2.01/2.02/2.12, recherche, export, formulaire + principal front l.105-117, pré-remplissage onglet Contact l.142). +2. Retirer les 5 colonnes du modèle `supplier`. +3. Marquer **supprimées** RG-2.01 et RG-2.02 (renvoi RG-2.04/RG-2.13) ; restreindre RG-2.12 + à `companyName` + `SupplierContact`. Ne pas renuméroter. +4. Refléter D1 (recherche : LEFT JOIN supplier_contact recommandé) et D2 (export depuis le + contact principal recommandé). +5. Front : retirer les champs de contact du formulaire principal ; retirer la phrase de + pré-remplissage du 1er bloc Contact ; présenter l'onglet Contact comme seul lieu de saisie. +6. Bumper la version + historique daté (2026-06-03). + +## Garde-fous + +- Uniquement les `.md` de specs M2. Style existant conservé. +- Cohérence stricte avec l'amendement des tickets M2 et avec la décision M1 (jumeau). + +## Vérification + +Relire les 2 specs : plus aucune mention des 5 champs inline dans le modèle `supplier` ; +RG-2.01/2.02 supprimées ; versions bumpées. diff --git a/docs/specs/M1-clients/refonte-contact/README.md b/docs/specs/M1-clients/refonte-contact/README.md new file mode 100644 index 0000000..3e341aa --- /dev/null +++ b/docs/specs/M1-clients/refonte-contact/README.md @@ -0,0 +1,84 @@ +# Refonte « contact » — suppression du contact inline des tiers (Client M1 + Supplier M2) + +> Dossier de tickets transverse. Source de vérité de la décision et de son découpage. +> Rédigé le 2026-06-03. Owner : Matthieu. + +## 1. Décision + +Le **contact « principal » inline** (les 5 colonnes plates `first_name`, `last_name`, +`phone_primary`, `phone_secondary`, `email`) est **supprimé de l'entité tier** (`Client`, +puis `Supplier`). La gestion des contacts passe **exclusivement** par la sous-entité +dédiée (`ClientContact` / `SupplierContact`), c.-à-d. l'**onglet « Contacts »**. + +### Pourquoi + +- **Modèle unique, zéro duplication.** Aujourd'hui le contact est saisi deux fois : une + fois dans le bloc principal (inline sur le tier) et une fois dans l'onglet Contacts + (sous-entité). À la création, le front recopie même l'un dans l'autre + (`prefillFirstContact`). Deux sources pour la même information = risque de divergence. +- **Cohérence métier.** Un tier peut avoir plusieurs contacts ; il n'y a pas de raison + qu'un seul soit « privilégié » au niveau de la table tier. La notion de contact + appartient à la collection de contacts. +- **Garantie préservée.** L'invariant « il y a toujours au moins un contact » est déjà + assuré par la sous-entité : RG-1.05/RG-1.14 (M1) et RG-2.04/RG-2.13 (M2) imposent + **≥ 1 bloc Contact valide** (Nom OU Prénom). Supprimer le contact inline ne crée donc + aucun trou : le contact reste obligatoire, mais au bon endroit. + +### Règles de gestion impactées + +| RG | Avant | Après | +|---|---|---| +| RG-1.01 / RG-2.01 (firstName OU lastName obligatoire **sur le tier**) | sur `Client` / `Supplier` | **supprimée** du tier — équivalent assuré par RG-1.05 / RG-2.04 sur la sous-entité | +| RG-1.02 / RG-2.02 (max 2 téléphones **sur le tier**) | sur le tier | **supprimée** du tier — reste sur la sous-entité | +| RG-1.19/1.20/1.21 — RG-2.12 (normalisation Capitalize / chiffres / lowercase) | appliquée aux champs **du tier ET** de la sous-entité | ne s'applique plus aux champs du tier (qui n'existent plus) — **inchangée** sur la sous-entité | + +## 2. Périmètre & découpage + +### M1 — Clients (code DÉJÀ livré → suppression + migration de données) + +| # | Ticket | Tag | Effort | +|---|--------|-----|--------| +| 1 | `M1-ticket-01-back` — supprimer le contact inline du `Client` (migration + backfill + entité + processor + provider + export + fixtures + tests) | Backend | M | +| 2 | `M1-ticket-02-front` — retirer le bloc contact principal des écrans création / consultation / modification | Frontend | M | +| 3 | `M1-ticket-03-specs` — acter la décision dans les specs M1 (back + front + cahier de test) | Maintenance | S | + +### M2 — Fournisseurs (NON codé → on retire le contact inline dès la conception) + +| # | Action | Tag | Effort | +|---|--------|-----|--------| +| 4 | `M2-ticket-specs` — mettre à jour les specs M2 déjà écrites (back + front) pour retirer le contact inline du `Supplier` | Maintenance | S | +| — | `M2-amendement-tickets` — amender les tickets M2 existants (n° 84–97) impactés (migration, entités, processor, export, front, tests, i18n) | — | — | + +> M2 ne nécessite **pas** de migration de suppression ni de backfill : il suffit de **ne +> jamais créer** les 5 colonnes inline sur `supplier`. Le travail M2 est donc un +> ajustement de specs + un amendement des tickets « prêts à dev ». + +## 3. Décisions transverses à trancher (mêmes pour M1 et M2) + +Deux comportements s'appuyaient sur les colonnes inline du tier. À la suppression, il faut +choisir leur nouvelle source. Recommandation par défaut entre parenthèses. + +- **D1 — Recherche serveur** (`?search=`). Aujourd'hui : fuzzy sur `companyName` + + `lastName` + `email` **du tier**. Après suppression, deux options : + - (a) restreindre la recherche à `companyName` seul (simple, mais perte de la recherche + par contact) ; + - (b) **[recommandé]** étendre la recherche en `LEFT JOIN` sur la sous-entité contact + (`first_name` / `last_name` / `email` du contact), pour préserver l'UX « recherche par + nom / contact / email » annoncée dans la barre de recherche. +- **D2 — Colonnes de l'export XLSX** (Nom contact / Prénom / Téléphone / Téléphone 2 / + Email). Après suppression : + - (a) supprimer ces colonnes ; + - (b) **[recommandé]** les alimenter depuis le **contact principal** (le contact de plus + petit `position`), pour garder un export utile. + +Ces deux décisions sont à valider par le métier (Matthieu) avant implémentation et sont +rappelées dans chaque ticket concerné. + +## 4. Fichiers de ce dossier + +- `README.md` (ce fichier) — décision + découpage. +- `M1-ticket-01-back.md` / `.prompt.md` — description + prompt d'implémentation. +- `M1-ticket-02-front.md` / `.prompt.md`. +- `M1-ticket-03-specs.md` / `.prompt.md`. +- `M2-ticket-specs.md` / `.prompt.md`. +- `M2-amendement-tickets.md` — bandeau d'amendement + liste des tickets M2 à mettre à jour. diff --git a/docs/specs/M1-clients/spec-back.md b/docs/specs/M1-clients/spec-back.md index 5cafdf9..0476b45 100644 --- a/docs/specs/M1-clients/spec-back.md +++ b/docs/specs/M1-clients/spec-back.md @@ -5,8 +5,11 @@ nom: "Répertoire clients" ecran: repertoire-clients owner_spec: Matthieu backup_spec: Tristan -version: V0 +version: V1 date_redaction: 2026-05-28 +# Historique : V1 (2026-06-03) — Refonte contact : suppression du contact principal inline +# du Client (firstName/lastName/phonePrimary/phoneSecondary/email retirés de la table client). +# Les contacts vivent uniquement dans ClientContact. Cf. docs/specs/M1-clients/refonte-contact/README.md # === LIENS === spec_front: ./spec-front.md @@ -203,11 +206,11 @@ Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter V | | +-----------------------+ | (Catalog) | | id (PK) | +--------------+ | company_name | -| first_name | +-----------------------+ +--------------+ -| last_name |--1:n-->| client_contact | | site | -| phone_primary | +-----------------------+ | (Sites) | -| phone_secondary | +--------------+ -| email | +-----------------------+ ^ +| (contact inline | +-----------------------+ +--------------+ +| retiré V1 — |--1:n-->| client_contact | | site | +| firstName, | +-----------------------+ | (Sites) | +| lastName, phones,| +--------------+ +| email) | +-----------------------+ ^ | distributor_id |--1:n-->| client_address |--n:m---------+ | broker_id | +-----------------------+ | triage_service | | @@ -302,11 +305,8 @@ CREATE TABLE client ( id SERIAL PRIMARY KEY, -- Formulaire principal company_name VARCHAR(180) NOT NULL, - first_name VARCHAR(120), - last_name VARCHAR(120), - phone_primary VARCHAR(20) NOT NULL, - phone_secondary VARCHAR(20), - email VARCHAR(180) NOT NULL, + -- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary / + -- phone_secondary / email vivent désormais uniquement dans client_contact (onglet Contacts). distributor_id INT REFERENCES client(id) ON DELETE SET NULL, broker_id INT REFERENCES client(id) ON DELETE SET NULL, triage_service BOOLEAN NOT NULL DEFAULT FALSE, @@ -580,32 +580,9 @@ class Client implements TimestampableInterface, BlamableInterface #[Groups(['client:read', 'client:write:main'])] private ?string $companyName = null; - // RG-1.01 — first_name OU last_name obligatoire (validation Assert\Callback - // au niveau de l'entite, levee dans le Processor). - #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] - #[Groups(['client:read', 'client:write:main'])] - private ?string $firstName = null; - - #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] - #[Groups(['client:read', 'client:write:main'])] - private ?string $lastName = null; - - #[ORM\Column(length: 20)] - #[Assert\NotBlank] - #[Groups(['client:read', 'client:write:main'])] - private ?string $phonePrimary = null; - - #[ORM\Column(length: 20, nullable: true)] - #[Groups(['client:read', 'client:write:main'])] - private ?string $phoneSecondary = null; - - #[ORM\Column(length: 180)] - #[Assert\NotBlank] - #[Assert\Email] - #[Groups(['client:read', 'client:write:main'])] - private ?string $email = null; + // Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary / + // phoneSecondary / email ne sont plus portés par Client — ils vivent dans ClientContact + // (onglet Contacts). La garantie « ≥ 1 contact nommé » est portée par RG-1.05 + RG-1.14. // RG-1.03 — distributor / broker auto-references (mutuellement exclusives, // contrainte CHECK en base). @@ -749,7 +726,7 @@ class Client implements TimestampableInterface, BlamableInterface - **Query params** : - `includeArchived=true|false` (default `false`) - `categoryCode=` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`) - - `search=` (recherche fuzzy sur companyName + lastName + email) + - `search=` (recherche fuzzy sur companyName + contacts liés `client_contact` (firstName / lastName / email) via LEFT JOIN groupé par `client.id` — décision D1, refonte-contact) - **Tri par défaut** : `companyName ASC` - **Pagination** : front via `` (volumétrie cible faible). Pas de pagination serveur au M1. - **Réponse 200** (JSON-LD Hydra) : items avec champs `client:read` UNIQUEMENT (pas les champs `client:read:accounting` sauf si l'user a la permission `accounting.view`). @@ -768,10 +745,6 @@ class Client implements TimestampableInterface, BlamableInterface ```json { "companyName": "ACME SAS", - "firstName": "Jean", - "lastName": "Dupont", - "phonePrimary": "0612345678", - "email": "jean.dupont@acme.fr", "categories": ["/api/categories/3", "/api/categories/7"], "distributor": null, "broker": null, @@ -783,7 +756,7 @@ class Client implements TimestampableInterface, BlamableInterface - `201` / `400` / `401` / `403` - `409 Conflict` si doublon de nom de société (`companyName` — RG-1.16). SIREN et email ne sont pas uniques (cf. Q4, § 2.4). - `422 Unprocessable Entity` : - - RG-1.01 : ni firstName ni lastName + - (RG-1.01 supprimée V1 — la complétude du contact est portée par l'onglet Contacts : RG-1.05 / RG-1.14) - RG-1.03 : distributor + broker remplis simultanément - Catégories vides (Assert\Count min=1) @@ -885,8 +858,8 @@ Cf. § 2.6. Pattern Shared standard. ### Formulaire principal -- **RG-1.01** : Au moins l'un des champs `firstName` (Prénom du contact principal) ou `lastName` (Nom du contact principal) doit être renseigné. Sinon → 422. -- **RG-1.02** : Le champ `phoneSecondary` est optionnel et apparaît au clic sur un bouton `+` côté front. Maximum 2 téléphones (primary + secondary). Comportement purement front au niveau UI ; côté serveur, les 2 colonnes existent et sont distinctes. +- ~~**RG-1.01**~~ _(SUPPRIMÉE — V1, 2026-06-03, refonte-contact)_ : le contact principal inline est retiré du `Client`. La garantie « au moins un contact nommé » est désormais portée par **RG-1.05** (bloc Contact valide) + **RG-1.14** (≥ 1 bloc Contact) sur `ClientContact`. +- ~~**RG-1.02**~~ _(SUPPRIMÉE du Client — V1, refonte-contact)_ : plus de téléphones inline sur le `Client`. Le « maximum 2 téléphones » reste applicable aux blocs `ClientContact` (normalisation RG-1.20). - **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. Un `distributor` référencé doit porter une **`Category` de code `DISTRIBUTEUR`** ; un `broker` une **`Category` de code `COURTIER`** — sinon 422. _(Refonte ERP-78 : le filtrage se fait sur le `code` de la `Category`, plus sur le type — `ClientProcessor::hasCategoryCode`.)_ La liste front de `distributor` = clients ayant une catégorie de code `DISTRIBUTEUR`, via `GET /api/clients?categoryCode=DISTRIBUTEUR` ; idem `broker` avec `COURTIER`. ### Onglet Information @@ -929,9 +902,9 @@ Cf. § 2.6. Pattern Shared standard. ### Normalisation serveur (formatage) - **RG-1.18** : `companyName` est **upper-cased** intégralement côté serveur avant validation et persistance (`mb_strtoupper(trim($v), 'UTF-8')`). Le client n'a pas besoin de saisir en majuscules ; la BDD stocke en majuscules. -- **RG-1.19** : `firstName`, `lastName` (sur `Client` et `ClientContact`) sont **capitalize**-és serveur (`mb_convert_case(trim($v), MB_CASE_TITLE, 'UTF-8')`). Exemple : `JEAN dupont` → `Jean Dupont`. -- **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `Client`, et idem sur `ClientContact`) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front). -- **RG-1.21** : `email` (`Client.email`, `ClientAddress.billingEmail`, `ClientContact.email`) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`). +- **RG-1.19** : `firstName`, `lastName` (sur `ClientContact` ; scope `Client` retiré en V1) sont **capitalize**-és serveur (`mb_convert_case(trim($v), MB_CASE_TITLE, 'UTF-8')`). Exemple : `JEAN dupont` → `Jean Dupont`. +- **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `ClientContact` ; scope `Client` retiré en V1) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front). +- **RG-1.21** : `email` (`ClientAddress.billingEmail`, `ClientContact.email` ; `Client.email` retiré en V1) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`). ### Archivage @@ -960,8 +933,8 @@ Cf. § 2.6. Pattern Shared standard. ### 8.1 Cas à couvrir (back — PHPUnit) -- [ ] **RG-1.01** : POST sans firstName ni lastName → 422 -- [ ] **RG-1.02** : POST avec phoneSecondary rempli → persistance OK ; PATCH ajoutant un 3e téléphone → côté API, 2 colonnes uniquement (test que le payload ne peut pas créer un 3e) +- [ ] ~~RG-1.01~~ _(supprimée V1)_ : la complétude du contact est couverte par RG-1.05 / RG-1.14 sur `ClientContact` +- [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`) - [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201 - [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`) - [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200 @@ -975,9 +948,9 @@ Cf. § 2.6. Pattern Shared standard. - [ ] **RG-1.14** : front-driven uniquement, pas de test back - [ ] **RG-1.16** : POST avec `companyName` déjà pris → 409 ; POST avec même `companyName` après archivage de l'ancien → 201. SIREN et email dupliqués → 201 (plus d'unicité — RG-1.15/1.17 supprimées, Q4). - [ ] **RG-1.18** : POST `companyName="acme sas"` → BDD persiste `"ACME SAS"` -- [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` → persiste `"Jean"`, `"Dupont"` -- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` → persiste `"0612345678"` -- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` → persiste `"jean.dupont@acme.fr"` +- [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` (via un bloc `ClientContact`) → persiste `"Jean"`, `"Dupont"` +- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` (via un bloc `ClientContact`) → persiste `"0612345678"` +- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` (via `ClientContact` ou `ClientAddress.billingEmail`) → persiste `"jean.dupont@acme.fr"` - [ ] **RG-1.22/23** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; PATCH isArchived=false sur un client archivé dont le SIREN a été repris → 409 - [ ] **RG-1.24/25** : GET liste sans flag → exclut archivés ; avec `?includeArchived=true` → inclut - [ ] **RG-1.26** : GET liste → tri companyName ASC diff --git a/docs/specs/M1-clients/spec-front.md b/docs/specs/M1-clients/spec-front.md index 142f245..b476095 100644 --- a/docs/specs/M1-clients/spec-front.md +++ b/docs/specs/M1-clients/spec-front.md @@ -5,7 +5,10 @@ nom: "Répertoire clients" ecran: repertoire-clients owner_spec: Matthieu backup_spec: Tristan -version: V0 +version: V1 +# Historique : V1 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline +# (Nom/Prénom/Téléphone/Téléphone 2/Email retirés du formulaire principal et des écrans). +# Saisie via l'onglet Contacts uniquement. Cf. docs/specs/M1-clients/refonte-contact/README.md date_redaction: 2026-05-28 # === LIENS === @@ -68,9 +71,6 @@ Composant : ``. Colonnes (à raffiner avec Tristan en revue maqu | Colonne | Source | Tri | |---|---|---| | **Nom entreprise** | `client.companyName` | ASC par défaut | -| **Contact principal** | `firstName + lastName` | Oui | -| **Téléphone principal** | `phonePrimary` (formaté `XX XX XX XX XX`) | Non | -| **Email principal** | `email` | Oui | | **Catégories** | liste des codes catégories séparés par `,` | Non | | **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non | @@ -86,15 +86,12 @@ Création par **onglets successifs avec validation incrémentale** : pour pouvoi C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles. +> **V1 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent désormais dans l'onglet **Contacts** (RG-1.05 / RG-1.14). Le formulaire principal ne contient plus que Entreprise + Catégorie + relation Distributeur/Courtier. + | Champ | Type composant | Obligatoire | Règle | |---|---|---|---| | **Nom du client (Entreprise)** | `` | Oui | RG-1.18 (normalisation UPPERCASE serveur) | -| **Nom du contact principal** | `` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) | -| **Prénom du contact principal** | `` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) | | **Catégorie** | `` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category | -| **Téléphone principal** | `` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) | -| **Téléphone secondaire** | `` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. | -| **Email** | `` type email | Oui | RG-1.21 (lowercase) | | **Distributeur / Courtier** | `` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. | | **Nom du distributeur** | `` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de **code** `DISTRIBUTEUR` (ERP-78), via `GET /api/clients?categoryCode=DISTRIBUTEUR`. RG-1.03. | | **Nom du courtier** | `` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de **code** `COURTIER` (ERP-78), via `GET /api/clients?categoryCode=COURTIER`. RG-1.03. | @@ -120,7 +117,7 @@ Saisir les informations de l'entreprise. ### Onglet « Contact » -Saisir un ou plusieurs contacts associés au client. Le 1er bloc est **pré-rempli** depuis les champs du formulaire principal (Nom, Prénom, Téléphone, Email — édition autorisée). +Saisir un ou plusieurs contacts associés au client. **(V1 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-1.14). **Bloc Contact** : @@ -250,7 +247,7 @@ Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back. |---|---|---| | Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE | | Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique | -| Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) | +| Téléphone (téléphones des blocs `ClientContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) | | Email | lowercase intégral | identique | > **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`. @@ -275,7 +272,7 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse. | 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. | | 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. | | 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. | -| 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. | +| 8 | Téléphones (max 2) | Sur les blocs `ClientContact` (`phone_primary` + `phone_secondary`). _(V1 : retirés du Client — refonte-contact.)_ | | 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. | | 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). | | 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. | diff --git a/docs/specs/M2-suppliers/spec-back.md b/docs/specs/M2-suppliers/spec-back.md new file mode 100644 index 0000000..2194273 --- /dev/null +++ b/docs/specs/M2-suppliers/spec-back.md @@ -0,0 +1,1123 @@ +--- +# === IDENTITÉ === +module: M2 +nom: "Répertoire fournisseurs" +ecran: repertoire-fournisseurs +owner_spec: Matthieu +backup_spec: Tristan +version: V0.2 +# Historique : V0.2 (2026-06-03) — Refonte contact : suppression du contact inline du Supplier +# (firstName/lastName/phonePrimary/phoneSecondary/email retirés). Contacts uniquement dans +# SupplierContact. Aligné sur M1. Cf. docs/specs/M1-clients/refonte-contact/README.md +date_redaction: 2026-06-02 + +# === LIENS === +spec_front: ./spec-front.md +maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev" + +# === LIEN LESSTIME === +lesstime_taskgroup_id: 26 # M2 — Répertoire fournisseurs (projet STARSEED #6) +lesstime_project_id: 6 +statut_global: a_dev + +# === DÉPENDANCES AMONT === +depend_de: + - M1-clients # référentiels comptables (TvaMode/PaymentDelay/PaymentType/Bank) + pattern Client* réutilisé + - M0-categories # Category + CategoryType (étendu par seed M2 : type FOURNISSEUR) + - 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 2 : Répertoire fournisseurs + +## 1. Contexte + +Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (`M2-reportoire-fournisseurs.docx` du 01/06/2026, historique V0 22/05 → V0.1 01/06) 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** : extension du module `Commercial` existant (`src/Module/Commercial/`), aux côtés des Clients (M1). Le M2 est la **deuxième sous-section métier Tiers** du Commercial (Fournisseurs), construite sur le **pattern jumeau de `Client`** déjà éprouvé au M1 (`Supplier` / `SupplierContact` / `SupplierAddress` / `SupplierRib`). + +**Dépendances déjà en place sur `develop`** (héritées du M1) : +- `Commercial` → `Client*` + 4 référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (entités lecture seule, déjà seedées — **partagées sans duplication** par le M2). +- `Catalog` (M0) → `Category` + `CategoryType` (le M2 ajoute le type `FOURNISSEUR`). +- `Sites` → 3 sites Châtellerault (86) / Saint-Jean (17) / Pommevic (82). +- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52). +- `Core` → User, Role, Permission, Audit, JWT. + +## 2. Décisions d'archi + +### 2.1 Module — Extension de `Commercial`, entités jumelles de `Client` + +Le fournisseur M2 vit sous `src/Module/Commercial/` (déjà existant). Pas de nouveau module `Suppliers`. Rationale identique au M1 : + +- Cohérence MALIO : `Commercial` = couche **Tiers** (Clients + Fournisseurs + Prestataires). +- Le M1 a déjà posé le pattern `Client` / `ClientContact` / `ClientAddress` / `ClientRib` + Provider/Processor + normalisation + archivage. Le M2 le **réplique à l'identique** sous `Supplier*` (décision : tables dédiées, pas de table polymorphe partagée — clients et fournisseurs divergeront fonctionnellement, l'isolation prime). +- La sidebar porte déjà l'item `suppliers` → `/suppliers` (sans permission). Le M2 lui attache `commercial.suppliers.view`. + +Le `CommercialModule.php` actuel expose déjà les 5 permissions `commercial.clients.*`. Le M2 **ajoute 5 permissions `commercial.suppliers.*`** (cf. § 5.1). + +### 2.2 IDs entier auto-increment Postgres natif + +Cohérent avec M0/M1 et l'ensemble Starseed. Pas d'UUID, pas de ULID. + +### 2.3 Référentiels comptables — réutilisation M1 (zéro duplication) + +Les 4 tables `tva_mode` / `payment_delay` / `payment_type` / `bank` (+ leurs entités lecture seule et leurs seeds) sont **celles du M1**. Le M2 ne crée **aucune** nouvelle table de référentiel comptable : `supplier.tva_mode_id`, `supplier.payment_delay_id`, `supplier.payment_type_id`, `supplier.bank_id` pointent vers les mêmes tables. + +Conséquence sur les endpoints : `GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent déjà (M1). La seule évolution : leur `security` doit autoriser **aussi** les rôles fournisseurs (cf. § 4.7). + +> **Confirmé sur le JSON réel (02/06)** : les formes sont conformes (`id`/`code`/`label`/`position`). Les codes pivots `VIREMENT` et `LCR` (RG-2.07/2.08) existent bien dans `payment_types`. **Nuance** : `tva_modes` ne contient que des modes « ventes » (`FRANCE_VENTES`/`EXPORT_VENTES`/`INTRACOM_VENTES`). La spec fonctionnelle (docx) dit seulement « Mode de TVA — liste depuis une table », **sans** distinguer achats/ventes → au M2 on **réutilise les modes existants** (pas de seed « achats »). Point à confirmer avec le métier : si un mode « achats » est requis pour les fournisseurs, l'ajouter via un seed (référentiel partagé). Tracé en HP-M3-2. + +### 2.4 Catégories — nouveau `CategoryType` `FOURNISSEUR` + +Le multi-select « Catégorie » du fournisseur référence des `Category` rattachées à un **nouveau `CategoryType` de code `FOURNISSEUR`** (label « Fournisseur »), seedé par le M2. Décision Matthieu (02/06) : on assume des **types distincts** (`CLIENT` / `FOURNISSEUR`, et `PRESTA` à venir) — chacun avec sa taxonomie. Rationale : les catégories clients (Agro-alimentaire…) ne sont pas valides pour un fournisseur (Négociant, Coopérative…). + +> ⚠️ **CONSTAT JSON RÉEL (02/06) — brique manquante à construire** : la refonte **ERP-78 a unifié sur un type unique `CLIENT`** et **le filtre `?typeCode=` est INOPÉRANT** (`GET /api/categories?typeCode=FOURNISSEUR` renvoie les 11 catégories CLIENT, filtre ignoré ; `GET /api/category_types` → un seul type `CLIENT`). Donc le M2 doit : +> 1. **recréer** un `CategoryType` `FOURNISSEUR` (seed migration + fixture idempotente) ; +> 2. **implémenter** un vrai filtre `?typeCode=` sur `/api/categories` (module Catalog) — il n'existe pas en prod ; +> 3. seeder les catégories fournisseurs (Négociant, Coopérative…) sous ce type. +> → matérialisé en **ticket back dédié** (cf. § Tickets). Réintroduit volontairement le multi-type qu'ERP-78 avait retiré. + +> ⚠️ **Forme réelle de `Category`** : expose `code` **et `name`** (PAS `label`) sous `category:read`, plus `categoryType{ id, code, label }`. Le **libellé affiché côté front = `category.name`**. Le M2M `supplier_category` / `supplier_address_category` ne contraint que des `Category` de type `FOURNISSEUR` (RG-2.10). + +> **Pas d'auto-référence distributeur/courtier au M2** : contrairement au `Client`, le `Supplier` n'a pas de relation `distributor`/`broker`. On ne réimporte aucune classe d'un autre module : on consomme le contrat partagé / les read-groups de `Category`. + +### 2.5 Archive vs soft delete — deux mécanismes distincts (identique M1) + +| Mécanisme | Colonne | Visibilité défaut | Restauration | Utilisateur | +|---|---|---|---|---| +| **Archive** (fonctionnel) | `is_archived` (bool, default false) + `archived_at` | masqué | Oui (toggle UI) | **Admin seul** via `commercial.suppliers.archive` | +| **Soft delete** (technique) | `deleted_at` (timestamptz nullable) | masqué | HP M3+ | Aucun rôle au M2 (HP) | + +Conséquences (miroir M1) : +- `DELETE /api/suppliers/{id}` **non exposé** au M2 (404 si appelé). +- `GET /api/suppliers?includeArchived=true` permet de voir les archivés (permission `commercial.suppliers.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). + +> **Différence RBAC notable avec le docx** : le tableau « Rôles & permissions » du docx ne donne l'Archive qu'à **Admin** (Bureau/Compta/Commerciale = « Non »). On s'aligne strictement : `commercial.suppliers.archive` = Admin uniquement. + +### 2.6 Unicité partielle Postgres — nom de société + +> **Décision validée (Matthieu, 02/06/2026 — alignée sur la décision Q4 du M1)** : l'unicité métier porte **uniquement sur le nom de fournisseur** (`company_name`). Le SIREN et l'email principal ne sont **pas** uniques (un même SIREN peut couvrir plusieurs établissements ; un email peut servir plusieurs fiches). + +Index unique partiel (`WHERE is_archived = FALSE AND deleted_at IS NULL`) sur `LOWER(company_name)`. Doublon → `409 Conflict` géré par le `SupplierProcessor`. + +### 2.7 Audit & traces temporelles + +Pattern Starseed standard, miroir M1 : +- `#[Auditable]` sur `Supplier`, `SupplierContact`, `SupplierAddress`, `SupplierRib`. +- **Tous les champs auditables** (pas d'`#[AuditIgnore]`) — y compris `SupplierRib.iban` et `SupplierRib.bic` (audit admin-only côté Starseed → traçabilité comptable, décision M1 reportée). +- Audit M2M automatique sur `supplier.categories` (`{categories: {added:[...], removed:[...]}}`). + +### 2.8 Timestampable + Blamable + +Toutes les entités métier nouvelles implémentent `TimestampableInterface` + `BlamableInterface` et utilisent `TimestampableBlamableTrait` : `Supplier`, `SupplierContact`, `SupplierAddress`, `SupplierRib`. Les référentiels partagés (`TvaMode`...) restent whitelistés dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` (déjà fait au M1). + +### 2.9 Permissions RBAC — granularité (5 permissions, identique M1) + +| Permission | Admin | Bureau | Compta | Commerciale | Usine | +|---|---|---|---|---|---| +| `commercial.suppliers.view` | ✅ | ✅ | ✅ | ✅ (sauf compta) | ❌ | +| `commercial.suppliers.manage` | ✅ | ✅ | ❌ | ✅ | ❌ | +| `commercial.suppliers.accounting.view` | ✅ | ❌ | ✅ | ❌ | ❌ | +| `commercial.suppliers.accounting.manage` | ✅ | ❌ | ✅ | ❌ | ❌ | +| `commercial.suppliers.archive` | ✅ | ❌ | ❌ | ❌ | ❌ | + +Notes (miroir M1) : +- **Compta édite uniquement l'onglet Comptabilité** (`accounting.manage`) d'un fournisseur existant. Compta ne peut pas **créer** un fournisseur (pas de `manage` global). +- **Commerciale** a `view` + `manage` mais **pas** `accounting.view` → l'onglet Comptabilité est masqué (front) et filtré (back, 2 niveaux : `security` API Platform + `SupplierProvider`). +- **Bureau** : `view` + `manage` (tout sauf Comptabilité). +- **Usine** : aucune permission → item sidebar invisible, accès direct 403. + +### 2.10 Validation incrémentale par onglet (workflow front-driven, identique M1) + +Le `Supplier` est créé en BDD **dès validation du formulaire principal** via `POST /api/suppliers`. Les onglets suivants déclenchent des **PATCH partiels** avec des groupes de sérialisation dédiés : + +- `supplier:write:main` — formulaire principal (POST + PATCH) +- `supplier:write:information` — onglet Information +- `supplier:write:contacts` — onglet Contact (sous-ressource `supplier_contact`) +- `supplier:write:addresses` — onglet Adresse (sous-ressource `supplier_address`) +- `supplier:write:accounting` — onglet Comptabilité (security séparée) +- `supplier:write:archive` — toggle archive (security `commercial.suppliers.archive`) + +**Pas de state machine côté back** (pas de `status = draft|active`). Le fournisseur est actif dès POST réussi. La complétude des onglets est de la responsabilité du front. + +### 2.11 Normalisation serveur des entrées texte (identique M1) + +Réutilisation du même pattern que `ClientFieldNormalizer`, dupliqué en `SupplierFieldNormalizer` (service interne appelé par les Processors avant validation) : + +```php +final class SupplierFieldNormalizer +{ + 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 côté front. Le back stocke `0612345678` (chiffres seuls). + +### 2.12 Liste : embed catégories + sites + fetch-joins (cohérence M1/ERP-62) + +Décision d'alignement (02/06/2026) : la **liste** `GET /api/suppliers` **embarque** les `categories[]` (avec `code`/`name`) et les `sites[]` (avec `name`/`postalCode` — pas de `code`), comme la liste Clients après ERP-62 — et **non** des champs dérivés aplatis. Conséquence performance : le `DoctrineSupplierRepository` **DOIT** poser des **fetch-joins** (`leftJoin`+`addSelect`) sur `categories` et `addresses.sites` dans la requête de liste pour éviter le N+1. Les `sites` de la liste sont agrégés/dédoublonnés via `Supplier::getSites()` (cf. § 3.3). Le contrat de sérialisation (groupes `category:read` / `site:read` dans le contexte) est posé **une seule fois** sur l'entité — source de vérité unique, le front ne le redéfinit pas. + +> Dépendance confirmée sur le JSON réel (#82 mergé) : `Category` expose `code`/`name` sous `category:read` ; `Site` expose `name`/`postalCode`/`city`/`color` sous `site:read` (**pas de `code`**). L'embed est pleinement matérialisé. + +## 3. Modèle de données + +### 3.1 Diagramme + +``` ++----------------------+ +--------------------------+ +--------------+ +| supplier |--n:m-->| supplier_category |<--n:m--| category | +| | +--------------------------+ | type=FOURNI. | +| id (PK) | +--------------+ +| company_name | +| (contact inline | +--------------------------+ +--------------+ +| retiré V1 — |--1:n-->| supplier_contact | | site | +| firstName, | +--------------------------+ | (Sites) | +| lastName, phones, | +--------------+ +| email) | +--------------------------+ ^ +| is_archived |--1:n-->| supplier_address |--n:m-------+ +| archived_at | +--------------------------+ +| deleted_at | | (address_type radio) +| -- Information -- | +--n:m--+--> supplier_contact +| description | | +| competitors | +--------------------------+ +-----------------+ +| founded_at |--1:n-->| supplier_rib | | tva_mode (M1) | +| employees_count | +--------------------------+ | payment_* (M1) | +| revenue_amount | label / bic / iban | bank (M1) | +| director_name | +-----------------+ +| profit_amount | +| volume_forecast (NEW) | -- Comptabilité (sur supplier) -- ++----------------------+ siren / account_number / tva_mode_id / + n_tva / payment_delay_id / payment_type_id / + bank_id (nullable) +``` + +**Particularités M2 (différences vs `client`)** : +- **Pas** de `distributor_id` / `broker_id` (pas d'auto-référence), donc pas de contrainte CHECK distributor/broker. +- **Pas** de `triage_service` sur l'entité principale — le « Prestataire de triage » est porté **par l'adresse** (`supplier_address.triage_provider`). +- Ajout d'un champ Information **`volume_forecast`** (Volume prévisionnel — entier) absent du `client`. +- `supplier_address` remplace les 3 booléens M1 (`is_prospect`/`is_delivery`/`is_billing`) par **un seul champ enum `address_type`** (radio Prospect / Départ / Rendu — mutuellement exclusifs par construction). Plus de `billing_email` (pas d'email facturation au M2). +- `supplier_address` ajoute `bennes` (entier, nullable) et `triage_provider` (booléen). +- 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** : la migration crée un schéma avec **FK cross-module** (`user`, `category`, `site`, et FK vers les référentiels comptables M1). Le namespace modulaire casserait l'ordre (`make db-reset`) car Doctrine Migrations 3.x trie par FQCN alphabétique. → exception racine de la règle ABSOLUE n°11. Le seed du `CategoryType FOURNISSEUR` se fait **en deux endroits** (migration `ON CONFLICT` pour la prod + fixture idempotente pour survivre au purger en dev/test — cf. M1 § 3.3). + +> **Rappel règle ABSOLUE n°12** : chaque colonne créée ci-dessous DOIT recevoir son `COMMENT ON COLUMN`. Les 4 colonnes Timestampable/Blamable passent par le helper `addStandardTimestampableBlamableComments($schema, '')`. Le SQL ci-dessous montre la structure ; les `COMMENT ON COLUMN` (un par colonne métier) sont à écrire dans la migration (exemples §3.2.bis). + +```sql +-- ===================================================================== +-- Seed taxonomie : nouveau type FOURNISSEUR (référentiels comptables = M1, non recréés) +-- ===================================================================== +INSERT INTO category_type (code, label) VALUES ('FOURNISSEUR', 'Fournisseur') + ON CONFLICT (code) DO NOTHING; + +-- ===================================================================== +-- Table principale `supplier` +-- ===================================================================== +CREATE TABLE supplier ( + id SERIAL PRIMARY KEY, + -- Formulaire principal + company_name VARCHAR(180) NOT NULL, + -- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary / + -- phone_secondary / email vivent uniquement dans supplier_contact (onglet Contacts). + -- Onglet Information (Commerciale obligatoire — RG-2.03 — null sinon) + description TEXT, + competitors VARCHAR(255), + founded_at DATE, + employees_count INT, + revenue_amount NUMERIC(15,2), + director_name VARCHAR(120), + profit_amount NUMERIC(15,2), + volume_forecast INT, -- NEW vs client + -- 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é M2) + is_archived BOOLEAN NOT NULL DEFAULT FALSE, + archived_at TIMESTAMPTZ, + -- Soft delete (préparé, non exposé au M2) + deleted_at TIMESTAMPTZ, + -- Timestampable + Blamable + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ 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_supplier_is_archived ON supplier(is_archived); +CREATE INDEX idx_supplier_deleted_at ON supplier(deleted_at); +CREATE INDEX idx_supplier_created_by ON supplier(created_by); +CREATE INDEX idx_supplier_updated_by ON supplier(updated_by); + +-- Unicité métier (partielle : ignore archives + soft-delete) — nom de société uniquement (cf. § 2.6) +CREATE UNIQUE INDEX uq_supplier_company_name_active + ON supplier (LOWER(company_name)) + WHERE is_archived = FALSE AND deleted_at IS NULL; + +-- ===================================================================== +-- M2M supplier ↔ category (catégories de type FOURNISSEUR — RG-2.10) +-- ===================================================================== +CREATE TABLE supplier_category ( + supplier_id INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE, + category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, + PRIMARY KEY (supplier_id, category_id) +); +CREATE INDEX idx_supplier_category_category ON supplier_category(category_id); + +-- ===================================================================== +-- Sous-collection : Contacts (1:n) +-- ===================================================================== +CREATE TABLE supplier_contact ( + id SERIAL PRIMARY KEY, + supplier_id INT NOT NULL REFERENCES supplier(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 TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL, + -- RG-2.04 : au moins Nom OU Prénom + CONSTRAINT chk_supplier_contact_name + CHECK (first_name IS NOT NULL OR last_name IS NOT NULL) +); +CREATE INDEX idx_supplier_contact_supplier ON supplier_contact(supplier_id); + +-- ===================================================================== +-- Sous-collection : Adresses (1:n) +-- ===================================================================== +CREATE TABLE supplier_address ( + id SERIAL PRIMARY KEY, + supplier_id INT NOT NULL REFERENCES supplier(id) ON DELETE CASCADE, + -- Radio Prospect / Départ / Rendu (mutuellement exclusifs — RG-2.09) + address_type VARCHAR(20) NOT NULL, -- 'PROSPECT' | 'DEPART' | 'RENDU' + 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), + bennes INT, -- NEW (spécifique fournisseur) + triage_provider BOOLEAN NOT NULL DEFAULT FALSE, -- NEW (Prestataire de triage) + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + created_by INT REFERENCES "user"(id) ON DELETE SET NULL, + updated_by INT REFERENCES "user"(id) ON DELETE SET NULL, + -- RG-2.09 : valeur enum contrôlée + CONSTRAINT chk_supplier_address_type + CHECK (address_type IN ('PROSPECT', 'DEPART', 'RENDU')) +); +CREATE INDEX idx_supplier_address_supplier ON supplier_address(supplier_id); + +-- M2M supplier_address ↔ site (RG-2.06 : ≥ 1 site) +CREATE TABLE supplier_address_site ( + supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE, + site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT, + PRIMARY KEY (supplier_address_id, site_id) +); + +-- M2M supplier_address ↔ supplier_contact +CREATE TABLE supplier_address_contact ( + supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE, + supplier_contact_id INT NOT NULL REFERENCES supplier_contact(id) ON DELETE CASCADE, + PRIMARY KEY (supplier_address_id, supplier_contact_id) +); + +-- M2M supplier_address ↔ category (catégorie d'adresse, type FOURNISSEUR — RG-2.10) +CREATE TABLE supplier_address_category ( + supplier_address_id INT NOT NULL REFERENCES supplier_address(id) ON DELETE CASCADE, + category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, + PRIMARY KEY (supplier_address_id, category_id) +); + +-- ===================================================================== +-- Sous-collection : RIB (1:n) +-- ===================================================================== +CREATE TABLE supplier_rib ( + id SERIAL PRIMARY KEY, + supplier_id INT NOT NULL REFERENCES supplier(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 TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ 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_supplier_rib_supplier ON supplier_rib(supplier_id); +``` + +### 3.2.bis Commentaires SQL obligatoires (échantillon) + +```php +$this->addSql("COMMENT ON TABLE supplier IS 'Répertoire fournisseurs (M2 Commercial) — entités archivables.'"); +$this->addSql("COMMENT ON COLUMN supplier.company_name IS 'Raison sociale du fournisseur — stockée en MAJUSCULES. Unique parmi non-archivés/non-supprimés (RG-2.06).'"); +$this->addSql("COMMENT ON COLUMN supplier.volume_forecast IS 'Volume prévisionnel (entier) — onglet Information. Obligatoire pour le rôle Commerciale (RG-2.03).'"); +$this->addSql("COMMENT ON COLUMN supplier.payment_type_id IS 'Type de règlement — FK -> payment_type.id (référentiel partagé M1), ON DELETE RESTRICT. Pilote RG-2.07 (Banque) et RG-2.08 (RIB).'"); +$this->addSql("COMMENT ON COLUMN supplier.bank_id IS 'Banque — FK -> bank.id (M1). Obligatoire ssi payment_type=VIREMENT (RG-2.07), null sinon.'"); +$this->addSql("COMMENT ON COLUMN supplier_address.address_type IS 'Type d''adresse : PROSPECT | DEPART | RENDU (radio exclusif — RG-2.09).'"); +$this->addSql("COMMENT ON COLUMN supplier_address.bennes IS 'Nombre de bennes sur le site fournisseur (entier nullable).'"); +$this->addSql("COMMENT ON COLUMN supplier_address.triage_provider IS 'Le fournisseur est prestataire de triage sur cette adresse. Faux par défaut.'"); +// + COMMENT ON COLUMN sur TOUTES les autres colonnes métier (cf. règle n°12) +$this->addStandardTimestampableBlamableComments($schema, 'supplier'); +$this->addStandardTimestampableBlamableComments($schema, 'supplier_contact'); +$this->addStandardTimestampableBlamableComments($schema, 'supplier_address'); +$this->addStandardTimestampableBlamableComments($schema, 'supplier_rib'); +``` + +### 3.3 Entité `Supplier` — squelette + +```php + [ + 'supplier:read', + 'category:read', + 'site:read', + 'default:read', + ]], + provider: SupplierProvider::class, + ), + new Get( + security: "is_granted('commercial.suppliers.view')", + // RETEX M1 §1/§2 : le DÉTAIL embarque les sous-collections (contacts, + // adresses, ribs) ET leurs relations imbriquées. Les 3 maillons doivent + // être présents : groupe sur la propriété (supplier:item:read), groupe + // dans ce contexte, ET read-group de chaque entité imbriquée + // (category:read, site:read) — sinon embed = IRI vide. + normalizationContext: ['groups' => [ + 'supplier:read', + 'supplier:item:read', // embed contacts / addresses + 'supplier:read:accounting', // scalaires compta + embed ribs (filtré par le Provider selon accounting.view) + 'category:read', // embed des Category (id/code/name) — relation imbriquée + 'site:read', // embed des Site (id/name/postalCode/city/color, pas de code) — relation imbriquée + 'default:read', + ]], + // Le Provider RETIRE supplier:read:accounting du contexte si l'user + // n'a pas is_granted('commercial.suppliers.accounting.view'). + provider: SupplierProvider::class, + ), + new Post( + security: "is_granted('commercial.suppliers.manage')", + normalizationContext: ['groups' => ['supplier:read', 'default:read']], + denormalizationContext: ['groups' => ['supplier:write:main']], + processor: SupplierProcessor::class, + ), + new Patch( + security: "is_granted('commercial.suppliers.manage')", + // Le SupplierProcessor inspecte les groupes envoyés pour autoriser + // onglet par onglet (cf. § 2.10 + § 5). Patch des champs comptables + // exige is_granted('commercial.suppliers.accounting.manage') ; + // patch isArchived exige is_granted('commercial.suppliers.archive'). + normalizationContext: ['groups' => ['supplier:read', 'default:read']], + denormalizationContext: ['groups' => [ + 'supplier:write:main', + 'supplier:write:information', + 'supplier:write:accounting', + 'supplier:write:archive', + ]], + provider: SupplierProvider::class, + processor: SupplierProcessor::class, + ), + // Pas de Delete au M2 (HP M3). Archivage via PATCH { isArchived: true }. + ], +)] +#[ORM\Entity(repositoryClass: DoctrineSupplierRepository::class)] +#[ORM\Table(name: 'supplier')] +#[Auditable] +class Supplier implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; + + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + #[Groups(['supplier:read'])] + private ?int $id = null; + + #[ORM\Column(length: 180)] + #[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')] + #[Assert\Length(min: 2, max: 180, normalizer: 'trim')] + #[Groups(['supplier:read', 'supplier:write:main'])] + private ?string $companyName = null; + + // Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary / + // phoneSecondary / email ne sont plus portés par Supplier — ils vivent dans SupplierContact + // (onglet Contacts). Garantie « ≥ 1 contact nommé » via RG-2.04 + RG-2.13. + + /** @var Collection Catégories de type FOURNISSEUR (RG-2.10) */ + // Embarquée en LISTE et DÉTAIL (cohérence M1/ERP-62). Collection bornée. + // Maillon (c) : pour voir id/code/name, le contexte inclut 'category:read'. + #[ORM\ManyToMany(targetEntity: Category::class)] + #[ORM\JoinTable(name: 'supplier_category')] + #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] + #[Groups(['supplier:read', 'supplier:write:main'])] + private Collection $categories; + + // === Sites agrégés pour la LISTE (colonne « Site » du répertoire) === + // Cohérence M1/ERP-62 : on EMBARQUE les Site (objets entiers). Renvoie les Site + // dédoublonnés issus des adresses ; sérialisés via 'site:read' → name/postalCode/ + // city/color (⚠ Site N'A PAS de champ `code` : « 86/17/82 » = préfixe du postalCode, + // libellé = `name`). Identique au Client.getSites() racine déjà en prod (fix #82). + // ⚠ Fetch-join obligatoire (addresses.sites) côté repository — anti N+1 (§ 2.12). + /** @return array */ + #[Groups(['supplier:read'])] + public function getSites(): array + { + $sites = []; + foreach ($this->addresses as $a) { + foreach ($a->getSites() as $s) { + $sites[$s->getId()] = $s; // dédoublonnage par id + } + } + return array_values($sites); + } + + // === Onglet Information === + #[ORM\Column(type: 'text', nullable: true)] + #[Groups(['supplier:read', 'supplier:write:information'])] + private ?string $description = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['supplier:read', 'supplier:write:information'])] + private ?string $competitors = null; + + #[ORM\Column(type: 'date_immutable', nullable: true)] + #[Groups(['supplier:read', 'supplier:write:information'])] + private ?DateTimeImmutable $foundedAt = null; + + #[ORM\Column(nullable: true)] + #[Assert\PositiveOrZero] + #[Groups(['supplier:read', 'supplier:write:information'])] + private ?int $employeesCount = null; + + #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] + #[Groups(['supplier:read', 'supplier:write:information'])] + private ?string $revenueAmount = null; + + #[ORM\Column(length: 120, nullable: true)] + #[Groups(['supplier:read', 'supplier:write:information'])] + private ?string $directorName = null; + + #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] + #[Groups(['supplier:read', 'supplier:write:information'])] + private ?string $profitAmount = null; + + // NEW vs Client : Volume prévisionnel + #[ORM\Column(nullable: true)] + #[Assert\PositiveOrZero] + #[Groups(['supplier:read', 'supplier:write:information'])] + private ?int $volumeForecast = null; + + // === Onglet Comptabilité (lecture/écriture conditionnées par permission — cf. M1) === + #[ORM\Column(length: 20, nullable: true)] + #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] + private ?string $siren = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] + private ?string $accountNumber = null; + + #[ORM\ManyToOne(targetEntity: TvaMode::class)] + #[ORM\JoinColumn(name: 'tva_mode_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] + private ?TvaMode $tvaMode = null; + + #[ORM\Column(length: 40, nullable: true)] + #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] + private ?string $nTva = null; + + #[ORM\ManyToOne(targetEntity: PaymentDelay::class)] + #[ORM\JoinColumn(name: 'payment_delay_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] + private ?PaymentDelay $paymentDelay = null; + + #[ORM\ManyToOne(targetEntity: PaymentType::class)] + #[ORM\JoinColumn(name: 'payment_type_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] + private ?PaymentType $paymentType = null; + + #[ORM\ManyToOne(targetEntity: Bank::class)] + #[ORM\JoinColumn(name: 'bank_id', nullable: true, onDelete: 'RESTRICT')] + #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] + private ?Bank $bank = null; + + // === Sous-collections — EMBARQUÉES dans le DÉTAIL (RETEX M1 §2) === + // Maillon (a) OBLIGATOIRE : sans #[Groups], jamais sérialisées (erreur n°1 du M1). + // Embed borné dans le Get racine → ne viole pas la règle n°13 (pas une GetCollection exposée). + // Édition via sous-ressources POST/PATCH/DELETE (cf. § 4.5). + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['supplier:item:read'])] + private Collection $contacts; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['supplier:item:read'])] + private Collection $addresses; + + /** @var Collection RIB embarqués dans le groupe COMPTA (gated par le Provider) */ + #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: SupplierRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[Groups(['supplier: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 du M1, cf. § 4.0.ter) : le #[Groups] DOIT être sur + // le GETTER avec #[SerializedName] — sinon Symfony dérive l'attribut "archived" + // (strip de "is") et droppe la clé "isArchived" du JSON. À tester sur JSON réel. + #[Groups(['supplier:read', 'supplier:write:archive'])] + #[SerializedName('isArchived')] + public function isArchived(): bool + { + return $this->isArchived; + } + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + #[Groups(['supplier:read'])] + private ?DateTimeImmutable $archivedAt = null; + + // NB : `updatedAt` (du TimestampableBlamableTrait) doit être exposé dans le + // groupe `supplier:read` — il alimente la colonne « Dernière activité » du + // datatable du répertoire (cf. spec-front). + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?DateTimeImmutable $deletedAt = null; + + public function __construct() + { + $this->categories = new ArrayCollection(); + $this->contacts = new ArrayCollection(); + $this->addresses = new ArrayCollection(); + $this->ribs = new ArrayCollection(); + } + + // Getters / setters omis — pattern Starseed standard. +} +``` + +### 3.4 Squelettes des autres entités + +Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamableTrait`, FK `supplier_id`). **Chaque propriété affichée porte un read-group** (RETEX M1 §1 maillon (a)) : + +**`SupplierContact`** — toutes les propriétés métier dans `['supplier:item:read', 'supplier:write:contacts']` : +`firstName`, `lastName`, `jobTitle`, `phonePrimary`, `phoneSecondary`, `email`, `id`. Embed sous `supplier.contacts` au détail ; éditables via la sous-ressource. + +**`SupplierAddress`** — propriétés dans `['supplier:item:read', 'supplier:write:addresses']` : +`addressType` (enum string `PROSPECT|DEPART|RENDU`, `#[Assert\Choice]`), `country`, `postalCode`, `city`, `street`, `streetComplement`, `bennes` (int nullable), `triageProvider` (bool — ⚠ piège #3 : `#[Groups]` + `#[SerializedName('triageProvider')]` **sur le getter** `isTriageProvider()`/`getTriageProvider()`, sinon clé droppée), `id`. Relations imbriquées (maillon (c) — read-groups à inclure dans le contexte du `Get` racine) : +- M2M `sites` → `#[Groups(['supplier:item:read'])]` sur la propriété ; `Site` expose `id`/`name`/`postalCode`/`city`/`color` en `site:read` (**pas de `code`** — cf. § 2.4) (`Assert\Count(min:1)` — RG-2.06). +- M2M `contacts` → `#[Groups(['supplier:item:read'])]` ; embarque des `SupplierContact` (déjà en `supplier:item:read`). +- M2M `categories` → `#[Groups(['supplier:item:read'])]` ; `Category` expose `id`/`code`/`name` en `category:read` (libellé = `name` ; type FOURNISSEUR — RG-2.10). +**Pas** de `billingEmail`. + +**`SupplierRib`** — propriétés dans `['supplier:read:accounting', 'supplier:write:accounting']` : +`label`, `bic`, `iban`, `id`. Embed sous `supplier.ribs` **uniquement** si l'user a `accounting.view` (le Provider gère le retrait du groupe). Aucun `#[AuditIgnore]` sur `iban`/`bic` (audit admin-only, décision M1 reportée). + +> ⚠ **`Site` et `Category` appartiennent à d'autres modules** — on ne les importe pas pour de la logique ; on consomme leurs read-groups (`site:read`, `category:read`), confirmés sur le JSON réel : `Category` = `code` + **`name`** (pas `label`) ; `Site` = `name`/`postalCode`/`city`/`color` (**pas de `code`** ; « 86/17/82 » = préfixe `postalCode`). L'embed est pleinement matérialisé (fix M1 #82 OK). Côté Catalog, le **filtre `?typeCode=` reste à implémenter** (cf. § 2.4). + +**Référentiels (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`)** : **réutilisés du M1**, aucune nouvelle entité (cf. § 2.3). Embarqués dans les scalaires compta via `supplier:read:accounting` (id + label). + +## 4. API REST (API Platform) + +### 4.0 Contrat de sérialisation (RETEX M1 — section critique) + +> **Leçon M1** : ~80 % des frictions venaient du contrat de sérialisation, pas du métier. Pour **chaque champ affiché** par le front (liste OU détail), les **3 maillons** doivent être prouvés ici : (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. Si un seul manque → champ vide / IRI. + +**Contexte par opération** : + +| Opération | `normalizationContext` (groupes) | +|---|---| +| `GetCollection` (liste) | `supplier:read` + `category:read` + `site:read` + `default:read` | +| `Get` (détail) | `supplier:read` + `supplier:item:read` + `supplier:read:accounting`¹ + `category:read` + `site:read` + `default:read` | + +¹ `supplier:read:accounting` retiré par le `SupplierProvider` si l'user n'a pas `commercial.suppliers.accounting.view`. + +**LISTE — champ datatable → maillons** : + +| Champ affiché | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) | +|---|---|---|---| +| Nom | `companyName` ∈ `supplier:read` | ✅ | — | +| Catégories | `categories` ∈ `supplier:read` (embed) | ✅ | `category:read` ✅ (code/**name**) | +| Site | `getSites()` ∈ `supplier:read` (embed, Site[] dédoublonné) | ✅ | `site:read` ✅ (**name**/postalCode, pas de code) | +| Dernière activité | `updatedAt` ∈ `supplier:read` | ✅ | — | + +> Choix d'alignement M1/ERP-62 (§ 2.12) : la liste **embarque** `categories[]` (code/name) et `sites[]` (name/postalCode). Elle n'embarque pas `contacts`/`addresses` complets. **Fetch-joins obligatoires** (`categories`, `addresses.sites`) dans le repository pour éviter le N+1. + +**DÉTAIL — champ → maillons** : + +| Bloc / champ | Propriété (a) | Dans contexte détail (b) | Imbriqué (c) | +|---|---|---|---| +| Scalaires principaux + Information | `supplier:read` | ✅ | — | +| `categories[]` (id/code/name) | `categories` ∈ `supplier:read` | ✅ | `category:read` ✅ | +| `contacts[]` (5 champs) | `contacts` ∈ `supplier:item:read` | ✅ | propriétés `SupplierContact` ∈ `supplier:item:read` ✅ | +| `addresses[]` (scalaires) | `addresses` ∈ `supplier:item:read` | ✅ | propriétés `SupplierAddress` ∈ `supplier:item:read` ✅ | +| `addresses[].sites[]` | `sites` ∈ `supplier:item:read` | ✅ | `site:read` ✅ | +| `addresses[].categories[]` | `categories` ∈ `supplier:item:read` | ✅ | `category:read` ✅ | +| `addresses[].contacts[]` | `contacts` ∈ `supplier:item:read` | ✅ | propriétés `SupplierContact` ∈ `supplier:item:read` ✅ | +| Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` | +| `ribs[]` (label/bic/iban) | `ribs` ∈ `supplier:read:accounting` | ✅ (gated) | — | + +### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle) + +> **Definition of Done de cette spec back (RETEX M1 §3)** : avant d'écrire les tickets front, créer un fournisseur de test et **coller ici les réponses RÉELLES** de `GET /api/suppliers` et `GET /api/suppliers/{id}`. Les containers n'étant pas lancés au moment de la rédaction, le JSON ci-dessous est le **contrat CIBLE** — à valider/remplacer par la réponse réelle (`make start` puis `curl`). Toute donnée affichée par le front DOIT apparaître dans ce JSON. + +> **Forme d'enveloppe confirmée sur le M1 réel** (API Platform 4.2) : JSON-LD **sans préfixe `hydra:`** → clés `member` / `totalItems` / `view`, avec `@type: "Collection"` et `view.@type: "PartialCollectionView"`. `Content-Type: application/ld+json; charset=utf-8`. Pagination défaut 10 confirmée. Login réel = `POST /api/login_check` (nginx réécrit vers `/login_check`), réponse `204` + cookie HttpOnly `BEARER`. + +`GET /api/suppliers` (liste, ADMIN) : +```json +{ + "@context": "/api/contexts/Supplier", + "@id": "/api/suppliers", + "@type": "Collection", + "totalItems": 13, + "member": [ + { + "@id": "/api/suppliers/1", + "@type": "Supplier", + "id": 1, + "companyName": "RECYCLA SAS", + "categories": [ + {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"} + ], + "sites": [ + {"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}, + {"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#…"} + ], + "updatedAt": "2026-02-17T09:30:00+00:00", + "isArchived": false + } + ], + "view": { + "@id": "/api/suppliers?page=1", + "@type": "PartialCollectionView", + "first": "/api/suppliers?page=1", + "last": "/api/suppliers?page=2", + "next": "/api/suppliers?page=2" + } +} +``` + +> Les fournisseurs archivés sont **exclus** du `totalItems` (sur le M1, 14 clients en base → `totalItems: 13` car 1 archivé filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository obligatoires (anti N+1). + +`GET /api/suppliers/1` (détail — user avec `accounting.view`) : +```json +{ + "@id": "/api/suppliers/1", + "@type": "Supplier", + "id": 1, + "companyName": "RECYCLA SAS", + "categories": [ + {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"} + ], + "description": "…", "competitors": "…", "foundedAt": "2008-04-01", + "employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…", + "profitAmount": "120000.00", "volumeForecast": 8000, + "contacts": [ + {"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin", + "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null, + "email": "marie.martin@recycla.fr"} + ], + "addresses": [ + {"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART", + "country": "France", "postalCode": "86000", "city": "Poitiers", + "street": "12 rue des Acacias", "streetComplement": null, + "bennes": 3, "triageProvider": true, + "sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], + "categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}], + "contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]} + ], + "siren": "123456789", "accountNumber": "F0001", + "tvaMode": {"@id": "/api/tva_modes/1", "id": 1, "label": "France (ventes)"}, + "nTva": "FR00123456789", + "paymentDelay": {"@id": "/api/payment_delays/2", "id": 2, "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/2", "id": 2, "code": "LCR", "label": "LCR"}, + "bank": null, + "ribs": [ + {"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal", + "bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"} + ], + "isArchived": false, "archivedAt": null, + "updatedAt": "2026-02-17T09:30:00+00:00" +} +``` + +> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées car le Provider retire le groupe). Le gating par **omission de clé** est confirmé confortable côté front. Le blame `updatedBy` est sérialisé en **IRI** (`"/api/me"` quand c'est l'user courant) — en tenir compte côté front. + +### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE) + +> Capture réelle du contrat M1 (clients) effectuée le 02/06/2026. Les 5 divergences ci-dessous sont **des bugs présents en prod sur le M1** ; chacune a une parade à appliquer/vérifier au M2. Tous sont des oublis silencieux du contrat de sérialisation (aucune erreur levée). + +| # | Bug constaté sur M1 réel | Cause | Parade M2 | +|---|---|---|---| +| 1 | `categories[]` embarquées sous Client = `@id`/`@type`/`createdAt`/`updatedAt` seulement — **pas de `code` ni `name`** | `Category.code`/`name` portent **uniquement** `category:read`, absent du contexte de sérialisation du `Get` Client | LISTE **et** DÉTAIL : `category:read` est inclus dans le `normalizationContext` (§ 3.3 / § 2.12). **Test sur JSON réel** que `categories[].code` et `.name` sont présents en liste ET en détail. ✅ Confirmé OK sur M1 réel (fix). | +| 2 | `addresses[].sites[]` embarqués = `@id`/`@type` nu | `Site` expose ses champs sous `site:read`/`me:read`, absent du contexte Client | LISTE (via `getSites()`) **et** DÉTAIL : `site:read` inclus dans le contexte (§ 3.3 / § 2.12). **Fetch-joins** repository pour le N+1. ✅ **Fix M1 #82 confirmé OK** : `Site` embarqué entier (`name`/`postalCode`/`city`/`color` — pas de `code`). | +| 3 | 🔴 `ClientAddress.isProspect/isDelivery/isBilling` **totalement absents du JSON** alors que `is_delivery=TRUE` en base | Le `#[Groups]` est sur la **propriété** `isDelivery`, mais le **getter** `isDelivery()` n'a ni `#[Groups]` ni `#[SerializedName]` → Symfony dérive l'attribut `delivery` (strip du préfixe `is`) et **droppe** le champ | M2 a **éliminé ces 3 booléens** (remplacés par l'enum `addressType` string — RG-2.09, donc immunisé). MAIS pour **tout booléen restant** (`triageProvider`, `isArchived`), poser `#[Groups]` **+** `#[SerializedName('isX')]` **sur le getter** (cf. § 3.3) et le **tester sur le JSON réel**. | +| 4 | 🔴 `ribs[]` (label/bic/iban) **visibles par la Commerciale** (sans `accounting.view`) | `ClientRib` sous `client_rib:read`, présent **inconditionnellement** dans le contexte `Get` ; le context builder ne gate QUE les 7 scalaires de Client, pas les RIB | M2 met `ribs` dans le groupe **`supplier:read:accounting`** (§ 3.3) — le même groupe gaté/retiré par le `SupplierProvider`. **Test obligatoire** : Commerciale → `ribs` ABSENT (§ 8.1). | +| 5 | `member`/`totalItems`/`view` sans préfixe `hydra:` ; `updatedBy` en IRI `/api/me` | Forme JSON-LD d'API Platform 4.2 | Contrat documenté tel quel (§ 4.0.bis). Le front consomme `member`/`totalItems`/`view` (déjà géré par `usePaginatedList`). | + +> **Dépendance confirmée sur le JSON réel (02/06)** : l'embed des `sites[]` (liste via `getSites()` ET détail via `addresses[].sites[]`) est **pleinement matérialisé** (fix M1 #82 OK). `site:read` expose `name`/`street`/`postalCode`/`city`/`color`/`fullAddress` — **il n'y a PAS de champ `code`** : le « 86/17/82 » de la maquette est le **préfixe du `postalCode`** (86100/17400/82400) et le libellé du site est `name` (Chatellerault/Saint-Jean/Pommevic). La spec front référence donc `name` + `postalCode`, jamais `Site.code`. Côté Catalog, le **filtre `?typeCode=` reste à implémenter** (§ 2.4) et le type `FOURNISSEUR` à recréer. + +> **Règle de rédaction M2 (anti-régression)** : aucun champ n'est déclaré « exposé/embarqué » sans avoir été **vu dans un JSON réel**. Les tests fonctionnels assertent sur le **corps de réponse**, jamais sur l'annotation. + +### 4.1 `GET /api/suppliers` — Liste + +- **Security** : `is_granted('commercial.suppliers.view')` +- **Query params** (alimentent le panneau « Filtrer » du front — cf. spec-front) : + - `includeArchived=true|false` (default `false`) + - `categoryCode=` (filtre les fournisseurs ayant ≥ 1 `Category` de ce code ; répétable pour multi-sélection) + - `siteId=` (filtre les fournisseurs ayant ≥ 1 adresse rattachée à ce site ; répétable — jointure `addresses.sites`) + - `search=` (fuzzy sur `companyName` + contacts liés `supplier_contact` (firstName / lastName / email) via LEFT JOIN groupé par `supplier.id` — décision D1, refonte-contact) +- **Tri par défaut** : `companyName ASC` +- **Pagination** : standard Starseed (règle ABSOLUE n°13) — Hydra activée, 10/page, `?pagination=false` pour les selects. `SupplierProvider` branché sur `ApiPlatform\Doctrine\Orm\Paginator`. +- **Fetch-joins (anti N+1, § 2.12)** : la requête de liste du `DoctrineSupplierRepository` pose des `leftJoin`+`addSelect` sur `categories` et `addresses.sites` (la pagination Doctrine reste correcte car ces relations sont des collections chargées via Paginator). +- **Réponse 200** (JSON-LD) : champs `supplier:read` + `categories[]` (code/name) / `sites[]` (name/postalCode) embarqués. Les champs `supplier:read:accounting` n'apparaissent que si l'user a `accounting.view`. +- **Codes** : `200` / `401` / `403` + +### 4.2 `GET /api/suppliers/{id}` — Détail + +- **Security** : `is_granted('commercial.suppliers.view')` +- **Comportement** : fournisseur + contacts + adresses + RIBs. Les champs `supplier:read:accounting` sont inclus seulement si `commercial.suppliers.accounting.view`. +- **Codes** : `200` / `404` / `401` / `403` + +### 4.3 `POST /api/suppliers` — Création (formulaire principal) + +- **Security** : `is_granted('commercial.suppliers.manage')` +- **Body** (groupe `supplier:write:main`) : +```json +{ + "companyName": "RECYCLA SAS", + "categories": ["/api/categories/12", "/api/categories/15"] +} +``` +- **Réponse 201** : le fournisseur 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-2.11). SIREN/email non uniques. + - `422` : catégories vides ; catégorie hors type FOURNISSEUR (RG-2.10). _(RG-2.01 supprimée V1 — complétude contact via onglet Contacts : RG-2.04 / RG-2.13.)_ + +### 4.4 `PATCH /api/suppliers/{id}` — Modification + +- **Security base** : `is_granted('commercial.suppliers.manage')` +- **Security additionnelle** (dans le `SupplierProcessor`) : + - payload contenant un champ `supplier:write:accounting` → exige `commercial.suppliers.accounting.manage` + - payload contenant `isArchived` → exige `commercial.suppliers.archive` + - **mode strict** (RG-2.16) : 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/suppliers/{id}/contacts`, `PATCH /api/supplier_contacts/{id}`, `DELETE /api/supplier_contacts/{id}`. +- **Security** : `is_granted('commercial.suppliers.manage')` +- **RG-2.13** : au moins 1 bloc Contact valide (Nom OU Prénom) pour finaliser l'onglet côté front. Côté back, la collection peut rester vide (pas de state machine). + +**Adresses** : `POST /api/suppliers/{id}/addresses`, `PATCH /api/supplier_addresses/{id}`, `DELETE /api/supplier_addresses/{id}`. +- **Security** : `is_granted('commercial.suppliers.manage')` +- Validations : `addressType ∈ {PROSPECT,DEPART,RENDU}` (RG-2.09) ; ≥ 1 site (RG-2.06) ; catégories de type FOURNISSEUR uniquement (RG-2.10) ; `postalCode` matche `^[0-9]{4,5}$` (RG-2.05). + +**RIBs** : `POST /api/suppliers/{id}/ribs`, `PATCH /api/supplier_ribs/{id}`, `DELETE /api/supplier_ribs/{id}`. +- **Security** : `is_granted('commercial.suppliers.accounting.manage')` +- **RG-2.08** : si `paymentType.code = LCR`, suppression du dernier RIB → 409. + +### 4.6 `GET /api/suppliers/export.xlsx` — Export + +- **Security** : `is_granted('commercial.suppliers.view')` +- **Comportement** : XLSX des fournisseurs **affichés** (mêmes filtres que la liste, non archivés par défaut). +- Colonnes : Nom fournisseur, 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. _(V1, décision D2 : colonnes contact alimentées depuis le **contact principal** `supplier_contact` de plus petit `position` — plus de contact inline sur le Supplier.)_ +- **Implémentation** : controller custom `SupplierExportController` avec `#[Route(priority: 1)]` (règle ABSOLUE — conflit API Platform `{id}`). Lib : PhpSpreadsheet (déjà présente, M1). +- **Réponse 200** : `Content-Disposition: attachment; filename="repertoire-fournisseurs-{YYYYMMDD}.xlsx"` + +### 4.7 Référentiels (réutilisés M1 — évolution security) + +`GET /api/tva_modes`, `/api/payment_delays`, `/api/payment_types`, `/api/banks` existent (M1). **Évolution M2** : élargir leur `security` pour autoriser aussi les rôles fournisseurs, p.ex. `is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')`. Tri `position ASC` puis `label ASC`. Pas d'écriture exposée (HP). + +`GET /api/categories?typeCode=FOURNISSEUR` alimentera les multi-selects Catégorie (fournisseur + adresse). ⚠️ **Ce filtre n'existe pas en prod** (vérifié sur le JSON réel : `?typeCode=` est ignoré, seul le type CLIENT existe — ERP-78). Le M2 doit le **recréer** : type `FOURNISSEUR` + filtre `?typeCode=` sur `/api/categories` (module Catalog). Cf. § 2.4 + ticket dédié. + +## 5. Autorisation + +### 5.1 Déclaration des permissions + +Enrichir `CommercialModule::permissions()` (ajout aux 5 permissions clients existantes) : + +```php +// ... commercial.clients.* déjà présentes ... +['code' => 'commercial.suppliers.view', 'label' => 'Voir les fournisseurs'], +['code' => 'commercial.suppliers.manage', 'label' => 'Créer / modifier les fournisseurs (hors onglet Comptabilité)'], +['code' => 'commercial.suppliers.accounting.view', 'label' => 'Voir l\'onglet Comptabilité d\'un fournisseur'], +['code' => 'commercial.suppliers.accounting.manage', 'label' => 'Modifier l\'onglet Comptabilité d\'un fournisseur'], +['code' => 'commercial.suppliers.archive', 'label' => 'Archiver / restaurer un fournisseur'], +``` + +Synchronisation : `php bin/console app:sync-permissions`. + +### 5.2 Mapping rôles MALIO ↔ permissions + +Cf. § 2.9 (matrice détaillée — identique à la matrice M1 transposée sur `suppliers`). + +### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES — règle ABSOLUE Starseed n°8) + +1. **`config/sidebar.php`** — item « Répertoire fournisseurs » déjà présent (`to => '/suppliers'`), **à compléter** avec la permission : +```php +[ + 'label' => 'sidebar.commercial.suppliers', + 'to' => '/suppliers', + 'icon' => 'mdi:account-arrow-left-outline', + 'module' => 'commercial', + 'permission' => 'commercial.suppliers.view', // ← à ajouter +], +``` + +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` + - Usine : aucune + +3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back des mêmes personas. + +> ⚠ Toute modif d'une de ces 3 sources sans les 2 autres = drift garanti (test cassé). Les 3 doivent être touchées dans le même commit. + +### 5.4 Vérification front + +- `usePermissions()` filtre l'item sidebar et masque l'onglet Comptabilité (`commercial.suppliers.accounting.view`). +- Bouton « Archiver » visible si `commercial.suppliers.archive` (Admin seul). + +## 6. Audit & dates + +- `Supplier`, `SupplierContact`, `SupplierAddress`, `SupplierRib` : `#[Auditable]`, tous champs audités (y compris `iban`/`bic` — cf. § 2.7). +- Audit M2M automatique sur `supplier.categories` → `{categories: {added:[...], removed:[...]}}`. +- Timestampable + Blamable : pattern Shared standard (cf. § 2.8). + +## 7. Règles de gestion (RG) + +Les RG-2.01 → RG-2.08 reprennent **mot pour mot** le docx source. Les RG-2.09 → RG-2.17 sont des **précisions back** (miroir M1) explicitement marquées. + +### Formulaire principal + +- ~~**RG-2.01**~~ _(SUPPRIMÉE — V1, 2026-06-03, refonte-contact)_ : le contact principal inline est retiré du `Supplier`. Garantie « au moins un contact nommé » portée par **RG-2.04** + **RG-2.13** sur `SupplierContact`. +- ~~**RG-2.02**~~ _(SUPPRIMÉE du Supplier — V1)_ : plus de téléphones inline sur le `Supplier`. Le « maximum 2 téléphones » reste applicable aux blocs `SupplierContact`. + +### Onglet Information + +- **RG-2.03** : Pour le rôle **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`, `volumeForecast`) sont obligatoires sur **POST et tout PATCH**. Pour les autres rôles, optionnels. Validator custom `SupplierInformationCompletenessValidator` invoqué par le `SupplierProcessor` quand l'user porte le rôle Commerciale. + - **Conséquence** (miroir RG-1.04) : le POST n'exposant que `supplier:write:main`, une Commerciale obtient **422** sur tout POST tant que l'Information n'est pas complète → la complétude se fait via les PATCH `supplier:write:information`. Un Admin (non gaté) crée normalement (201). + +### Onglet Contact + +- **RG-2.04** : Un bloc Contact est valide dès qu'**au moins** `firstName` OU `lastName` est rempli. CHECK BDD `chk_supplier_contact_name`. Côté UI, le bouton « + Nouveau contact » est bloqué tant que le bloc en cours n'a pas Prénom OU Nom. + +### Onglet Adresse + +- **RG-2.05** : `city` préremplie depuis `postalCode` via l'API **BAN** (api-adresse.data.gouv.fr), appel **direct front** via `useAddressAutocomplete()` (composable déjà créé au M1, réutilisé). L'adresse est une saisie assistée basée sur CP + ville. 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. +- **RG-2.06** : Au moins un des 3 sites (86 / 17 / 82) doit être sélectionné sur chaque adresse. `Assert\Count(min: 1)` sur `supplierAddress.sites`. + +### Onglet Comptabilité + +- **RG-2.07** : Le champ `bank` est visible et obligatoire **uniquement** si `paymentType.code = 'VIREMENT'` (options SG / CIC / CA). Validation server-side dans le `SupplierProcessor` : `payment_type = VIREMENT` et `bank IS NULL` → 422. +- **RG-2.08** : Les champs RIB (`label`, `bic`, `iban`) sont obligatoires si `paymentType.code = 'LCR'`. C'est-à-dire : + - `paymentType = LCR` ET `supplier.ribs.count() = 0` → 422 « Au moins un RIB est obligatoire pour le type LCR ». + - DELETE du dernier RIB d'un fournisseur en LCR → 409. + - Autres types : RIBs optionnels (0..n). + +### Précisions back (miroir M1) + +- **RG-2.09** _(précision back)_ : `address_type` est un **enum exclusif** `PROSPECT | DEPART | RENDU` (radio côté front, une seule valeur). CHECK BDD `chk_supplier_address_type`. Remplace les 3 booléens prospect/livraison/facturation du `client`. +- **RG-2.10** _(précision back)_ : les `Category` posées sur `supplier.categories` ET sur `supplier_address.categories` doivent être de **type `FOURNISSEUR`**. Toute catégorie d'un autre type → **422** (`categories: "Type de catégorie non autorisé (FOURNISSEUR attendu)."`). Front : les multi-selects sont alimentés par `GET /api/categories?typeCode=FOURNISSEUR`. +- **RG-2.11** _(précision back)_ : `companyName` unique (case-insensitive) parmi les fournisseurs non archivés ET non soft-deletés (index partiel `uq_supplier_company_name_active`). Doublon → 409 « Un fournisseur nommé "{companyName}" existe déjà. » SIREN et email **non** uniques (cf. § 2.6). +- **RG-2.12** _(normalisation serveur)_ : `companyName` **UPPERCASE** ; `firstName`/`lastName` (sur `SupplierContact` ; scope `Supplier` retiré en V1) **Capitalize** ; téléphones **chiffres uniquement** ; `email` **lowercase**. Formatage `XX XX XX XX XX` à l'affichage front. +- **RG-2.13** _(front-driven)_ : au moins 1 bloc Contact valide pour finaliser l'onglet (cf. RG-2.04). Pas de test back. +- **RG-2.14** _(archivage)_ : PATCH `{ "isArchived": true }` exige `commercial.suppliers.archive` (**Admin seul**). Pose `isArchived = true` + `archivedAt = now()`. Aucun autre champ dans la même requête. +- **RG-2.15** _(restauration)_ : PATCH `{ "isArchived": false }` exige la même permission. Pose `isArchived = false` + `archivedAt = null`. Conflit d'unicité (un autre fournisseur actif a pris le nom) → 409. +- **RG-2.16** _(PATCH mix de groupes, mode strict)_ : un PATCH mélangeant plusieurs groupes de sérialisation 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 (onglets masqués = pas de payload). +- **RG-2.17** _(liste / tri)_ : `GET /api/suppliers` 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`. + +## 8. Tests à automatiser + +### 8.1 Cas à couvrir (back — PHPUnit) + +- [ ] ~~RG-2.01~~ _(supprimée V1)_ : complétude contact couverte par RG-2.04 / RG-2.13 sur `SupplierContact` +- [ ] ~~RG-2.02~~ _(supprimée du Supplier V1)_ : téléphones inline retirés du Supplier (testés sur `SupplierContact`) +- [ ] **RG-2.03** : PATCH Information par Commerciale incomplet → 422 ; par Admin → 200 ; POST par Commerciale → 422 (Information non renseignable au POST) +- [ ] **RG-2.04** : POST contact sans firstName ni lastName → 422 (CHECK) +- [ ] **RG-2.05** : POST adresse `postalCode` invalide (3 chiffres) → 422 ; CP/ville incohérents → 200 (pas de contrôle strict) +- [ ] **RG-2.06** : POST adresse sans aucun site → 422 +- [ ] **RG-2.07** : POST Comptabilité `paymentType=VIREMENT` sans `bank` → 422 ; avec `bank` → 200 +- [ ] **RG-2.08** : POST `paymentType=LCR` sans RIB → 422 ; DELETE du dernier RIB en LCR → 409 +- [ ] **RG-2.09** : POST adresse `addressType` hors enum → 422 (CHECK / Assert\Choice) ; les 3 valeurs valides → 200 +- [ ] **RG-2.10** : POST `categories` avec une `Category` de type ≠ FOURNISSEUR → 422 (sur supplier ET sur supplier_address) +- [ ] **RG-2.11** : POST `companyName` déjà pris → 409 ; même nom après archivage de l'ancien → 201 ; SIREN/email dupliqués → 201 +- [ ] **RG-2.12** : POST `companyName="recycla sas"` → persiste `"RECYCLA SAS"` ; normalisation `firstName`/`phonePrimary`/`email` testée via un bloc `SupplierContact` (`"MARIE"`→`"Marie"`, `"06.12.34.56.78"`→`"0612345678"`, `"Marie@RECYCLA.FR"`→`"marie@recycla.fr"`) +- [ ] **RG-2.14/15** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; restauration en conflit de nom → 409 +- [ ] **RG-2.16** : Bureau PATCH `{companyName, siren}` → 403 sur tout le payload (strict) +- [ ] **RG-2.17** : 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 +- [ ] **Compta** : GET fournisseur retourne les champs accounting ; PATCH accounting → 200 ; PATCH info/contacts/adresses → 403 ; POST création → 403 (pas de `manage` global) +- [ ] **Commerciale** : GET fournisseur **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, pas sur l'annotation) +- [ ] **🔴 Sérialisation booléens (bug #3 M1)** : POST fournisseur + adresse `triageProvider=true`, fournisseur `isArchived` → GET détail expose bien les clés `triageProvider` et `isArchived` dans le JSON réel +- [ ] **Embed relations (bugs #1/#2 M1)** : GET **liste ET détail** → `categories[].code` + `.name` présents ; `sites[]` (liste, via `getSites()`) et `addresses[].sites[]` (détail) exposent `name` + `postalCode` (objet Site entier, PAS un IRI nu) +- [ ] **Filtre typeCode (brique à créer)** : `GET /api/categories?typeCode=FOURNISSEUR` ne renvoie QUE les catégories de type FOURNISSEUR (aujourd'hui le filtre est ignoré → test rouge tant que non implémenté) +- [ ] **Anti N+1 liste (§ 2.12)** : sur `GET /api/suppliers` avec N fournisseurs, compter les requêtes SQL — les fetch-joins (`categories`, `addresses.sites`) doivent éviter l'explosion (pas de requête par ligne) +- [ ] **Audit** : POST + PATCH + archive → audit_log `entity_type='Supplier'`, `changes` correct ; iban/bic présents dans le diff +- [ ] **Pagination** (règle n°13) : `GET /api/suppliers` renvoie l'envelope Hydra (`totalItems` / `view`) ; `?pagination=false` renvoie tout (alim. select) +- [ ] **Migration** : `make db-reset` → schéma OK ; namespace racine ; CategoryType FOURNISSEUR présent APRÈS db-reset (fixture idempotente) ; index partiel `uq_supplier_company_name_active` présent ; **toutes les colonnes ont un `COMMENT ON COLUMN`** (`ColumnsHaveSqlCommentTest` vert) + +### 8.2 Cas à couvrir (front — Vitest) + +- [ ] `useSuppliersRepository()` / `usePaginatedList({url:'/suppliers'})` : exclusion archivés par défaut, envelope Hydra +- [ ] `useSupplierForm()` : workflow par onglet (validation incrémentale, PATCH partiel) +- [ ] `useAddressAutocomplete()` : réutilisation M1 (cas nominal + dégradé) — pas de nouveau test si déjà couvert +- [ ] Radio `addressType` (Prospect/Départ/Rendu) : exclusivité, mapping enum +- [ ] `` : `` + « + Ajouter » → `/suppliers/new` +- [ ] Permissions : Compta accède à `/suppliers/{id}` mais onglet Comptabilité éditable seul ; Commerciale ne voit pas l'onglet Comptabilité + +### 8.3 Tests E2E + +**Non prévus au M2** (règle ABSOLUE n°7). Extension des personas existants pour ajouter les permissions `commercial.suppliers.*` — cf. § 5.3. + +### 8.4 Seed & fixtures démo (RETEX M1 §7 — prévu dès la spec) + +Le M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour le M2, prévoir **dès le ticket migration/fixtures** un `SupplierFixtures` idempotent couvrant **tous les cas des RG**, pour vérifier le gating et le golden path sans bricolage : +- Catégories de type FOURNISSEUR seedées (`CategoryFixtures` étendu) — au moins « Négociant », « Coopérative ». +- ≥ 1 fournisseur **complet** (Information remplie, ≥ 1 contact, ≥ 1 adresse multi-sites, comptabilité + RIB). +- 1 fournisseur **en LCR avec RIB** (RG-2.08) et 1 **en VIREMENT avec banque** (RG-2.07). +- 1 fournisseur avec une adresse de chaque `addressType` (PROSPECT / DEPART / RENDU — RG-2.09). +- 1 fournisseur **archivé** (vérifier exclusion liste + restauration). +- Réutiliser les comptes de rôles démo existants (`bureau`, `compta`, `commerciale`, `usine`, `admin`) pour tester la matrice § 2.9. + +> Idempotence obligatoire (le purger Doctrine vide `category`/`category_type` au `db-reset` — cf. M1 § 3.3). Le `CategoryType FOURNISSEUR` 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** +- [ ] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)* +- [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16) +- [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit, routes à plat : rappelés +- [x] Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, `usePaginatedList`, blocs, archive, normalisation) +- [x] Seed/fixtures démo planifiés (§ 8.4) + +## 9. Hors-périmètre (HP) + +- **HP-M3-1** : **DELETE / soft delete d'un fournisseur** (colonne `deleted_at` préparée, non exposée au M2). +- **HP-M3-2** : **CRUD admin des référentiels comptables** (`TvaMode` / `PaymentDelay` / `PaymentType` / `Bank`) — partagés M1, seed seulement. +- **HP-M3-3** : **CRUD admin de `CategoryType`** (le M2 seed seulement le type FOURNISSEUR). +- **HP-M3-4** : **Onglet Transport** (front placeholder « À venir » — cf. spec-front ; aucun modèle ni API back). +- **HP-M3-5** : **Onglet Statistiques** (placeholder « À venir »). +- **HP-M3-6** : **Onglet Rapports** (placeholder « À venir »). +- **HP-M3-7** : **Onglet Échanges** (placeholder « À venir »). +- **HP-M3-8** : **Périmètre Commerciale** (« consultation selon périmètre » — formulation floue du docx). Au M2, Commerciale voit **tous** les fournisseurs (sauf Comptabilité). Cloisonnement par portefeuille = spec dédiée. +- **HP-M3-9** : **Validation IBAN/BIC stricte** (au M2, `Assert\Iban` / `Assert\Bic` standard sur `SupplierRib`). +- **HP-M3-10** : **Validation SIREN stricte** (Luhn) — au M2, `Assert\Length(9)` + `Assert\Regex('/^\d{9}$/')`. +- **HP-M3-11** : **Référencement entrant** (modules futurs ajoutant une FK `supplier_id` : Commandes fournisseurs, Réceptions, etc.). +- **HP-M3-12** : **Export CSV** (XLSX uniquement au M2). +- **HP-M3-13** : **Liaison Client ↔ Fournisseur** (un même tiers à la fois client et fournisseur). Au M2, entités strictement séparées. + +## 10. Liens & dépendances + +### Liens + +- Spec front : [`./spec-front.md`](./spec-front.md) +- Spec M1 clients (pattern de référence) : [`../M1-clients/spec-back.md`](../M1-clients/spec-back.md) +- Spec M0 catégories : [`../M0-categories/spec-back.md`](../M0-categories/spec-back.md) +- Doc audit-log : [`../../audit-log.md`](../../audit-log.md) +- BAN api : `https://adresse.data.gouv.fr/api-doc/adresse` +- Maquette Figma : `https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev` +- Trace fonctionnelle V0.1 : `M2-reportoire-fournisseurs.docx` / `M2-reportoire-fournisseurs-V01.pdf` + +### Dépendances amont (déjà en place dans Starseed) + +- Module `Commercial` (M1) : `Client*` + référentiels comptables `TvaMode` / `PaymentDelay` / `PaymentType` / `Bank` (**partagés**) +- Module `Catalog` (M0) : `Category` + `CategoryType` (+ seed type FOURNISSEUR au M2) +- Module `Sites` : `Site` (3 sites 86/17/82) — M2M `supplier_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 M2 + +- **M-Commandes fournisseurs** : FK `supplier_id`. +- **M-Réceptions / Triage** : exploitation de `supplier_address.bennes` + `triage_provider`. + +--- + +## 📦 Tickets Lesstime (à découper) + +**TaskGroup Lesstime** : à créer — `M2 — Répertoire fournisseurs` (projet `ERP / Starseed`, projectId=6). + +Ordre indicatif (back avant front, migration en tête) : +0. **Taxonomie FOURNISSEUR (Catalog)** — recréer le `CategoryType` `FOURNISSEUR` (seed migration + fixture idempotente) + **implémenter le filtre `?typeCode=`** sur `/api/categories` (inopérant en prod, ERP-78) + seed catégories fournisseurs (Négociant, Coopérative…). Prérequis du multi-select Catégorie. +1. **Migration BDD M2** (tables supplier + sous-collections + M2M + index partiel + COMMENT ON COLUMN) +2. **Entités + Repositories** (Supplier, SupplierContact, SupplierAddress, SupplierRib) + **fetch-joins liste** (categories, addresses.sites — § 2.12) +3. **Provider + Processor** (SupplierProvider paginé, SupplierProcessor — normalisation, archivage, accounting conditionnel, mode strict) +4. **Sous-ressources** (SupplierContactProcessor, SupplierAddressProcessor, SupplierRibProcessor) +5. **Validators** (SupplierInformationCompletenessValidator, contrôle catégorie type FOURNISSEUR, RG-2.07/2.08) +6. **Export XLSX** (SupplierExportController, priority:1) +7. **RBAC** : `CommercialModule::permissions()` + sync 3 sources + tests personas +8. **Tests PHPUnit** : matrice RG-2.01 → RG-2.17 (§ 8.1) +9. **Front : page Répertoire** (`/suppliers`) + `usePaginatedList` +10. **Front : page Création** (`/suppliers/new`) + `useSupplierForm` +11. **Front : page Consultation** (`/suppliers/{id}`) + onglets placeholder « À venir » +12. **Front : page Modification** (`/suppliers/{id}/edit`) +13. **i18n + Sidebar** (clé `sidebar.commercial.suppliers` + permission, traductions) + +### Actions manuelles dans Lesstime (Matthieu) + +1. Créer le TaskGroup `M2 — Répertoire fournisseurs` (projet ERP / Starseed). +2. Créer les ~14 tickets ci-dessus (ticket 0 taxonomie inclus) avec dépendances séquentielles. +3. Mettre à jour le frontmatter (`lesstime_taskgroup_id`) avec l'id réel. diff --git a/docs/specs/M2-suppliers/spec-front.md b/docs/specs/M2-suppliers/spec-front.md new file mode 100644 index 0000000..7415b22 --- /dev/null +++ b/docs/specs/M2-suppliers/spec-front.md @@ -0,0 +1,331 @@ +--- +# === IDENTITÉ === +module: M2 +nom: "Répertoire fournisseurs" +ecran: repertoire-fournisseurs +owner_spec: Matthieu +backup_spec: Tristan +version: V0.2 +date_redaction: 2026-06-02 +# Historique : V0.2 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline +# du formulaire Supplier (Nom/Prénom/Téléphone/Téléphone 2/Email). Saisie via l'onglet Contacts. +# Aligné sur M1. Cf. docs/specs/M1-clients/refonte-contact/README.md + +# === LIENS === +maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev" +regles_metier: [RG-2.01, RG-2.02, RG-2.03, RG-2.04, RG-2.05, RG-2.06, RG-2.07, RG-2.08, RG-2.09, RG-2.10, RG-2.11, RG-2.12, RG-2.13, RG-2.14, RG-2.15, RG-2.16, RG-2.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)" + resume: "Module 2 — Répertoire fournisseurs. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders 'À venir')." + trace_archivee: "uploads/M2-reportoire-fournisseurs.docx (V0.1) + M2-reportoire-fournisseurs-V01.pdf" + +# === LIEN LESSTIME === +lesstime_taskgroup_id: 26 +lesstime_project_id: 6 +statut_global: a_dev +--- + +# Module 2 — Répertoire fournisseurs (V0.1 front) + +> **Origine** : spec front livrée le 22/05/2026 (V0), amendée le 01/06/2026 (V0.1) — `M2-reportoire-fournisseurs.docx`. Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié ; toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M2 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md). + +## But + +Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des fournisseurs** de l'organisation : consultation, création, modification, archivage. C'est la **deuxième porte d'entrée du module Commercial** (aux côtés des Clients). + +## Accès + +- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire fournisseurs » (route `/suppliers`). +- **Rôles autorisés** : + +| 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** | ❌ (pas d'accès) | ❌ | ❌ | + +> **Note** : RBAC identique au M1, transposée sur `commercial.suppliers.*`. Compta édite uniquement l'onglet Comptabilité (SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) d'un fournisseur existant ; Compta ne peut pas **créer** un fournisseur. **L'archivage est réservé à Admin** (cf. tableau du docx). + +## Navigation + +Page d'entrée du module **Commercial** (route `/suppliers`). Titre : « **Répertoire fournisseurs** ». + +- Affichage principal : un **datatable** listant tous les fournisseurs **actifs** (les archivés sont masqués par défaut — toggle UI dédié). +- **Clic sur une ligne** → écran **Consultation fournisseur** (page dédiée). +- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un fournisseur**. +- **Bouton « Filtrer »** (haut droite, **à côté de « + Ajouter »**) → ouvre le **panneau de filtres** (cf. ci-dessous). Un badge/compteur indique le nombre de filtres actifs ; un bouton « Réinitialiser » les vide. +- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des fournisseurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md). + +### Panneau de filtres (bouton « Filtrer ») + +Ouvre un drawer/popover (composant à confirmer côté équipe front — réutiliser le pattern M1 s'il existe). Filtres proposés, branchés sur les query params de `GET /api/suppliers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) : + +| Filtre | Composant | Query param back | +|---|---|---| +| **Recherche** (nom entreprise / contact / email — recherche contact via `supplier_contact`, décision D1) | `` | `?search=` | +| **Catégorie** | `` (multi, type FOURNISSEUR) | `?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/suppliers` avec les params. +- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6). Le bouton « Filtrer » + son panneau remplacent/regroupent l'ancien toggle « archivés » isolé. + +## Datatable du Répertoire + +Composant : `` branché sur `usePaginatedList({ url: '/suppliers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes **conformes à la maquette Figma** (4 colonnes) : + +| Colonne | Source | Tri | +|---|---|---| +| **Nom** | `supplier.companyName` | ASC par défaut | +| **Catégories** | `supplier.categories[].name` (embarquées en liste — cohérence M1/ERP-62 ; libellé = `name`, pas `label`) | Non | +| **Site** | `supplier.sites[].name` (agrégat des adresses via `getSites()` ; `Site` n'a pas de `code`) | Non | +| **Dernière activité** | `supplier.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `supplier:read` | Oui | + +> **Clic sur une ligne** (texte en bleu lien) → écran Consultation. +> **Filtres** : regroupés dans le panneau du bouton « Filtrer » (cf. section précédente), dont l'inclusion des archivés (désactivée par défaut). **État local** (jamais dans l'URL — règle ABSOLUE n°6). +> **Pagination** : `` + `usePaginatedList`, options **standard Starseed 10 / 25 / 50 (défaut 10)** — on **n'applique pas** le « Ligne : 20 » de la maquette (décision Matthieu : on reste sur le standard). Tri serveur `companyName ASC` par défaut. + +## Écran « Ajouter un fournisseur » + +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). + +**Barre d'onglets en création (5 onglets, conforme maquette)** : `Information` · `Contacts` · `Adresses` · `Transport` · `Comptabilité`. L'onglet `Information` est actif par défaut juste après validation du formulaire principal. Les onglets `Statistiques`, `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification. + +### Formulaire principal (pré-onglets) + +1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/suppliers`, puis bascule sur l'onglet Information ; les champs passent en readonly. + +> **V0.2 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent dans l'onglet **Contacts** (RG-2.04 / RG-2.13). Le formulaire principal ne contient plus que Entreprise + Catégorie. + +| Champ | Type composant | Obligatoire | Règle | +|---|---|---|---| +| **Nom du fournisseur (Entreprise)** | `` | Oui | RG-2.12 (UPPERCASE serveur) | +| **Catégorie** | `` (multi) | Oui | `Category` de **type FOURNISSEUR** via `GET /api/categories?typeCode=FOURNISSEUR` (RG-2.10). Libellé affiché = `category.name`. ⚠️ Le type + le filtre `?typeCode=` sont **à créer** côté back (n'existent pas en prod — cf. spec-back § 2.4). | + +**Action** : « Valider » (``) → POST `/api/suppliers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Information ». + +### Onglet « Information » + +Saisir les informations du fournisseur. + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Description** | `` | Conditionnel | RG-2.03 (obligatoire rôle Commerciale) | +| **Concurrent** | `` | Conditionnel | RG-2.03 | +| **Date création** (entreprise) | `` (exception Malio — `// TODO migrer`) | Conditionnel | RG-2.03 | +| **Nombre de salariés** | `` | Conditionnel | RG-2.03 | +| **CA €** | `` | Conditionnel | RG-2.03 | +| **Dirigeant** | `` | Conditionnel | RG-2.03 | +| **Résultat €** | `` | Conditionnel | RG-2.03 | +| **Volume Prévisionnel** | `` | Conditionnel | RG-2.03 (champ spécifique fournisseur) | + +> **Disposition maquette** : 3 colonnes — ligne 1 (Description / Concurrent / Date création), ligne 2 (Nombre de salariés / CA / Dirigeant), ligne 3 (Résultat / Volume Prévisionnel). + +**Action** : « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:information`). + +### Onglet « Contact » + +Saisir un ou plusieurs contacts. **(V0.2 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-2.13). + +**Bloc Contact** : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Nom** | `` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) | +| **Prénom** | `` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) | +| **Fonction** | `` | Non | — | +| **Téléphone** (x1, +1 possible) | `` | Non | RG-2.12 (format) | +| **Email** | `` type email | Non | RG-2.12 (lowercase) | + +**RG-2.04 / RG-2.13** : au moins 1 bloc Contact valide (Nom OU Prénom rempli) pour valider l'onglet — l'onglet Contact ne peut pas être finalisé vide. + +**Actions** : +- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas Prénom OU Nom** (RG-2.04). +- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc. +- « Valider » → PATCH `/api/suppliers/{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 | +|---|---|---|---| +| **Type d'adresse** | `` — `Prospect` / `Départ` / `Rendu` | Oui | RG-2.09 (exclusif, enum `PROSPECT`/`DEPART`/`RENDU`) | +| **Pays** | `` (saisie assistée — préremplie « France ») | Oui | — | +| **Code postal** | `` (saisie assistée) | Oui | RG-2.05 — déclenche autocomplete ville (BAN) | +| **Ville** | `` (saisie assistée) | Oui | RG-2.05 — alimentée par api-adresse.data.gouv.fr suivant le CP | +| **Adresse** | `` (saisie assistée) | Oui | RG-2.05 — autocomplete BAN | +| **Adresse complémentaire** | `` | Non | — | +| **Sélecteur de site** | `` (86 / 17 / 82) | Oui | RG-2.06 — ≥ 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). | +| **Catégories** | `` (multi) | Oui | Catégories de type FOURNISSEUR (RG-2.10), liées aux catégories du fournisseur | +| **Contact** | `` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact | +| **Benne(s)** | `` (stepper −/+ , défaut 0) | Non | Champ spécifique fournisseur | +| **Prestation de triage** | `` | Non | Champ spécifique fournisseur (porté par l'adresse — colonne back `triage_provider`) | + +> **Disposition maquette par bloc** : ligne 1 = radio (Prospect / Départ / Rendu) + Pays + Code postal ; ligne 2 = Ville + Adresse + Adresse complémentaire ; ligne 3 = sites (86 / 17 / 82) + Catégories + Contact ; ligne 4 = Benne(s) + Prestation de triage. Icône corbeille en haut à droite de chaque bloc pour le supprimer. + +**Actions** : +- « + Nouvelle Adresse » : ajoute un bloc identique au premier. +- « Supprimer » : modal de confirmation puis suppression. +- « Valider » → PATCH `/api/suppliers/{id}/addresses`. + +### Onglet « Transport » + +🚧 **Onglet placeholder minimal au M2.** Conforme à la maquette : la frame est **vide** (aucun champ, aucun bouton de validation, aucune API back). L'onglet reste navigable. Un libellé discret « À venir » est toléré mais non requis (la maquette ne l'affiche pas). Cet onglet **fait partie de la barre de création** (entre Adresses et Comptabilité). + +### Onglet « Comptabilité » + +⚠ **Accessible aux rôles avec `commercial.suppliers.accounting.view`** (Admin + Compta au M2). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un fournisseur (pas de `manage` global). + +**Champs comptables** : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **SIREN** | `` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. § 2.6) | +| **Numéro de compte** | `` | Oui | — | +| **Mode de TVA** | `` | Oui | Liste depuis `/api/tva_modes` (référentiel 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-2.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-2.08) : + +| Champ | Type | Obligatoire | Règle | +|---|---|---|---| +| **Libellé** | `` | Oui (si LCR) | RG-2.08 | +| **BIC** | `` | Oui (si LCR) | RG-2.08 | +| **IBAN** | `` | Oui (si LCR) | RG-2.08 | + +**Actions** : +- « + RIB » : ajoute un bloc. +- « Supprimer » (icône) : modal de confirmation. +- « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:accounting`) + sous-ressource RIBs. + +### Onglets « Statistiques » / « Rapports » / « Échanges » + +🚧 **Placeholders minimaux au M2 — uniquement en Consultation / Modification** (ils n'apparaissent **pas** dans le flux de création, cf. maquette). Frames vides, pas de validation, pas d'API. + +## Écran « Consultation fournisseur » + +Tous les champs en **lecture seule**. 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 `commercial.suppliers.manage`) → écran Modification. +- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `commercial.suppliers.archive`) → modal de confirmation, puis PATCH `/api/suppliers/{id}` `{ "isArchived": true }`. + +> Un fournisseur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. + +### Onglets affichés en consultation + +Information / Contacts / Adresses / Transport / Statistiques / Rapports / Échanges / Comptabilité (les 4 derniers métiers en placeholder « À venir », Comptabilité selon permission). L'utilisateur navigue **librement** entre les onglets (pas de séquence forcée en consultation). + +## Écran « Modification fournisseur » + +Comportement identique à l'écran Ajouter sauf : +- **Pas de formulaire principal** réaffiché (champs principaux édités via les onglets correspondants). +- Les champs sont **pré-remplis** avec les valeurs actuelles. +- **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). +- Les onglets placeholders « À venir » restent non éditables. + +## Composants UI à utiliser (`@malio/layer-ui`) + +- **Datatable** : `` (+ `usePaginatedList`) +- **Input texte** : `` +- **Input numérique** : `` (Nombre de salariés, Volume prévisionnel, Bennes) +- **Input montant** : `` (CA, Résultat) +- **TextArea** : `` (Description) +- **Select simple** : `` (Pays, Ville, référentiels comptables) +- **Select multi (cases à cocher)** : `` (Catégorie, Sites, Contacts rattachés) +- **Radio** : `` (Type d'adresse Prospect / Départ / Rendu — RG-2.09) +- **Checkbox** : `` (Prestataire de triage) +- **Bouton** : ``, `` +- **Toasts** : standards via `useApi()` + +**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) : +- `` pour « Date Création » (`MalioDate` non couvert). +- Modal de confirmation : `` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1 si présent). + +## Composables & appels API + +- `usePaginatedList({ url: '/suppliers' })` — liste paginée (obligatoire, règle frontend). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cohérence M1/ERP-62, cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)). Côté back, fetch-joins anti-N+1. +- `useSupplier(id)` — charge le détail via `GET /api/suppliers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Les écrans Consultation et Modification se peuplent depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1 d'appels). **DoD avant intégration** : vérifier que le JSON réel contient bien ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)). +- `useSupplierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useClientForm()`. +- `useAddressAutocomplete()` — **réutilisé du M1** (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é du M1** pour l'affichage `XX XX XX XX XX`. + +## Règles de formatage et normalisation + +Le serveur normalise systématiquement (RG-2.12 — cf. [`spec-back.md`](./spec-back.md)) : + +| Champ | Normalisation serveur | Affichage front | +|---|---|---| +| Nom fournisseur (`companyName`) | UPPERCASE intégral | UPPERCASE | +| Nom + Prénom contact | Capitalize | identique | +| Téléphones (blocs `SupplierContact`) | 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. Cohérent avec `useApi()`. + +## 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** (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. +- À 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 M1 (clients) + +| Zone | M1 clients | M2 fournisseurs | +|---|---|---| +| Distributeur / Courtier | Auto-référence Client (RG-1.03) | **Absent** | +| Prestation de triage | Booléen sur le client (formulaire principal) | **Booléen sur l'adresse** (`triage_provider`) | +| Type d'adresse | 3 checkboxes Prospect / Livraison / Facturation | **Radio exclusif** Prospect / Départ / Rendu (RG-2.09) | +| Email facturation sur adresse | Oui (conditionnel) | **Absent** | +| Champ adresse « Bennes » | — | **Présent** (nombre) | +| Onglet Information | 7 champs | **8 champs** (ajout « Volume prévisionnel ») | +| Catégories | type unique `CLIENT` (codes ERP-78) | **nouveau type `FOURNISSEUR`** | +| Archivage | Admin | **Admin uniquement** (idem) | +| Onglets « À venir » | frames blanches | **placeholder « À venir »** (minimal) | + +## Points résolus côté back + +| # | Zone d'ombre | Résolution (cf. `spec-back.md`) | +|---|---|---| +| 1 | Catégorie multi-select | M2M `supplier_category`, `Category` de type **FOURNISSEUR** (RG-2.10) | +| 2 | Type d'adresse Prospect/Départ/Rendu | Enum exclusif `address_type` (RG-2.09) | +| 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 » (Transport / Stats / 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 fournisseur uniquement (à valider — § 2.6). SIREN/email non uniques | +| 8 | Référentiels comptables | Réutilisés du M1 (zéro duplication) | +| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1 | +| 10 | Format export | XLSX uniquement (CSV = HP) | + +--- + +## 📦 Tickets Lesstime + +**TaskGroup Lesstime** : à créer — `M2 — Répertoire fournisseurs` (projet `ERP / Starseed`, projectId=6). + +> Détail complet et action manuelle → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper). diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 8a2b0b0..4fa1c99 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -87,7 +87,33 @@ "archiveSuccess": "Client archivé avec succès", "restoreSuccess": "Client restauré avec succès", "error": "Une erreur est survenue. Réessayez.", - "exportError": "L'export du répertoire clients a échoué. Réessayez." + "exportError": "L'export du répertoire clients a échoué. Réessayez.", + "restoreConflict": "Impossible de restaurer : un client actif portant ce nom existe déjà." + }, + "consultation": { + "title": "Consultation client", + "back": "Retour au répertoire", + "loading": "Chargement du client…", + "notFound": "Client introuvable.", + "emptyContacts": "Aucun contact enregistré.", + "emptyAddresses": "Aucune adresse enregistrée.", + "confirmArchive": { + "title": "Archiver le client", + "message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?" + }, + "confirmRestore": { + "title": "Restaurer le client", + "message": "Ce client réapparaîtra dans le répertoire actif. Confirmer la restauration ?" + } + }, + "edit": { + "title": "Modifier le client", + "back": "Retour au répertoire", + "loading": "Chargement du client…", + "notFound": "Client introuvable.", + "emptyContacts": "Aucun contact enregistré.", + "emptyAddresses": "Aucune adresse enregistrée.", + "save": "Valider" }, "validation": { "informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.", diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index 53a93da..3539dbf 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -88,9 +88,11 @@ sur l'input interne, pas sur la cellule de grille. Le wrapper porte le col-span-2, le champ le remplit (w-full). -->
- + props.modelValue) // Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre. const degraded = ref(false) -const cityOptions = ref([]) +// Villes proposees par la BAN (alimentees a la saisie du code postal). +const banCityOptions = ref([]) const addressOptions = ref([]) + +// Options ville effectives : on garantit que la ville courante figure toujours +// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options) +// afficherait un champ vide en lecture seule (consultation 1.11) ou en edition +// d'une adresse existante (1.12), ou la BAN n'a pas (re)peuple les suggestions. +const cityOptions = computed(() => { + const current = props.modelValue.city + if (current && !banCityOptions.value.some(o => o.value === current)) { + return [{ value: current, label: current }, ...banCityOptions.value] + } + return banCityOptions.value +}) const addressLoading = ref(false) // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. let lastAddressSuggestions: AddressSuggestion[] = [] @@ -248,7 +263,7 @@ async function onPostalCodeChange(value: string): Promise { } try { const suggestions = await autocomplete.searchCity(digits) - cityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city })) + banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city })) } catch { enterDegraded() diff --git a/frontend/modules/commercial/composables/__tests__/useClient.spec.ts b/frontend/modules/commercial/composables/__tests__/useClient.spec.ts new file mode 100644 index 0000000..f11b5f0 --- /dev/null +++ b/frontend/modules/commercial/composables/__tests__/useClient.spec.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom). +const mockGet = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: mockPatch, + delete: vi.fn(), +})) + +const { useClient } = await import('../useClient') + +const SAMPLE = { '@id': '/api/clients/42', id: 42, companyName: 'ACME', isArchived: false } + +describe('useClient', () => { + beforeEach(() => { + mockGet.mockReset() + mockPatch.mockReset() + mockGet.mockResolvedValue(SAMPLE) + mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true }) + }) + + it('charge le detail via GET /clients/{id} en Hydra, sans toast', async () => { + const { client, load } = useClient(42) + await load() + + expect(mockGet).toHaveBeenCalledWith( + '/clients/42', + {}, + expect.objectContaining({ + headers: { Accept: 'application/ld+json' }, + toast: false, + }), + ) + expect(client.value).toEqual(SAMPLE) + }) + + it('bascule loading pendant le chargement et le retombe a false', async () => { + const { loading, load } = useClient(42) + const promise = load() + expect(loading.value).toBe(true) + await promise + expect(loading.value).toBe(false) + }) + + it('marque error et laisse client null si le GET echoue (404...)', async () => { + mockGet.mockRejectedValueOnce(new Error('not found')) + const { client, error, load } = useClient(99) + await load() + expect(error.value).toBe(true) + expect(client.value).toBeNull() + }) + + it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => { + // 1er GET = chargement initial, 2e GET = rechargement post-archivage. + mockGet.mockResolvedValueOnce(SAMPLE) + mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true }) + const { client, load, archive } = useClient(42) + await load() + await archive() + + expect(mockPatch).toHaveBeenCalledWith( + '/clients/42', + { isArchived: true }, + expect.objectContaining({ toast: false }), + ) + // Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet). + expect(mockGet).toHaveBeenCalledTimes(2) + expect(client.value?.isArchived).toBe(true) + }) + + it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => { + const { load, restore } = useClient(42) + await load() + await restore() + + expect(mockPatch).toHaveBeenCalledWith( + '/clients/42', + { isArchived: false }, + expect.objectContaining({ toast: false }), + ) + }) + + it('propage l\'erreur (ex: 409 conflit homonyme RG-1.23) au lieu de l\'avaler', async () => { + const conflict = { response: { status: 409 } } + mockPatch.mockRejectedValueOnce(conflict) + const { load, restore } = useClient(42) + await load() + await expect(restore()).rejects.toBe(conflict) + }) +}) diff --git a/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts b/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts new file mode 100644 index 0000000..67202f6 --- /dev/null +++ b/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter +// les appels de chargement des referentiels et simuler un endpoint en echec +// (ex: 403 sur /categories pour un role sans la permission de lecture). +// Meme pattern que useClientsRepository.spec.ts. +const mockGet = vi.hoisted(() => vi.fn()) +vi.stubGlobal('useApi', () => ({ + get: mockGet, + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), +})) + +// Import APRES le stub pour que useApi soit bien resolu au top-level du module. +const { useClientReferentials } = await import('../useClientReferentials') + +describe('useClientReferentials.loadCommon (resilience ERP-102)', () => { + beforeEach(() => { + mockGet.mockReset() + }) + + it('un referentiel en echec (403) ne vide QUE son select, pas les autres', async () => { + // /categories rejette (simulateur d'un 403), tous les autres repondent. + mockGet.mockImplementation((url: string) => { + if (url === '/categories') { + return Promise.reject(new Error('403 Forbidden')) + } + if (url === '/sites') { + return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] }) + } + return Promise.resolve({ + member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }], + }) + }) + + const refs = useClientReferentials() + // loadCommon ne doit JAMAIS rejeter : l'echec d'un referentiel est isole. + await refs.loadCommon() + + // Resilience : les referentiels OK sont peuples malgre l'echec de /categories. + expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }]) + expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) + expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) + + // Seul le select en echec reste vide. + expect(refs.categories.value).toEqual([]) + }) + + it('charge tous les referentiels quand tout repond', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/categories') { + return Promise.resolve({ + member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }], + }) + } + if (url === '/sites') { + return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] }) + } + return Promise.resolve({ member: [] }) + }) + + const refs = useClientReferentials() + await refs.loadCommon() + + expect(refs.categories.value).toEqual([ + { value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }, + ]) + expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }]) + }) +}) diff --git a/frontend/modules/commercial/composables/useClient.ts b/frontend/modules/commercial/composables/useClient.ts new file mode 100644 index 0000000..eff43cc --- /dev/null +++ b/frontend/modules/commercial/composables/useClient.ts @@ -0,0 +1,70 @@ +import { ref } from 'vue' +import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation' + +/** + * Chargement et actions d'archivage d'un client unique (ecran « Consultation + * client », 1.11). Lit le detail embarque via `GET /api/clients/{id}` (contacts / + * adresses / ribs sous `client:item:read` / `client:read:accounting`) et expose + * les bascules d'archivage (PATCH `isArchived` SEUL — tout autre champ => 422). + * + * L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload + * Hydra complet (sans lui, API Platform 4 renvoie une representation reduite). + * + * Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs + * d'archivage/restauration (notamment le 409 RG-1.23 : homonyme actif a la + * restauration) sont PROPAGEES a l'appelant, qui decide du toast a afficher. + */ +export function useClient(id: number | string) { + const api = useApi() + + const client = 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( + `/clients/${id}`, + {}, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + } + + /** Charge le detail du client. En cas d'echec : `error = true`, `client = null`. */ + async function load(): Promise { + loading.value = true + error.value = false + try { + client.value = await fetchDetail() + } + catch { + error.value = true + client.value = null + } + finally { + loading.value = false + } + } + + /** + * Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422), + * puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe + * `client:read` (ni l'embed contacts/adresses/ribs ni les libelles des + * referentiels comptables), un simple merge laisserait l'affichage incoherent. + * Toute erreur (notamment le 409 d'homonyme actif a la restauration, RG-1.23) + * est propagee a l'appelant AVANT le rechargement. + */ + async function setArchived(isArchived: boolean): Promise { + await api.patch(`/clients/${id}`, { isArchived }, { toast: false }) + client.value = await fetchDetail() + } + + return { + client, + loading, + error, + load, + archive: () => setArchived(true), + restore: () => setArchived(false), + } +} diff --git a/frontend/modules/commercial/composables/useClientReferentials.ts b/frontend/modules/commercial/composables/useClientReferentials.ts index 941b111..0f5bb8f 100644 --- a/frontend/modules/commercial/composables/useClientReferentials.ts +++ b/frontend/modules/commercial/composables/useClientReferentials.ts @@ -85,26 +85,32 @@ export function useClientReferentials() { /** * Charge en parallele les referentiels communs (hors distributeurs/courtiers, - * charges a la demande selon la relation choisie). Les selects compta ne sont - * pertinents que si l'utilisateur a acces a l'onglet, mais le cout est - * negligeable et simplifie l'orchestration. + * charges a la demande selon la relation choisie). + * + * Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole. + * Necessaire pour les roles metier qui n'ont pas toutes les permissions de + * lecture — ex. Compta a `commercial.clients.view` (donc /tva_modes, /banks... + * accessibles) mais PAS `catalog.categories.view` ni `sites.view` : sans + * isolation, le 403 sur /categories ferait echouer tout le bloc et viderait + * les selects comptables dont Compta a besoin sur l'ecran de modification. + * Un referentiel en echec reste simplement vide (l'ecran d'edition complete + * l'affichage des valeurs courantes depuis l'embed du detail client). */ async function loadCommon(): Promise { - const [cats, sitesList, tva, delays, types, banksList] = await Promise.all([ - fetchAll('/categories'), - fetchAll('/sites'), - fetchAll('/tva_modes'), - fetchAll('/payment_delays'), - fetchAll('/payment_types'), - fetchAll('/banks'), + await Promise.allSettled([ + fetchAll('/categories') + .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), + fetchAll('/sites') + .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }), + fetchAll('/tva_modes') + .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), + fetchAll('/payment_delays') + .then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }), + fetchAll('/payment_types') + .then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }), + fetchAll('/banks') + .then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }), ]) - - categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) - sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) - tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) - paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) - paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) - banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) } /** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */ diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue new file mode 100644 index 0000000..e1f64e5 --- /dev/null +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -0,0 +1,909 @@ + + + diff --git a/frontend/modules/commercial/pages/clients/[id]/index.vue b/frontend/modules/commercial/pages/clients/[id]/index.vue new file mode 100644 index 0000000..52ea3cd --- /dev/null +++ b/frontend/modules/commercial/pages/clients/[id]/index.vue @@ -0,0 +1,481 @@ + + + diff --git a/frontend/modules/commercial/utils/__tests__/clientConsultation.spec.ts b/frontend/modules/commercial/utils/__tests__/clientConsultation.spec.ts new file mode 100644 index 0000000..fbed776 --- /dev/null +++ b/frontend/modules/commercial/utils/__tests__/clientConsultation.spec.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest' +import { + canEditClient, + categoryOptionsOf, + contactOptionsOf, + iriOf, + mapAccountingDraft, + mapAddressToDraft, + mapAddressView, + mapContactToDraft, + mapRibToDraft, + referentialOptionOf, + relationOf, + showArchiveAction, + showRestoreAction, + siteOptionsOf, + type ClientDetail, +} from '../clientConsultation' + +describe('iriOf', () => { + it('retourne l\'@id d\'une relation embarquee (objet)', () => { + expect(iriOf({ '@id': '/api/payment_types/10', code: 'LCR' })).toBe('/api/payment_types/10') + }) + + it('retourne la chaine telle quelle si la relation est deja un IRI', () => { + expect(iriOf('/api/banks/3')).toBe('/api/banks/3') + }) + + it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => { + expect(iriOf(null)).toBeNull() + expect(iriOf(undefined)).toBeNull() + }) +}) + +describe('relationOf', () => { + it('detecte une relation distributeur et expose son nom', () => { + const client = { distributor: { '@id': '/api/clients/15', companyName: 'DISTRIB GRAND SUD-OUEST' } } as ClientDetail + expect(relationOf(client)).toEqual({ type: 'distributeur', name: 'DISTRIB GRAND SUD-OUEST' }) + }) + + it('detecte une relation courtier et expose son nom', () => { + const client = { broker: { '@id': '/api/clients/16', companyName: 'CABINET LEONARD' } } as ClientDetail + expect(relationOf(client)).toEqual({ type: 'courtier', name: 'CABINET LEONARD' }) + }) + + it('retourne type null quand aucune relation n\'est posee (cles omises)', () => { + expect(relationOf({} as ClientDetail)).toEqual({ type: null, name: null }) + }) +}) + +describe('mapContactToDraft', () => { + it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => { + const draft = mapContactToDraft({ + '@id': '/api/client_contacts/18', + id: 18, + firstName: 'Sophie', + lastName: 'Léonard', + jobTitle: 'Gérante', + phonePrimary: '0549112233', + email: 'sophie@x.fr', + }) + expect(draft.id).toBe(18) + expect(draft.iri).toBe('/api/client_contacts/18') + expect(draft.phonePrimary).toBe('05 49 11 22 33') + expect(draft.hasSecondaryPhone).toBe(false) + }) + + it('revele le 2e telephone quand phoneSecondary est present', () => { + const draft = mapContactToDraft({ + '@id': '/api/client_contacts/19', + id: 19, + phonePrimary: '0600000000', + phoneSecondary: '0611111111', + }) + expect(draft.hasSecondaryPhone).toBe(true) + expect(draft.phoneSecondary).toBe('06 11 11 11 11') + }) +}) + +describe('mapAddressToDraft', () => { + it('extrait les iris de sites / categories / contacts (objets ou chaines)', () => { + const draft = mapAddressToDraft({ + '@id': '/api/client_addresses/18', + id: 18, + country: 'France', + postalCode: '86100', + city: 'Châtellerault', + street: '5 rue des Courtiers', + billingEmail: 'factures@x.fr', + isProspect: false, + isDelivery: false, + isBilling: true, + sites: [{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#056CF2' }], + categories: [{ '@id': '/api/categories/3', code: 'SECTEUR' }], + contacts: [{ '@id': '/api/client_contacts/18' }, '/api/client_contacts/20'], + }) + expect(draft.siteIris).toEqual(['/api/sites/4']) + expect(draft.categoryIris).toEqual(['/api/categories/3']) + expect(draft.contactIris).toEqual(['/api/client_contacts/18', '/api/client_contacts/20']) + expect(draft.isBilling).toBe(true) + expect(draft.city).toBe('Châtellerault') + expect(draft.country).toBe('France') + }) + + it('tolere les sous-collections absentes (defaut tableau vide, pays France)', () => { + const draft = mapAddressToDraft({ '@id': '/api/client_addresses/9', id: 9 }) + expect(draft.siteIris).toEqual([]) + expect(draft.categoryIris).toEqual([]) + expect(draft.contactIris).toEqual([]) + expect(draft.country).toBe('France') + expect(draft.isBilling).toBe(false) + }) +}) + +describe('mapRibToDraft', () => { + it('mappe label / bic / iban et l\'id serveur', () => { + const draft = mapRibToDraft({ '@id': '/api/client_ribs/3', id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' }) + expect(draft).toEqual({ id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' }) + }) +}) + +describe('mapAccountingDraft', () => { + it('mappe les scalaires et resout les iris des referentiels embarques', () => { + const acc = mapAccountingDraft({ + '@id': '/api/clients/1', + id: 1, + siren: '123456789', + accountNumber: '411000', + nTva: 'FR123', + tvaMode: { '@id': '/api/tva_modes/1' }, + paymentDelay: { '@id': '/api/payment_delays/2' }, + paymentType: { '@id': '/api/payment_types/10', code: 'LCR' }, + bank: { '@id': '/api/banks/3' }, + } as ClientDetail) + expect(acc).toEqual({ + siren: '123456789', + accountNumber: '411000', + nTva: 'FR123', + tvaModeIri: '/api/tva_modes/1', + paymentDelayIri: '/api/payment_delays/2', + paymentTypeIri: '/api/payment_types/10', + bankIri: '/api/banks/3', + }) + }) + + it('renvoie des null quand les champs comptables sont absents (sans accounting.view)', () => { + const acc = mapAccountingDraft({} as ClientDetail) + expect(acc).toEqual({ + siren: null, + accountNumber: null, + nTva: null, + tvaModeIri: null, + paymentDelayIri: null, + paymentTypeIri: null, + bankIri: null, + }) + }) +}) + +describe('options construites depuis l\'embed (role-independantes)', () => { + it('categoryOptionsOf expose value=IRI, label=nom, code', () => { + expect(categoryOptionsOf([{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }])).toEqual([ + { value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }, + ]) + }) + + it('siteOptionsOf expose value=IRI, label=nom', () => { + expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([ + { value: '/api/sites/4', label: 'Chatellerault' }, + ]) + }) + + it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => { + expect(contactOptionsOf([ + { '@id': '/api/client_contacts/1', id: 1, firstName: 'Jean', lastName: 'Dupont' }, + { '@id': '/api/client_contacts/2', id: 2, email: 'a@b.fr' }, + ])).toEqual([ + { value: '/api/client_contacts/1', label: 'Jean Dupont' }, + { value: '/api/client_contacts/2', label: 'a@b.fr' }, + ]) + }) + + it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => { + expect(referentialOptionOf({ '@id': '/api/payment_types/10', label: 'LCR' })).toEqual([ + { value: '/api/payment_types/10', label: 'LCR' }, + ]) + expect(referentialOptionOf('/api/banks/3')).toEqual([]) + expect(referentialOptionOf(null)).toEqual([]) + }) + + it('mapAddressView assemble brouillon + options propres a l\'adresse', () => { + const view = mapAddressView({ + '@id': '/api/client_addresses/18', + id: 18, + city: 'Châtellerault', + sites: [{ '@id': '/api/sites/4', name: 'Chatellerault' }], + categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }], + }) + expect(view.draft.id).toBe(18) + expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }]) + expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }]) + }) +}) + +describe('canEditClient', () => { + const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c)) + + it('visible pour manage', () => { + expect(canEditClient(can(['commercial.clients.manage']))).toBe(true) + }) + + it('visible pour accounting.manage (role Compta)', () => { + expect(canEditClient(can(['commercial.clients.accounting.manage']))).toBe(true) + }) + + it('masque sans aucune des deux permissions (role Usine)', () => { + expect(canEditClient(can(['commercial.clients.view']))).toBe(false) + }) +}) + +describe('showArchiveAction / showRestoreAction', () => { + const can = (granted: string[]) => (code: string) => granted.includes(code) + + it('Archiver : visible avec la permission archive ET client non archive', () => { + expect(showArchiveAction(can(['commercial.clients.archive']), false)).toBe(true) + expect(showArchiveAction(can(['commercial.clients.archive']), true)).toBe(false) + expect(showArchiveAction(can([]), false)).toBe(false) + }) + + it('Restaurer : visible avec la permission archive ET client archive', () => { + expect(showRestoreAction(can(['commercial.clients.archive']), true)).toBe(true) + expect(showRestoreAction(can(['commercial.clients.archive']), false)).toBe(false) + expect(showRestoreAction(can([]), true)).toBe(false) + }) +}) diff --git a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts new file mode 100644 index 0000000..5de9d8b --- /dev/null +++ b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest' +import { + buildAccountingPayload, + buildAddressPayload, + buildContactPayload, + buildInformationPayload, + buildMainPayload, + buildRibPayload, + mapAccountingFormDraft, + mapInformationDraft, + mapMainDraft, + resolveTabEditability, + type AccountingFormDraft, + type InformationFormDraft, + type MainFormDraft, +} from '../clientEdit' +import type { ClientDetail } from '../clientConsultation' +import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' + +// ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ───── + +function mainDraft(overrides: Partial = {}): MainFormDraft { + return { + companyName: 'ACME', + firstName: 'Jean', + lastName: 'Dupont', + email: 'jean@acme.fr', + phonePrimary: '05 49 11 22 33', + phoneSecondary: null, + hasSecondaryPhone: false, + categoryIris: ['/api/categories/1'], + relationType: null, + distributorIri: null, + brokerIri: null, + triageService: false, + ...overrides, + } +} + +function informationDraft(overrides: Partial = {}): InformationFormDraft { + return { + description: 'desc', + competitors: 'concurrents', + foundedAt: '2010-05-01', + employeesCount: '42', + revenueAmount: '1000000', + profitAmount: '50000', + directorName: 'PDG', + ...overrides, + } +} + +function accountingDraft(overrides: Partial = {}): AccountingFormDraft { + return { + siren: '123456789', + accountNumber: 'C-001', + nTva: 'FR123', + tvaModeIri: '/api/tva_modes/1', + paymentDelayIri: '/api/payment_delays/1', + paymentTypeIri: '/api/payment_types/1', + bankIri: '/api/banks/1', + ...overrides, + } +} + +// Champs de chaque groupe de serialisation (miroir back ClientProcessor). +const MAIN_KEYS = [ + 'companyName', 'firstName', 'lastName', 'email', 'phonePrimary', + 'phoneSecondary', 'categories', 'distributor', 'broker', 'triageService', +] +const INFORMATION_KEYS = [ + 'description', 'competitors', 'foundedAt', 'employeesCount', + 'revenueAmount', 'profitAmount', 'directorName', +] +const ACCOUNTING_KEYS = ['siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', 'bank'] + +describe('buildMainPayload — scoping strict groupe client:write:main', () => { + it('n\'expose QUE les champs du groupe main (aucune fuite information/accounting)', () => { + expect(Object.keys(buildMainPayload(mainDraft())).sort()).toEqual([...MAIN_KEYS].sort()) + }) + + it('relation distributeur : renseigne distributor, force broker a null (RG-1.03)', () => { + const payload = buildMainPayload(mainDraft({ + relationType: 'distributeur', + distributorIri: '/api/clients/9', + brokerIri: '/api/clients/7', + })) + expect(payload.distributor).toBe('/api/clients/9') + expect(payload.broker).toBeNull() + }) + + it('relation courtier : renseigne broker, force distributor a null (RG-1.03)', () => { + const payload = buildMainPayload(mainDraft({ + relationType: 'courtier', + distributorIri: '/api/clients/9', + brokerIri: '/api/clients/7', + })) + expect(payload.broker).toBe('/api/clients/7') + expect(payload.distributor).toBeNull() + }) + + it('sans relation : distributor et broker a null', () => { + const payload = buildMainPayload(mainDraft({ relationType: null })) + expect(payload.distributor).toBeNull() + expect(payload.broker).toBeNull() + }) + + it('telephone secondaire non revele : envoie null meme si une valeur traine', () => { + const payload = buildMainPayload(mainDraft({ hasSecondaryPhone: false, phoneSecondary: '06 00 00 00 00' })) + expect(payload.phoneSecondary).toBeNull() + }) +}) + +describe('buildInformationPayload — scoping strict groupe client:write:information', () => { + it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => { + expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort()) + }) + + it('convertit employeesCount en nombre et vide -> null', () => { + expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42) + expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull() + expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull() + }) + + it('chaines vides normalisees en null', () => { + const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' })) + expect(payload.description).toBeNull() + expect(payload.directorName).toBeNull() + }) +}) + +describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => { + it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => { + expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort()) + }) + + it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => { + expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1') + expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull() + }) +}) + +describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => { + it('contact : telephone secondaire ignore si non revele', () => { + const contact: ContactFormDraft = { + id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B', + jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000', + email: null, hasSecondaryPhone: false, + } + expect(buildContactPayload(contact).phoneSecondary).toBeNull() + }) + + it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => { + const address: AddressFormDraft = { + id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France', + postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null, + categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [], + billingEmail: 'facturation@acme.fr', + } + expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr') + expect(buildAddressPayload(address, false).billingEmail).toBeNull() + }) + + it('rib : label / bic / iban transmis tels quels', () => { + const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' } + expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }) + }) +}) + +describe('mapMainDraft — pre-remplissage bloc principal', () => { + it('formate les telephones, resout la relation et extrait les IRI', () => { + const client = { + '@id': '/api/clients/1', id: 1, + companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr', + phonePrimary: '0549112233', phoneSecondary: '0600000000', triageService: true, + categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }], + distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' }, + } as ClientDetail + + const draft = mapMainDraft(client) + expect(draft.phonePrimary).toBe('05 49 11 22 33') + expect(draft.phoneSecondary).toBe('06 00 00 00 00') + expect(draft.hasSecondaryPhone).toBe(true) + expect(draft.categoryIris).toEqual(['/api/categories/1']) + expect(draft.relationType).toBe('distributeur') + expect(draft.distributorIri).toBe('/api/clients/9') + expect(draft.brokerIri).toBeNull() + expect(draft.triageService).toBe(true) + }) + + it('gere les cles omises (skip_null_values) sans planter', () => { + const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail) + expect(draft.companyName).toBeNull() + expect(draft.hasSecondaryPhone).toBe(false) + expect(draft.categoryIris).toEqual([]) + expect(draft.relationType).toBeNull() + expect(draft.triageService).toBe(false) + }) +}) + +describe('mapInformationDraft — pre-remplissage onglet Information', () => { + it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => { + const draft = mapInformationDraft({ + '@id': '/api/clients/1', id: 1, + foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000', + } as ClientDetail) + expect(draft.foundedAt).toBe('2010-05-01') + expect(draft.employeesCount).toBe('42') + expect(draft.revenueAmount).toBe('1000000') + }) + + it('cles omises -> null', () => { + const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail) + expect(draft.foundedAt).toBeNull() + expect(draft.employeesCount).toBeNull() + expect(draft.description).toBeNull() + }) +}) + +describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => { + it('extrait les scalaires et les IRI des referentiels embarques', () => { + const draft = mapAccountingFormDraft({ + '@id': '/api/clients/1', id: 1, + siren: '123456789', accountNumber: 'C-001', nTva: 'FR123', + tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' }, + paymentType: '/api/payment_types/3', + } as ClientDetail) + expect(draft.siren).toBe('123456789') + expect(draft.tvaModeIri).toBe('/api/tva_modes/2') + expect(draft.paymentTypeIri).toBe('/api/payment_types/3') + expect(draft.bankIri).toBeNull() + }) +}) + +describe('resolveTabEditability — gating par role (matrice § 2.7)', () => { + it('Admin : tout editable', () => { + expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true })) + .toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true }) + }) + + it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => { + expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false })) + .toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false }) + }) + + it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => { + expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true })) + .toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true }) + }) + + it('Sans permission d\'edition : rien d\'editable', () => { + expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false })) + .toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false }) + }) +}) diff --git a/frontend/modules/commercial/utils/clientConsultation.ts b/frontend/modules/commercial/utils/clientConsultation.ts new file mode 100644 index 0000000..a5b0ada --- /dev/null +++ b/frontend/modules/commercial/utils/clientConsultation.ts @@ -0,0 +1,321 @@ +/** + * Helpers purs de l'ecran « Consultation client » (M1 Commercial, lecture seule). + * + * Mappent le payload `GET /api/clients/{id}` (relations embarquees, cf. groupe + * `client:item:read` + `client:read:accounting`) vers les brouillons « plats » + * partages avec les blocs reutilisables `ClientContactBlock` / `ClientAddressBlock` + * et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables + * unitairement (cf. clientConsultation.spec.ts). + * + * Rappels de contrat back (verifies sur l'API reelle) : + * - les relations ManyToOne (distributor/broker/tvaMode/paymentType/...) sont + * serialisees en OBJETS embarques (avec @id + companyName/code/label), pas en IRI nu ; + * - les champs nuls sont OMIS du JSON (skip_null_values) → toujours lire avec `?? null` ; + * - les champs comptables et `ribs` sont TOTALEMENT ABSENTS sans permission + * accounting.view (gate serveur via ClientReadGroupContextBuilder). + */ + +import { formatPhoneFR } from '~/shared/utils/phone' +import type { + AddressFormDraft, + ContactFormDraft, + RibFormDraft, +} from '~/modules/commercial/types/clientForm' + +/** 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 dans une adresse (groupe site:read). */ +export interface SiteRead extends HydraRef { + name?: string + color?: string +} + +/** Categorie embarquee (groupe category:read). */ +export interface CategoryRead extends HydraRef { + code?: string + name?: string +} + +/** Contact embarque (groupe client_contact: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 client_address:read). */ +export interface AddressRead extends HydraRef { + id: number + country?: string | null + postalCode?: string | null + city?: string | null + street?: string | null + streetComplement?: string | null + billingEmail?: string | null + isProspect?: boolean + isDelivery?: boolean + isBilling?: boolean + 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 client:read:accounting, present ssi accounting.view). */ +export interface RibRead extends HydraRef { + id: number + label?: string | null + bic?: string | null + iban?: string | null +} + +/** Client relie (distributeur / courtier) embarque (groupe client:read). */ +export interface RelatedClientRead extends HydraRef { + companyName?: string | null +} + +/** + * Detail d'un client tel que renvoye par `GET /api/clients/{id}`. Tous les + * champs sont optionnels : skip_null_values cote serveur et gating accounting + * peuvent omettre n'importe quelle cle. + */ +export interface ClientDetail extends HydraRef { + id: number + companyName?: string | null + firstName?: string | null + lastName?: string | null + phonePrimary?: string | null + phoneSecondary?: string | null + email?: string | null + triageService?: boolean + isArchived?: boolean + categories?: CategoryRead[] + distributor?: RelatedClientRead | string | null + broker?: RelatedClientRead | string | null + contacts?: ContactRead[] + addresses?: AddressRead[] + ribs?: RibRead[] + // Onglet Information + description?: string | null + competitors?: string | null + foundedAt?: string | null + employeesCount?: number | null + revenueAmount?: string | null + profitAmount?: string | null + directorName?: string | null + // Onglet Comptabilite (present ssi accounting.view) + siren?: string | null + accountNumber?: string | null + nTva?: string | null + tvaMode?: Relation + paymentDelay?: Relation + paymentType?: Relation + bank?: Relation +} + +/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire 1.10). */ +export interface AccountingDraft { + siren: string | null + accountNumber: string | null + nTva: string | null + tvaModeIri: string | null + paymentDelayIri: string | null + paymentTypeIri: string | null + bankIri: string | null +} + +/** Relation Distributeur/Courtier resolue pour l'affichage en lecture seule. */ +export interface ClientRelation { + type: 'distributeur' | 'courtier' | null + name: string | null +} + +/** Option de select ({ value, label }) construite a partir de l'embed. */ +export interface SelectOption { + value: string + label: string +} + +/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */ +export interface CategorySelectOption extends SelectOption { + code: string +} + +/** + * Vue d'une adresse pour la consultation : le brouillon + ses options de select + * construites a partir de l'embed (sites/categories propres a CETTE adresse). + */ +export interface AddressView { + draft: AddressFormDraft + siteOptions: SelectOption[] + categoryOptions: CategorySelectOption[] +} + +/** 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 +} + +/** + * Resout la relation Distributeur/Courtier (RG-1.03 : mutuellement exclusives). + * Le nom est lu sur l'objet embarque (`companyName`) ; null si la relation est + * un IRI nu ou absente. + */ +export function relationOf(client: ClientDetail): ClientRelation { + const nameOf = (rel: RelatedClientRead | string | null | undefined): string | null => + rel && typeof rel === 'object' ? (rel.companyName ?? null) : null + + if (client.distributor) { + return { type: 'distributeur', name: nameOf(client.distributor) } + } + if (client.broker) { + return { type: 'courtier', name: nameOf(client.broker) } + } + return { type: null, name: null } +} + +/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */ +export function mapContactToDraft(contact: ContactRead): ContactFormDraft { + 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): AddressFormDraft { + return { + id: address.id, + isProspect: address.isProspect ?? false, + isDelivery: address.isDelivery ?? false, + isBilling: address.isBilling ?? false, + 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'])), + billingEmail: address.billingEmail ?? null, + } +} + +/** Mappe un RIB embarque vers un brouillon. */ +export function mapRibToDraft(rib: RibRead): RibFormDraft { + return { + id: rib.id, + label: rib.label ?? null, + bic: rib.bic ?? null, + iban: rib.iban ?? null, + } +} + +/** Mappe les champs comptables du client (scalaires + IRI des referentiels). */ +export function mapAccountingDraft(client: ClientDetail): AccountingDraft { + return { + siren: client.siren ?? null, + accountNumber: client.accountNumber ?? null, + nTva: client.nTva ?? null, + tvaModeIri: iriOf(client.tvaMode), + paymentDelayIri: iriOf(client.paymentDelay), + paymentTypeIri: iriOf(client.paymentType), + bankIri: iriOf(client.bank), + } +} + +/** + * Options de categories (value=IRI, label=nom, code) construites depuis l'embed. + * Source role-independante : evite de dependre de `GET /categories` (403 pour les + * roles metier non-admin), qui laisserait les libelles vides. + */ +export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] { + return (categories ?? []).map(c => ({ + value: c['@id'], + label: c.name ?? c.code ?? c['@id'], + code: c.code ?? '', + })) +} + +/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */ +export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] { + 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 client. */ +export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] { + 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 (`label` ou `name`), jamais d'un + * `GET` de referentiel — l'affichage reste correct quel que soit le role. + */ +export function referentialOptionOf(relation: Relation): SelectOption[] { + 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 }] +} + +/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */ +export function mapAddressView(address: AddressRead): AddressView { + return { + draft: mapAddressToDraft(address), + siteOptions: siteOptionsOf(address.sites), + categoryOptions: categoryOptionsOf(address.categories), + } +} + +/** + * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet + * — `manage` (formulaire/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 (1.12). + */ +export function canEditClient(canAny: (codes: string[]) => boolean): boolean { + return canAny(['commercial.clients.manage', 'commercial.clients.accounting.manage']) +} + +/** Bouton « Archiver » : permission archive ET client encore actif. */ +export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('commercial.clients.archive') && !isArchived +} + +/** Bouton « Restaurer » : permission archive ET client deja archive. */ +export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('commercial.clients.archive') && isArchived +} diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts new file mode 100644 index 0000000..9031076 --- /dev/null +++ b/frontend/modules/commercial/utils/clientEdit.ts @@ -0,0 +1,266 @@ +/** + * Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12). + * + * Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) : + * 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed + * contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites + * par la page et les blocs reutilisables (mappers contacts/adresses/ribs/ + * comptabilite reutilises depuis clientConsultation). + * 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / ERP-74) : chaque + * onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un + * payload mixte — un champ hors-permission = 403 sur l'integralite cote back. + * + * Ces helpers ne touchent ni a l'API ni a l'etat reactif. + * + * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON + * miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de + * role et Bureau partage les permissions de Commerciale. Le back l'applique de + * maniere fiable (422) ; on laisse remonter ce 422 en toast. + */ + +import { + iriOf, + relationOf, + type ClientDetail, +} from '~/modules/commercial/utils/clientConsultation' +import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' +import { formatPhoneFR } from '~/shared/utils/phone' + +/** + * Etat « plat » du bloc principal (groupe client:write:main). Distinct des + * brouillons Contact : ces champs vivent sur le Client lui-meme (companyName, + * contact principal, telephones, email, categories, relation, triage), pas sur + * une sous-ressource ClientContact. + */ +export interface MainFormDraft { + companyName: string | null + firstName: string | null + lastName: string | null + email: string | null + phonePrimary: string | null + phoneSecondary: string | null + /** UI : le 2e numero a ete revele (ou existait deja au chargement). */ + hasSecondaryPhone: boolean + /** IRI des categories rattachees (M2M). */ + categoryIris: string[] + relationType: 'distributeur' | 'courtier' | null + distributorIri: string | null + brokerIri: string | null + triageService: boolean +} + +/** Etat « plat » de l'onglet Information (groupe client:write:information). */ +export interface InformationFormDraft { + description: string | null + competitors: string | null + /** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */ + foundedAt: string | null + /** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */ + employeesCount: string | null + revenueAmount: string | null + profitAmount: string | null + directorName: string | null +} + +/** Etat « plat » de l'onglet Comptabilite (groupe client:write:accounting). */ +export interface AccountingFormDraft { + siren: string | null + accountNumber: string | null + nTva: string | null + tvaModeIri: string | null + paymentDelayIri: string | null + paymentTypeIri: string | null + bankIri: string | null +} + +/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un client. */ +export interface ClientEditAbilities { + /** `commercial.clients.manage` : bloc principal + onglets metier. */ + canManage: boolean + /** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */ + canAccountingView: boolean + /** `commercial.clients.accounting.manage` : edition de l'onglet Comptabilite. */ + canAccountingManage: boolean +} + +/** Editabilite resolue par zone d'onglet (deduite des permissions). */ +export interface TabEditability { + /** Bloc principal + onglets Information / Contact / Adresse editables. */ + businessEditable: boolean + /** Onglet Comptabilite present (affiche). */ + accountingVisible: boolean + /** Onglet Comptabilite editable. */ + accountingEditable: boolean +} + +// ── Pre-remplissage (GET detail -> brouillons) ────────────────────────────── + +/** + * Mappe le detail client vers le brouillon du bloc principal. Les telephones + * sont reformates XX XX XX XX XX (RG d'affichage). La relation Distributeur/ + * Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait de l'embed. + */ +export function mapMainDraft(client: ClientDetail): MainFormDraft { + const relation = relationOf(client) + const phoneSecondary = client.phoneSecondary ?? null + + return { + companyName: client.companyName ?? null, + firstName: client.firstName ?? null, + lastName: client.lastName ?? null, + email: client.email ?? null, + phonePrimary: client.phonePrimary ? formatPhoneFR(client.phonePrimary) : null, + phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null, + hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '', + categoryIris: (client.categories ?? []).map(c => c['@id']), + relationType: relation.type, + distributorIri: iriOf(client.distributor), + brokerIri: iriOf(client.broker), + triageService: client.triageService === true, + } +} + +/** Mappe le detail client vers le brouillon de l'onglet Information. */ +export function mapInformationDraft(client: ClientDetail): InformationFormDraft { + return { + description: client.description ?? null, + competitors: client.competitors ?? null, + // MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime. + foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null, + employeesCount: client.employeesCount != null ? String(client.employeesCount) : null, + revenueAmount: client.revenueAmount ?? null, + profitAmount: client.profitAmount ?? null, + directorName: client.directorName ?? null, + } +} + +/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */ +export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft { + return { + siren: client.siren ?? null, + accountNumber: client.accountNumber ?? null, + nTva: client.nTva ?? null, + tvaModeIri: iriOf(client.tvaMode), + paymentDelayIri: iriOf(client.paymentDelay), + paymentTypeIri: iriOf(client.paymentType), + bankIri: iriOf(client.bank), + } +} + +// ── Scoping strict des payloads PATCH ──────────────────────────────────────── + +/** + * Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation + * Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne + * que la FK correspondant au type choisi, l'autre est forcee a null. + */ +export function buildMainPayload(main: MainFormDraft): Record { + return { + companyName: main.companyName, + firstName: main.firstName || null, + lastName: main.lastName || null, + email: main.email, + phonePrimary: main.phonePrimary || null, + phoneSecondary: main.hasSecondaryPhone ? (main.phoneSecondary || null) : null, + categories: main.categoryIris, + distributor: main.relationType === 'distributeur' ? main.distributorIri : null, + broker: main.relationType === 'courtier' ? main.brokerIri : null, + triageService: main.triageService, + } +} + +/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */ +export function buildInformationPayload(information: InformationFormDraft): Record { + return { + description: information.description || null, + competitors: information.competitors || null, + foundedAt: information.foundedAt || null, + employeesCount: information.employeesCount ? Number(information.employeesCount) : null, + revenueAmount: information.revenueAmount || null, + profitAmount: information.profitAmount || null, + directorName: information.directorName || null, + } +} + +/** + * Payload des scalaires de l'onglet Comptabilite — groupe client:write:accounting + * UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque + * n'a de sens que pour un Virement (RG-1.12) : forcee a null sinon. + */ +export function buildAccountingPayload( + accounting: AccountingFormDraft, + 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 contact (sous-ressource client_contact). */ +export function buildContactPayload(contact: ContactFormDraft): 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, + } +} + +/** Payload d'une adresse (sous-ressource client_address). */ +export function buildAddressPayload( + address: AddressFormDraft, + isBillingEmailRequired: boolean, +): Record { + return { + isProspect: address.isProspect, + isDelivery: address.isDelivery, + isBilling: address.isBilling, + 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, + billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, + } +} + +/** Payload d'un RIB (sous-ressource client_rib). */ +export function buildRibPayload(rib: RibFormDraft): Record { + return { + label: rib.label, + bic: rib.bic, + iban: rib.iban, + } +} + +// ── Gating par permission ──────────────────────────────────────────────────── + +/** + * Resout l'editabilite par zone a partir des permissions (option 1 ERP-74, + * miroir UI du re-gating champ-par-champ du ClientProcessor) : + * - bloc principal + Information/Contact/Adresse : editables ssi `manage` ; + * - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`. + * + * Produit le comportement attendu : + * - Admin : tout editable. + * - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee. + * - Compta (accounting seul, sans manage) : metier readonly, Compta editable. + */ +export function resolveTabEditability(abilities: ClientEditAbilities): TabEditability { + return { + businessEditable: abilities.canManage, + accountingVisible: abilities.canAccountingView, + accountingEditable: abilities.canAccountingManage, + } +} diff --git a/migrations/Version20260603120000.php b/migrations/Version20260603120000.php new file mode 100644 index 0000000..8b41584 --- /dev/null +++ b/migrations/Version20260603120000.php @@ -0,0 +1,131 @@ + ce DROP s'executerait avant le CREATE TABLE client + * (Version20260601000000). Le namespace racine garantit l'ordre par timestamp. + */ +final class Version20260603120000 extends AbstractMigration +{ + /** Colonnes de contact inline supprimees du `client`. */ + private const array INLINE_CONTACT_COLUMNS = [ + 'first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email', + ]; + + public function getDescription(): string + { + return 'M1 : suppression du contact inline du Client (backfill vers client_contact puis DROP des 5 colonnes).'; + } + + public function up(Schema $schema): void + { + // 1. Backfill : tout client SANS contact recoit une ligne client_contact + // (position 0) reprenant ses champs inline. phone_primary / email du + // client sont NOT NULL -> toujours une donnee a reporter. Le CHECK + // chk_client_contact_name (first_name OU last_name) est garanti par le + // fallback company_name si jamais les deux noms etaient null (cas qui ne + // devrait pas exister, RG-1.01 ayant ete appliquee a l'ecriture). + // created_at/updated_at NOT NULL -> NOW() ; created_by/updated_by null + // (backfill hors contexte HTTP, libelle « Systeme » cote front). + $this->addSql(<<<'SQL' + INSERT INTO client_contact ( + client_id, first_name, last_name, phone_primary, phone_secondary, + email, position, created_at, updated_at + ) + SELECT + c.id, + c.first_name, + CASE + WHEN c.first_name IS NULL AND c.last_name IS NULL THEN c.company_name + ELSE c.last_name + END, + c.phone_primary, + c.phone_secondary, + c.email, + 0, + NOW(), + NOW() + FROM client c + WHERE NOT EXISTS ( + SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id + ) + SQL); + + // 2. DROP des 5 colonnes inline (rien a documenter : suppression). + $this->addSql(<<<'SQL' + ALTER TABLE client + DROP COLUMN first_name, + DROP COLUMN last_name, + DROP COLUMN phone_primary, + DROP COLUMN phone_secondary, + DROP COLUMN email + SQL); + } + + public function down(Schema $schema): void + { + // Best-effort : on RECREE les 5 colonnes (en NULLABLE — l'etat NOT NULL + // d'origine de phone_primary/email ne peut etre restaure sur une table + // peuplee sans risque) et on retro-alimente depuis le contact principal + // (position minimale) de chaque client. La donnee n'est pas garantie + // identique a l'origine : ce down() sert au rollback technique, pas a une + // restauration fidele. + $this->addSql('ALTER TABLE client ADD COLUMN first_name VARCHAR(120) DEFAULT NULL'); + $this->addSql('ALTER TABLE client ADD COLUMN last_name VARCHAR(120) DEFAULT NULL'); + $this->addSql('ALTER TABLE client ADD COLUMN phone_primary VARCHAR(20) DEFAULT NULL'); + $this->addSql('ALTER TABLE client ADD COLUMN phone_secondary VARCHAR(20) DEFAULT NULL'); + $this->addSql('ALTER TABLE client ADD COLUMN email VARCHAR(180) DEFAULT NULL'); + + // Retro-alimentation depuis le contact de position la plus basse. + $this->addSql(<<<'SQL' + UPDATE client c SET + first_name = cc.first_name, + last_name = cc.last_name, + phone_primary = cc.phone_primary, + phone_secondary = cc.phone_secondary, + email = cc.email + FROM ( + SELECT DISTINCT ON (client_id) + client_id, first_name, last_name, phone_primary, phone_secondary, email + FROM client_contact + ORDER BY client_id, position ASC, id ASC + ) cc + WHERE cc.client_id = c.id + SQL); + + // Re-pose des commentaires d'origine (regle ABSOLUE n°12) — dollar-quoting + // Postgres pour eviter tout echappement d apostrophe. + $this->addSql('COMMENT ON COLUMN client.first_name IS $_$Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$'); + $this->addSql('COMMENT ON COLUMN client.last_name IS $_$Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$'); + $this->addSql('COMMENT ON COLUMN client.phone_primary IS $_$Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.$_$'); + $this->addSql('COMMENT ON COLUMN client.phone_secondary IS $_$Telephone secondaire optionnel — chiffres uniquement (RG-1.20).$_$'); + $this->addSql('COMMENT ON COLUMN client.email IS $_$Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).$_$'); + } +} diff --git a/src/Module/Catalog/CatalogModule.php b/src/Module/Catalog/CatalogModule.php index 1bae95f..1465e57 100644 --- a/src/Module/Catalog/CatalogModule.php +++ b/src/Module/Catalog/CatalogModule.php @@ -38,6 +38,11 @@ final class CatalogModule return [ ['code' => 'catalog.categories.view', 'label' => 'Voir les categories'], ['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'], + // Lecture-referentiel transverse (ERP-102) : permet de LISTER les categories + // pour alimenter les selects des modules Tiers (clients, fournisseurs...), + // sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue + // dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7. + ['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'], ]; } } diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 3c55a94..6f1648c 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -42,13 +42,19 @@ use Symfony\Component\Validator\Constraints as Assert; */ #[ApiResource( operations: [ + // Lecture (liste + item) : permission d'administration `view` OU permission + // de lecture-referentiel transverse `read_ref` (ERP-102). Les referentiels + // categories sont consommes par les modules Tiers (selects creation/filtre + // client) : tout role qui gere des tiers doit pouvoir les lire sans porter + // l'acces admin du Catalogue. `read_ref` est une permission Catalog (pas un + // code d'un autre module) -> isolement inter-module preserve. new GetCollection( - security: "is_granted('catalog.categories.view')", + security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')", normalizationContext: ['groups' => ['category:read', 'default:read']], provider: CategoryProvider::class, ), new Get( - security: "is_granted('catalog.categories.view')", + security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')", normalizationContext: ['groups' => ['category:read', 'default:read']], provider: CategoryProvider::class, ), diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index bdcac63..1a26cd4 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -151,31 +151,9 @@ class Client implements TimestampableInterface, BlamableInterface #[Groups(['client:read', 'client:write:main'])] private ?string $companyName = null; - // RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor). - #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] - #[Groups(['client:read', 'client:write:main'])] - private ?string $firstName = null; - - #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] - #[Groups(['client:read', 'client:write:main'])] - private ?string $lastName = null; - - #[ORM\Column(length: 20)] - #[Assert\NotBlank] - #[Groups(['client:read', 'client:write:main'])] - private ?string $phonePrimary = null; - - #[ORM\Column(length: 20, nullable: true)] - #[Groups(['client:read', 'client:write:main'])] - private ?string $phoneSecondary = null; - - #[ORM\Column(length: 180)] - #[Assert\NotBlank] - #[Assert\Email] - #[Groups(['client:read', 'client:write:main'])] - private ?string $email = null; + // Le contact principal n'est plus porte inline par le Client : les contacts + // vivent uniquement dans ClientContact (onglet Contact). RG-1.01 / RG-1.02 + // supprimees du Client (equivalent RG-1.05 / RG-1.14 sur ClientContact). // RG-1.03 : distributor / broker auto-references mutuellement exclusives // (CHECK chk_client_distrib_or_broker en base). @@ -326,66 +304,6 @@ class Client implements TimestampableInterface, BlamableInterface 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 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 getDistributor(): ?Client { return $this->distributor; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php index fa1f6c8..293900f 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -29,7 +29,7 @@ use Symfony\Component\Validator\ConstraintViolationList; /** * Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 / - * § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28. + * § 2.9 / § 4.3 / § 4.4 + RG-1.03 a RG-1.28 (RG-1.01/1.02 supprimees : contact inline retire). * * Sequence (POST / PATCH) : * 1. Autorisation additionnelle par groupe d'onglet. La security d'operation @@ -41,7 +41,7 @@ use Symfony\Component\Validator\ConstraintViolationList; * - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et * interdit toute autre modification dans la meme requete (RG-1.22, 422). * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. - * 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker + * 3. Regles metier : RG-1.03 (distributor/broker * exclusifs + type de categorie), RG-1.12 (Virement -> banque), * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST * et tout PATCH pour le role Commerciale). @@ -60,8 +60,7 @@ final class ClientProcessor implements ProcessorInterface { /** Champs de l'onglet principal (groupe client:write:main). */ private const array MAIN_FIELDS = [ - 'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary', - 'email', 'distributor', 'broker', 'triageService', 'categories', + 'companyName', 'distributor', 'broker', 'triageService', 'categories', ]; /** Champs de l'onglet Information (groupe client:write:information). */ @@ -124,7 +123,6 @@ final class ClientProcessor implements ProcessorInterface // valeurs normalisees des deux cotes (l'etat persiste l'a deja ete). $this->guardManage($data); - $this->validateMainContact($data); $this->validateDistributorBroker($data); $this->validateAccountingConsistency($data); $this->validateInformationCompleteness($data); @@ -274,11 +272,6 @@ final class ClientProcessor implements ProcessorInterface { $newValues = [ 'companyName' => $data->getCompanyName(), - 'firstName' => $data->getFirstName(), - 'lastName' => $data->getLastName(), - 'phonePrimary' => $data->getPhonePrimary(), - 'phoneSecondary' => $data->getPhoneSecondary(), - 'email' => $data->getEmail(), 'distributor' => $data->getDistributor(), 'broker' => $data->getBroker(), 'triageService' => $data->isTriageService(), @@ -420,39 +413,17 @@ final class ClientProcessor implements ProcessorInterface } /** - * Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables - * (companyName, email, phonePrimary) ne sont touches que si une valeur est - * presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel. + * Normalisation serveur du formulaire principal (RG-1.18). Seul companyName + * subsiste cote Client depuis la suppression du contact inline (les champs de + * contact — noms, telephones, email — sont normalises par ClientContactProcessor). + * 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(Client $data): void { if (null !== $data->getCompanyName()) { $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName())); } - if (null !== $data->getEmail()) { - $data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail())); - } - if (null !== $data->getPhonePrimary()) { - $data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary())); - } - - $data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName())); - $data->setLastName($this->normalizer->normalizePersonName($data->getLastName())); - $data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary())); - } - - /** - * RG-1.01 : au moins le prenom OU le nom du contact principal. - */ - private function validateMainContact(Client $data): void - { - if (null === $data->getFirstName() && null === $data->getLastName()) { - $this->throwViolation( - 'firstName', - 'Le prénom ou le nom du contact principal est obligatoire.', - $data, - ); - } } /** diff --git a/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php index 6bb0c50..153fb0b 100644 --- a/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php +++ b/src/Module/Commercial/Infrastructure/Controller/ClientExportController.php @@ -86,7 +86,9 @@ final class ClientExportController } /** - * Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la + * Colonnes de l'export. Depuis la suppression du contact inline (refonte + * contact, D2), les colonnes de contact principal sont retirees : l'export + * ne porte plus que les donnees propres au Client. SIREN inseree avant la * date de creation, uniquement si l'utilisateur a accounting.view. * * @return list @@ -95,11 +97,6 @@ final class ClientExportController { $headers = [ 'Nom entreprise', - 'Nom contact principal', - 'Prénom', - 'Téléphone principal', - 'Téléphone secondaire', - 'Email', 'Catégories', 'Sites', ]; @@ -123,11 +120,6 @@ final class ClientExportController foreach ($clients as $client) { $row = [ $client->getCompanyName(), - $client->getLastName(), - $client->getFirstName(), - $client->getPhonePrimary(), - $client->getPhoneSecondary(), - $client->getEmail(), $this->formatCategories($client), $this->formatSites($client), ]; diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php index 8e77ccc..40a899f 100644 --- a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php +++ b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php @@ -112,10 +112,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$gso, $gsoIsNew] = $this->ensureClient( $manager, companyName: 'Distrib Grand Sud-Ouest', - firstName: 'Paul', - lastName: 'Garnier', - phonePrimary: '05 56 10 20 30', - email: 'contact@distrib-gso.fr', categoryNames: ['Distributeur'], ); if ($gsoIsNew) { @@ -127,10 +123,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$leonard, $leonardIsNew] = $this->ensureClient( $manager, companyName: 'Cabinet Léonard Assurances', - firstName: 'Sophie', - lastName: 'Léonard', - phonePrimary: '05 49 11 22 33', - email: 'contact@cabinet-leonard.fr', categoryNames: ['Courtier'], ); if ($leonardIsNew) { @@ -142,10 +134,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$dubois, $isNew] = $this->ensureClient( $manager, companyName: 'Menuiserie Dubois', - firstName: 'Jean', - lastName: 'Dubois', - phonePrimary: '05 49 00 00 01', - email: 'contact@menuiserie-dubois.fr', categoryNames: ['BTP'], ); if ($isNew) { @@ -159,10 +147,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$garage, $isNew] = $this->ensureClient( $manager, companyName: 'Garage Martin', - firstName: 'Luc', - lastName: 'Martin', - phonePrimary: '05 56 44 55 66', - email: 'accueil@garage-martin.fr', categoryNames: ['Services'], ); if ($isNew) { @@ -175,10 +159,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$boulangerie, $isNew] = $this->ensureClient( $manager, companyName: 'Boulangerie Lemoine', - firstName: 'Marie', - lastName: 'Lemoine', - phonePrimary: '05 49 77 88 99', - email: 'bonjour@boulangerie-lemoine.fr', categoryNames: ['Agro-alimentaire'], ); if ($isNew) { @@ -191,10 +171,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$transports, $isNew] = $this->ensureClient( $manager, companyName: 'Transports Rapides', - firstName: null, - lastName: 'Bernard', - phonePrimary: '05 56 12 13 14', - email: 'exploitation@transports-rapides.fr', categoryNames: ['Transport/Logistique'], ); if ($isNew) { @@ -209,10 +185,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$industries, $isNew] = $this->ensureClient( $manager, companyName: 'Industries Vertes', - firstName: 'Claire', - lastName: 'Moreau', - phonePrimary: '05 49 21 22 23', - email: 'contact@industries-vertes.fr', categoryNames: ['Industrie'], ); if ($isNew) { @@ -229,12 +201,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$agro, $isNew] = $this->ensureClient( $manager, companyName: 'Agro Distribution Sud', - firstName: 'Thomas', - lastName: 'Petit', - phonePrimary: '05 56 31 32 33', - email: 'contact@agro-sud.fr', categoryNames: ['Agro-alimentaire'], - phoneSecondary: '06 01 02 03 04', ); if ($isNew) { $this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0); @@ -247,10 +214,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$ancienne, $isNew] = $this->ensureClient( $manager, companyName: 'Ancienne Société Oubliée', - firstName: null, - lastName: 'Durand', - phonePrimary: '05 49 99 99 99', - email: 'contact@ancienne-societe.fr', categoryNames: ['Association'], isArchived: true, ); @@ -263,10 +226,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$services, $isNew] = $this->ensureClient( $manager, companyName: 'Services Pro Conseil', - firstName: 'Nadia', - lastName: 'Benali', - phonePrimary: '05 49 41 42 43', - email: 'contact@services-pro.fr', categoryNames: ['Services'], ); if ($isNew) { @@ -279,10 +238,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$holding, $isNew] = $this->ensureClient( $manager, companyName: 'Holding Premium Invest', - firstName: 'Antoine', - lastName: 'Lefèvre', - phonePrimary: '05 56 51 52 53', - email: 'direction@holding-premium.fr', categoryNames: ['Industrie'], ); if ($isNew) { @@ -301,10 +256,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$conglo, $isNew] = $this->ensureClient( $manager, companyName: 'Conglomérat Multi Activités', - firstName: 'Hélène', - lastName: 'Faure', - phonePrimary: '05 49 61 62 63', - email: 'contact@conglomerat-multi.fr', categoryNames: ['BTP', 'Industrie', 'Services'], ); if ($isNew) { @@ -316,10 +267,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$prospect, $isNew] = $this->ensureClient( $manager, companyName: 'Prospect Futur Client', - firstName: 'Olivier', - lastName: 'Renard', - phonePrimary: '05 56 71 72 73', - email: 'olivier.renard@prospect-futur.fr', categoryNames: ['BTP'], ); if ($isNew) { @@ -331,10 +278,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface [$association, $isNew] = $this->ensureClient( $manager, companyName: 'Association des Riverains', - firstName: null, - lastName: 'Caron', - phonePrimary: '05 49 81 82 83', - email: 'contact@asso-riverains.fr', categoryNames: ['Association'], ); if ($isNew) { @@ -350,6 +293,9 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface * sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la * reconstruction des sous-collections (idempotence sans doublon). * + * Le contact principal n'est plus porte par le Client (refonte contact) : les + * coordonnees de contact sont fournies via addContact() dans le bloc isNew. + * * @param list $categoryNames * * @return array{0: Client, 1: bool} @@ -357,12 +303,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface private function ensureClient( ObjectManager $manager, string $companyName, - ?string $firstName, - ?string $lastName, - string $phonePrimary, - string $email, array $categoryNames, - ?string $phoneSecondary = null, bool $isArchived = false, ): array { $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName); @@ -374,11 +315,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface $client = new Client(); $client->setCompanyName($normalizedName); - $client->setFirstName($this->normalizer->normalizePersonName($firstName)); - $client->setLastName($this->normalizer->normalizePersonName($lastName)); - $client->setPhonePrimary((string) $this->normalizer->normalizePhone($phonePrimary)); - $client->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary)); - $client->setEmail((string) $this->normalizer->normalizeEmail($email)); foreach ($categoryNames as $categoryName) { $client->addCategory($this->category($manager, $categoryName)); diff --git a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php index 1457b21..cf70bf3 100644 --- a/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php +++ b/src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php @@ -103,9 +103,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client } /** - * Recherche fuzzy insensible a la casse sur companyName + lastName + email. - * Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester - * litteraux. + * Recherche fuzzy insensible a la casse sur companyName (D1, refonte contact). + * Depuis la suppression du contact inline du Client, la recherche ne porte + * plus que sur le nom d'entreprise (les anciens criteres lastName / email + * vivaient sur les colonnes inline disparues). Les metacaracteres LIKE + * (%, _, \) saisis sont echappes pour rester litteraux. */ private function applySearch(QueryBuilder $qb, ?string $search): void { @@ -116,11 +118,9 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; - $qb->andWhere( - 'LOWER(c.companyName) LIKE :search ' - .'OR LOWER(c.lastName) LIKE :search ' - .'OR LOWER(c.email) LIKE :search', - )->setParameter('search', $pattern); + $qb->andWhere('LOWER(c.companyName) LIKE :search') + ->setParameter('search', $pattern) + ; } /** diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php index 777b943..6c6c327 100644 --- a/src/Module/Core/Application/Rbac/RbacSeeder.php +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -62,6 +62,9 @@ final class RbacSeeder 'permissions' => [ 'commercial.clients.view', 'commercial.clients.manage', + // Lecture des referentiels transverses pour les selects client (ERP-102). + 'catalog.categories.read_ref', + 'sites.read_ref', ], ], self::ROLE_COMPTA => [ @@ -70,6 +73,9 @@ final class RbacSeeder 'commercial.clients.view', 'commercial.clients.accounting.view', 'commercial.clients.accounting.manage', + // Lecture des referentiels transverses pour les selects client (ERP-102). + 'catalog.categories.read_ref', + 'sites.read_ref', ], ], self::ROLE_COMMERCIALE => [ @@ -77,6 +83,9 @@ final class RbacSeeder 'permissions' => [ 'commercial.clients.view', 'commercial.clients.manage', + // Lecture des referentiels transverses pour les selects client (ERP-102). + 'catalog.categories.read_ref', + 'sites.read_ref', ], ], self::ROLE_USINE => [ diff --git a/src/Module/Sites/Domain/Entity/Site.php b/src/Module/Sites/Domain/Entity/Site.php index fd0855d..cc008ff 100644 --- a/src/Module/Sites/Domain/Entity/Site.php +++ b/src/Module/Sites/Domain/Entity/Site.php @@ -40,13 +40,18 @@ use Symfony\Component\Validator\Constraints as Assert; */ #[ApiResource( operations: [ + // Lecture (liste + item) : permission d'administration `sites.view` OU + // permission de lecture-referentiel transverse `sites.read_ref` (ERP-102). + // Le referentiel sites alimente les selects d'adresse des modules Tiers : + // tout role qui gere des tiers doit pouvoir le lire sans porter l'acces + // admin des Sites. new GetCollection( normalizationContext: ['groups' => ['site:read']], - security: "is_granted('sites.view')", + security: "is_granted('sites.view') or is_granted('sites.read_ref')", ), new Get( normalizationContext: ['groups' => ['site:read']], - security: "is_granted('sites.view')", + security: "is_granted('sites.view') or is_granted('sites.read_ref')", ), new Post( normalizationContext: ['groups' => ['site:read']], diff --git a/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteCollectionScopedExtension.php b/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteCollectionScopedExtension.php index 8339159..0486a6f 100644 --- a/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteCollectionScopedExtension.php +++ b/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteCollectionScopedExtension.php @@ -30,6 +30,8 @@ use function sprintf; * - resource != Site::class → no-op (les autres resources sont * gerees par SiteScopedQueryExtension) ; * - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ; + * - is_granted('sites.read_ref') → pas de filtre (lecture-referentiel + * transverse complet, ERP-102) ; * - user non authentifie → no-op (API Platform renvoie 401 avant) ; * - user sans aucun site → WHERE 1 = 0 (aucun acces) ; * - cas normal → WHERE site.id IN (:allowedSites). @@ -84,6 +86,16 @@ final class SiteCollectionScopedExtension implements QueryCollectionExtensionInt return; } + // 2bis) Lecture-referentiel transverse (ERP-102) : `sites.read_ref` donne + // acces a la LISTE COMPLETE des sites (selects d'adresse des modules Tiers). + // Sans ce bypass, le cloisonnement par site rattache reduirait le select + // aux seuls sites de l'utilisateur (voire a rien s'il n'en a aucun) et le + // referentiel ne serait plus "transverse". `read_ref` est une lecture seule : + // il ouvre la visibilite sans permettre la moindre ecriture. + if ($this->security->isGranted('sites.read_ref')) { + return; + } + // 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont). $user = $this->security->getUser(); if (!$user instanceof User) { diff --git a/src/Module/Sites/SitesModule.php b/src/Module/Sites/SitesModule.php index d1cceb5..bfcd88f 100644 --- a/src/Module/Sites/SitesModule.php +++ b/src/Module/Sites/SitesModule.php @@ -33,6 +33,11 @@ final class SitesModule ['code' => 'sites.view', 'label' => 'Voir les sites'], ['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'], ['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'], + // Lecture-referentiel transverse (ERP-102) : permet de LISTER les sites + // pour alimenter les selects des modules Tiers (adresses client...), sans + // donner l'acces d'administration `.view` (qui ouvre la page Sites dans la + // sidebar). Accordee aux roles metier via la matrice RBAC § 2.7. + ['code' => 'sites.read_ref', 'label' => 'Lire le referentiel sites (transverse, lecture seule)'], ]; } } diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index a60dc90..faa1188 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -166,14 +166,12 @@ final class ColumnCommentsCatalog ], 'client' => [ - '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', - 'id' => 'Identifiant interne auto-incremente.', - 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', - 'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', - 'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', - 'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.', - 'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).', - 'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).', + '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', + 'id' => 'Identifiant interne auto-incremente.', + 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', + // Contact principal inline supprime (refonte contact) : first_name, + // last_name, phone_primary, phone_secondary, email vivent desormais + // uniquement sur client_contact. 'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.', 'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.', 'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.', diff --git a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php index 66159d9..4b2f704 100644 --- a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php +++ b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php @@ -126,9 +126,6 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase // Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait // produit le ClientProcessor via l'API. $client->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); - $client->setLastName('Seed'); - $client->setPhonePrimary('0102030405'); - $client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test'); $client->addCategory($this->createCategory($categoryCode)); $client->setIsArchived($isArchived); if ($isArchived) { diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php index 90827cc..445447b 100644 --- a/tests/Module/Commercial/Api/ClientApiTest.php +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -25,7 +25,7 @@ final class ClientApiTest extends AbstractCommercialApiTestCase { private const string LD = 'application/ld+json'; - public function testPostNormalizesTextFields(): void + public function testPostNormalizesCompanyName(): void { $client = $this->createAdminClient(); $cat = $this->createCategory('SECTEUR'); @@ -33,23 +33,18 @@ final class ClientApiTest extends AbstractCommercialApiTestCase $response = $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ - 'companyName' => 'acme sas', - 'firstName' => 'JEAN', - 'lastName' => 'dupont', - 'phonePrimary' => '06.12.34.56.78', - 'email' => 'Jean.DUPONT@ACME.FR', - 'categories' => ['/api/categories/'.$cat->getId()], + 'companyName' => 'acme sas', + 'categories' => ['/api/categories/'.$cat->getId()], ], ]); self::assertResponseStatusCodeSame(201); $data = $response->toArray(); - // RG-1.18 / 1.19 / 1.20 / 1.21 + // RG-1.18 : companyName normalise en MAJUSCULES. Les champs de contact + // inline ont disparu (refonte contact) -> plus de normalisation ici. self::assertSame('ACME SAS', $data['companyName']); - self::assertSame('Jean', $data['firstName']); - self::assertSame('Dupont', $data['lastName']); - self::assertSame('0612345678', $data['phonePrimary']); - self::assertSame('jean.dupont@acme.fr', $data['email']); + self::assertArrayNotHasKey('firstName', $data); + self::assertArrayNotHasKey('email', $data); self::assertFalse($data['isArchived']); } @@ -60,41 +55,18 @@ final class ClientApiTest extends AbstractCommercialApiTestCase $iri = '/api/categories/'.$cat->getId(); $payload = [ - 'companyName' => 'Doublon SARL', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'dup@test.fr', - 'categories' => [$iri], + 'companyName' => 'Doublon SARL', + 'categories' => [$iri], ]; $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); self::assertResponseStatusCodeSame(201); // Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16). - $payload['email'] = 'dup2@test.fr'; $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); self::assertResponseStatusCodeSame(409); } - public function testPostWithoutFirstOrLastNameReturns422(): void - { - $client = $this->createAdminClient(); - $cat = $this->createCategory('SECTEUR'); - - $client->request('POST', '/api/clients', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => [ - 'companyName' => 'No Contact Name', - 'phonePrimary' => '0102030405', - 'email' => 'nc@test.fr', - 'categories' => ['/api/categories/'.$cat->getId()], - ], - ]); - - // RG-1.01 - self::assertResponseStatusCodeSame(422); - } - public function testPostWithoutCategoryReturns422(): void { $client = $this->createAdminClient(); @@ -102,11 +74,8 @@ final class ClientApiTest extends AbstractCommercialApiTestCase $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ - 'companyName' => 'No Category', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'nocat@test.fr', - 'categories' => [], + 'companyName' => 'No Category', + 'categories' => [], ], ]); @@ -124,9 +93,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Mutex Client', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'mutex@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'distributor' => '/api/clients/'.$distributor->getId(), 'broker' => '/api/clients/'.$distributor->getId(), @@ -147,9 +113,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Bad Distrib Ref', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'baddistrib@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'distributor' => '/api/clients/'.$notDistro->getId(), ], @@ -169,9 +132,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Client Avec Distrib', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'okdistrib@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'distributor' => '/api/clients/'.$distributor->getId(), ], @@ -190,9 +150,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Bad Broker Ref', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'badbroker@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'broker' => '/api/clients/'.$notBroker->getId(), ], @@ -212,9 +169,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Client Avec Courtier', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'okbroker@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], 'broker' => '/api/clients/'.$broker->getId(), ], diff --git a/tests/Module/Commercial/Api/ClientAuditTest.php b/tests/Module/Commercial/Api/ClientAuditTest.php index 6466c86..0fbc0b1 100644 --- a/tests/Module/Commercial/Api/ClientAuditTest.php +++ b/tests/Module/Commercial/Api/ClientAuditTest.php @@ -68,9 +68,6 @@ final class ClientAuditTest extends AbstractCommercialApiTestCase 'headers' => ['Content-Type' => self::LD], 'json' => [ 'companyName' => 'Blamable Co', - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'blamable@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], ], ])->toArray(); diff --git a/tests/Module/Commercial/Api/ClientFormulaireMainTest.php b/tests/Module/Commercial/Api/ClientFormulaireMainTest.php index e6b6eec..165954f 100644 --- a/tests/Module/Commercial/Api/ClientFormulaireMainTest.php +++ b/tests/Module/Commercial/Api/ClientFormulaireMainTest.php @@ -7,12 +7,15 @@ namespace App\Tests\Module\Commercial\Api; use App\Module\Commercial\Domain\Entity\Client as ClientEntity; /** - * Tests fonctionnels du formulaire principal — combler les trous (ERP-60). + * Tests fonctionnels du formulaire principal apres la refonte contact. * - * RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs - * + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les - * reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire), - * non encore testee. + * RG-1.01 (prenom OU nom) et RG-1.02 (telephone secondaire) ont ete SUPPRIMEES + * du Client : le contact principal n'est plus porte inline, il vit uniquement + * dans ClientContact (onglet Contact). Ce fichier verifie que : + * - le formulaire principal se cree avec les seuls champs subsistants + * (companyName + categories), sans aucun champ de contact ; + * - les anciens champs de contact (firstName, lastName, phonePrimary, + * phoneSecondary, email) ne sont plus exposes ni persistes. * * @internal */ @@ -21,11 +24,10 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase private const string LD = 'application/ld+json'; /** - * RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes - * distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur - * la colonne secondaire. + * Le formulaire principal n'exige plus que companyName + au moins une + * categorie (RG-1.16 / RG sur categories). Aucun champ de contact requis. */ - public function testPostPersistsSecondaryPhoneNormalized(): void + public function testPostMainFormWithoutContactFields(): void { $client = $this->createAdminClient(); $cat = $this->createCategory('SECTEUR'); @@ -33,26 +35,28 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase $data = $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ - 'companyName' => 'Two Phones SARL', - 'firstName' => 'A', - 'phonePrimary' => '06.12.34.56.78', - 'phoneSecondary' => '05 49 00 11 22', - 'email' => 'twophones@test.fr', - 'categories' => ['/api/categories/'.$cat->getId()], + 'companyName' => 'Main Form SARL', + 'categories' => ['/api/categories/'.$cat->getId()], ], ])->toArray(); self::assertResponseStatusCodeSame(201); - self::assertSame('0612345678', $data['phonePrimary']); - self::assertSame('0549001122', $data['phoneSecondary']); + self::assertSame('MAIN FORM SARL', $data['companyName']); + + // Les champs de contact inline ont disparu de la representation. + self::assertArrayNotHasKey('firstName', $data); + self::assertArrayNotHasKey('lastName', $data); + self::assertArrayNotHasKey('phonePrimary', $data); + self::assertArrayNotHasKey('phoneSecondary', $data); + self::assertArrayNotHasKey('email', $data); } /** - * RG-1.02 : maximum 2 telephones — le modele n'expose que phonePrimary et - * phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est - * ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero. + * Les anciens champs de contact envoyes par un appel API direct (payload + * historique) sont ignores par le denormaliseur : ils n'apparaissent pas + * dans la representation et ne creent aucune colonne sur le client. */ - public function testThirdPhoneFieldIsIgnored(): void + public function testLegacyContactFieldsAreIgnored(): void { $client = $this->createAdminClient(); $cat = $this->createCategory('SECTEUR'); @@ -60,25 +64,25 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase $data = $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ - 'companyName' => 'Third Phone SARL', - 'firstName' => 'A', + 'companyName' => 'Legacy Fields SARL', + 'firstName' => 'Ignored', + 'lastName' => 'Ignored', 'phonePrimary' => '0612345678', 'phoneSecondary' => '0549001122', - 'phoneTertiary' => '0700000000', - 'email' => 'thirdphone@test.fr', + 'email' => 'ignored@test.fr', 'categories' => ['/api/categories/'.$cat->getId()], ], ])->toArray(); self::assertResponseStatusCodeSame(201); - // Le champ inconnu est ignore par le denormaliseur : il n'apparait pas - // dans la representation et n'a pas ete persiste. - self::assertArrayNotHasKey('phoneTertiary', $data); + self::assertArrayNotHasKey('firstName', $data); + self::assertArrayNotHasKey('phonePrimary', $data); + self::assertArrayNotHasKey('email', $data); - // Confirmation cote base : seules les 2 colonnes telephone existent. + // Confirmation cote base : le client cree ne porte aucun contact inline + // (les colonnes n'existent plus, l'entite n'a plus les proprietes). $persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']); self::assertNotNull($persisted); - self::assertSame('0612345678', $persisted->getPhonePrimary()); - self::assertSame('0549001122', $persisted->getPhoneSecondary()); + self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName()); } } diff --git a/tests/Module/Commercial/Api/ClientMigrationTest.php b/tests/Module/Commercial/Api/ClientMigrationTest.php index 2e0af76..1c54f54 100644 --- a/tests/Module/Commercial/Api/ClientMigrationTest.php +++ b/tests/Module/Commercial/Api/ClientMigrationTest.php @@ -14,10 +14,41 @@ namespace App\Tests\Module\Commercial\Api; * - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active * (RG-1.17) ont ete supprimes / ne sont jamais crees. * + * Verifie aussi la refonte contact (Version20260603120000) : les 5 colonnes de + * contact principal inline ont ete supprimees de la table `client`. + * * @internal */ final class ClientMigrationTest extends AbstractCommercialApiTestCase { + /** + * Refonte contact : first_name / last_name / phone_primary / phone_secondary + * / email ne doivent plus exister sur la table `client` (deplaces vers + * client_contact). NB : le backfill de la migration ne s'exerce que sur une + * base portant des donnees pre-refonte ; sur le schema de test (table client + * vierge au moment de la migration) il est un no-op, donc non assertable ici + * au runtime — seul l'etat de schema final est verifie. + */ + public function testInlineContactColumnsAreDropped(): void + { + self::bootKernel(); + + /** @var list $columns */ + $columns = $this->getEm()->getConnection()->fetchAllAssociative( + "SELECT column_name FROM information_schema.columns " + ."WHERE table_schema = 'public' AND table_name = 'client'", + ); + $names = array_map(static fn (array $r): string => $r['column_name'], $columns); + + foreach (['first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email'] as $dropped) { + self::assertNotContains( + $dropped, + $names, + sprintf('La colonne client.%s aurait du etre supprimee (refonte contact).', $dropped), + ); + } + } + public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void { $rows = $this->clientIndexes(); diff --git a/tests/Module/Commercial/Api/ClientRBACMatrixTest.php b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php index 772c336..014ab66 100644 --- a/tests/Module/Commercial/Api/ClientRBACMatrixTest.php +++ b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php @@ -6,6 +6,7 @@ namespace App\Tests\Module\Commercial\Api; use ApiPlatform\Symfony\Bundle\Test\Client; use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures; +use App\Module\Sites\Domain\Entity\Site; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; @@ -272,15 +273,60 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(200); } + public function testBusinessRolesCanReadCategoriesAndSitesReferentials(): void + { + // ERP-102 : /categories et /sites sont des referentiels TRANSVERSES. + // Tout role qui gere des clients (bureau / compta / commerciale) doit + // pouvoir les LISTER pour alimenter les selects de creation/filtre client, + // via la permission de lecture-referentiel dediee (catalog.categories.read_ref + // / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter + // la permission d'administration `.view`. Usine, sans aucune permission, + // reste interdit. + // Le referentiel /sites est TRANSVERSE et COMPLET : le cloisonnement par + // site rattache (SiteCollectionScopedExtension) est neutralise par + // `sites.read_ref` (ERP-102). Les comptes demo ne sont rattaches qu'a un + // seul site (Chatellerault) alors que la base en compte plusieurs : on + // verifie donc que le role voit la TOTALITE du referentiel, pas son seul + // site rattache. Sans le bypass de scope, totalItems vaudrait 1. + $totalSites = $this->getEm()->getRepository(Site::class)->count([]); + self::assertGreaterThan( + 1, + $totalSites, + 'Pre-requis du test : la base doit contenir plusieurs sites pour distinguer scope et bypass.', + ); + + foreach (['bureau', 'compta', 'commerciale'] as $role) { + $client = $this->authAs($role); + + $client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role)); + + $response = $client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role)); + self::assertSame( + $totalSites, + $response->toArray()['totalItems'] ?? null, + sprintf('Le role %s doit voir tout le referentiel sites (%d), pas seulement son site rattache', $role, $totalSites), + ); + } + + // Usine : aucune permission -> reste a 403 sur les referentiels. + $usine = $this->authAs('usine'); + $usine->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /categories'); + $usine->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /sites'); + } + private function authAs(string $role): Client { return $this->authenticatedClient($role, self::PWD); } /** - * Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ; - * une categorie SECTEUR). Si $categoryId est null, une categorie est creee a - * la volee. + * Payload minimal valide de l'onglet principal (companyName + une categorie + * SECTEUR ; le contact inline a ete supprime). Si $categoryId est null, une + * categorie est creee a la volee. * * @return array */ @@ -289,11 +335,8 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase $categoryId ??= $this->createCategory('SECTEUR')->getId(); return [ - 'companyName' => $companyName, - 'firstName' => 'Jean', - 'phonePrimary' => '0612345678', - 'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test', - 'categories' => ['/api/categories/'.$categoryId], + 'companyName' => $companyName, + 'categories' => ['/api/categories/'.$categoryId], ]; } } diff --git a/tests/Module/Commercial/Api/ClientSerializationContractTest.php b/tests/Module/Commercial/Api/ClientSerializationContractTest.php index 680c9e3..7a3e345 100644 --- a/tests/Module/Commercial/Api/ClientSerializationContractTest.php +++ b/tests/Module/Commercial/Api/ClientSerializationContractTest.php @@ -232,9 +232,6 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas $client = new ClientEntity(); $client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); - $client->setLastName('Complet'); - $client->setPhonePrimary('0102030405'); - $client->setEmail('complet'.$suffix.'@seed.test'); $client->addCategory($this->createCategory('SECTEUR')); // Bloc comptable non nul (gating par omission cote Commerciale). $client->setSiren('123456789'); diff --git a/tests/Module/Commercial/Api/ClientUniquenessTest.php b/tests/Module/Commercial/Api/ClientUniquenessTest.php index ab4cff1..0f313af 100644 --- a/tests/Module/Commercial/Api/ClientUniquenessTest.php +++ b/tests/Module/Commercial/Api/ClientUniquenessTest.php @@ -12,41 +12,13 @@ use App\Module\Commercial\Domain\Entity\Client as ClientEntity; * RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par * ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier * verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee) - * et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques. + * n'est PLUS contraint unique. (L'email — RG-1.17 — a disparu du Client avec la + * refonte contact, il vit desormais sur ClientContact.) * * @internal */ final class ClientUniquenessTest extends AbstractCommercialApiTestCase { - private const string LD = 'application/ld+json'; - - /** - * RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme - * email principal — aucune contrainte d'unicite (un email peut servir - * plusieurs clients). - */ - public function testDuplicateEmailIsAllowed(): void - { - $client = $this->createAdminClient(); - $cat = $this->createCategory('SECTEUR'); - $iri = '/api/categories/'.$cat->getId(); - - $payload = static fn (string $name): array => [ - 'companyName' => $name, - 'firstName' => 'A', - 'phonePrimary' => '0102030405', - 'email' => 'partage@test.fr', - 'categories' => [$iri], - ]; - - $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]); - self::assertResponseStatusCodeSame(201); - - // Meme email, nom different -> doit passer (pas d'index unique email). - $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]); - self::assertResponseStatusCodeSame(201); - } - /** * RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements * multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on diff --git a/tests/Module/Commercial/Unit/ClientProcessorTest.php b/tests/Module/Commercial/Unit/ClientProcessorTest.php index d6270d6..8a025f3 100644 --- a/tests/Module/Commercial/Unit/ClientProcessorTest.php +++ b/tests/Module/Commercial/Unit/ClientProcessorTest.php @@ -134,14 +134,11 @@ final class ClientProcessorTest extends TestCase 'isArchived' => false, ], managed: true, - // Etat persiste complet (valeurs normalisees) : sans les champs - // metier, guardManage (ERP-74) les croirait modifies (companyName, - // lastName... compares a null) et leverait un 403 parasite. + // Etat persiste (valeurs normalisees) : sans companyName, guardManage + // (ERP-74) le croirait modifie (compare a null) et leverait un 403 + // parasite. originalData: [ 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -164,13 +161,10 @@ final class ClientProcessorTest extends TestCase managed: true, // getOriginalEntityData renvoie tous les champs mappes d'une entite // geree : isArchived (non-null) y figure toujours, ainsi que les - // champs metier (sinon guardManage les croirait modifies). + // champs metier (companyName) sinon guardManage les croirait modifies. originalData: [ 'siren' => '123456789', 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -193,9 +187,6 @@ final class ClientProcessorTest extends TestCase managed: true, originalData: [ 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -220,9 +211,6 @@ final class ClientProcessorTest extends TestCase originalData: [ 'siren' => '111111111', 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -324,9 +312,6 @@ final class ClientProcessorTest extends TestCase managed: true, originalData: [ 'companyName' => 'TEST CO', - 'lastName' => 'Dupont', - 'phonePrimary' => '0102030405', - 'email' => 't@test.fr', 'triageService' => false, 'isArchived' => false, ], @@ -401,16 +386,14 @@ final class ClientProcessorTest extends TestCase } /** - * Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) — suffisant - * pour atteindre les validations testees. + * Client minimal — companyName seul depuis la suppression du contact inline. + * Suffisant pour atteindre les validations testees (le contact vit desormais + * dans ClientContact, hors scope du ClientProcessor). */ private function minimalClient(): Client { $client = new Client(); $client->setCompanyName('Test Co'); - $client->setLastName('Dupont'); - $client->setPhonePrimary('0102030405'); - $client->setEmail('t@test.fr'); return $client; } diff --git a/tests/Module/Sites/SitesModuleTest.php b/tests/Module/Sites/SitesModuleTest.php index be9e330..88d9a31 100644 --- a/tests/Module/Sites/SitesModuleTest.php +++ b/tests/Module/Sites/SitesModuleTest.php @@ -16,17 +16,18 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; */ final class SitesModuleTest extends KernelTestCase { - public function testPermissionsSetContainsExactlyThreeCodes(): void + public function testPermissionsSetContainsExactlyFourCodes(): void { // Garde-fou : si quelqu'un ajoute une permission sans ajuster les // tests ou la doc, ce test casse explicitement. Si au contraire une // permission disparait (ex: bypass_scope retire par erreur), meme - // effet. Le set de 3 permissions est fige par ce test. + // effet. Le set de permissions est fige par ce test. + // `sites.read_ref` ajoutee en ERP-102 (lecture-referentiel transverse). $codes = array_column(SitesModule::permissions(), 'code'); sort($codes); self::assertSame( - ['sites.bypass_scope', 'sites.manage', 'sites.view'], + ['sites.bypass_scope', 'sites.manage', 'sites.read_ref', 'sites.view'], $codes, ); }