diff --git a/config/version.yaml b/config/version.yaml index a018d1c..1bd718d 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.73' + 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).