Compare commits

..

1 Commits

Author SHA1 Message Date
Matthieu 54fe48993f fix(rbac) : referentiels /categories et /sites lisibles par les roles metier (ERP-102)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m50s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m24s
Les roles metier (bureau / compta / commerciale) prenaient un 403 sur
GET /api/categories et GET /api/sites : la security des GetCollection/Get
exigeait catalog.categories.view / sites.view, permissions reservees a
l'administration du Catalogue et des Sites. Or ces referentiels sont
transverses (selects de creation/filtre client) : creation de client
cassee et filtres vides pour ces roles.

Correctif back (Option C — permission de lecture-referentiel dediee) :
- Nouvelles permissions catalog.categories.read_ref et sites.read_ref,
  distinctes de .view (pas d'item sidebar admin) et de .manage. Chaque
  permission appartient a son propre module -> aucun couplage inter-module
  (regle ABSOLUE n°1) et reutilisable tel quel par M2 Fournisseurs.
- Security lecture (liste + item) elargie : view OR read_ref sur Category
  et Site.
- Matrice RBAC § 2.7 (RbacSeeder) : read_ref attache a bureau / compta /
  commerciale. Usine reste sans acces.

Durcissement front (resilience, requis dans tous les cas) :
- useClientReferentials.loadCommon passe de Promise.all a Promise.allSettled
  avec affectation isolee par referentiel : l'echec d'un endpoint ne vide
  que SON select, plus la totalite du formulaire.

Tests :
- ClientRBACMatrixTest : les roles metier listent /categories et /sites (200),
  usine reste a 403.
- SitesModuleTest : set de permissions porte a 4 codes.
- useClientReferentials.spec : resilience d'un referentiel en echec.

Miroirs E2E (personas.ts / SeedE2ECommand) non touches : read_ref n'ajoute
aucun lien sidebar, le persona user-full lit deja via .view, et aucun
persona ne modelise un role metier seul ; pas de nouveau test E2E (regle n°7).
2026-06-03 14:13:45 +02:00
52 changed files with 695 additions and 3328 deletions
+153 -315
View File
@@ -1,362 +1,209 @@
# Starseed # Starseed
CRM/ERP en architecture **modular monolith DDD** — Symfony 8 (API Platform 4) + Nuxt 4. CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
Le backend est la **source de vérité unique** : il décide des modules actifs et de
l'organisation de la sidebar. Le frontend scanne `frontend/modules/*/` comme layers
Nuxt et consomme l'API pour la navigation.
---
## Sommaire
- [Stack](#stack)
- [Prérequis](#prérequis)
- [Démarrage rapide](#démarrage-rapide)
- [Dev local : avec ou sans données de seed](#dev-local--avec-ou-sans-données-de-seed)
- [Comptes (dev)](#comptes-dev)
- [Bases de données : dev et test](#bases-de-données--dev-et-test)
- [Tests](#tests)
- [Déploiement : seed RBAC en recette / prod](#déploiement--seed-rbac-en-recette--prod)
- [Commandes make](#commandes-make)
- [Architecture](#architecture)
- [Structure du dépôt](#structure-du-dépôt)
- [CI/CD](#cicd)
- [Conventions](#conventions)
---
## Stack ## Stack
- **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 - **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16
- **Frontend** : Nuxt 4 (SPA, SSR off), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, @nuxtjs/i18n - **Frontend** : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui
- **Auth** : JWT HTTP-only cookie (Lexik), login sur `/login_check` - **Auth** : JWT HTTP-only cookie (Lexik)
- **Infra** : Docker Compose (dev + prod multi-stage) - **Infra** : Docker Compose (dev + prod multi-stage)
- **CI/CD** : Gitea Actions (auto-tag + build Docker) - **CI/CD** : Gitea Actions (auto-tag + build Docker)
## Quick Start
```bash
make start # Demarrer les containers Docker
make install # Composer, migrations, fixtures, build Nuxt
```
Dev frontend (hot reload) :
```bash
make dev-nuxt # Port 3003
```
## Ports
| Service | Port | | Service | Port |
|---------------|------| |------------|------|
| API (Nginx)| 8083 | | API (Nginx)| 8083 |
| Frontend dev | 3004 | | Frontend | 3004 |
| PostgreSQL | 5437 | | PostgreSQL | 5437 |
--- ## Commandes
## Prérequis | Commande | Description |
|----------|-------------|
- Docker + Docker Compose | `make start` | Demarrer les containers |
- `make` | `make stop` | Arreter les containers |
- `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`) | `make restart` | Redemarrer les containers |
| `make install` | Install complet |
Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ; | `make reset` | Tout supprimer et reinstaller |
rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur | `make dev-nuxt` | Serveur dev Nuxt (hot reload) |
l'hôte (navigateur réel, voir [Tests](#tests)). | `make shell` | Shell dans le container PHP |
| `make cache-clear` | Vider le cache Symfony |
--- | `make migration-migrate` | Lancer les migrations |
| `make fixtures` | Charger les fixtures |
## Démarrage rapide | `make db-reset` | Reset BDD + migrations + fixtures |
| `make test` | PHPUnit (tests back) |
```bash | `make nuxt-test` | Vitest (tests unitaires front) |
make start # Démarre les containers Docker | `make test-e2e` | Playwright (tests E2E front) |
make install # Composer + clés JWT + migrations + permissions + BDD de test | `make test-e2e-ui` | Playwright UI interactive (debug) |
make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004) | `make seed-e2e` | Seed les 6 personas E2E |
``` | `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) |
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
`make install` prépare une base de dev **vierge** (schéma + RBAC structurel, sans | `make logs-dev` | Tail logs Symfony |
données de démo) et la base de **test**. Pour obtenir des comptes et des données de
démo prêtes à l'emploi, lis la section suivante.
> Override local possible : `make` lit `infra/dev/.env.docker`, surchargé par
> `infra/dev/.env.docker.local` s'il existe (créé automatiquement par `make env-init`).
---
## Dev local : avec ou sans données de seed
Le projet distingue deux états de base de données de dev. Les **fixtures Doctrine sont
en `require-dev`** : elles n'existent qu'en dev, jamais dans le build de prod.
### Sans données de seed (base vierge)
C'est ce que produit `make install`. La base contient :
- le **schéma** complet (toutes les migrations jouées) ;
- les **rôles système** `admin` / `user` (seedés en SQL par la migration RBAC) ;
- le **catalogue de permissions** synchronisé (`app:sync-permissions`).
Mais **aucun compte utilisateur ni donnée métier**. Pour pouvoir te connecter,
crée toi-même un compte :
```bash
make shell
php bin/console app:create-user admin monMotDePasse --admin # compte ROLE_ADMIN
```
Optionnel — provisionner les **rôles métier** (bureau / compta / commerciale / usine
+ matrice RBAC § 2.7) sans comptes de démo :
```bash
php bin/console app:seed-rbac
```
Cet état est utile pour repartir d'une base propre, reproduire un bug sur données
minimales, ou tester un parcours d'onboarding réel.
### Avec données de seed (base de démo)
`make db-reset` (ou `make fixtures` après un `make install`) recharge la base de dev
avec un jeu complet de données de démonstration, **idempotent** :
```bash
make db-reset # ATTENTION : drop + recrée la base de dev, puis charge tout le seed
```
Ce que les fixtures posent :
- **3 utilisateurs système** : `admin` (ROLE_ADMIN), `alice`, `bob` (ROLE_USER),
rattachés à des sites distincts ;
- **3 sites** : Chatellerault, Saint-Jean, Pommevic ;
- **les comptes de démo RBAC métier** (`bureau`, `compta`, `commerciale`, `usine`,
mot de passe `demo`) avec la matrice § 2.7 attachée ;
- les **référentiels et données métier** des modules (catégories, clients de démo,
référentiels comptables…).
Toutes les fixtures sont rejouables sans effet de bord (lookup par clé naturelle,
aucun doublon).
> Différence avec `make install` : `install` ne charge **pas** les fixtures sur la base
> de dev (il alimente uniquement la base de test). Utilise `make db-reset` ou
> `make fixtures` quand tu veux des données de démo en dev.
---
## Comptes (dev)
Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec seed ») :
| Username | Password | Rôle | RBAC métier |
|---------------|----------|------------|---------------------------------------------------------------|
| `admin` | `admin` | ROLE_ADMIN | bypass complet (`is_admin`) |
| `alice` | `alice` | ROLE_USER | — |
| `bob` | `bob` | ROLE_USER | — |
| `bureau` | `demo` | ROLE_USER | clients : view + manage |
| `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage |
| `commerciale` | `demo` | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| `usine` | `demo` | ROLE_USER | aucun accès clients |
---
## Bases de données : dev et test
Deux bases distinctes vivent dans le **même container PostgreSQL** (port 5437) :
| Base | Environnement | Construite par | Usage |
|------------|---------------|--------------------------------------|--------------------------------|
| `<db>` | `dev` | `make install` / `make db-reset` | développement manuel, dev-nuxt |
| `<db>_test` | `test` | `make test-db-setup` | PHPUnit (jamais touchée à la main) |
Le suffixe `_test` est appliqué **automatiquement** par Doctrine quand `APP_ENV=test`
(config `when@test` dans `config/packages/doctrine.yaml`). La base de test est donc
totalement **isolée** de la base de dev : lancer `make test` ne touche jamais tes
données de dev.
`make test-db-setup` fait davantage que jouer les migrations, car certaines structures
ne sont pas portées par des migrations « métier » :
1. `doctrine:migrations:migrate` — schéma métier réel ;
2. `doctrine:schema:update --force` — crée les tables mappées en `when@test`
uniquement (entités de test) ;
3. `app:apply-column-comments` — réapplique les `COMMENT ON COLUMN` que
`schema:update` efface sur les tables managées par l'ORM (garde-fou
`ColumnsHaveSqlCommentTest`) ;
4. `fixtures:load``sync-permissions``seed-rbac` — dans cet ordre précis
(le purger des fixtures vide la table `permission`, donc la sync passe après) ;
5. recréation des **index partiels uniques** (`LOWER(...) WHERE ...`) non exprimables
en attributs ORM, indispensables aux tests d'unicité (RG-1.07, RG-1.16, RG-1.03/1.29).
`make install` et `make db-reset` appellent déjà `test-db-setup` : tu n'as à le
relancer à la main que si la base de test diverge (nouvelle migration, nouvelle
permission) sans vouloir reseed la base de dev.
---
## Tests ## Tests
| Suite | Commande | Outil | Où | - **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`.
|-------------------|------------------|----------------------|-----------------------------------| - **Front unitaire** : `make nuxt-test` (Vitest, happy-dom). Composables, utils, stores — rapide, <30s.
| Back | `make test` | PHPUnit | container PHP, base `<db>_test` | - **Front E2E** : `make test-e2e` (Playwright). Couvre login + matrice RBAC sidebar. Suite volontairement minimaliste (11 tests) — voir la regle d'or dans `CLAUDE.md`.
| Front unitaire | `make nuxt-test` | Vitest (happy-dom) | container Node, < 30 s |
| Front E2E | `make test-e2e` | Playwright | **hôte** (navigateur réel requis) |
| Tout (back+front) | `make test-all` | PHPUnit + Vitest | — |
### Tests back (PHPUnit)
**Bootstrap E2E (une fois par poste)** :
```bash ```bash
make test # toute la suite make install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo)
make test FILES=tests/Module/Commercial # un dossier / fichier ciblé
``` ```
PHPUnit force `APP_ENV=test` (`phpunit.dist.xml`) : les tests tournent **toujours** **Workflow E2E** :
sur la base `<db>_test`, jamais sur la base de dev. Prérequis : que la base de test
existe — c'est le cas après `make install`. Si elle a divergé, rejoue
`make test-db-setup` (cf. [Bases de données](#bases-de-données--dev-et-test)).
### Tests front unitaires (Vitest)
```bash ```bash
make nuxt-test # composables, utils, stores — rapide et stable # Terminal 1 : containers + dev server
```
C'est la **place par défaut** pour étendre la couverture (cf. règle d'or ci-dessous).
### Tests E2E (Playwright)
Suite volontairement minimaliste (login + matrice RBAC sidebar). **Règle d'or : un
nouveau test E2E ne s'ajoute que si un bug critique est passé en prod** — sinon,
préférer un test Vitest ou étendre un persona existant.
Bootstrap (une fois par poste) :
```bash
make install-e2e-deps # télécharge Chromium + libs système (apt/dnf, sudo)
```
Workflow :
```bash
# Terminal 1 — containers, seed des personas, serveur dev
make start && make seed-e2e && make dev-nuxt make start && make seed-e2e && make dev-nuxt
# Terminal 2 tests # Terminal 2 : tests
make test-e2e # headless make test-e2e
make test-e2e-ui # UI interactive (debug)
``` ```
> Toute permission testable touche **3 miroirs** à garder alignés : `config/sidebar.php`,
> `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
---
## Déploiement : seed RBAC en recette / prod
Les fixtures Doctrine étant en `require-dev`, elles sont **absentes du build de prod**.
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente**, jouée dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions (commercial.clients.*, …)
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test. Le mot de
passe est fourni **explicitement** (jamais en dur, jamais committé) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'environnement RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de
compte). Pour créer un premier administrateur en prod :
```bash
php bin/console app:create-user <username> <password> --admin
```
---
## Commandes make
`make` (sans argument) ou `make help` affiche l'aide colorée. Les principales :
| Commande | Description |
|--------------------------------|----------------------------------------------------------|
| `make start` / `stop` / `restart` | Cycle de vie des containers |
| `make install` | Install complet (base dev vierge + base de test) |
| `make reset` | Tout supprimer et réinstaller (**drop la BDD**) |
| `make dev-nuxt` | Serveur Nuxt hot reload (port 3004) |
| `make shell` / `shell-root` | Shell bash dans le container PHP |
| `make migration-migrate` | Jouer les migrations Doctrine |
| `make fixtures` | Charger les fixtures (données de démo dev) |
| `make sync-permissions` | Synchroniser le catalogue RBAC |
| `make seed-rbac` | Seed RBAC métier (rôles + matrice § 2.7) |
| `make db-reset` | Reset base dev : drop + migrate + fixtures + RBAC |
| `make test-db-setup` | (Re)construire la base de test |
| `make test` | PHPUnit (back) |
| `make nuxt-test` | Vitest (front unitaire) |
| `make test-all` | PHPUnit + Vitest |
| `make test-e2e` / `test-e2e-ui`| Playwright (E2E, sur l'hôte) |
| `make seed-e2e` | Seed des 6 personas E2E |
| `make php-cs-fixer-allow-risky`| Fix du code style PHP |
| `make php-cs-fixer-check` | Dry-run du fixer (CI / avant push) |
| `make logs-dev` | Tail des logs Symfony |
---
## Architecture ## Architecture
**Modular Monolith DDD** : chaque module est un bounded context autonome, **Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar.
activable / désactivable par tenant. Le backend est la seule source de vérité pour
l'activation des modules et l'organisation de la sidebar.
- `config/modules.php` — liste des modules actifs - `config/modules.php` — liste des modules actifs
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner) - `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
- `GET /api/modules` — IDs des modules actifs (public) - `GET /api/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees
- `GET /api/sidebar` — sections filtrées par modules actifs + routes désactivées (public) - Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
**Désactiver un module** : commenter sa ligne dans `config/modules.php`, vider le cache. Pour desactiver un module : commenter sa ligne dans `config/modules.php`, clear cache. Ses items de sidebar disparaissent et ses routes sont bloquees par le middleware front.
Ses items de sidebar disparaissent et ses routes sont bloquées par le middleware front.
Le code reste dans le bundle (layer auto-détecté) → réactivation instantanée.
**Réorganiser la sidebar** : éditer `config/sidebar.php` uniquement le code des Pour reorganiser la sidebar (ex: deplacer un item d'une section a l'autre) : editer `config/sidebar.php` uniquement, le code des modules n'est pas touche.
modules n'est pas touché.
**Communication inter-modules** : jamais d'import direct d'un module à l'autre. Passer ## Structure
par `Shared/Domain/Contract/` (interfaces) ou des domain events.
---
## Structure du dépôt
``` ```
src/ # Backend Symfony src/ # Backend Symfony
Shared/ # Noyau technique partagé (Domain/, Application/Bus/, Infrastructure/ApiPlatform/) Kernel.php
Shared/ # Noyau technique partage
Domain/
ValueObject/ # Email, ...
Event/ # DomainEventInterface
Contract/ # Interfaces inter-modules
Application/
Bus/ # CommandBusInterface, QueryBusInterface
Infrastructure/
ApiPlatform/
Resource/ # AppVersion, ModulesResource, SidebarResource
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
Module/ Module/
Core/ # Module obligatoire (auth, users, RBAC) Core/ # Module obligatoire (auth, users)
CoreModule.php # Déclaration (ID, LABEL, REQUIRED, permissions()) CoreModule.php # Declaration (ID, LABEL, REQUIRED)
Domain/ Application/ Infrastructure/ Domain/
Commercial/ Catalog/ Sites/ # Modules métier Entity/ # User
Repository/ # UserRepositoryInterface
Event/ # UserCreated
Application/
DTO/ # UserOutput
Infrastructure/
Doctrine/ # DoctrineUserRepository, Migrations/
ApiPlatform/State/
Provider/ # MeProvider
Processor/ # UserPasswordHasherProcessor
Console/ # CreateUserCommand
DataFixtures/ # AppFixtures
Commercial/ # Autre module (exemple)
CommercialModule.php
config/ config/
modules.php # Source de vérité : activation modules.php # Source de verite activation
sidebar.php # Source de vérité : navigation sidebar.php # Source de verite navigation
packages/ # Config Symfony (doctrine, api_platform, security…) version.yaml
migrations/ # Migrations d'initialisation (namespace racine : setup, RBAC, seed de base) packages/ # Config Symfony
jwt/ # Cles JWT
migrations/ # Anciennes migrations
frontend/ # App Nuxt 4 (SPA) frontend/ # App Nuxt 4 (SPA)
app/ # Shell : layouts, middlewares (auth.global, modules.global) app/
shared/ # Code inter-modules (composables, stores, utils, types) layouts/ # default.vue, auth.vue
modules/ # Layers Nuxt auto-détectés (core/, commercial/…) middleware/ # auth.global.ts, modules.global.ts
i18n/locales/ # Traductions (sidebar.*, audit.entity.*, …) shared/ # Code partage (hors modules)
composables/ # useApi, useAppVersion, useSidebar
components/ui/ # AppTopNav, ...
stores/ # auth, ui
services/ # auth
types/ # SidebarSection, UserData
utils/ # api (Hydra)
modules/ # Modules auto-detectes comme layers Nuxt
core/
nuxt.config.ts # Marqueur layer
pages/ # index, login, logout
commercial/
nuxt.config.ts
pages/ # commercial.vue
app.vue
nuxt.config.ts # Scanne modules/*/ automatiquement
i18n/locales/ # Traductions (sidebar.*, etc.)
assets/ # CSS, images
public/ # Fichiers statiques
infra/ infra/
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug, .env.docker) dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
prod/ # Docker prod (multi-stage, nginx, php-prod.ini) prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
.gitea/workflows/ # CI Gitea (auto-tag, build Docker) .gitea/workflows/ # CI Gitea (auto-tag, build Docker)
.claude/
skills/create-module/ # Skill Claude Code pour scaffolder un module
``` ```
---
## CI/CD ## CI/CD
- **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z` - **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z`
- **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry - **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry
Secrets requis dans Gitea : Secrets requis dans Gitea :
- `RELEASE_TOKEN` — PAT avec droits `write:repository` - `RELEASE_TOKEN` — PAT avec droits `write:repository`
- `REGISTRY_TOKEN` — token pour le registry Docker - `REGISTRY_TOKEN` — token pour le registry Docker
--- ## Déploiement — seed RBAC (recette / prod)
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente** (présente dans le build prod,
contrairement aux fixtures Doctrine en `require-dev`). À jouer dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions commercial.clients.*
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test (mot de passe
fourni explicitement, jamais en dur) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'env RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de compte).
En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes démo).
## Credentials (dev)
| Username | Password | Role | RBAC métier |
|----------|----------|------|-------------|
| admin | admin | ROLE_ADMIN | bypass (is_admin) |
| alice | alice | ROLE_USER | — |
| bob | bob | ROLE_USER | — |
| bureau | demo | ROLE_USER | clients : view + manage |
| compta | demo | ROLE_USER | clients : view + accounting.view/manage |
| commerciale | demo | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| usine | demo | ROLE_USER | aucun accès clients |
## Conventions ## Conventions
@@ -366,13 +213,4 @@ Secrets requis dans Gitea :
<type>(<scope optionnel>) : <message> <type>(<scope optionnel>) : <message>
``` ```
Espaces obligatoires autour du `:`. Types : `build`, `chore`, `ci`, `docs`, `feat`, Types : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`
`fix`, `perf`, `refactor`, `revert`, `style`, `test`.
### Langue
- UI et communication : **français**
- Code (classes, méthodes, variables) : **anglais**
- Commentaires (PHP, TS, Vue) : **français**
> Règles détaillées : `CLAUDE.md` et `.claude/rules/`.
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.77' app.version: '0.1.72'
+2 -2
View File
@@ -18,8 +18,8 @@ merge de la stack.
| RG | Intitulé | Test(s) | Source | | RG | Intitulé | Test(s) | Source |
|----|----------|---------|--------| |----|----------|---------|--------|
| ~~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.01 | Prénom OU nom obligatoire → 422 | `ClientApiTest::testPostWithoutFirstOrLastNameReturns422` ; `ClientProcessorTest` (unit) | ERP-55 |
| ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact | | RG-1.02 | phoneSecondary persisté ; max 2 téléphones | `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` ; `::testThirdPhoneFieldIsIgnored` | **ERP-60** |
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 | | 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.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 | | RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
@@ -1,135 +0,0 @@
# 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<timestamp>.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).
@@ -1,58 +0,0 @@
# 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).
@@ -1,74 +0,0 @@
# 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.
@@ -1,47 +0,0 @@
# 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.
@@ -1,51 +0,0 @@
# 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).
@@ -1,38 +0,0 @@
# 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.
@@ -1,57 +0,0 @@
# Amendement des tickets M2 existants — suppression du contact inline du `Supplier`
Les 14 tickets M2 (n° 8497, 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).
@@ -1,55 +0,0 @@
# 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.
@@ -1,36 +0,0 @@
# 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.
@@ -1,84 +0,0 @@
# 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° 8497) 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.
+53 -26
View File
@@ -5,11 +5,8 @@ nom: "Répertoire clients"
ecran: repertoire-clients ecran: repertoire-clients
owner_spec: Matthieu owner_spec: Matthieu
backup_spec: Tristan backup_spec: Tristan
version: V1 version: V0
date_redaction: 2026-05-28 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 === # === LIENS ===
spec_front: ./spec-front.md spec_front: ./spec-front.md
@@ -206,11 +203,11 @@ Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter V
| | +-----------------------+ | (Catalog) | | | +-----------------------+ | (Catalog) |
| id (PK) | +--------------+ | id (PK) | +--------------+
| company_name | | company_name |
| (contact inline | +-----------------------+ +--------------+ | first_name | +-----------------------+ +--------------+
| retiré V1 — |--1:n-->| client_contact | | site | | last_name |--1:n-->| client_contact | | site |
| firstName, | +-----------------------+ | (Sites) | | phone_primary | +-----------------------+ | (Sites) |
| lastName, phones,| +--------------+ | phone_secondary | +--------------+
| email) | +-----------------------+ ^ | email | +-----------------------+ ^
| distributor_id |--1:n-->| client_address |--n:m---------+ | distributor_id |--1:n-->| client_address |--n:m---------+
| broker_id | +-----------------------+ | broker_id | +-----------------------+
| triage_service | | | triage_service | |
@@ -305,8 +302,11 @@ CREATE TABLE client (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
-- Formulaire principal -- Formulaire principal
company_name VARCHAR(180) NOT NULL, company_name VARCHAR(180) NOT NULL,
-- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary / first_name VARCHAR(120),
-- phone_secondary / email vivent désormais uniquement dans client_contact (onglet Contacts). last_name VARCHAR(120),
phone_primary VARCHAR(20) NOT NULL,
phone_secondary VARCHAR(20),
email VARCHAR(180) NOT NULL,
distributor_id INT REFERENCES client(id) ON DELETE SET NULL, distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
broker_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, triage_service BOOLEAN NOT NULL DEFAULT FALSE,
@@ -580,9 +580,32 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
// Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary / // RG-1.01 — first_name OU last_name obligatoire (validation Assert\Callback
// phoneSecondary / email ne sont plus portés par Client — ils vivent dans ClientContact // au niveau de l'entite, levee dans le Processor).
// (onglet Contacts). La garantie « ≥ 1 contact nommé » est portée par RG-1.05 + RG-1.14. #[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;
// RG-1.03 — distributor / broker auto-references (mutuellement exclusives, // RG-1.03 — distributor / broker auto-references (mutuellement exclusives,
// contrainte CHECK en base). // contrainte CHECK en base).
@@ -726,7 +749,7 @@ class Client implements TimestampableInterface, BlamableInterface
- **Query params** : - **Query params** :
- `includeArchived=true|false` (default `false`) - `includeArchived=true|false` (default `false`)
- `categoryCode=<code>` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`) - `categoryCode=<code>` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`)
- `search=<text>` (recherche fuzzy sur companyName + contacts liés `client_contact` (firstName / lastName / email) via LEFT JOIN groupé par `client.id` — décision D1, refonte-contact) - `search=<text>` (recherche fuzzy sur companyName + lastName + email)
- **Tri par défaut** : `companyName ASC` - **Tri par défaut** : `companyName ASC`
- **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible). Pas de pagination serveur au M1. - **Pagination** : front via `<MalioDataTable>` (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`). - **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`).
@@ -745,6 +768,10 @@ class Client implements TimestampableInterface, BlamableInterface
```json ```json
{ {
"companyName": "ACME SAS", "companyName": "ACME SAS",
"firstName": "Jean",
"lastName": "Dupont",
"phonePrimary": "0612345678",
"email": "jean.dupont@acme.fr",
"categories": ["/api/categories/3", "/api/categories/7"], "categories": ["/api/categories/3", "/api/categories/7"],
"distributor": null, "distributor": null,
"broker": null, "broker": null,
@@ -756,7 +783,7 @@ class Client implements TimestampableInterface, BlamableInterface
- `201` / `400` / `401` / `403` - `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). - `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` : - `422 Unprocessable Entity` :
- (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.01 : ni firstName ni lastName
- RG-1.03 : distributor + broker remplis simultanément - RG-1.03 : distributor + broker remplis simultanément
- Catégories vides (Assert\Count min=1) - Catégories vides (Assert\Count min=1)
@@ -858,8 +885,8 @@ Cf. § 2.6. Pattern Shared standard.
### Formulaire principal ### Formulaire principal
- ~~**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.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**~~ _(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.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.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`. - **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 ### Onglet Information
@@ -902,9 +929,9 @@ Cf. § 2.6. Pattern Shared standard.
### Normalisation serveur (formatage) ### 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.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 `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.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 `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.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` (`ClientAddress.billingEmail`, `ClientContact.email` ; `Client.email` retiré en V1) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`). - **RG-1.21** : `email` (`Client.email`, `ClientAddress.billingEmail`, `ClientContact.email`) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`).
### Archivage ### Archivage
@@ -933,8 +960,8 @@ Cf. § 2.6. Pattern Shared standard.
### 8.1 Cas à couvrir (back — PHPUnit) ### 8.1 Cas à couvrir (back — PHPUnit)
- [ ] ~~RG-1.01~~ _(supprimée V1)_ : la complétude du contact est couverte par RG-1.05 / RG-1.14 sur `ClientContact` - [ ] **RG-1.01** : POST sans firstName ni lastName → 422
- [ ] ~~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.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.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201 - [ ] **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.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 - [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200
@@ -948,9 +975,9 @@ Cf. § 2.6. Pattern Shared standard.
- [ ] **RG-1.14** : front-driven uniquement, pas de test back - [ ] **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.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.18** : POST `companyName="acme sas"` → BDD persiste `"ACME SAS"`
- [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` (via un bloc `ClientContact`) → persiste `"Jean"`, `"Dupont"` - [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` → persiste `"Jean"`, `"Dupont"`
- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` (via un bloc `ClientContact`) → persiste `"0612345678"` - [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` → persiste `"0612345678"`
- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` (via `ClientContact` ou `ClientAddress.billingEmail`) → persiste `"jean.dupont@acme.fr"` - [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` → 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.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.24/25** : GET liste sans flag → exclut archivés ; avec `?includeArchived=true` → inclut
- [ ] **RG-1.26** : GET liste → tri companyName ASC - [ ] **RG-1.26** : GET liste → tri companyName ASC
+13 -11
View File
@@ -5,10 +5,7 @@ nom: "Répertoire clients"
ecran: repertoire-clients ecran: repertoire-clients
owner_spec: Matthieu owner_spec: Matthieu
backup_spec: Tristan backup_spec: Tristan
version: V1 version: V0
# 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 date_redaction: 2026-05-28
# === LIENS === # === LIENS ===
@@ -71,6 +68,9 @@ Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maqu
| Colonne | Source | Tri | | Colonne | Source | Tri |
|---|---|---| |---|---|---|
| **Nom entreprise** | `client.companyName` | ASC par défaut | | **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 | | **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 | | **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
@@ -86,12 +86,15 @@ 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. 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 | | Champ | Type composant | Obligatoire | Règle |
|---|---|---|---| |---|---|---|---|
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) | | **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
| **Nom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
| **Prénom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category | | **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
| **Téléphone principal** | `<MalioInputText>` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) |
| **Téléphone secondaire** | `<MalioInputText>` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. |
| **Email** | `<MalioInputText>` type email | Oui | RG-1.21 (lowercase) |
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. | | **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
| **Nom du distributeur** | `<MalioSelect>` | 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 distributeur** | `<MalioSelect>` | 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** | `<MalioSelect>` | 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. | | **Nom du courtier** | `<MalioSelect>` | 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. |
@@ -117,7 +120,7 @@ Saisir les informations de l'entreprise.
### Onglet « Contact » ### Onglet « Contact »
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). 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).
**Bloc Contact** : **Bloc Contact** :
@@ -247,7 +250,7 @@ Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.
|---|---|---| |---|---|---|
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE | | Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique | | Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
| Téléphone (téléphones des blocs `ClientContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) | | Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
| Email | lowercase intégral | identique | | 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()`. > **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()`.
@@ -258,8 +261,7 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.
- Composable dédié `useAddressAutocomplete()` (à créer en M1). - Composable dédié `useAddressAutocomplete()` (à créer en M1).
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back. - Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back.
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}` (sans filtre `type`) → suggestions adresse. - Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse.
- ⚠ **Ne pas forcer `type=housenumber`** sur la recherche d'adresse (corrigé en ERP-66) : la BAN ne renvoie un résultat de ce type qu'une fois un numéro saisi, donc une recherche par nom de rue (« boulevard du port ») renverrait **0 résultat** pendant toute la frappe. Sans filtre `type`, la BAN classe rues + numéros par pertinence — comportement d'autocomplétion attendu.
- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre. - Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre.
## Points laissés ouverts par la V0 (résolus côté back) ## Points laissés ouverts par la V0 (résolus côté back)
@@ -273,7 +275,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. | | 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. | | 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. | | 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) | Sur les blocs `ClientContact` (`phone_primary` + `phone_secondary`). _(V1 : retirés du Client — refonte-contact.)_ | | 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. |
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. | | 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). | | 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. | | 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
File diff suppressed because it is too large Load Diff
-331
View File
@@ -1,331 +0,0 @@
---
# === 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) | `<MalioInputText>` | `?search=` |
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type FOURNISSEUR) | `?categoryCode=` |
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/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 : `<MalioDataTable>` branché sur `usePaginatedList<Supplier>({ 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** : `<MalioDataTable>` + `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)** | `<MalioInputText>` | Oui | RG-2.12 (UPPERCASE serveur) |
| **Catégorie** | `<MalioSelectCheckbox>` (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 » (`<MalioButton>`) → 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** | `<MalioInputTextArea>` | Conditionnel | RG-2.03 (obligatoire rôle Commerciale) |
| **Concurrent** | `<MalioInputText>` | Conditionnel | RG-2.03 |
| **Date création** (entreprise) | `<input type="date">` (exception Malio — `// TODO migrer`) | Conditionnel | RG-2.03 |
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-2.03 |
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-2.03 |
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
| **Volume Prévisionnel** | `<MalioInputNumber>` | 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** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
| **Fonction** | `<MalioInputText>` | Non | — |
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-2.12 (format) |
| **Email** | `<MalioInputText>` 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** | `<MalioRadioButton>``Prospect` / `Départ` / `Rendu` | Oui | RG-2.09 (exclusif, enum `PROSPECT`/`DEPART`/`RENDU`) |
| **Pays** | `<MalioSelect>` (saisie assistée — préremplie « France ») | Oui | — |
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — déclenche autocomplete ville (BAN) |
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-2.05 — alimentée par api-adresse.data.gouv.fr suivant le CP |
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — autocomplete BAN |
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
| **Sélecteur de site** | `<MalioSelectCheckbox>` (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** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type FOURNISSEUR (RG-2.10), liées aux catégories du fournisseur |
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
| **Benne(s)** | `<MalioInputNumber>` (stepper /+ , défaut 0) | Non | Champ spécifique fournisseur |
| **Prestation de triage** | `<MalioCheckbox>` | 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** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. § 2.6) |
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel M1) |
| **N° de TVA** | `<MalioInputText>` | Oui | — |
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
| **Banque** | `<MalioSelect>` | Conditionnel | RG-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é** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
| **IBAN** | `<MalioInputText>` | 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** : `<MalioDataTable>` (+ `usePaginatedList`)
- **Input texte** : `<MalioInputText>`
- **Input numérique** : `<MalioInputNumber>` (Nombre de salariés, Volume prévisionnel, Bennes)
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
- **TextArea** : `<MalioInputTextArea>` (Description)
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
- **Radio** : `<MalioRadioButton>` (Type d'adresse Prospect / Départ / Rendu — RG-2.09)
- **Checkbox** : `<MalioCheckbox>` (Prestataire de triage)
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
- **Toasts** : standards via `useApi()`
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
- `<input type="date">` pour « Date Création » (`MalioDate` non couvert).
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1 si présent).
## Composables & appels API
- `usePaginatedList<Supplier>({ 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 `<MalioInputText>` 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).
+5 -5
View File
@@ -10,11 +10,7 @@
"confirm": "Confirmer", "confirm": "Confirmer",
"yes": "Oui", "yes": "Oui",
"no": "Non", "no": "Non",
"actions": "Actions", "actions": "Actions"
"comingSoon": {
"title": "En cours de dev",
"subtitle": "Cette fonctionnalité arrive bientôt."
}
}, },
"sidebar": { "sidebar": {
"administration": { "administration": {
@@ -99,6 +95,8 @@
"back": "Retour au répertoire", "back": "Retour au répertoire",
"loading": "Chargement du client…", "loading": "Chargement du client…",
"notFound": "Client introuvable.", "notFound": "Client introuvable.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"confirmArchive": { "confirmArchive": {
"title": "Archiver le client", "title": "Archiver le client",
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?" "message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
@@ -113,6 +111,8 @@
"back": "Retour au répertoire", "back": "Retour au répertoire",
"loading": "Chargement du client…", "loading": "Chargement du client…",
"notFound": "Client introuvable.", "notFound": "Client introuvable.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"save": "Valider" "save": "Valider"
}, },
"validation": { "validation": {
@@ -201,8 +201,7 @@ const model = computed(() => props.modelValue)
const degraded = ref(false) const degraded = ref(false)
// Villes proposees par la BAN (alimentees a la saisie du code postal). // Villes proposees par la BAN (alimentees a la saisie du code postal).
const banCityOptions = ref<RefOption[]>([]) const banCityOptions = ref<RefOption[]>([])
// Adresses proposees par la BAN (alimentees a la saisie d'adresse). const addressOptions = ref<RefOption[]>([])
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours // Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options) // dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
@@ -215,20 +214,6 @@ const cityOptions = computed<RefOption[]>(() => {
} }
return banCityOptions.value return banCityOptions.value
}) })
// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit
// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout
// l'affichage depuis ses options) laisse le champ VIDE des que la liste de
// suggestions BAN est vide typiquement juste apres validation (remontage) ou
// a l'edition d'une adresse existante (1.12), alors que la valeur est bien
// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false) const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
@@ -295,7 +280,7 @@ async function onAddressSearch(query: string): Promise<void> {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode) const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
} }
catch { catch {
enterDegraded() enterDegraded()
@@ -0,0 +1,14 @@
<template>
<!--
Placeholder des onglets non encore implementes (Transport, Statistiques,
Rapports, Echanges). Frame vide blanche : aucun champ, aucun bouton,
aucun message « En cours » (decision Tristan 28/05). L'orchestrateur passe
automatiquement a l'onglet suivant — ce composant n'est qu'une coquille
visuelle reutilisee par 1.11/1.12.
-->
<div class="min-h-[240px] rounded-md bg-white" />
</template>
<script setup lang="ts">
// Composant purement presentationnel : aucune prop, aucun event.
</script>
@@ -1,76 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyAddress } from '~/modules/commercial/types/clientForm'
import ClientAddressBlock from '../ClientAddressBlock.vue'
// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee.
// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions
// vide » (remontage apres validation / edition d'une adresse existante).
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: vi.fn(),
searchAddress: vi.fn(),
}),
}))
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
// Stub de MalioInputAutocomplete : expose les `value` des options recues, pour
// verifier que la rue courante figure bien dans la liste (sinon le composant
// Malio ne peut pas resoudre/afficher la valeur liee -> champ vide).
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: {
modelValue: { type: [String, Number, null], default: undefined },
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
loading: { type: Boolean, default: false },
minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', {
'data-testid': 'addr-autocomplete',
'data-options': JSON.stringify(props.options.map(o => o.value)),
})
},
})
function mountBlock(street: string | null) {
return mount(ClientAddressBlock, {
props: {
modelValue: { ...emptyAddress(), street },
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
it('inclut la rue courante dans les options de l\'autocomplete meme sans recherche BAN', () => {
const wrapper = mountBlock('8 Boulevard du Port')
const el = wrapper.find('[data-testid="addr-autocomplete"]')
const values = JSON.parse(el.attributes('data-options') ?? '[]')
expect(values).toContain('8 Boulevard du Port')
})
})
@@ -45,7 +45,6 @@ interface CategoryMember extends HydraMember {
interface SiteMember extends HydraMember { interface SiteMember extends HydraMember {
name: string name: string
postalCode: string
} }
interface ReferentialMember extends HydraMember { interface ReferentialMember extends HydraMember {
@@ -102,10 +101,7 @@ export function useClientReferentials() {
fetchAll<CategoryMember>('/categories') fetchAll<CategoryMember>('/categories')
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }),
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
// expose par /sites (groupe site:read) — aucune colonne a ajouter.
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
fetchAll<ReferentialMember>('/tva_modes') fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays') fetchAll<ReferentialMember>('/payment_delays')
@@ -179,6 +179,9 @@
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyContacts') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6"> <div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
@@ -216,6 +219,9 @@
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@degraded="onAddressDegraded" @degraded="onAddressDegraded"
/> />
<p v-if="addresses.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyAddresses') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6"> <div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
@@ -239,7 +245,7 @@
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -306,7 +312,7 @@
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -344,10 +350,10 @@
</template> </template>
<!-- Onglets non encore implementes : frame vide (navigation libre). --> <!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template> <template #transport><TabPlaceholderBlank /></template>
<template #statistics><ComingSoonPlaceholder /></template> <template #statistics><TabPlaceholderBlank /></template>
<template #reports><ComingSoonPlaceholder /></template> <template #reports><TabPlaceholderBlank /></template>
<template #exchanges><ComingSoonPlaceholder /></template> <template #exchanges><TabPlaceholderBlank /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -489,11 +495,6 @@ function hydrate(detail: ClientDetail): void {
contacts.value = (detail.contacts ?? []).map(mapContactToDraft) contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft) addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
ribs.value = (detail.ribs ?? []).map(mapRibToDraft) ribs.value = (detail.ribs ?? []).map(mapRibToDraft)
// Chaque bloc reste visible meme vide : si une collection est vide, on amorce
// un bloc vierge (non persiste tant qu'incomplet cf. submit*/canValidate*).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
if (ribs.value.length === 0) ribs.value.push(emptyRib())
// Charge les listes distributeur / courtier si une relation est deja posee. // Charge les listes distributeur / courtier si une relation est deja posee.
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {}) if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {}) if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
@@ -693,8 +694,6 @@ function askRemoveContact(index: number): void {
const removed = contacts.value[index] const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id) if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1) contacts.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
}) })
} }
@@ -743,9 +742,7 @@ const canValidateAddresses = computed(() =>
addresses.value.length > 0 addresses.value.length > 0
&& addresses.value.every((a) => { && addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return a.siteIris.length >= 1 return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail)
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}), }),
) )
@@ -758,8 +755,6 @@ function askRemoveAddress(index: number): void {
const removed = addresses.value[index] const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id) if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
}) })
} }
@@ -838,8 +833,6 @@ function askRemoveRib(index: number): void {
const removed = ribs.value[index] const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id) if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1) ribs.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
} }
@@ -159,6 +159,9 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
readonly readonly
/> />
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyContacts') }}
</p>
</div> </div>
</template> </template>
@@ -171,11 +174,14 @@
:model-value="view.draft" :model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:category-options="view.categoryOptions" :category-options="view.categoryOptions"
:site-options="allSiteOptions" :site-options="view.siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
readonly readonly
/> />
<p v-if="addressViews.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyAddresses') }}
</p>
</div> </div>
</template> </template>
@@ -183,7 +189,7 @@
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
:model-value="accounting.siren" :model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -238,7 +244,7 @@
:key="rib.id ?? index" :key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
:model-value="rib.label" :model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -260,10 +266,10 @@
</template> </template>
<!-- Onglets non encore implementes : frame vide (navigation libre). --> <!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template> <template #transport><TabPlaceholderBlank /></template>
<template #statistics><ComingSoonPlaceholder /></template> <template #statistics><TabPlaceholderBlank /></template>
<template #reports><ComingSoonPlaceholder /></template> <template #reports><TabPlaceholderBlank /></template>
<template #exchanges><ComingSoonPlaceholder /></template> <template #exchanges><TabPlaceholderBlank /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -314,7 +320,6 @@ import {
type SelectOption, type SelectOption,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import { formatPhoneFR } from '~/shared/utils/phone' import { formatPhoneFR } from '~/shared/utils/phone'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
// Masques d'affichage (purement visuels, la donnee reste celle du serveur). // Masques d'affichage (purement visuels, la donnee reste celle du serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -325,7 +330,6 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const { can, canAny } = usePermissions() const { can, canAny } = usePermissions()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est // Gating de la route : la consultation exige `view`. Usine (sans view) est
// redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7. // redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7.
@@ -368,21 +372,10 @@ const information = computed(() => ({
directorName: client.value?.directorName ?? null, directorName: client.value?.directorName ?? null,
})) }))
// Chaque bloc reste visible meme vide en consultation : si la collection est const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft))
// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun »).
const contacts = computed(() => {
const list = (client.value?.contacts ?? []).map(mapContactToDraft)
return list.length ? list : [emptyContact()]
})
// Vue par adresse : brouillon + options (sites/categories) propres a l'adresse. // Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
const addressViews = computed(() => { const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView))
const views = (client.value?.addresses ?? []).map(mapAddressView) const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
})
const ribs = computed(() => {
const list = (client.value?.ribs ?? []).map(mapRibToDraft)
return list.length ? list : [emptyRib()]
})
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view). // Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -392,18 +385,6 @@ const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as Clie
const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories)) const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories))
const contactOptions = computed(() => contactOptionsOf(client.value?.contacts)) const contactOptions = computed(() => contactOptionsOf(client.value?.contacts))
// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read donc
// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero
// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS
// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non
// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris).
const allSiteOptions = computed<SelectOption[]>(() =>
(authStore.user?.sites ?? []).map(s => ({
value: `/api/sites/${s.id}`,
label: (s.postalCode ?? '').slice(0, 2),
})),
)
const relationOptions = computed<SelectOption[]>(() => [ const relationOptions = computed<SelectOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
@@ -233,7 +233,7 @@
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -301,7 +301,7 @@
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)" @click="askRemoveRib(index)"
/> />
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-3 gap-x-[80px] gap-y-5">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -341,7 +341,7 @@
<!-- Onglet non encore implemente : frame vide, passage automatique. <!-- Onglet non encore implemente : frame vide, passage automatique.
Statistiques / Rapports / Echanges sont edit-only (absents a la Statistiques / Rapports / Echanges sont edit-only (absents a la
creation) cf. buildClientFormTabKeys. --> creation) cf. buildClientFormTabKeys. -->
<template #transport><ComingSoonPlaceholder /></template> <template #transport><TabPlaceholderBlank /></template>
</MalioTabList> </MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). --> <!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
@@ -755,9 +755,7 @@ const canValidateAddresses = computed(() =>
addresses.value.length > 0 addresses.value.length > 0
&& addresses.value.every((a) => { && addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return a.siteIris.length >= 1 return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail)
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}), }),
) )
@@ -872,8 +870,6 @@ function addRib(): void {
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => { askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
ribs.value.splice(index, 1) ribs.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce au montage).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
} }
@@ -960,8 +956,5 @@ interface ContactResponse {
onMounted(() => { onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides. // Echec du chargement des referentiels non bloquant : les selects restent vides.
referentials.loadCommon().catch(() => {}) referentials.loadCommon().catch(() => {})
// Au moins un bloc RIB toujours visible en creation : on amorce un bloc vide
// (non persiste tant qu'incomplet RG-1.13).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
</script> </script>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

@@ -1,51 +0,0 @@
<template>
<!--
Placeholder generique « En cours de dev » pour les ecrans / onglets non
encore implementes. Composant PARTAGE (shared/components) : auto-importe
sans prefixe (`<ComingSoonPlaceholder>`) et reutilisable depuis n'importe
quel module. Affiche un gif (asset local par defaut) + un message i18n.
-->
<div class="flex min-h-[240px] flex-col items-center justify-center gap-4 rounded-md bg-white py-10">
<img
v-if="!imageFailed"
:src="src"
:alt="resolvedTitle"
class="max-h-[220px] w-auto rounded-md"
@error="imageFailed = true"
>
<!-- Repli si le gif ne charge pas (offline, CSP, asset absent) :
illustration emoji, le message reste affiche. -->
<div v-else class="text-5xl" aria-hidden="true">🚧 👨💻 🚧</div>
<div class="text-center">
<p class="text-xl font-bold text-black">{{ resolvedTitle }}</p>
<p class="mt-1 text-black/60">{{ resolvedSubtitle }}</p>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
/** Source de l'image/gif affichee. Defaut : asset local `/coming-soon.gif`. */
src?: string
/** Titre. Defaut : i18n `common.comingSoon.title`. */
title?: string
/** Sous-titre. Defaut : i18n `common.comingSoon.subtitle`. */
subtitle?: string
}>(),
{
src: '/coming-soon.gif',
title: '',
subtitle: '',
},
)
const { t } = useI18n()
const imageFailed = ref(false)
// Les props priment sur les libelles i18n par defaut (permet a un module
// d'override le texte sans toucher au composant).
const resolvedTitle = computed(() => props.title || t('common.comingSoon.title'))
const resolvedSubtitle = computed(() => props.subtitle || t('common.comingSoon.subtitle'))
</script>
@@ -1,132 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
useAddressAutocomplete,
AddressAutocompleteUnavailableError,
} from '../useAddressAutocomplete'
// On mocke le helper d'appel externe : aucun vrai appel reseau a la BAN.
// vi.mock est hoiste par Vitest au-dessus des imports.
const mockHttp = vi.hoisted(() => vi.fn())
vi.mock('~/shared/utils/httpExternal', () => ({ httpExternal: mockHttp }))
const BAN_URL = 'https://api-adresse.data.gouv.fr/search/'
describe('useAddressAutocomplete', () => {
beforeEach(() => {
mockHttp.mockReset()
})
describe('searchCity', () => {
it('interroge la BAN en type=municipality et mappe { city, postalCode }', async () => {
mockHttp.mockResolvedValueOnce({
type: 'FeatureCollection',
features: [
{ properties: { city: 'Amiens', postcode: '80000', name: 'Amiens', type: 'municipality' } },
{ properties: { city: 'Amiens', postcode: '80080', name: 'Amiens', type: 'municipality' } },
],
})
const { searchCity } = useAddressAutocomplete()
const res = await searchCity('80000')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({ query: { q: '80000', type: 'municipality' } }),
)
expect(res).toEqual([
{ city: 'Amiens', postalCode: '80000' },
{ city: 'Amiens', postalCode: '80080' },
])
})
it('throw une AddressAutocompleteUnavailableError sur erreur reseau / 5xx', async () => {
mockHttp.mockRejectedValueOnce(new Error('500 Server Error'))
const { searchCity } = useAddressAutocomplete()
await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError)
})
it('throw une AddressAutocompleteUnavailableError sur timeout', async () => {
mockHttp.mockRejectedValueOnce(new Error('The operation was aborted due to timeout'))
const { searchCity } = useAddressAutocomplete()
await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError)
})
})
describe('searchAddress', () => {
it('interroge la BAN avec postcode et mappe la suggestion', async () => {
mockHttp.mockResolvedValueOnce({
type: 'FeatureCollection',
features: [
{
properties: {
label: '8 Boulevard du Port 80000 Amiens',
name: '8 Boulevard du Port',
street: 'Boulevard du Port',
postcode: '80000',
city: 'Amiens',
type: 'housenumber',
},
},
],
})
const { searchAddress } = useAddressAutocomplete()
const res = await searchAddress('8 boulevard du port', '80000')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({
query: { q: '8 boulevard du port', postcode: '80000' },
}),
)
expect(res).toEqual([
{
label: '8 Boulevard du Port 80000 Amiens',
street: '8 Boulevard du Port',
postalCode: '80000',
city: 'Amiens',
},
])
})
it('omet le parametre postcode quand aucun code postal n\'est fourni', async () => {
mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] })
const { searchAddress } = useAddressAutocomplete()
await searchAddress('8 boulevard du port')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({
query: { q: '8 boulevard du port' },
}),
)
})
it('ne restreint PAS la recherche a type=housenumber (sinon la BAN ne renvoie rien tant qu\'aucun numero n\'est saisi)', async () => {
// Regression : avec `type=housenumber`, une saisie de nom de rue sans
// numero (ex: « boulevard du port ») renvoie 0 resultat cote BAN.
mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] })
const { searchAddress } = useAddressAutocomplete()
await searchAddress('boulevard du port', '80000')
const sentQuery = mockHttp.mock.calls[0]?.[1]?.query as Record<string, string>
expect(sentQuery.type).toBeUndefined()
})
it('throw une AddressAutocompleteUnavailableError sur erreur reseau', async () => {
mockHttp.mockRejectedValueOnce(new Error('network down'))
const { searchAddress } = useAddressAutocomplete()
await expect(searchAddress('8 boulevard du port', '80000')).rejects.toBeInstanceOf(
AddressAutocompleteUnavailableError,
)
})
})
})
@@ -1,29 +1,27 @@
import { httpExternal } from '~/shared/utils/httpExternal' // STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66.
// Autocompletion d'adresse branchee sur la Base Adresse Nationale (BAN),
// `api-adresse.data.gouv.fr` — service public francais, gratuit, CORS ouvert.
// //
// Appel HTTP DIRECT depuis le front (pas de proxy back), conformement a la spec // Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre
// M1 (§ API adresse postale). On passe par `httpExternal` et NON `useApi()` : // qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels
// la BAN est un domaine externe, sans cookie de session ni enveloppe Hydra. // api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS
// changer leur signature ni l'usage côté composant.
// //
// Contrat (fige) : // Contrat figé par ERP-66 (c'est lui qui fait foi) :
// searchCity(postalCode) -> liste { city, postalCode } // searchCity(postalCode) -> liste { city, postalCode }
// searchAddress(query, cp?) -> liste { label, street, postalCode, city } // searchAddress(query, cp?) -> liste { label, street, postalCode, city }
// En cas d'erreur/timeout, la methode THROW une AddressAutocompleteUnavailableError. // En cas d'erreur/timeout, la méthode THROW. Le composant catch l'erreur,
// Le composant consommateur catch, affiche un toast d'avertissement et bascule // affiche un toast d'avertissement et bascule en saisie libre (MalioInputText).
// en saisie libre (MalioInputText). //
// Comportement du stub : les deux méthodes throw systématiquement → l'onglet
// Adresse part directement en mode dégradé (Ville + Adresse en saisie libre,
// Code postal saisi manuellement). Aucun appel réseau n'est émis ici.
/** URL de l'endpoint de recherche BAN. */ /** Une suggestion de ville renvoyée à partir d'un code postal. */
const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/'
/** Une suggestion de ville renvoyee a partir d'un code postal. */
export interface CitySuggestion { export interface CitySuggestion {
city: string city: string
postalCode: string postalCode: string
} }
/** Une suggestion d'adresse complete (saisie assistee du champ « Adresse »). */ /** Une suggestion d'adresse complète (saisie assistée du champ « Adresse »). */
export interface AddressSuggestion { export interface AddressSuggestion {
label: string label: string
street: string street: string
@@ -36,82 +34,27 @@ export interface AddressAutocomplete {
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
} }
/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */ /** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */
export class AddressAutocompleteUnavailableError extends Error { export class AddressAutocompleteUnavailableError extends Error {
constructor() { constructor() {
// Message technique (non affiche tel quel) : le composant remonte son // Message technique (non affiché tel quel) : le composant remonte son
// propre libelle i18n. Sert au debug / aux logs uniquement. // propre libellé i18n. Sert au debug / aux logs uniquement.
super('Address autocomplete (BAN) is not available.') super('Address autocomplete (BAN) is not available yet — ERP-66 stub.')
this.name = 'AddressAutocompleteUnavailableError' this.name = 'AddressAutocompleteUnavailableError'
} }
} }
/** Proprietes d'une « feature » GeoJSON renvoyee par la BAN (champs utilises). */ /**
interface BanFeatureProperties { * STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes
label?: string * échouent toujours, forçant le mode dégradé côté onglet Adresse.
name?: string */
street?: string
postcode?: string
city?: string
}
/** Reponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
features?: { properties?: BanFeatureProperties }[]
}
export function useAddressAutocomplete(): AddressAutocomplete { export function useAddressAutocomplete(): AddressAutocomplete {
return { return {
async searchCity(postalCode: string): Promise<CitySuggestion[]> { async searchCity(_postalCode: string): Promise<CitySuggestion[]> {
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
query: { q: postalCode, type: 'municipality' },
})
} catch {
// Reseau coupe, 5xx, timeout... -> mode degrade cote composant.
throw new AddressAutocompleteUnavailableError() throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
city: props.city ?? props.name ?? '',
postalCode: props.postcode ?? '',
}
})
}, },
async searchAddress(_query: string, _postalCode?: string): Promise<AddressSuggestion[]> {
async searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> {
// IMPORTANT : pas de `type=housenumber` ici. La BAN ne renvoie un
// resultat de ce type qu'une fois un numero saisi → une recherche par
// nom de rue (« boulevard du port ») renverrait 0 resultat pendant
// toute la frappe. Sans filtre `type`, la BAN classe rues + numeros
// par pertinence (comportement d'autocompletion attendu).
// On n'ajoute `postcode` que s'il est fourni (sinon recherche large).
const banQuery: Record<string, string> = { q: query }
if (postalCode) {
banQuery.postcode = postalCode
}
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, { query: banQuery })
} catch {
throw new AddressAutocompleteUnavailableError() throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
label: props.label ?? '',
// `name` porte la ligne d'adresse complete (numero + voie) ;
// `street` ne contient que la voie. On privilegie `name`.
street: props.name ?? props.street ?? '',
postalCode: props.postcode ?? '',
city: props.city ?? '',
}
})
}, },
} }
} }
@@ -1,56 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { httpExternal } from '../httpExternal'
// On mocke ofetch : httpExternal s'appuie sur $fetch sans jamais toucher le
// reseau pendant les tests. vi.mock est hoiste par Vitest au-dessus des imports.
const mockFetch = vi.hoisted(() => vi.fn())
vi.mock('ofetch', () => ({ $fetch: mockFetch }))
describe('httpExternal', () => {
beforeEach(() => {
mockFetch.mockReset()
})
it('retourne le JSON parse renvoye par $fetch', async () => {
mockFetch.mockResolvedValueOnce({ ok: true })
const res = await httpExternal<{ ok: boolean }>('https://example.test/api')
expect(res).toEqual({ ok: true })
})
it('transmet la query, coupe le cookie (credentials omit) et pose un timeout par defaut', async () => {
mockFetch.mockResolvedValueOnce([])
await httpExternal('https://example.test/search', {
query: { q: '80000', type: 'municipality' },
})
expect(mockFetch).toHaveBeenCalledWith(
'https://example.test/search',
expect.objectContaining({
query: { q: '80000', type: 'municipality' },
credentials: 'omit',
retry: 0,
timeout: 5000,
}),
)
})
it('permet de surcharger le timeout', async () => {
mockFetch.mockResolvedValueOnce(null)
await httpExternal('https://example.test', { timeoutMs: 1000 })
expect(mockFetch).toHaveBeenCalledWith(
'https://example.test',
expect.objectContaining({ timeout: 1000 }),
)
})
it('propage l\'erreur reseau / timeout (throw)', async () => {
mockFetch.mockRejectedValueOnce(new Error('network down'))
await expect(httpExternal('https://example.test')).rejects.toThrow('network down')
})
})
@@ -20,27 +20,4 @@ describe('formatPhoneFR', () => {
it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => { it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => {
expect(formatPhoneFR('123')).toBe('12 3') expect(formatPhoneFR('123')).toBe('12 3')
}) })
it('formate une saisie courte (<= 4 chiffres) sans planter', () => {
expect(formatPhoneFR('1')).toBe('1')
expect(formatPhoneFR('12')).toBe('12')
expect(formatPhoneFR('1234')).toBe('12 34')
})
it('strip les caracteres non numeriques (lettres, espaces, ponctuation)', () => {
expect(formatPhoneFR('abc')).toBe('')
expect(formatPhoneFR('Tel : 06.12')).toBe('06 12')
expect(formatPhoneFR(' 06 12 ')).toBe('06 12')
})
it('conserve l\'indicatif international (+33) sans le transformer', () => {
// Comportement fige : on retire seulement le `+`, on ne deduit pas le
// prefixe pays. Le `+33...` est donc groupe brut par paquets de 2.
expect(formatPhoneFR('+33612345678')).toBe('33 61 23 45 67 8')
})
it('groupe sans tronquer une saisie plus longue que 10 chiffres', () => {
// Aucune troncature silencieuse : on figure tous les chiffres groupes par 2.
expect(formatPhoneFR('061234567899')).toBe('06 12 34 56 78 99')
})
}) })
-40
View File
@@ -1,40 +0,0 @@
import { $fetch } from 'ofetch'
/**
* Options d'un appel HTTP externe.
*/
export interface HttpExternalOptions {
/** Parametres de query string (encodes par ofetch). */
query?: Record<string, string | number | undefined>
/** Timeout en millisecondes avant abandon (defaut 5000). */
timeoutMs?: number
}
/**
* Petit client HTTP pour les APIs PUBLIQUES EXTERNES (domaine tiers, hors `/api`).
*
* Pourquoi un helper dedie plutot que `useApi()` : `useApi()` est le client de
* l'API interne Starseed (baseURL `/api`, cookie JWT `credentials: 'include'`,
* parsing/erreurs Hydra, redirection `/login` sur 401, toasts i18n). Tout cela
* est inadapte voire indesirable pour un endpoint public externe comme la
* Base Adresse Nationale (`api-adresse.data.gouv.fr`).
*
* Ce helper est donc le SEUL point d'entree autorise pour un `$fetch` brut vers
* l'externe (cf. regle frontend n°4 : pas de `$fetch` eparpille dans les
* composants). Il :
* - cible une URL absolue (pas de baseURL `/api`) ;
* - n'envoie PAS le cookie de session (`credentials: 'omit'`) ;
* - ne retente pas (`retry: 0`) et applique un timeout ;
* - laisse remonter l'erreur (throw) au consommateur de gerer le mode degrade.
*/
export async function httpExternal<T>(
url: string,
opts: HttpExternalOptions = {},
): Promise<T> {
return $fetch<T>(url, {
query: opts.query,
credentials: 'omit',
retry: 0,
timeout: opts.timeoutMs ?? 5000,
})
}
-131
View File
@@ -1,131 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M1 Suppression du contact principal inline du `Client` (refonte contact).
*
* Modele AVANT : le `Client` portait 5 colonnes de contact principal
* (first_name, last_name, phone_primary, phone_secondary, email) en doublon
* conceptuel de la sous-entite `ClientContact` (onglet Contact).
*
* Modele APRES (decision produit, README refonte-contact) : les contacts vivent
* UNIQUEMENT dans `client_contact`. Les 5 colonnes inline disparaissent du
* `client`. RG-1.01 (firstName OU lastName sur Client) et RG-1.02 (max 2
* telephones sur Client) sont supprimees : leur equivalent vit deja sur
* `client_contact` (RG-1.05 / RG-1.14).
*
* Le code etant deja en prod, la suppression est precedee d'un BACKFILL : pour
* tout client n'ayant encore AUCUN contact, on materialise son contact principal
* inline en une ligne `client_contact` (position 0) avant le DROP, afin de ne
* perdre aucune donnee.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
* Commercial : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
* FQCN alphabetique (AlphabeticalComparator). Une migration
* `App\Module\Commercial\...` trierait AVANT toutes les `DoctrineMigrations\...`
* sur base vide -> ce DROP s'executerait avant le CREATE TABLE client
* (Version20260601000000). Le namespace racine garantit l'ordre par timestamp.
*/
final class Version20260603120000 extends AbstractMigration
{
/** Colonnes de contact inline supprimees du `client`. */
private const array INLINE_CONTACT_COLUMNS = [
'first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email',
];
public function getDescription(): string
{
return 'M1 : suppression du contact inline du Client (backfill vers client_contact puis DROP des 5 colonnes).';
}
public function up(Schema $schema): void
{
// 1. Backfill : tout client SANS contact recoit une ligne client_contact
// (position 0) reprenant ses champs inline. phone_primary / email du
// client sont NOT NULL -> toujours une donnee a reporter. Le CHECK
// chk_client_contact_name (first_name OU last_name) est garanti par le
// fallback company_name si jamais les deux noms etaient null (cas qui ne
// devrait pas exister, RG-1.01 ayant ete appliquee a l'ecriture).
// created_at/updated_at NOT NULL -> NOW() ; created_by/updated_by null
// (backfill hors contexte HTTP, libelle « Systeme » cote front).
$this->addSql(<<<'SQL'
INSERT INTO client_contact (
client_id, first_name, last_name, phone_primary, phone_secondary,
email, position, created_at, updated_at
)
SELECT
c.id,
c.first_name,
CASE
WHEN c.first_name IS NULL AND c.last_name IS NULL THEN c.company_name
ELSE c.last_name
END,
c.phone_primary,
c.phone_secondary,
c.email,
0,
NOW(),
NOW()
FROM client c
WHERE NOT EXISTS (
SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id
)
SQL);
// 2. DROP des 5 colonnes inline (rien a documenter : suppression).
$this->addSql(<<<'SQL'
ALTER TABLE client
DROP COLUMN first_name,
DROP COLUMN last_name,
DROP COLUMN phone_primary,
DROP COLUMN phone_secondary,
DROP COLUMN email
SQL);
}
public function down(Schema $schema): void
{
// Best-effort : on RECREE les 5 colonnes (en NULLABLE — l'etat NOT NULL
// d'origine de phone_primary/email ne peut etre restaure sur une table
// peuplee sans risque) et on retro-alimente depuis le contact principal
// (position minimale) de chaque client. La donnee n'est pas garantie
// identique a l'origine : ce down() sert au rollback technique, pas a une
// restauration fidele.
$this->addSql('ALTER TABLE client ADD COLUMN first_name VARCHAR(120) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN last_name VARCHAR(120) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN phone_primary VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN phone_secondary VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN email VARCHAR(180) DEFAULT NULL');
// Retro-alimentation depuis le contact de position la plus basse.
$this->addSql(<<<'SQL'
UPDATE client c SET
first_name = cc.first_name,
last_name = cc.last_name,
phone_primary = cc.phone_primary,
phone_secondary = cc.phone_secondary,
email = cc.email
FROM (
SELECT DISTINCT ON (client_id)
client_id, first_name, last_name, phone_primary, phone_secondary, email
FROM client_contact
ORDER BY client_id, position ASC, id ASC
) cc
WHERE cc.client_id = c.id
SQL);
// Re-pose des commentaires d'origine (regle ABSOLUE n°12) — dollar-quoting
// Postgres pour eviter tout echappement d apostrophe.
$this->addSql('COMMENT ON COLUMN client.first_name IS $_$Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
$this->addSql('COMMENT ON COLUMN client.last_name IS $_$Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
$this->addSql('COMMENT ON COLUMN client.phone_primary IS $_$Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.$_$');
$this->addSql('COMMENT ON COLUMN client.phone_secondary IS $_$Telephone secondaire optionnel — chiffres uniquement (RG-1.20).$_$');
$this->addSql('COMMENT ON COLUMN client.email IS $_$Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).$_$');
}
}
+85 -3
View File
@@ -151,9 +151,31 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
// Le contact principal n'est plus porte inline par le Client : les contacts // RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor).
// vivent uniquement dans ClientContact (onglet Contact). RG-1.01 / RG-1.02 #[ORM\Column(length: 120, nullable: true)]
// supprimees du Client (equivalent RG-1.05 / RG-1.14 sur ClientContact). #[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;
// RG-1.03 : distributor / broker auto-references mutuellement exclusives // RG-1.03 : distributor / broker auto-references mutuellement exclusives
// (CHECK chk_client_distrib_or_broker en base). // (CHECK chk_client_distrib_or_broker en base).
@@ -304,6 +326,66 @@ class Client implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getPhonePrimary(): ?string
{
return $this->phonePrimary;
}
public function setPhonePrimary(string $phonePrimary): static
{
$this->phonePrimary = $phonePrimary;
return $this;
}
public function getPhoneSecondary(): ?string
{
return $this->phoneSecondary;
}
public function setPhoneSecondary(?string $phoneSecondary): static
{
$this->phoneSecondary = $phoneSecondary;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getDistributor(): ?Client public function getDistributor(): ?Client
{ {
return $this->distributor; return $this->distributor;
@@ -177,14 +177,12 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private Collection $contacts; private Collection $contacts;
// Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse).
// RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes). // RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_address_category')] #[ORM\JoinTable(name: 'client_address_category')]
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private Collection $categories; private Collection $categories;
@@ -29,7 +29,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
/** /**
* Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 / * Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 /
* § 2.9 / § 4.3 / § 4.4 + RG-1.03 a RG-1.28 (RG-1.01/1.02 supprimees : contact inline retire). * § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28.
* *
* Sequence (POST / PATCH) : * Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet. La security d'operation * 1. Autorisation additionnelle par groupe d'onglet. La security d'operation
@@ -41,7 +41,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et * - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
* interdit toute autre modification dans la meme requete (RG-1.22, 422). * interdit toute autre modification dans la meme requete (RG-1.22, 422).
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.03 (distributor/broker * 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque), * exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
* et tout PATCH pour le role Commerciale). * et tout PATCH pour le role Commerciale).
@@ -60,7 +60,8 @@ final class ClientProcessor implements ProcessorInterface
{ {
/** Champs de l'onglet principal (groupe client:write:main). */ /** Champs de l'onglet principal (groupe client:write:main). */
private const array MAIN_FIELDS = [ private const array MAIN_FIELDS = [
'companyName', 'distributor', 'broker', 'triageService', 'categories', 'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary',
'email', 'distributor', 'broker', 'triageService', 'categories',
]; ];
/** Champs de l'onglet Information (groupe client:write:information). */ /** Champs de l'onglet Information (groupe client:write:information). */
@@ -123,6 +124,7 @@ final class ClientProcessor implements ProcessorInterface
// valeurs normalisees des deux cotes (l'etat persiste l'a deja ete). // valeurs normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data); $this->guardManage($data);
$this->validateMainContact($data);
$this->validateDistributorBroker($data); $this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data); $this->validateAccountingConsistency($data);
$this->validateInformationCompleteness($data); $this->validateInformationCompleteness($data);
@@ -272,6 +274,11 @@ final class ClientProcessor implements ProcessorInterface
{ {
$newValues = [ $newValues = [
'companyName' => $data->getCompanyName(), 'companyName' => $data->getCompanyName(),
'firstName' => $data->getFirstName(),
'lastName' => $data->getLastName(),
'phonePrimary' => $data->getPhonePrimary(),
'phoneSecondary' => $data->getPhoneSecondary(),
'email' => $data->getEmail(),
'distributor' => $data->getDistributor(), 'distributor' => $data->getDistributor(),
'broker' => $data->getBroker(), 'broker' => $data->getBroker(),
'triageService' => $data->isTriageService(), 'triageService' => $data->isTriageService(),
@@ -413,17 +420,39 @@ final class ClientProcessor implements ProcessorInterface
} }
/** /**
* Normalisation serveur du formulaire principal (RG-1.18). Seul companyName * Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables
* subsiste cote Client depuis la suppression du contact inline (les champs de * (companyName, email, phonePrimary) ne sont touches que si une valeur est
* contact noms, telephones, email sont normalises par ClientContactProcessor). * presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
* Le setter non-nullable n'est touche que si une valeur est presente, pour ne
* jamais ecraser l'existant lors d'un PATCH partiel.
*/ */
private function normalize(Client $data): void private function normalize(Client $data): void
{ {
if (null !== $data->getCompanyName()) { if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName())); $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
} }
if (null !== $data->getEmail()) {
$data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail()));
}
if (null !== $data->getPhonePrimary()) {
$data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary()));
}
$data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName()));
$data->setLastName($this->normalizer->normalizePersonName($data->getLastName()));
$data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary()));
}
/**
* RG-1.01 : au moins le prenom OU le nom du contact principal.
*/
private function validateMainContact(Client $data): void
{
if (null === $data->getFirstName() && null === $data->getLastName()) {
$this->throwViolation(
'firstName',
'Le prénom ou le nom du contact principal est obligatoire.',
$data,
);
}
} }
/** /**
@@ -86,9 +86,7 @@ final class ClientExportController
} }
/** /**
* Colonnes de l'export. Depuis la suppression du contact inline (refonte * Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la
* contact, D2), les colonnes de contact principal sont retirees : l'export
* ne porte plus que les donnees propres au Client. SIREN inseree avant la
* date de creation, uniquement si l'utilisateur a accounting.view. * date de creation, uniquement si l'utilisateur a accounting.view.
* *
* @return list<string> * @return list<string>
@@ -97,6 +95,11 @@ final class ClientExportController
{ {
$headers = [ $headers = [
'Nom entreprise', 'Nom entreprise',
'Nom contact principal',
'Prénom',
'Téléphone principal',
'Téléphone secondaire',
'Email',
'Catégories', 'Catégories',
'Sites', 'Sites',
]; ];
@@ -120,6 +123,11 @@ final class ClientExportController
foreach ($clients as $client) { foreach ($clients as $client) {
$row = [ $row = [
$client->getCompanyName(), $client->getCompanyName(),
$client->getLastName(),
$client->getFirstName(),
$client->getPhonePrimary(),
$client->getPhoneSecondary(),
$client->getEmail(),
$this->formatCategories($client), $this->formatCategories($client),
$this->formatSites($client), $this->formatSites($client),
]; ];
@@ -112,6 +112,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$gso, $gsoIsNew] = $this->ensureClient( [$gso, $gsoIsNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Distrib Grand Sud-Ouest', companyName: 'Distrib Grand Sud-Ouest',
firstName: 'Paul',
lastName: 'Garnier',
phonePrimary: '05 56 10 20 30',
email: 'contact@distrib-gso.fr',
categoryNames: ['Distributeur'], categoryNames: ['Distributeur'],
); );
if ($gsoIsNew) { if ($gsoIsNew) {
@@ -123,6 +127,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$leonard, $leonardIsNew] = $this->ensureClient( [$leonard, $leonardIsNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Cabinet Léonard Assurances', companyName: 'Cabinet Léonard Assurances',
firstName: 'Sophie',
lastName: 'Léonard',
phonePrimary: '05 49 11 22 33',
email: 'contact@cabinet-leonard.fr',
categoryNames: ['Courtier'], categoryNames: ['Courtier'],
); );
if ($leonardIsNew) { if ($leonardIsNew) {
@@ -134,6 +142,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$dubois, $isNew] = $this->ensureClient( [$dubois, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Menuiserie Dubois', companyName: 'Menuiserie Dubois',
firstName: 'Jean',
lastName: 'Dubois',
phonePrimary: '05 49 00 00 01',
email: 'contact@menuiserie-dubois.fr',
categoryNames: ['BTP'], categoryNames: ['BTP'],
); );
if ($isNew) { if ($isNew) {
@@ -147,6 +159,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$garage, $isNew] = $this->ensureClient( [$garage, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Garage Martin', companyName: 'Garage Martin',
firstName: 'Luc',
lastName: 'Martin',
phonePrimary: '05 56 44 55 66',
email: 'accueil@garage-martin.fr',
categoryNames: ['Services'], categoryNames: ['Services'],
); );
if ($isNew) { if ($isNew) {
@@ -159,6 +175,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$boulangerie, $isNew] = $this->ensureClient( [$boulangerie, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Boulangerie Lemoine', companyName: 'Boulangerie Lemoine',
firstName: 'Marie',
lastName: 'Lemoine',
phonePrimary: '05 49 77 88 99',
email: 'bonjour@boulangerie-lemoine.fr',
categoryNames: ['Agro-alimentaire'], categoryNames: ['Agro-alimentaire'],
); );
if ($isNew) { if ($isNew) {
@@ -171,6 +191,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$transports, $isNew] = $this->ensureClient( [$transports, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Transports Rapides', companyName: 'Transports Rapides',
firstName: null,
lastName: 'Bernard',
phonePrimary: '05 56 12 13 14',
email: 'exploitation@transports-rapides.fr',
categoryNames: ['Transport/Logistique'], categoryNames: ['Transport/Logistique'],
); );
if ($isNew) { if ($isNew) {
@@ -185,6 +209,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$industries, $isNew] = $this->ensureClient( [$industries, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Industries Vertes', companyName: 'Industries Vertes',
firstName: 'Claire',
lastName: 'Moreau',
phonePrimary: '05 49 21 22 23',
email: 'contact@industries-vertes.fr',
categoryNames: ['Industrie'], categoryNames: ['Industrie'],
); );
if ($isNew) { if ($isNew) {
@@ -201,7 +229,12 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$agro, $isNew] = $this->ensureClient( [$agro, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Agro Distribution Sud', companyName: 'Agro Distribution Sud',
firstName: 'Thomas',
lastName: 'Petit',
phonePrimary: '05 56 31 32 33',
email: 'contact@agro-sud.fr',
categoryNames: ['Agro-alimentaire'], categoryNames: ['Agro-alimentaire'],
phoneSecondary: '06 01 02 03 04',
); );
if ($isNew) { if ($isNew) {
$this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0); $this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0);
@@ -214,6 +247,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$ancienne, $isNew] = $this->ensureClient( [$ancienne, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Ancienne Société Oubliée', companyName: 'Ancienne Société Oubliée',
firstName: null,
lastName: 'Durand',
phonePrimary: '05 49 99 99 99',
email: 'contact@ancienne-societe.fr',
categoryNames: ['Association'], categoryNames: ['Association'],
isArchived: true, isArchived: true,
); );
@@ -226,6 +263,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$services, $isNew] = $this->ensureClient( [$services, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Services Pro Conseil', companyName: 'Services Pro Conseil',
firstName: 'Nadia',
lastName: 'Benali',
phonePrimary: '05 49 41 42 43',
email: 'contact@services-pro.fr',
categoryNames: ['Services'], categoryNames: ['Services'],
); );
if ($isNew) { if ($isNew) {
@@ -238,6 +279,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$holding, $isNew] = $this->ensureClient( [$holding, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Holding Premium Invest', companyName: 'Holding Premium Invest',
firstName: 'Antoine',
lastName: 'Lefèvre',
phonePrimary: '05 56 51 52 53',
email: 'direction@holding-premium.fr',
categoryNames: ['Industrie'], categoryNames: ['Industrie'],
); );
if ($isNew) { if ($isNew) {
@@ -256,6 +301,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$conglo, $isNew] = $this->ensureClient( [$conglo, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Conglomérat Multi Activités', companyName: 'Conglomérat Multi Activités',
firstName: 'Hélène',
lastName: 'Faure',
phonePrimary: '05 49 61 62 63',
email: 'contact@conglomerat-multi.fr',
categoryNames: ['BTP', 'Industrie', 'Services'], categoryNames: ['BTP', 'Industrie', 'Services'],
); );
if ($isNew) { if ($isNew) {
@@ -267,6 +316,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$prospect, $isNew] = $this->ensureClient( [$prospect, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Prospect Futur Client', companyName: 'Prospect Futur Client',
firstName: 'Olivier',
lastName: 'Renard',
phonePrimary: '05 56 71 72 73',
email: 'olivier.renard@prospect-futur.fr',
categoryNames: ['BTP'], categoryNames: ['BTP'],
); );
if ($isNew) { if ($isNew) {
@@ -278,6 +331,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$association, $isNew] = $this->ensureClient( [$association, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Association des Riverains', companyName: 'Association des Riverains',
firstName: null,
lastName: 'Caron',
phonePrimary: '05 49 81 82 83',
email: 'contact@asso-riverains.fr',
categoryNames: ['Association'], categoryNames: ['Association'],
); );
if ($isNew) { if ($isNew) {
@@ -293,9 +350,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
* sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la * sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la
* reconstruction des sous-collections (idempotence sans doublon). * reconstruction des sous-collections (idempotence sans doublon).
* *
* Le contact principal n'est plus porte par le Client (refonte contact) : les
* coordonnees de contact sont fournies via addContact() dans le bloc isNew.
*
* @param list<string> $categoryNames * @param list<string> $categoryNames
* *
* @return array{0: Client, 1: bool} * @return array{0: Client, 1: bool}
@@ -303,7 +357,12 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
private function ensureClient( private function ensureClient(
ObjectManager $manager, ObjectManager $manager,
string $companyName, string $companyName,
?string $firstName,
?string $lastName,
string $phonePrimary,
string $email,
array $categoryNames, array $categoryNames,
?string $phoneSecondary = null,
bool $isArchived = false, bool $isArchived = false,
): array { ): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName); $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
@@ -315,6 +374,11 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$client = new Client(); $client = new Client();
$client->setCompanyName($normalizedName); $client->setCompanyName($normalizedName);
$client->setFirstName($this->normalizer->normalizePersonName($firstName));
$client->setLastName($this->normalizer->normalizePersonName($lastName));
$client->setPhonePrimary((string) $this->normalizer->normalizePhone($phonePrimary));
$client->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$client->setEmail((string) $this->normalizer->normalizeEmail($email));
foreach ($categoryNames as $categoryName) { foreach ($categoryNames as $categoryName) {
$client->addCategory($this->category($manager, $categoryName)); $client->addCategory($this->category($manager, $categoryName));
@@ -103,11 +103,9 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
} }
/** /**
* Recherche fuzzy insensible a la casse sur companyName (D1, refonte contact). * Recherche fuzzy insensible a la casse sur companyName + lastName + email.
* Depuis la suppression du contact inline du Client, la recherche ne porte * Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
* plus que sur le nom d'entreprise (les anciens criteres lastName / email * litteraux.
* vivaient sur les colonnes inline disparues). Les metacaracteres LIKE
* (%, _, \) saisis sont echappes pour rester litteraux.
*/ */
private function applySearch(QueryBuilder $qb, ?string $search): void private function applySearch(QueryBuilder $qb, ?string $search): void
{ {
@@ -118,9 +116,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere('LOWER(c.companyName) LIKE :search') $qb->andWhere(
->setParameter('search', $pattern) 'LOWER(c.companyName) LIKE :search '
; .'OR LOWER(c.lastName) LIKE :search '
.'OR LOWER(c.email) LIKE :search',
)->setParameter('search', $pattern);
} }
/** /**
@@ -30,8 +30,6 @@ use function sprintf;
* - resource != Site::class no-op (les autres resources sont * - resource != Site::class no-op (les autres resources sont
* gerees par SiteScopedQueryExtension) ; * gerees par SiteScopedQueryExtension) ;
* - is_granted('sites.bypass_scope') pas de filtre (admin / bypass) ; * - is_granted('sites.bypass_scope') pas de filtre (admin / bypass) ;
* - is_granted('sites.read_ref') pas de filtre (lecture-referentiel
* transverse complet, ERP-102) ;
* - user non authentifie no-op (API Platform renvoie 401 avant) ; * - user non authentifie no-op (API Platform renvoie 401 avant) ;
* - user sans aucun site WHERE 1 = 0 (aucun acces) ; * - user sans aucun site WHERE 1 = 0 (aucun acces) ;
* - cas normal WHERE site.id IN (:allowedSites). * - cas normal WHERE site.id IN (:allowedSites).
@@ -86,16 +84,6 @@ final class SiteCollectionScopedExtension implements QueryCollectionExtensionInt
return; return;
} }
// 2bis) Lecture-referentiel transverse (ERP-102) : `sites.read_ref` donne
// acces a la LISTE COMPLETE des sites (selects d'adresse des modules Tiers).
// Sans ce bypass, le cloisonnement par site rattache reduirait le select
// aux seuls sites de l'utilisateur (voire a rien s'il n'en a aucun) et le
// referentiel ne serait plus "transverse". `read_ref` est une lecture seule :
// il ouvre la visibilite sans permettre la moindre ecriture.
if ($this->security->isGranted('sites.read_ref')) {
return;
}
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont). // 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
$user = $this->security->getUser(); $user = $this->security->getUser();
if (!$user instanceof User) { if (!$user instanceof User) {
@@ -169,9 +169,11 @@ final class ColumnCommentsCatalog
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).',
// Contact principal inline supprime (refonte contact) : first_name, 'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
// last_name, phone_primary, phone_secondary, email vivent desormais 'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
// uniquement sur client_contact. 'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.',
'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).',
'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).',
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.', 'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.', 'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.', 'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
@@ -126,6 +126,9 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
// Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait // Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait
// produit le ClientProcessor via l'API. // produit le ClientProcessor via l'API.
$client->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); $client->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$client->setLastName('Seed');
$client->setPhonePrimary('0102030405');
$client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test');
$client->addCategory($this->createCategory($categoryCode)); $client->addCategory($this->createCategory($categoryCode));
$client->setIsArchived($isArchived); $client->setIsArchived($isArchived);
if ($isArchived) { if ($isArchived) {
@@ -169,7 +169,6 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Non Billing Empty Email'); $seed = $this->seedClient('Non Billing Empty Email');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -180,7 +179,6 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()], 'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
], ],
]); ]);
@@ -288,29 +286,6 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
} }
/**
* Spec-front § Adresse : au moins une categorie est obligatoire sur une
* adresse. POST sans categorie (mais avec site) -> 422.
*/
public function testAddressRequiresAtLeastOneCategory(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address No Cat');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
self::assertResponseStatusCodeSame(422);
}
/** /**
* Retourne l'IRI du premier site seede (fixtures Sites). * Retourne l'IRI du premier site seede (fixtures Sites).
*/ */
+51 -5
View File
@@ -25,7 +25,7 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
{ {
private const string LD = 'application/ld+json'; private const string LD = 'application/ld+json';
public function testPostNormalizesCompanyName(): void public function testPostNormalizesTextFields(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR'); $cat = $this->createCategory('SECTEUR');
@@ -34,17 +34,22 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'acme sas', 'companyName' => 'acme sas',
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
$data = $response->toArray(); $data = $response->toArray();
// RG-1.18 : companyName normalise en MAJUSCULES. Les champs de contact // RG-1.18 / 1.19 / 1.20 / 1.21
// inline ont disparu (refonte contact) -> plus de normalisation ici.
self::assertSame('ACME SAS', $data['companyName']); self::assertSame('ACME SAS', $data['companyName']);
self::assertArrayNotHasKey('firstName', $data); self::assertSame('Jean', $data['firstName']);
self::assertArrayNotHasKey('email', $data); self::assertSame('Dupont', $data['lastName']);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
self::assertFalse($data['isArchived']); self::assertFalse($data['isArchived']);
} }
@@ -56,6 +61,9 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$payload = [ $payload = [
'companyName' => 'Doublon SARL', 'companyName' => 'Doublon SARL',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'dup@test.fr',
'categories' => [$iri], 'categories' => [$iri],
]; ];
@@ -63,10 +71,30 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
// Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16). // Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16).
$payload['email'] = 'dup2@test.fr';
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(409); self::assertResponseStatusCodeSame(409);
} }
public function testPostWithoutFirstOrLastNameReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Contact Name',
'phonePrimary' => '0102030405',
'email' => 'nc@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
]);
// RG-1.01
self::assertResponseStatusCodeSame(422);
}
public function testPostWithoutCategoryReturns422(): void public function testPostWithoutCategoryReturns422(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -75,6 +103,9 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'No Category', 'companyName' => 'No Category',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'nocat@test.fr',
'categories' => [], 'categories' => [],
], ],
]); ]);
@@ -93,6 +124,9 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Mutex Client', 'companyName' => 'Mutex Client',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'mutex@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(), 'distributor' => '/api/clients/'.$distributor->getId(),
'broker' => '/api/clients/'.$distributor->getId(), 'broker' => '/api/clients/'.$distributor->getId(),
@@ -113,6 +147,9 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Bad Distrib Ref', 'companyName' => 'Bad Distrib Ref',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'baddistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$notDistro->getId(), 'distributor' => '/api/clients/'.$notDistro->getId(),
], ],
@@ -132,6 +169,9 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Client Avec Distrib', 'companyName' => 'Client Avec Distrib',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'okdistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(), 'distributor' => '/api/clients/'.$distributor->getId(),
], ],
@@ -150,6 +190,9 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Bad Broker Ref', 'companyName' => 'Bad Broker Ref',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'badbroker@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'broker' => '/api/clients/'.$notBroker->getId(), 'broker' => '/api/clients/'.$notBroker->getId(),
], ],
@@ -169,6 +212,9 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Client Avec Courtier', 'companyName' => 'Client Avec Courtier',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'okbroker@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'broker' => '/api/clients/'.$broker->getId(), 'broker' => '/api/clients/'.$broker->getId(),
], ],
@@ -68,6 +68,9 @@ final class ClientAuditTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Blamable Co', 'companyName' => 'Blamable Co',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'blamable@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
])->toArray(); ])->toArray();
@@ -7,15 +7,12 @@ namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity; use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
/** /**
* Tests fonctionnels du formulaire principal apres la refonte contact. * Tests fonctionnels du formulaire principal combler les trous (ERP-60).
* *
* RG-1.01 (prenom OU nom) et RG-1.02 (telephone secondaire) ont ete SUPPRIMEES * RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs
* du Client : le contact principal n'est plus porte inline, il vit uniquement * + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les
* dans ClientContact (onglet Contact). Ce fichier verifie que : * reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire),
* - le formulaire principal se cree avec les seuls champs subsistants * non encore testee.
* (companyName + categories), sans aucun champ de contact ;
* - les anciens champs de contact (firstName, lastName, phonePrimary,
* phoneSecondary, email) ne sont plus exposes ni persistes.
* *
* @internal * @internal
*/ */
@@ -24,10 +21,11 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
private const string LD = 'application/ld+json'; private const string LD = 'application/ld+json';
/** /**
* Le formulaire principal n'exige plus que companyName + au moins une * RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes
* categorie (RG-1.16 / RG sur categories). Aucun champ de contact requis. * distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur
* la colonne secondaire.
*/ */
public function testPostMainFormWithoutContactFields(): void public function testPostPersistsSecondaryPhoneNormalized(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR'); $cat = $this->createCategory('SECTEUR');
@@ -35,28 +33,26 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
$data = $client->request('POST', '/api/clients', [ $data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Main Form SARL', 'companyName' => 'Two Phones SARL',
'firstName' => 'A',
'phonePrimary' => '06.12.34.56.78',
'phoneSecondary' => '05 49 00 11 22',
'email' => 'twophones@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
])->toArray(); ])->toArray();
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
self::assertSame('MAIN FORM SARL', $data['companyName']); self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('0549001122', $data['phoneSecondary']);
// Les champs de contact inline ont disparu de la representation.
self::assertArrayNotHasKey('firstName', $data);
self::assertArrayNotHasKey('lastName', $data);
self::assertArrayNotHasKey('phonePrimary', $data);
self::assertArrayNotHasKey('phoneSecondary', $data);
self::assertArrayNotHasKey('email', $data);
} }
/** /**
* Les anciens champs de contact envoyes par un appel API direct (payload * RG-1.02 : maximum 2 telephones le modele n'expose que phonePrimary et
* historique) sont ignores par le denormaliseur : ils n'apparaissent pas * phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est
* dans la representation et ne creent aucune colonne sur le client. * ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero.
*/ */
public function testLegacyContactFieldsAreIgnored(): void public function testThirdPhoneFieldIsIgnored(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR'); $cat = $this->createCategory('SECTEUR');
@@ -64,25 +60,25 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
$data = $client->request('POST', '/api/clients', [ $data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Legacy Fields SARL', 'companyName' => 'Third Phone SARL',
'firstName' => 'Ignored', 'firstName' => 'A',
'lastName' => 'Ignored',
'phonePrimary' => '0612345678', 'phonePrimary' => '0612345678',
'phoneSecondary' => '0549001122', 'phoneSecondary' => '0549001122',
'email' => 'ignored@test.fr', 'phoneTertiary' => '0700000000',
'email' => 'thirdphone@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
])->toArray(); ])->toArray();
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
self::assertArrayNotHasKey('firstName', $data); // Le champ inconnu est ignore par le denormaliseur : il n'apparait pas
self::assertArrayNotHasKey('phonePrimary', $data); // dans la representation et n'a pas ete persiste.
self::assertArrayNotHasKey('email', $data); self::assertArrayNotHasKey('phoneTertiary', $data);
// Confirmation cote base : le client cree ne porte aucun contact inline // Confirmation cote base : seules les 2 colonnes telephone existent.
// (les colonnes n'existent plus, l'entite n'a plus les proprietes).
$persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']); $persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']);
self::assertNotNull($persisted); self::assertNotNull($persisted);
self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName()); self::assertSame('0612345678', $persisted->getPhonePrimary());
self::assertSame('0549001122', $persisted->getPhoneSecondary());
} }
} }
@@ -14,41 +14,10 @@ namespace App\Tests\Module\Commercial\Api;
* - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active * - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active
* (RG-1.17) ont ete supprimes / ne sont jamais crees. * (RG-1.17) ont ete supprimes / ne sont jamais crees.
* *
* Verifie aussi la refonte contact (Version20260603120000) : les 5 colonnes de
* contact principal inline ont ete supprimees de la table `client`.
*
* @internal * @internal
*/ */
final class ClientMigrationTest extends AbstractCommercialApiTestCase final class ClientMigrationTest extends AbstractCommercialApiTestCase
{ {
/**
* Refonte contact : first_name / last_name / phone_primary / phone_secondary
* / email ne doivent plus exister sur la table `client` (deplaces vers
* client_contact). NB : le backfill de la migration ne s'exerce que sur une
* base portant des donnees pre-refonte ; sur le schema de test (table client
* vierge au moment de la migration) il est un no-op, donc non assertable ici
* au runtime seul l'etat de schema final est verifie.
*/
public function testInlineContactColumnsAreDropped(): void
{
self::bootKernel();
/** @var list<array{column_name: string}> $columns */
$columns = $this->getEm()->getConnection()->fetchAllAssociative(
"SELECT column_name FROM information_schema.columns "
."WHERE table_schema = 'public' AND table_name = 'client'",
);
$names = array_map(static fn (array $r): string => $r['column_name'], $columns);
foreach (['first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email'] as $dropped) {
self::assertNotContains(
$dropped,
$names,
sprintf('La colonne client.%s aurait du etre supprimee (refonte contact).', $dropped),
);
}
}
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
{ {
$rows = $this->clientIndexes(); $rows = $this->clientIndexes();
@@ -6,7 +6,6 @@ namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client; use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures; use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\NullOutput;
@@ -282,32 +281,14 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
// / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter // / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter
// la permission d'administration `.view`. Usine, sans aucune permission, // la permission d'administration `.view`. Usine, sans aucune permission,
// reste interdit. // reste interdit.
// Le referentiel /sites est TRANSVERSE et COMPLET : le cloisonnement par
// site rattache (SiteCollectionScopedExtension) est neutralise par
// `sites.read_ref` (ERP-102). Les comptes demo ne sont rattaches qu'a un
// seul site (Chatellerault) alors que la base en compte plusieurs : on
// verifie donc que le role voit la TOTALITE du referentiel, pas son seul
// site rattache. Sans le bypass de scope, totalItems vaudrait 1.
$totalSites = $this->getEm()->getRepository(Site::class)->count([]);
self::assertGreaterThan(
1,
$totalSites,
'Pre-requis du test : la base doit contenir plusieurs sites pour distinguer scope et bypass.',
);
foreach (['bureau', 'compta', 'commerciale'] as $role) { foreach (['bureau', 'compta', 'commerciale'] as $role) {
$client = $this->authAs($role); $client = $this->authAs($role);
$client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]); $client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role)); self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role));
$response = $client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]); $client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role)); self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role));
self::assertSame(
$totalSites,
$response->toArray()['totalItems'] ?? null,
sprintf('Le role %s doit voir tout le referentiel sites (%d), pas seulement son site rattache', $role, $totalSites),
);
} }
// Usine : aucune permission -> reste a 403 sur les referentiels. // Usine : aucune permission -> reste a 403 sur les referentiels.
@@ -324,9 +305,9 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
} }
/** /**
* Payload minimal valide de l'onglet principal (companyName + une categorie * Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ;
* SECTEUR ; le contact inline a ete supprime). Si $categoryId est null, une * une categorie SECTEUR). Si $categoryId est null, une categorie est creee a
* categorie est creee a la volee. * la volee.
* *
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@@ -336,6 +317,9 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
return [ return [
'companyName' => $companyName, 'companyName' => $companyName,
'firstName' => 'Jean',
'phonePrimary' => '0612345678',
'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test',
'categories' => ['/api/categories/'.$categoryId], 'categories' => ['/api/categories/'.$categoryId],
]; ];
} }
@@ -232,6 +232,9 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas
$client = new ClientEntity(); $client = new ClientEntity();
$client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); $client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$client->setLastName('Complet');
$client->setPhonePrimary('0102030405');
$client->setEmail('complet'.$suffix.'@seed.test');
$client->addCategory($this->createCategory('SECTEUR')); $client->addCategory($this->createCategory('SECTEUR'));
// Bloc comptable non nul (gating par omission cote Commerciale). // Bloc comptable non nul (gating par omission cote Commerciale).
$client->setSiren('123456789'); $client->setSiren('123456789');
@@ -113,7 +113,6 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Host'); $seed = $this->seedClient('Address Host');
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR');
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -124,7 +123,6 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
'city' => 'Châtellerault', 'city' => 'Châtellerault',
'street' => '1 rue du Test', 'street' => '1 rue du Test',
'sites' => [$siteIri], 'sites' => [$siteIri],
'categories' => ['/api/categories/'.$category->getId()],
], ],
])->toArray(); ])->toArray();
@@ -12,13 +12,41 @@ use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
* RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par * RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par
* ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier * ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier
* verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee) * verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee)
* n'est PLUS contraint unique. (L'email RG-1.17 a disparu du Client avec la * et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques.
* refonte contact, il vit desormais sur ClientContact.)
* *
* @internal * @internal
*/ */
final class ClientUniquenessTest extends AbstractCommercialApiTestCase final class ClientUniquenessTest extends AbstractCommercialApiTestCase
{ {
private const string LD = 'application/ld+json';
/**
* RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme
* email principal aucune contrainte d'unicite (un email peut servir
* plusieurs clients).
*/
public function testDuplicateEmailIsAllowed(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$iri = '/api/categories/'.$cat->getId();
$payload = static fn (string $name): array => [
'companyName' => $name,
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'partage@test.fr',
'categories' => [$iri],
];
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]);
self::assertResponseStatusCodeSame(201);
// Meme email, nom different -> doit passer (pas d'index unique email).
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]);
self::assertResponseStatusCodeSame(201);
}
/** /**
* RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements * RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements
* multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on * multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on
@@ -134,11 +134,14 @@ final class ClientProcessorTest extends TestCase
'isArchived' => false, 'isArchived' => false,
], ],
managed: true, managed: true,
// Etat persiste (valeurs normalisees) : sans companyName, guardManage // Etat persiste complet (valeurs normalisees) : sans les champs
// (ERP-74) le croirait modifie (compare a null) et leverait un 403 // metier, guardManage (ERP-74) les croirait modifies (companyName,
// parasite. // lastName... compares a null) et leverait un 403 parasite.
originalData: [ originalData: [
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -161,10 +164,13 @@ final class ClientProcessorTest extends TestCase
managed: true, managed: true,
// getOriginalEntityData renvoie tous les champs mappes d'une entite // getOriginalEntityData renvoie tous les champs mappes d'une entite
// geree : isArchived (non-null) y figure toujours, ainsi que les // geree : isArchived (non-null) y figure toujours, ainsi que les
// champs metier (companyName) sinon guardManage les croirait modifies. // champs metier (sinon guardManage les croirait modifies).
originalData: [ originalData: [
'siren' => '123456789', 'siren' => '123456789',
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -187,6 +193,9 @@ final class ClientProcessorTest extends TestCase
managed: true, managed: true,
originalData: [ originalData: [
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -211,6 +220,9 @@ final class ClientProcessorTest extends TestCase
originalData: [ originalData: [
'siren' => '111111111', 'siren' => '111111111',
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -312,6 +324,9 @@ final class ClientProcessorTest extends TestCase
managed: true, managed: true,
originalData: [ originalData: [
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -386,14 +401,16 @@ final class ClientProcessorTest extends TestCase
} }
/** /**
* Client minimal companyName seul depuis la suppression du contact inline. * Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) suffisant
* Suffisant pour atteindre les validations testees (le contact vit desormais * pour atteindre les validations testees.
* dans ClientContact, hors scope du ClientProcessor).
*/ */
private function minimalClient(): Client private function minimalClient(): Client
{ {
$client = new Client(); $client = new Client();
$client->setCompanyName('Test Co'); $client->setCompanyName('Test Co');
$client->setLastName('Dupont');
$client->setPhonePrimary('0102030405');
$client->setEmail('t@test.fr');
return $client; return $client;
} }