Compare commits

..

1 Commits

Author SHA1 Message Date
Matthieu f9c881c771 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 1m49s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m18s
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:12:17 +02:00
76 changed files with 1015 additions and 6968 deletions
-34
View File
@@ -44,40 +44,6 @@ Tout champ de formulaire / filtre doit utiliser les composants `Malio*` plutot q
Toute autre exception requiert validation avant merge. Toute autre exception requiert validation avant merge.
## Validation des formulaires — useFormErrors obligatoire (erreur par champ)
**Tout formulaire qui soumet a une API DOIT afficher les erreurs de validation 422 sous le champ concerne, via `useFormErrors`** (`frontend/shared/composables/useFormErrors.ts`). C'est le pendant front de « le back renvoie TOUTES les violations d'une 422 d'un coup » : un seul aller-retour, chaque erreur affichee inline sous son champ (prop `:error` des `Malio*`), pas un toast fourre-tout.
Principe cle : **le nom du champ cote front = le `propertyPath` renvoye par le back**. Aucun mapping manuel champ par champ.
Pattern de reference (champs scalaires) :
```ts
const { errors, setError, clearErrors, handleApiError } = useFormErrors()
async function submit() {
clearErrors()
try {
await useApi().post('/clients', payload, { toast: false }) // toast: false obligatoire
} catch (e) {
// 422 → mapping inline par champ (pas de toast) ; autre → toast de fallback.
handleApiError(e, { fallbackMessage: t('foo.error') })
}
}
```
```vue
<MalioInputText v-model="form.companyName" :error="errors.companyName" />
<MalioSelect v-model="form.siren" :error="errors.siren" />
```
Regles :
- **Toujours `{ toast: false }`** sur l'appel API qui veut un mapping inline (sinon le toast natif d'`useApi` masque le fin).
- **Cas metier specifique** (ex: 409 doublon) : `setError('champ', message)` + toast explicite **avant** de deleguer le reste a `handleApiError`. Cf. `useCategoryForm` (doublon RG-1.07).
- **Collections** (listes de sous-entites sauvees par un appel par ligne) : une erreur PAR LIGNE via un tableau `ref<Record<string, string>[]>` aligne sur l'index, peuple par `mapViolationsToRecord(error.response._data)` (util pur de `shared/utils/api.ts`). Le composant de ligne expose une prop `:errors` (`Record<string, string>`) bindee sur le `:error` de chaque champ. Cf. `ClientContactBlock` / `ClientAddressBlock` et les submits de `clients/new.vue` / `clients/[id]/edit.vue`.
**Interdit** : se contenter d'un toast global sur une 422 quand le back identifie les champs fautifs (`propertyPath`). Reimplementer un mapping `if/else` par champ a la main au lieu d'`useFormErrors` / `mapViolationsToRecord`.
## Tableaux de donnees — MalioDataTable obligatoire ## Tableaux de donnees — MalioDataTable obligatoire
Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` : Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer par `MalioDataTable` :
+147 -309
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)
| Service | Port | ## Quick Start
|---------------|------|
| API (Nginx) | 8083 |
| Frontend dev | 3004 |
| PostgreSQL | 5437 |
---
## Prérequis
- Docker + Docker Compose
- `make`
- `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`)
Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ;
rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur
l'hôte (navigateur réel, voir [Tests](#tests)).
---
## Démarrage rapide
```bash ```bash
make start # Démarre les containers Docker make start # Demarrer les containers Docker
make install # Composer + clés JWT + migrations + permissions + BDD de test make install # Composer, migrations, fixtures, build Nuxt
make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004)
``` ```
`make install` prépare une base de dev **vierge** (schéma + RBAC structurel, sans Dev frontend (hot reload) :
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 ```bash
make shell make dev-nuxt # Port 3003
php bin/console app:create-user admin monMotDePasse --admin # compte ROLE_ADMIN
``` ```
Optionnel — provisionner les **rôles métier** (bureau / compta / commerciale / usine ## Ports
+ matrice RBAC § 2.7) sans comptes de démo :
```bash | Service | Port |
php bin/console app:seed-rbac |------------|------|
``` | API (Nginx)| 8083 |
| Frontend | 3004 |
| PostgreSQL | 5437 |
Cet état est utile pour repartir d'une base propre, reproduire un bug sur données ## Commandes
minimales, ou tester un parcours d'onboarding réel.
### Avec données de seed (base de démo) | Commande | Description |
|----------|-------------|
`make db-reset` (ou `make fixtures` après un `make install`) recharge la base de dev | `make start` | Demarrer les containers |
avec un jeu complet de données de démonstration, **idempotent** : | `make stop` | Arreter les containers |
| `make restart` | Redemarrer les containers |
```bash | `make install` | Install complet |
make db-reset # ATTENTION : drop + recrée la base de dev, puis charge tout le seed | `make reset` | Tout supprimer et reinstaller |
``` | `make dev-nuxt` | Serveur dev Nuxt (hot reload) |
| `make shell` | Shell dans le container PHP |
Ce que les fixtures posent : | `make cache-clear` | Vider le cache Symfony |
| `make migration-migrate` | Lancer les migrations |
- **3 utilisateurs système** : `admin` (ROLE_ADMIN), `alice`, `bob` (ROLE_USER), | `make fixtures` | Charger les fixtures |
rattachés à des sites distincts ; | `make db-reset` | Reset BDD + migrations + fixtures |
- **3 sites** : Chatellerault, Saint-Jean, Pommevic ; | `make test` | PHPUnit (tests back) |
- **les comptes de démo RBAC métier** (`bureau`, `compta`, `commerciale`, `usine`, | `make nuxt-test` | Vitest (tests unitaires front) |
mot de passe `demo`) avec la matrice § 2.7 attachée ; | `make test-e2e` | Playwright (tests E2E front) |
- les **référentiels et données métier** des modules (catégories, clients de démo, | `make test-e2e-ui` | Playwright UI interactive (debug) |
référentiels comptables…). | `make seed-e2e` | Seed les 6 personas E2E |
| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) |
Toutes les fixtures sont rejouables sans effet de bord (lookup par clé naturelle, | `make php-cs-fixer-allow-risky` | Fix code style PHP |
aucun doublon). | `make logs-dev` | Tail logs Symfony |
> 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/`.
+19 -22
View File
@@ -38,28 +38,6 @@ declare(strict_types=1);
*/ */
return [ return [
// Section "Commerciale" : pole metier principal, remontee en tete de sidebar (ERP-71).
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
// du bloc, aucun gate touche).
[
'label' => 'sidebar.commercial.section',
'icon' => 'mdi:account-arrow-left-outline',
'items' => [
[
'label' => 'sidebar.commercial.clients',
'to' => '/clients',
'icon' => 'mdi:account-group-outline',
'module' => 'commercial',
'permission' => 'commercial.clients.view',
],
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
],
],
],
// Section "Administration" : regroupe toutes les pages de configuration // Section "Administration" : regroupe toutes les pages de configuration
// applicative (RBAC, users, sites, audit log). // applicative (RBAC, users, sites, audit log).
// //
@@ -121,6 +99,25 @@ return [
], ],
], ],
], ],
[
'label' => 'sidebar.commercial.section',
'icon' => 'mdi:account-arrow-left-outline',
'items' => [
[
'label' => 'sidebar.commercial.clients',
'to' => '/clients',
'icon' => 'mdi:account-group-outline',
'module' => 'commercial',
'permission' => 'commercial.clients.view',
],
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
],
],
],
// Section "Mon compte" : espace personnel. Accessible a tout user authentifie // Section "Mon compte" : espace personnel. Accessible a tout user authentifie
// (aucune permission RBAC requise, tous les items restent dans `core` pour // (aucune permission RBAC requise, tous les items restent dans `core` pour
// rester toujours presents meme quand les modules metier sont desactives). // rester toujours presents meme quand les modules metier sont desactives).
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.80' app.version: '0.1.69'
+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).
@@ -1,119 +0,0 @@
# ERP-101 — Mapping des erreurs de validation par champ (convention forms)
> Statut : design validé — implémentation TDD en cours
> Branche : `feat/ERP-101-form-field-validation-mapping`
> Date : 2026-06-03
## Problème
Quand le back renvoie une **422** (violations API Platform), il renvoie **toutes** les
violations d'un coup (un `propertyPath` + `message` par champ fautif). Aujourd'hui, seul
le drawer Catégorie (`useCategoryForm`) exploite ce détail pour afficher l'erreur **sous
le champ concerné** ; il le fait via un `if/else` manuel par champ, non réutilisable.
Le formulaire Client (≈ 20 champs sur 5 submits, dont 3 collections) ne mappe rien : une
422 multi-champs ⇒ un seul **toast global**. On veut un retour par champ, et surtout
**une convention unique réutilisée par tous les modules**.
## Décisions
1. **Primitif générique** plutôt que composable par form : `useFormErrors()` partagé.
2. **Périmètre complet** sur Client : champs scalaires **et** collections (erreur par ligne).
## Architecture — 3 briques
### 1. `mapViolationsToRecord(data)` — `frontend/shared/utils/api.ts`
Util pur, fondation réutilisée partout. Transforme un payload 422 en
`Record<propertyPath, message>`. S'appuie sur `extractApiViolations` (déjà existant,
gère les formats `violations` et `hydra:violations`).
```ts
export function mapViolationsToRecord(data: unknown): Record<string, string> {
const out: Record<string, string> = {}
for (const v of extractApiViolations(data)) {
if (v.propertyPath) out[v.propertyPath] = v.message
}
return out
}
```
### 2. `useFormErrors()` — `frontend/shared/composables/useFormErrors.ts`
API que tous les forms **scalaires** consomment.
```ts
const { errors, hasErrors, setServerErrors, setError, clearError, clearErrors, handleApiError } = useFormErrors()
```
- `errors` : `reactive<Record<string, string>>` indexé par `propertyPath`.
- `setServerErrors(data)` : `mapViolationsToRecord` → remplit `errors`. Retourne `true`
si au moins une violation a été mappée.
- `setError(field, msg)` / `clearError(field)` / `clearErrors()` : manipulation fine.
- `hasErrors` : `computed` booléen.
- `handleApiError(e, opts?)` : dispatch standard depuis une erreur ofetch —
**422**`setServerErrors` (mapping inline, pas de toast) ; **autre** → toast
générique de fallback (message extrait via `extractApiErrorMessage`).
Côté template, le nom du champ **est** le `propertyPath` :
```vue
<MalioInputText v-model="main.companyName" :error="errors.companyName" />
<MalioInputText v-model="accounting.siren" :error="errors.siren" />
```
> L'unicité SIREN (RG-1.15) remonte en **422 `UniqueEntity` avec `propertyPath: "siren"`**
> → mappée automatiquement. Pas de cas 409 spécial (contrairement à Catégorie).
### 3. Collections — erreurs par ligne
Chaque ligne (contact / adresse / RIB) est persistée par **son propre appel API**, donc
le back renvoie un 422 **relatif à la sous-entité** (`propertyPath: "email"`, `"iban"`…).
- Le parent tient, par collection, un tableau d'erreurs **aligné sur l'index de ligne** :
`const contactErrors = ref<Record<string, string>[]>([])`.
- Au submit de la ligne `i` : `catch``contactErrors.value[i] = mapViolationsToRecord(data)`.
- On `clearErrors` la collection au début de chaque passe de submit.
- Les blocs reçoivent une prop `:errors` (`Record<string, string>`) et bindent
`:error="errors?.email"` sur chaque champ Malio.
## Fichiers touchés
| Fichier | Action |
|---|---|
| `shared/utils/api.ts` | + `mapViolationsToRecord` |
| `shared/composables/useFormErrors.ts` | **nouveau** composable |
| `modules/commercial/pages/clients/new.vue` | scalaires (Main/Info/Compta) + erreurs par ligne |
| `modules/commercial/pages/clients/[id]/edit.vue` | idem |
| `modules/commercial/components/ClientContactBlock.vue` | + prop `:errors`, bind `:error` |
| `modules/commercial/components/ClientAddressBlock.vue` | + prop `:errors`, bind `:error` |
| RIB (inline dans new/edit) | bind `:error` par ligne |
## Tests (Vitest — règle « pas d'E2E »)
- `mapViolationsToRecord` : formats `violations` / `hydra:violations`, payload vide,
`propertyPath` manquant.
- `useFormErrors` : `setServerErrors` mappe et retourne `true` / `false` sans violation,
`clearErrors`, fallback toast sur non-422.
## Convention posée pour tous les forms
À reporter dans `.claude/rules/frontend.md` une fois le pattern stabilisé :
> Tout form qui veut un retour d'erreur par champ : appels API en `{ toast: false }` +
> `useFormErrors` pour les champs scalaires (422 inline), `mapViolationsToRecord` par
> ligne pour les collections. `useCategoryForm` migrera sur `useFormErrors`.
## Fait dans la foulée (post-ERP-101 initial)
- **`useCategoryForm` migré sur `useFormErrors`** : `errors` devient le `reactive` du
composable (drawer adapté : `form.errors.name` au lieu de `form.errors.value.name`,
bloc `_global` retiré → erreur transverse en toast). 28 tests verts.
- **Convention reportée dans `.claude/rules/frontend.md`** (section « Validation des
formulaires — useFormErrors obligatoire »).
## Hors scope ERP-101 (suivi : ticket ERP-107)
- Langue / présence des messages de validation côté back : le `message` affiché est celui
renvoyé par le serveur. Audit des contraintes Symfony (présence d'un `message` FR,
contraintes manquantes, violations sans `propertyPath`) tracké dans **ERP-107**.
+10 -35
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": {
@@ -91,29 +87,7 @@
"archiveSuccess": "Client archivé avec succès", "archiveSuccess": "Client archivé avec succès",
"restoreSuccess": "Client restauré avec succès", "restoreSuccess": "Client restauré avec succès",
"error": "Une erreur est survenue. Réessayez.", "error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire clients a échoué. Réessayez.", "exportError": "L'export du répertoire clients a échoué. Réessayez."
"restoreConflict": "Impossible de restaurer : un client actif portant ce nom existe déjà."
},
"consultation": {
"title": "Consultation client",
"back": "Retour au répertoire",
"loading": "Chargement du client…",
"notFound": "Client introuvable.",
"confirmArchive": {
"title": "Archiver le client",
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
},
"confirmRestore": {
"title": "Restaurer le client",
"message": "Ce client réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
}
},
"edit": {
"title": "Modifier le client",
"back": "Retour au répertoire",
"loading": "Chargement du client…",
"notFound": "Client introuvable.",
"save": "Valider"
}, },
"validation": { "validation": {
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.", "informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
@@ -133,9 +107,14 @@
"duplicateCompany": "Un client portant ce nom de société existe déjà.", "duplicateCompany": "Un client portant ce nom de société existe déjà.",
"main": { "main": {
"companyName": "Nom du client (Entreprise)", "companyName": "Nom du client (Entreprise)",
"firstName": "Prénom du contact principal",
"lastName": "Nom du contact principal",
"email": "Email",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone (2)",
"addPhone": "Ajouter un numéro",
"categories": "Catégorie", "categories": "Catégorie",
"relation": "Distributeur / Courtier", "relation": "Distributeur / Courtier",
"relationNone": "Aucun",
"relationDistributor": "Dépend du distributeur", "relationDistributor": "Dépend du distributeur",
"relationBroker": "Dépend du courtier", "relationBroker": "Dépend du courtier",
"distributorName": "Nom du distributeur", "distributorName": "Nom du distributeur",
@@ -228,10 +207,7 @@
}, },
"sites": { "sites": {
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site." "notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
}, }
"title": "Erreur",
"generic": "Une erreur est survenue.",
"unknown": "Erreur inconnue."
}, },
"sites": { "sites": {
"selector": { "selector": {
@@ -288,8 +264,7 @@
"success": { "success": {
"auth": { "auth": {
"logout": "Deconnexion reussie" "logout": "Deconnexion reussie"
}, }
"title": "Succès"
}, },
"admin": { "admin": {
"roles": { "roles": {
@@ -20,7 +20,7 @@
:label="t('admin.categories.form.name')" :label="t('admin.categories.form.name')"
input-class="w-full" input-class="w-full"
:max-length="120" :max-length="120"
:error="form.errors.name" :error="form.errors.value.name"
required required
/> />
@@ -32,9 +32,15 @@
:options="typeOptions" :options="typeOptions"
:label="t('admin.categories.form.type')" :label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')" :empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.categoryType" :error="form.errors.value.categoryType"
:disabled="loadingTypes" :disabled="loadingTypes"
/> />
<!-- Erreur transverse (typiquement reseau / 5xx) separe des
erreurs de validation par champ. -->
<p v-if="form.errors.value._global" class="text-sm text-red-600">
{{ form.errors.value._global }}
</p>
</form> </form>
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body <!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, CategoryType } from '~/modules/catalog/types/category' import type { Category, CategoryType } from '~/modules/catalog/types/category'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useCategoryForm } from '../useCategoryForm' import { useCategoryForm } from '../useCategoryForm'
// Stubs des auto-imports Nuxt consommes par le composable. // Stubs des auto-imports Nuxt consommes par le composable.
@@ -22,9 +21,6 @@ vi.stubGlobal('useToast', () => ({
success: mockToastSuccess, success: mockToastSuccess,
error: mockToastError, error: mockToastError,
})) }))
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
// (elle consomme useToast, deja stubbe ci-dessus) pour tester l'integration.
vi.stubGlobal('useFormErrors', useFormErrors)
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus). // useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
// Quand le composable passe des params (ex: doublon), on les serialise pour // Quand le composable passe des params (ex: doublon), on les serialise pour
// pouvoir verifier que l'interpolation a bien recu le bon nom. // pouvoir verifier que l'interpolation a bien recu le bon nom.
@@ -65,7 +61,7 @@ describe('useCategoryForm', () => {
expect(form.name.value).toBe('Vis') expect(form.name.value).toBe('Vis')
expect(form.categoryTypeId.value).toBe(1) expect(form.categoryTypeId.value).toBe(1)
expect(form.errors).toEqual({}) expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
}) })
it('vide le formulaire en mode creation (null)', () => { it('vide le formulaire en mode creation (null)', () => {
@@ -109,7 +105,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameRequired') expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
}) })
it('signale erreur si name est whitespace-only (trim → vide)', () => { it('signale erreur si name est whitespace-only (trim → vide)', () => {
@@ -120,7 +116,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameRequired') expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
}) })
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => { it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
@@ -131,7 +127,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameLength') expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
}) })
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => { it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
@@ -142,7 +138,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.name).toBe('admin.categories.validation.nameLength') expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
}) })
it('signale erreur si categoryTypeId est null (RG-1.05)', () => { it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
@@ -153,7 +149,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired') expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
}) })
it('passe quand name et categoryType sont valides', () => { it('passe quand name et categoryType sont valides', () => {
@@ -164,22 +160,19 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(true) expect(ok).toBe(true)
expect(form.errors).toEqual({}) expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
}) })
it('reinitialise les erreurs avant chaque validation', () => { it('reinitialise les erreurs avant chaque validation', () => {
const form = useCategoryForm() const form = useCategoryForm()
// Erreur prealable : une validation en echec peuple errors.name. // Erreur prealable.
form.name.value = '' form.errors.value._global = 'erreur ancienne'
form.categoryTypeId.value = 1
form.validate()
expect(form.errors.name).toBeTruthy()
// Seconde validation avec des valeurs valides : errors repart vide.
form.name.value = 'Vis' form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.validate() form.validate()
expect(form.errors).toEqual({}) expect(form.errors.value._global).toBe('')
}) })
}) })
@@ -220,7 +213,7 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'success.title', title: 'Succès',
message: 'admin.categories.toast.created', message: 'admin.categories.toast.created',
}) })
}) })
@@ -238,8 +231,8 @@ describe('useCategoryForm', () => {
expect(result).toBeNull() expect(result).toBeNull()
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans // La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
// les params i18n (stub serialise les params). // les params i18n (stub serialise les params).
expect(form.errors.name).toContain('admin.categories.toast.duplicate') expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.name).toContain('"name":"Vis"') expect(form.errors.value.name).toContain('"name":"Vis"')
expect(mockToastError).toHaveBeenCalledTimes(1) expect(mockToastError).toHaveBeenCalledTimes(1)
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string } const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
expect(toastArg.message).toContain('Vis') expect(toastArg.message).toContain('Vis')
@@ -263,7 +256,7 @@ describe('useCategoryForm', () => {
const result = await form.submitCreate() const result = await form.submitCreate()
expect(result).toBeNull() expect(result).toBeNull()
expect(form.errors.name).toBe('name should not be blank.') expect(form.errors.value.name).toBe('name should not be blank.')
// Pas de toast quand on a mappe les violations : l erreur est // Pas de toast quand on a mappe les violations : l erreur est
// affichee inline sous le champ concerne. // affichee inline sous le champ concerne.
expect(mockToastError).not.toHaveBeenCalled() expect(mockToastError).not.toHaveBeenCalled()
@@ -286,10 +279,10 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(form.errors.categoryType).toBe('Type invalide.') expect(form.errors.value.categoryType).toBe('Type invalide.')
}) })
it('fallback en toast generique si le status n est ni 409 ni 422', async () => { it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
mockPost.mockRejectedValueOnce({ mockPost.mockRejectedValueOnce({
response: { status: 500, _data: { 'hydra:description': 'Boom server' } }, response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
}) })
@@ -299,10 +292,9 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
// Pas d'erreur inline par champ : l'erreur transverse part en toast. expect(form.errors.value._global).toBe('Boom server')
expect(form.errors).toEqual({})
expect(mockToastError).toHaveBeenCalledWith({ expect(mockToastError).toHaveBeenCalledWith({
title: 'errors.title', title: 'Erreur',
message: 'Boom server', message: 'Boom server',
}) })
}) })
@@ -378,7 +370,7 @@ describe('useCategoryForm', () => {
await form.submitUpdate(42) await form.submitUpdate(42)
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'success.title', title: 'Succès',
message: 'admin.categories.toast.updated', message: 'admin.categories.toast.updated',
}) })
}) })
@@ -394,8 +386,8 @@ describe('useCategoryForm', () => {
const result = await form.submitUpdate(42) const result = await form.submitUpdate(42)
expect(result).toBeNull() expect(result).toBeNull()
expect(form.errors.name).toContain('admin.categories.toast.duplicate') expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.name).toContain('"name":"Doublon"') expect(form.errors.value.name).toContain('"name":"Doublon"')
}) })
}) })
@@ -409,7 +401,7 @@ describe('useCategoryForm', () => {
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false }) expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
expect(ok).toBe(true) expect(ok).toBe(true)
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'success.title', title: 'Succès',
message: 'admin.categories.toast.deleted', message: 'admin.categories.toast.deleted',
}) })
}) })
@@ -423,6 +415,7 @@ describe('useCategoryForm', () => {
const ok = await form.submitDelete(42) const ok = await form.submitDelete(42)
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value._global).toBe('down')
expect(mockToastError).toHaveBeenCalled() expect(mockToastError).toHaveBeenCalled()
}) })
}) })
@@ -431,15 +424,15 @@ describe('useCategoryForm', () => {
it('vide le formulaire et les erreurs', () => { it('vide le formulaire et les erreurs', () => {
const form = useCategoryForm() const form = useCategoryForm()
form.loadFrom(CAT) form.loadFrom(CAT)
form.name.value = '' form.name.value = 'edit'
form.validate() // peuple errors.name form.errors.value._global = 'erreur'
form.submitting.value = true form.submitting.value = true
form.reset() form.reset()
expect(form.name.value).toBe('') expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull() expect(form.categoryTypeId.value).toBeNull()
expect(form.errors).toEqual({}) expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
expect(form.submitting.value).toBe(false) expect(form.submitting.value).toBe(false)
}) })
}) })
@@ -12,13 +12,14 @@
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur * elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
* revalide toujours (defense en profondeur). * revalide toujours (defense en profondeur).
* *
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les * Mapping erreurs API :
* violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ; * - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon * - 422 (violations API Platform) → mapping sur les champs concernes
* RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast. * - autre erreur globale `_global` + toast generique
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category' import type { Category } from '~/modules/catalog/types/category'
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
/** /**
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici * Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
@@ -36,9 +37,6 @@ export function useCategoryForm() {
const { t } = useI18n() const { t } = useI18n()
const toast = useToast() const toast = useToast()
// Etat d'erreurs par champ (indexe par propertyPath) + dispatch API 422.
const formErrors = useFormErrors()
// State local du formulaire — pas singleton, chaque appel a useCategoryForm // State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »). // cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('') const name = ref('')
@@ -50,6 +48,16 @@ export function useCategoryForm() {
const initialName = ref('') const initialName = ref('')
const initialCategoryTypeId = ref<number | null>(null) const initialCategoryTypeId = ref<number | null>(null)
const errors = ref<{
name: string
categoryType: string
_global: string
}>({
name: '',
categoryType: '',
_global: '',
})
const submitting = ref(false) const submitting = ref(false)
const isDirty = computed( const isDirty = computed(
@@ -64,7 +72,7 @@ export function useCategoryForm() {
* erreurs et le snapshot initial pour repartir d'un etat propre. * erreurs et le snapshot initial pour repartir d'un etat propre.
*/ */
function loadFrom(category: Category | null): void { function loadFrom(category: Category | null): void {
formErrors.clearErrors() errors.value = { name: '', categoryType: '', _global: '' }
if (category) { if (category) {
name.value = category.name name.value = category.name
categoryTypeId.value = category.categoryType.id categoryTypeId.value = category.categoryType.id
@@ -84,29 +92,32 @@ export function useCategoryForm() {
* mais le serveur retrim de toute facon — pas de risque de divergence. * mais le serveur retrim de toute facon — pas de risque de divergence.
*/ */
function validate(): boolean { function validate(): boolean {
formErrors.clearErrors() errors.value = { name: '', categoryType: '', _global: '' }
const trimmedName = name.value.trim() const trimmedName = name.value.trim()
// RG-1.02 — name obligatoire (vide / whitespace-only). // RG-1.02 — name obligatoire (vide / whitespace-only).
if (trimmedName === '') { if (trimmedName === '') {
formErrors.setError('name', t('admin.categories.validation.nameRequired')) errors.value.name = t('admin.categories.validation.nameRequired')
} else if (trimmedName.length < 2 || trimmedName.length > 120) { } else if (trimmedName.length < 2 || trimmedName.length > 120) {
// RG-1.04 — longueur 2-120 apres trim. // RG-1.04 — longueur 2-120 apres trim.
formErrors.setError('name', t('admin.categories.validation.nameLength')) errors.value.name = t('admin.categories.validation.nameLength')
} }
// RG-1.05 — categoryType obligatoire. // RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) { if (categoryTypeId.value === null) {
formErrors.setError('categoryType', t('admin.categories.validation.typeRequired')) errors.value.categoryType = t('admin.categories.validation.typeRequired')
} }
return !formErrors.errors.name && !formErrors.errors.categoryType return errors.value.name === '' && errors.value.categoryType === ''
} }
/** /**
* Construit le payload POST a partir du state. Le `categoryType` est * Construit le payload POST a partir du state. Le `categoryType` est
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
* Platform pour referencer une ressource liee. * Platform pour referencer une ressource liee. Retourne un object literal
* compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
* `CategoryCreateInput` ne serait pas assignable a `Record<string, unknown>`
* en TS strict).
*/ */
function buildCreatePayload(): Record<string, unknown> { function buildCreatePayload(): Record<string, unknown> {
return { return {
@@ -116,24 +127,72 @@ export function useCategoryForm() {
} }
/** /**
* Traite une erreur API : 409 (doublon RG-1.07) → erreur inline sur `name` * Mappe les violations 422 d'API Platform sur les champs du formulaire.
* + toast ; sinon delegue a `useFormErrors.handleApiError` (422 mappe inline * Renvoie true des qu'au moins une violation a ete posee — false sinon
* par champ sans toast, autre → toast de fallback). Retourne true si traitee * (payload sans violations exploitables, ou tous les `propertyPath` hors
* inline (409/422 mappe), false si fallback toast. * du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
* sur les futurs drawers de formulaire.
*/
function mapServerViolations(data: unknown): boolean {
const violations = extractApiViolations(data)
if (violations.length === 0) return false
let mapped = false
for (const v of violations) {
if (v.propertyPath === 'name') {
errors.value.name = v.message
mapped = true
} else if (v.propertyPath === 'categoryType') {
errors.value.categoryType = v.message
mapped = true
}
}
return mapped
}
/**
* Traite une erreur API : mappe selon le status, declenche les toasts
* appropries. Centralise la logique entre create/update.
*
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
* le nom soumis.
* - 422 : tentative de mapping fin via les violations API Platform — si au
* moins une violation est mappee, pas de toast (erreur affichee inline
* sous le champ concerne).
* - autre : message global + toast generique. Le toast natif d'useApi
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
*
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
* false sinon (fallback generique).
*/ */
function handleApiError(e: unknown, attemptedName: string): boolean { function handleApiError(e: unknown, attemptedName: string): boolean {
const status = (e as ApiFetchError)?.response?.status const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 409) { if (status === 409) {
const duplicateMessage = t('admin.categories.toast.duplicate', { const duplicateMessage = t('admin.categories.toast.duplicate', {
name: attemptedName, name: attemptedName,
}) })
formErrors.setError('name', duplicateMessage) errors.value.name = duplicateMessage
toast.error({ title: t('errors.title'), message: duplicateMessage }) toast.error({
title: 'Erreur',
message: duplicateMessage,
})
return true return true
} }
return formErrors.handleApiError(e, { fallbackMessage: t('errors.generic') }) if (status === 422 && mapServerViolations(data)) {
return true
}
const extracted = extractApiErrorMessage(data)
errors.value._global = extracted || 'Une erreur est survenue.'
toast.error({
title: 'Erreur',
message: errors.value._global,
})
return false
} }
/** /**
@@ -144,13 +203,14 @@ export function useCategoryForm() {
async function submitCreate(): Promise<Category | null> { async function submitCreate(): Promise<Category | null> {
if (!validate()) return null if (!validate()) return null
submitting.value = true submitting.value = true
errors.value._global = ''
const payload = buildCreatePayload() const payload = buildCreatePayload()
try { try {
const created = await api.post<Category>('/categories', payload, { const created = await api.post<Category>('/categories', payload, {
toast: false, toast: false,
}) })
toast.success({ toast.success({
title: t('success.title'), title: 'Succès',
message: t('admin.categories.toast.created'), message: t('admin.categories.toast.created'),
}) })
return created return created
@@ -170,6 +230,7 @@ export function useCategoryForm() {
async function submitUpdate(id: number): Promise<Category | null> { async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null if (!validate()) return null
submitting.value = true submitting.value = true
errors.value._global = ''
const payload: Record<string, unknown> = {} const payload: Record<string, unknown> = {}
if (name.value !== initialName.value) { if (name.value !== initialName.value) {
payload.name = name.value.trim() payload.name = name.value.trim()
@@ -189,7 +250,7 @@ export function useCategoryForm() {
toast: false, toast: false,
}) })
toast.success({ toast.success({
title: t('success.title'), title: 'Succès',
message: t('admin.categories.toast.updated'), message: t('admin.categories.toast.updated'),
}) })
return updated return updated
@@ -211,11 +272,11 @@ export function useCategoryForm() {
*/ */
async function submitDelete(id: number): Promise<boolean> { async function submitDelete(id: number): Promise<boolean> {
submitting.value = true submitting.value = true
formErrors.clearErrors() errors.value._global = ''
try { try {
await api.delete(`/categories/${id}`, {}, { toast: false }) await api.delete(`/categories/${id}`, {}, { toast: false })
toast.success({ toast.success({
title: t('success.title'), title: 'Succès',
message: t('admin.categories.toast.deleted'), message: t('admin.categories.toast.deleted'),
}) })
return true return true
@@ -236,7 +297,7 @@ export function useCategoryForm() {
categoryTypeId.value = null categoryTypeId.value = null
initialName.value = '' initialName.value = ''
initialCategoryTypeId.value = null initialCategoryTypeId.value = null
formErrors.clearErrors() errors.value = { name: '', categoryType: '', _global: '' }
submitting.value = false submitting.value = false
} }
@@ -244,7 +305,7 @@ export function useCategoryForm() {
// State // State
name, name,
categoryTypeId, categoryTypeId,
errors: formErrors.errors, errors,
submitting, submitting,
isDirty, isDirty,
// Methods // Methods
@@ -44,8 +44,7 @@
:options="categoryOptions" :options="categoryOptions"
:label="t('commercial.clients.form.address.categories')" :label="t('commercial.clients.form.address.categories')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :disabled="readonly"
:required="true"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -53,8 +52,7 @@
:model-value="model.country" :model-value="model.country"
:options="countryOptions" :options="countryOptions"
:label="t('commercial.clients.form.address.country')" :label="t('commercial.clients.form.address.country')"
:readonly="readonly" :disabled="readonly"
:required="true"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -63,8 +61,6 @@
:label="t('commercial.clients.form.address.postalCode')" :label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:required="true"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -75,10 +71,8 @@
:model-value="model.city" :model-value="model.city"
:options="cityOptions" :options="cityOptions"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:readonly="readonly" :disabled="readonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
<MalioInputText <MalioInputText
@@ -86,8 +80,6 @@
:model-value="model.city" :model-value="model.city"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:readonly="readonly" :readonly="readonly"
:required="true"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -96,19 +88,15 @@
sur l'input interne, pas sur la cellule de grille. Le wrapper porte sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). --> le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2"> <div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple en <!-- Adresse : saisie assistee (BAN) ou libre en mode degrade. -->
mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche
pas sa valeur liee, il n'afficherait rien en readonly). -->
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!degraded && !readonly" v-if="!degraded"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch" @search="onAddressSearch"
@select="onAddressSelect" @select="onAddressSelect"
@@ -118,8 +106,6 @@
:model-value="model.street" :model-value="model.street"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
@@ -129,7 +115,6 @@
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')" :label="t('commercial.clients.form.address.streetComplement')"
:readonly="readonly" :readonly="readonly"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
</div> </div>
@@ -152,7 +137,7 @@
:options="contactOptions" :options="contactOptions"
:label="t('commercial.clients.form.address.contacts')" :label="t('commercial.clients.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :disabled="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
@@ -164,7 +149,6 @@
:label="t('commercial.clients.form.address.billingEmail')" :label="t('commercial.clients.form.address.billingEmail')"
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:error="errors?.billingEmail"
@update:model-value="(v: string) => update('billingEmail', v)" @update:model-value="(v: string) => update('billingEmail', v)"
/> />
</div> </div>
@@ -197,8 +181,6 @@ const props = defineProps<{
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -215,36 +197,8 @@ const model = computed(() => props.modelValue)
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre. // Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
const degraded = ref(false) const degraded = ref(false)
// Villes proposees par la BAN (alimentees a la saisie du code postal). const cityOptions = ref<RefOption[]>([])
const banCityOptions = ref<RefOption[]>([]) const addressOptions = ref<RefOption[]>([])
// Adresses proposees par la BAN (alimentees a la saisie d'adresse).
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
// afficherait un champ vide en lecture seule (consultation 1.11) ou en edition
// d'une adresse existante (1.12), ou la BAN n'a pas (re)peuple les suggestions.
const cityOptions = computed<RefOption[]>(() => {
const current = props.modelValue.city
if (current && !banCityOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...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[] = []
@@ -294,7 +248,7 @@ async function onPostalCodeChange(value: string): Promise<void> {
} }
try { try {
const suggestions = await autocomplete.searchCity(digits) const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city })) cityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
} }
catch { catch {
enterDegraded() enterDegraded()
@@ -311,7 +265,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()
@@ -16,28 +16,24 @@
:model-value="model.lastName" :model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')" :label="t('commercial.clients.form.contact.lastName')"
:readonly="readonly" :readonly="readonly"
:error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.firstName" :model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')" :label="t('commercial.clients.form.contact.firstName')"
:readonly="readonly" :readonly="readonly"
:error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')" :label="t('commercial.clients.form.contact.jobTitle')"
:readonly="readonly" :readonly="readonly"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
<MalioInputEmail <MalioInputEmail
:model-value="model.email" :model-value="model.email"
:label="t('commercial.clients.form.contact.email')" :label="t('commercial.clients.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
/> />
<MalioInputPhone <MalioInputPhone
@@ -45,7 +41,6 @@
:label="t('commercial.clients.form.contact.phonePrimary')" :label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')" :add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)" @update:model-value="(v: string) => update('phonePrimary', v)"
@@ -57,7 +52,6 @@
:label="t('commercial.clients.form.contact.phoneSecondary')" :label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
@@ -79,8 +73,6 @@ const props = defineProps<{
removable?: boolean removable?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -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,132 +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')
})
})
/**
* Stub MalioInputText qui re-expose `label` + `error` recus : permet de cibler
* un champ par son libelle et de verifier l'erreur 422 propagee (ERP-101).
*/
const MalioInputTextProbe = defineComponent({
name: 'MalioInputTextProbe',
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', {
'data-testid': 'addr-text',
'data-label': props.label,
'data-error': props.error,
})
},
})
describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
function mountWithErrors(errors: Record<string, string>) {
return mount(ClientAddressBlock, {
props: {
modelValue: emptyAddress(),
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
errors,
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
MalioInputText: MalioInputTextProbe,
},
},
})
}
it('affiche l\'erreur serveur sur le champ code postal via la prop errors', () => {
const wrapper = mountWithErrors({ postalCode: 'Code postal invalide.' })
const field = wrapper.findAll('[data-testid="addr-text"]').find(
el => el.attributes('data-label') === 'commercial.clients.form.address.postalCode',
)
expect(field?.attributes('data-error')).toBe('Code postal invalide.')
})
})
@@ -1,64 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyContact } from '~/modules/commercial/types/clientForm'
import ClientContactBlock from '../ClientContactBlock.vue'
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
/**
* Stub d'un champ Malio qui re-expose la prop `error` recue dans un attribut
* data-* : permet de verifier que le bloc propage bien `:errors[champ]` sur le
* bon champ (ERP-101 — mapping erreur 422 par champ, par ligne de collection).
*/
function errorProbe(testid: string) {
return defineComponent({
name: `Probe-${testid}`,
props: {
modelValue: { type: [String, Number, null], default: undefined },
error: { type: String, default: '' },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
setup(props) {
return () => h('div', { 'data-testid': testid, 'data-error': props.error })
},
})
}
function mountBlock(errors?: Record<string, string>) {
return mount(ClientContactBlock, {
props: {
modelValue: emptyContact(),
title: 'Contact 1',
...(errors ? { errors } : {}),
},
global: {
stubs: {
MalioButtonIcon: true,
MalioInputPhone: true,
MalioInputText: errorProbe('contact-text'),
MalioInputEmail: errorProbe('contact-email'),
},
},
})
}
describe('ClientContactBlock — mapping erreur par champ (ERP-101)', () => {
it('affiche l\'erreur serveur sur le champ email via la prop errors', () => {
const wrapper = mountBlock({ email: 'Adresse e-mail invalide.' })
const email = wrapper.find('[data-testid="contact-email"]')
expect(email.attributes('data-error')).toBe('Adresse e-mail invalide.')
})
it('laisse les champs sans erreur quand errors est absent', () => {
const wrapper = mountBlock()
const email = wrapper.find('[data-testid="contact-email"]')
expect(email.attributes('data-error')).toBe('')
})
})
@@ -1,95 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
const mockGet = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
const { useClient } = await import('../useClient')
const SAMPLE = { '@id': '/api/clients/42', id: 42, companyName: 'ACME', isArchived: false }
describe('useClient', () => {
beforeEach(() => {
mockGet.mockReset()
mockPatch.mockReset()
mockGet.mockResolvedValue(SAMPLE)
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
})
it('charge le detail via GET /clients/{id} en Hydra, sans toast', async () => {
const { client, load } = useClient(42)
await load()
expect(mockGet).toHaveBeenCalledWith(
'/clients/42',
{},
expect.objectContaining({
headers: { Accept: 'application/ld+json' },
toast: false,
}),
)
expect(client.value).toEqual(SAMPLE)
})
it('bascule loading pendant le chargement et le retombe a false', async () => {
const { loading, load } = useClient(42)
const promise = load()
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
})
it('marque error et laisse client null si le GET echoue (404...)', async () => {
mockGet.mockRejectedValueOnce(new Error('not found'))
const { client, error, load } = useClient(99)
await load()
expect(error.value).toBe(true)
expect(client.value).toBeNull()
})
it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
// 1er GET = chargement initial, 2e GET = rechargement post-archivage.
mockGet.mockResolvedValueOnce(SAMPLE)
mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
const { client, load, archive } = useClient(42)
await load()
await archive()
expect(mockPatch).toHaveBeenCalledWith(
'/clients/42',
{ isArchived: true },
expect.objectContaining({ toast: false }),
)
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
expect(mockGet).toHaveBeenCalledTimes(2)
expect(client.value?.isArchived).toBe(true)
})
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
const { load, restore } = useClient(42)
await load()
await restore()
expect(mockPatch).toHaveBeenCalledWith(
'/clients/42',
{ isArchived: false },
expect.objectContaining({ toast: false }),
)
})
it('propage l\'erreur (ex: 409 conflit homonyme RG-1.23) au lieu de l\'avaler', async () => {
const conflict = { response: { status: 409 } }
mockPatch.mockRejectedValueOnce(conflict)
const { load, restore } = useClient(42)
await load()
await expect(restore()).rejects.toBe(conflict)
})
})
@@ -1,54 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { useFormErrors } from '~/shared/composables/useFormErrors'
import { useClientFormErrors } from '../useClientFormErrors'
// useFormErrors (auto-import) expose l'implementation reelle ; elle consomme
// useToast + useI18n, stubbes ici.
vi.stubGlobal('useToast', () => ({ error: vi.fn(), success: vi.fn() }))
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useFormErrors', useFormErrors)
/**
* Tests du composable partage `useClientFormErrors` — factorisation du cablage
* d'erreurs des ecrans client (creation/edition), suggestion de revue ERP-101.
* `mapRowError` ne toaste plus : il retourne un booleen et chaque page garde son
* propre fallback (toast.error en creation, showError en edition).
*/
describe('useClientFormErrors', () => {
it('expose les 3 etats scalaires (vides) et les 3 tableaux d\'erreurs par ligne', () => {
const f = useClientFormErrors()
expect(f.mainErrors.errors).toEqual({})
expect(f.informationErrors.errors).toEqual({})
expect(f.accountingErrors.errors).toEqual({})
expect(f.contactErrors.value).toEqual([])
expect(f.addressErrors.value).toEqual([])
expect(f.ribErrors.value).toEqual([])
})
it('mapRowError mappe une 422 sur target[index] et retourne true', () => {
const f = useClientFormErrors()
const error = {
response: {
status: 422,
_data: { violations: [{ propertyPath: 'email', message: 'Adresse invalide.' }] },
},
}
const mapped = f.mapRowError(error, f.contactErrors, 0)
expect(mapped).toBe(true)
expect(f.contactErrors.value[0]).toEqual({ email: 'Adresse invalide.' })
})
it('mapRowError retourne false et ne touche pas la cible pour une erreur non-422', () => {
const f = useClientFormErrors()
const error = { response: { status: 500, _data: {} } }
expect(f.mapRowError(error, f.ribErrors, 0)).toBe(false)
expect(f.ribErrors.value[0]).toBeUndefined()
})
it('mapRowError retourne false pour une 422 sans violation exploitable', () => {
const f = useClientFormErrors()
const error = { response: { status: 422, _data: { 'hydra:description': 'Donnees invalides.' } } }
expect(f.mapRowError(error, f.addressErrors, 0)).toBe(false)
expect(f.addressErrors.value[0]).toBeUndefined()
})
})
@@ -28,7 +28,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
return Promise.reject(new Error('403 Forbidden')) return Promise.reject(new Error('403 Forbidden'))
} }
if (url === '/sites') { if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] }) return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
} }
return Promise.resolve({ return Promise.resolve({
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }], member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
@@ -40,8 +40,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
await refs.loadCommon() await refs.loadCommon()
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories. // Resilience : les referentiels OK sont peuples malgre l'echec de /categories.
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal). expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }]) expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
@@ -57,7 +56,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
}) })
} }
if (url === '/sites') { if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] }) return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
} }
return Promise.resolve({ member: [] }) return Promise.resolve({ member: [] })
}) })
@@ -68,7 +67,6 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.categories.value).toEqual([ expect(refs.categories.value).toEqual([
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }, { value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
]) ])
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal). expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
}) })
}) })
@@ -1,70 +0,0 @@
import { ref } from 'vue'
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
/**
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
* client », 1.11). Lit le detail embarque via `GET /api/clients/{id}` (contacts /
* adresses / ribs sous `client:item:read` / `client:read:accounting`) et expose
* les bascules d'archivage (PATCH `isArchived` SEUL — tout autre champ => 422).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
* Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs
* d'archivage/restauration (notamment le 409 RG-1.23 : homonyme actif a la
* restauration) sont PROPAGEES a l'appelant, qui decide du toast a afficher.
*/
export function useClient(id: number | string) {
const api = useApi()
const client = ref<ClientDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
function fetchDetail(): Promise<ClientDetail> {
return api.get<ClientDetail>(
`/clients/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le detail du client. En cas d'echec : `error = true`, `client = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
client.value = await fetchDetail()
}
catch {
error.value = true
client.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422),
* puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
* `client:read` (ni l'embed contacts/adresses/ribs ni les libelles des
* referentiels comptables), un simple merge laisserait l'affichage incoherent.
* Toute erreur (notamment le 409 d'homonyme actif a la restauration, RG-1.23)
* est propagee a l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/clients/${id}`, { isArchived }, { toast: false })
client.value = await fetchDetail()
}
return {
client,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -1,55 +0,0 @@
/**
* Composable d'erreurs partage des ecrans client (creation + edition, M1
* Commercial). Factorise le cablage identique entre `clients/new.vue` et
* `clients/[id]/edit.vue` (suggestion de revue ERP-101) :
* - un `useFormErrors` par groupe scalaire (Principal / Information /
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
* adresses / RIB), aligne sur l'index du `v-for`.
*
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`
* (toast generique en creation, `showError` en edition) sans imposer un
* comportement commun.
*/
import { ref, type Ref } from 'vue'
import { mapViolationsToRecord } from '~/shared/utils/api'
export function useClientFormErrors() {
const mainErrors = useFormErrors()
const informationErrors = useFormErrors()
const accountingErrors = useFormErrors()
const contactErrors = ref<Record<string, string>[]>([])
const addressErrors = ref<Record<string, string>[]>([])
const ribErrors = ref<Record<string, string>[]>([])
/**
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
* 422 avec violations exploitables → erreurs inline sous les champs de la
* ligne + retourne true. Sinon → ne touche pas la cible et retourne false
* (le caller decide du fallback toast).
*/
function mapRowError(
error: unknown,
target: Ref<Record<string, string>[]>,
index: number,
): boolean {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
return true
}
return false
}
return {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
}
}
@@ -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 {
@@ -86,34 +85,37 @@ export function useClientReferentials() {
/** /**
* Charge en parallele les referentiels communs (hors distributeurs/courtiers, * Charge en parallele les referentiels communs (hors distributeurs/courtiers,
* charges a la demande selon la relation choisie). * charges a la demande selon la relation choisie). Les selects compta ne sont
* pertinents que si l'utilisateur a acces a l'onglet, mais le cout est
* negligeable et simplifie l'orchestration.
* *
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole. * Resilience (ERP-102) : chaque referentiel est charge et affecte
* Necessaire pour les roles metier qui n'ont pas toutes les permissions de * independamment via `Promise.allSettled`. Si UN endpoint echoue (ex: 403,
* lecture — ex. Compta a `commercial.clients.view` (donc /tva_modes, /banks... * coupure reseau), seul SON select reste vide — les autres sont peuples
* accessibles) mais PAS `catalog.categories.view` ni `sites.view` : sans * normalement. Un `Promise.all` rejetterait au premier echec et viderait la
* isolation, le 403 sur /categories ferait echouer tout le bloc et viderait * TOTALITE des selects, rendant le formulaire de creation client inutilisable.
* les selects comptables dont Compta a besoin sur l'ecran de modification. * `loadCommon` ne rejette donc jamais.
* Un referentiel en echec reste simplement vide (l'ecran d'edition complete
* l'affichage des valeurs courantes depuis l'embed du detail client).
*/ */
async function loadCommon(): Promise<void> { async function loadCommon(): Promise<void> {
await Promise.allSettled([ await Promise.allSettled([
fetchAll<CategoryMember>('/categories') fetchAll<CategoryMember>('/categories').then(cats => {
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code }))
fetchAll<SiteMember>('/sites') }),
// Libelle = numero de departement (2 premiers chiffres du code fetchAll<SiteMember>('/sites').then(sitesList => {
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name }))
// 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').then(tva => {
fetchAll<ReferentialMember>('/tva_modes') 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').then(delays => {
.then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }), paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label }))
fetchAll<ReferentialMember>('/payment_types') }),
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }), fetchAll<ReferentialMember>('/payment_types').then(types => {
fetchAll<ReferentialMember>('/banks') paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code }))
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }), }),
fetchAll<ReferentialMember>('/banks').then(banksList => {
banks.value = banksList.map(b => ({ value: b['@id'], label: b.label }))
}),
]) ])
} }
@@ -1,987 +0,0 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du client. -->
<div class="flex items-center gap-3">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
@click="goBack"
/>
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.edit.notFound') }}</p>
<template v-else-if="client">
<!-- Bloc principal (pre-rempli, editable si `manage`)
Decision Tristan : on conserve le bloc principal en modification
(« pour ne pas tout casser »), edite via son propre PATCH scope
sur le groupe client:write:main. Readonly pour les roles sans
`manage` (ex. Compta). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/>
<MalioSelect
:model-value="main.relationType"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="businessReadonly"
@update:model-value="onRelationChange"
/>
<MalioSelect
v-if="main.relationType === 'courtier'"
:model-value="main.brokerIri"
:options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/>
<MalioSelect
v-if="main.relationType === 'distributeur'"
:model-value="main.distributorIri"
:options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')"
:readonly="businessReadonly"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/>
<MalioCheckbox
v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
:readonly="businessReadonly"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!isMainValid || mainSubmitting"
@click="submitMain"
/>
</div>
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<MalioInputTextArea
v-model="information.description"
:label="t('commercial.clients.form.information.description')"
resize="none"
group-class="row-span-2 pt-1"
text-input="h-full text-lg"
:readonly="businessReadonly"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')"
:readonly="businessReadonly"
:error="informationErrors.errors.competitors"
/>
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly"
:error="informationErrors.errors.foundedAt"
/>
<MalioInputText
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="businessReadonly"
:error="informationErrors.errors.employeesCount"
/>
<MalioInputAmount
v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:readonly="businessReadonly"
:error="informationErrors.errors.revenueAmount"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')"
:readonly="businessReadonly"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:readonly="businessReadonly"
:error="informationErrors.errors.profitAmount"
/>
</div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="tabSubmitting"
@click="submitInformation"
/>
</div>
</template>
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<ClientContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? `new-${index}`"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1"
:readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.clients.form.contact.add')"
:disabled="!canAddContact"
@click="addContact"
/>
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateContacts || tabSubmitting"
@click="submitContacts"
/>
</div>
</div>
</template>
<!-- Onglet Adresse -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ClientAddressBlock
v-for="(address, index) in addresses"
:key="address.id ?? `new-${index}`"
:model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:category-options="addressCategoryOptions"
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="addresses.length > 1"
:readonly="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.clients.form.address.add')"
@click="addAddress"
/>
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateAddresses || tabSubmitting"
@click="submitAddresses"
/>
</div>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view ;
editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting>
<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="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/>
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange"
/>
<MalioSelect
v-if="isBankRequired"
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/>
</div>
</div>
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR (RG-1.13). -->
<div
v-for="(rib, index) in ribs"
:key="rib.id ?? `new-${index}`"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<MalioButtonIcon
v-if="!accountingReadonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
</div>
</div>
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
<MalioButton
variant="secondary"
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')"
@click="addRib"
/>
<MalioButton
variant="primary"
:label="t('commercial.clients.edit.save')"
:disabled="!canValidateAccounting || tabSubmitting"
@click="submitAccounting"
/>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
</template>
<p>{{ confirmModal.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('commercial.clients.form.confirmDelete.cancel')"
@click="confirmModal.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="t('commercial.clients.form.confirmDelete.confirm')"
@click="runConfirm"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import {
canEditClient,
categoryOptionsOf,
referentialOptionOf,
siteOptionsOf,
mapContactToDraft,
mapAddressToDraft,
mapRibToDraft,
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
type AccountingFormDraft,
type ClientEditAbilities,
type InformationFormDraft,
type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit'
import {
buildClientFormTabKeys,
hasAtLeastOneValidContact,
isBankRequiredForPaymentType,
isBillingEmailRequired,
isContactNamed,
isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/clientFormRules'
import {
emptyAddress,
emptyContact,
emptyRib,
type AddressFormDraft,
type ContactFormDraft,
type RibFormDraft,
} from '~/modules/commercial/types/clientForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
const EMPLOYEES_MASK = '#######'
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
const { t } = useI18n()
const api = useApi()
const toast = useToast()
const route = useRoute()
const router = useRouter()
const { can, canAny } = usePermissions()
// Gating de la route : l'edition exige de pouvoir editer au moins un onglet
// (`manage` OU `accounting.manage`). Usine et roles en lecture seule sont
// rediriges vers le repertoire (lui-meme protege).
if (!canEditClient(canAny)) {
await navigateTo('/clients')
}
const clientId = route.params.id as string
const { client, loading, error, load } = useClient(clientId)
const referentials = useClientReferentials()
// ── Permissions / editabilite par zone (option 1 ERP-74) ────────────────────
const abilities = computed<ClientEditAbilities>(() => ({
canManage: can('commercial.clients.manage'),
canAccountingView: can('commercial.clients.accounting.view'),
canAccountingManage: can('commercial.clients.accounting.manage'),
}))
const editability = computed(() => resolveTabEditability(abilities.value))
// Bloc principal + onglets Information / Contact / Adresse.
const businessReadonly = computed(() => !editability.value.businessEditable)
const canAccountingView = computed(() => editability.value.accountingVisible)
const accountingReadonly = computed(() => !editability.value.accountingEditable)
const headerTitle = computed(() => client.value?.companyName ?? t('commercial.clients.edit.title'))
// ── Brouillons editables (pre-remplis depuis le detail) ─────────────────────
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
const contacts = ref<ContactFormDraft[]>([])
const addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([])
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
const removedContactIds = ref<number[]>([])
const removedAddressIds = ref<number[]>([])
const removedRibIds = ref<number[]>([])
const mainSubmitting = ref(false)
const tabSubmitting = ref(false)
const addressDegradedNotified = ref(false)
/** Recopie le detail charge dans les brouillons editables. */
function hydrate(detail: ClientDetail): void {
Object.assign(main, mapMainDraft(detail))
Object.assign(information, mapInformationDraft(detail))
Object.assign(accounting, mapAccountingFormDraft(detail))
contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
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.
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
}
// ── Options de selects (referentiels UNION valeurs courantes de l'embed) ─────
// L'union garantit que les valeurs deja posees s'affichent meme quand le
// referentiel complet n'est pas chargeable (roles metier sans
// catalog.categories.view / sites.view → 403, cf. matrice § 2.7).
function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[] {
const seen = new Set(primary.map(o => o.value))
return [...primary, ...extra.filter(o => !seen.has(o.value))]
}
const embedCategoryOptions = computed<CategoryOption[]>(() => {
const fromClient = categoryOptionsOf(client.value?.categories)
const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
return mergeOptions(fromClient, fromAddresses)
})
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
const addressCategoryOptions = computed(() =>
mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
)
const embedSiteOptions = computed<RefOption[]>(() =>
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
)
const siteOptions = computed(() => mergeOptions(referentials.sites.value, embedSiteOptions.value))
// Contacts deja persistes (iri non null), rattachables a une adresse (M2M).
const contactOptions = computed<RefOption[]>(() =>
contacts.value
.filter(c => c.iri !== null)
.map(c => ({
value: c.iri as string,
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
})),
)
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
// Distributeur / courtier : referentiel charge a la demande UNION valeur courante.
const currentDistributorOption = computed<RefOption[]>(() => {
const d = client.value?.distributor
return d && typeof d === 'object' ? [{ value: d['@id'], label: d.companyName ?? d['@id'] }] : []
})
const currentBrokerOption = computed<RefOption[]>(() => {
const b = client.value?.broker
return b && typeof b === 'object' ? [{ value: b['@id'], label: b.companyName ?? b['@id'] }] : []
})
const distributorOptions = computed(() => mergeOptions(referentials.distributors.value, currentDistributorOption.value))
const brokerOptions = computed(() => mergeOptions(referentials.brokers.value, currentBrokerOption.value))
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(client.value?.tvaMode)))
const paymentDelayOptions = computed(() => mergeOptions(referentials.paymentDelays.value, referentialOptionOf(client.value?.paymentDelay)))
const paymentTypeOptions = computed(() => mergeOptions(
referentials.paymentTypes.value.map(p => ({ value: p.value, label: p.label })),
referentialOptionOf(client.value?.paymentType),
))
const bankOptions = computed(() => mergeOptions(referentials.banks.value, referentialOptionOf(client.value?.bank)))
// ── Onglets : navigation libre (4 actifs + 4 coquilles, comme la consultation) ─
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
transport: 'mdi:truck-delivery-outline',
accounting: 'mdi:bank-circle-outline',
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`commercial.clients.tab.${key}`),
icon: TAB_ICONS[key],
})))
const activeTab = ref('information')
// ── Navigation ──────────────────────────────────────────────────────────────
function goBack(): void {
router.push(`/clients/${clientId}`)
}
/**
* Message d'erreur a afficher : violation 422 / detail renvoye par le serveur,
* sinon un libelle generique. Le 409 d'unicite de nom (bloc principal) est
* traduit explicitement par l'appelant.
*/
function apiErrorMessage(e: unknown): string {
const data = (e as { data?: unknown })?.data
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
}
function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void {
const status = (e as { response?: { status?: number } })?.response?.status
toast.error({
title: t('commercial.clients.toast.error'),
message: opts.duplicateCompany && status === 409
? t('commercial.clients.form.duplicateCompany')
: apiErrorMessage(e),
})
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
// chaque collection (aligne sur l'index visible). `mapRowError` mappe une 422
// inline et retourne true ; il ne toaste pas, le fallback `showError` reste
// local a l'edition (cf. catch des submits de collection).
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
} = useClientFormErrors()
// ── Bloc principal ───────────────────────────────────────────────────────────
const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
const relationValid
= main.relationType === null
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName)
&& main.categoryIris.length >= 1
&& relationValid
})
async function onRelationChange(value: string | number | null): Promise<void> {
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
main.relationType = relation
// Une seule FK remplie a la fois (RG-1.03).
if (relation !== 'distributeur') main.distributorIri = null
if (relation !== 'courtier') main.brokerIri = null
if (relation === 'distributeur') await referentials.loadDistributors().catch(() => {})
if (relation === 'courtier') await referentials.loadBrokers().catch(() => {})
}
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true
mainErrors.clearErrors()
try {
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
headers: { Accept: 'application/ld+json' },
toast: false,
})
// Reaffiche les valeurs normalisees renvoyees par le serveur.
Object.assign(main, mapMainDraft(updated))
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
// 409 = doublon nom de societe → erreur inline + toast ; 422 → mapping
// inline par champ ; autre → toast de fallback. Cf. ERP-101.
const status = (e as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('commercial.clients.form.duplicateCompany')
mainErrors.setError('companyName', message)
toast.error({ title: t('commercial.clients.toast.error'), message })
}
else {
mainErrors.handleApiError(e, { fallbackMessage: t('commercial.clients.toast.error') })
}
}
finally {
mainSubmitting.value = false
}
}
// ── Onglet Information ───────────────────────────────────────────────────────
/** PATCH /clients/{id} — groupe client:write:information UNIQUEMENT. */
async function submitInformation(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false })
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
informationErrors.handleApiError(e, { fallbackMessage: t('commercial.clients.toast.error') })
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Contact ───────────────────────────────────────────────────────────
const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1]
return last === undefined || isContactNamed(last)
})
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
function addContact(): void {
if (canAddContact.value) contacts.value.push(emptyContact())
}
function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
})
}
/**
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
* collection contacts (endpoints client_contact dedies).
*/
async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true
contactErrors.value = []
try {
for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
}
removedContactIds.value = []
for (let index = 0; index < contacts.value.length; index++) {
const contact = contacts.value[index]
if (!isContactNamed(contact)) continue
const body = buildContactPayload(contact)
try {
if (contact.id === null) {
const created = await api.post<{ '@id'?: string, id: number }>(
`/clients/${clientId}/contacts`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
contact.id = created.id
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
}
catch (error) {
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe.
if (!mapRowError(error, contactErrors, index)) {
showError(error)
}
return
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() =>
addresses.value.length > 0
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
)
function addAddress(): void {
addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
})
}
function onAddressDegraded(): void {
if (addressDegradedNotified.value) return
addressDegradedNotified.value = true
toast.warning({
title: t('commercial.clients.toast.error'),
message: t('commercial.clients.form.address.degraded'),
})
}
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true
addressErrors.value = []
try {
for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
}
removedAddressIds.value = []
for (let index = 0; index < addresses.value.length; index++) {
const address = addresses.value[index]
const body = buildAddressPayload(address, isBillingEmailRequired(address))
try {
if (address.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/addresses`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
address.id = created.id
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
}
catch (error) {
if (!mapRowError(error, addressErrors, index)) {
showError(error)
}
return
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
finally {
tabSubmitting.value = false
}
}
// ── Onglet Comptabilite ──────────────────────────────────────────────────────
const selectedPaymentTypeCode = computed(() =>
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
)
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
function onPaymentTypeChange(value: string | number | null): void {
accounting.paymentTypeIri = value === null ? null : String(value)
if (!isBankRequired.value) accounting.bankIri = null
}
function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean {
const filled = (v: string | null) => v !== null && v.trim() !== ''
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
}
const canValidateAccounting = computed(() => {
if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
return true
})
function addRib(): void {
ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1)
ribErrors.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())
})
}
/**
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting,
* exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la
* sous-ressource. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true
accountingErrors.clearErrors()
ribErrors.value = []
try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
// 2) POST/PATCH des RIB (erreurs inline par ligne).
for (let index = 0; index < ribs.value.length; index++) {
const rib = ribs.value[index]
if (!ribIsComplete(rib)) continue
const body = buildRibPayload(rib)
try {
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
}
catch (error) {
if (!mapRowError(error, ribErrors, index)) {
showError(error)
}
return
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
finally {
tabSubmitting.value = false
}
}
// ── Modal de confirmation generique ──────────────────────────────────────────
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
useHead({ title: headerTitle })
onMounted(async () => {
// Referentiels en best-effort (echec non bloquant : l'embed alimente les
// libelles des valeurs courantes).
referentials.loadCommon().catch(() => {})
await load()
if (client.value) hydrate(client.value)
})
</script>
@@ -1,471 +0,0 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
<div class="flex items-center gap-3">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canEdit"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('commercial.clients.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('commercial.clients.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('commercial.clients.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.consultation.notFound') }}</p>
<template v-else-if="client">
<!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="client.companyName"
:label="t('commercial.clients.form.main.companyName')"
readonly
/>
<MalioSelectCheckbox
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
readonly
/>
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
<MalioSelect
:model-value="relation.type"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
readonly
/>
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
aucune valeur sans relation meme comportement qu'en edition). -->
<MalioInputText
v-if="relation.type"
:model-value="relation.name"
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
readonly
/>
<MalioCheckbox
:model-value="client.triageService === true"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
readonly
/>
</div>
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<MalioInputTextArea
:model-value="information.description"
:label="t('commercial.clients.form.information.description')"
resize="none"
group-class="row-span-2 pt-1"
text-input="h-full text-lg"
readonly
/>
<MalioInputText
:model-value="information.competitors"
:label="t('commercial.clients.form.information.competitors')"
readonly
/>
<MalioDate
:model-value="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
readonly
/>
<MalioInputText
:model-value="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
readonly
/>
<MalioInputAmount
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
readonly
/>
<MalioInputText
:model-value="information.directorName"
:label="t('commercial.clients.form.information.directorName')"
readonly
/>
<MalioInputAmount
:model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
readonly
/>
</div>
</template>
<!-- Onglet Contact -->
<template #contact>
<div class="mt-12 flex flex-col gap-6">
<ClientContactBlock
v-for="(contact, index) in contacts"
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
readonly
/>
</div>
</template>
<!-- Onglet Adresse -->
<template #address>
<div class="mt-12 flex flex-col gap-6">
<ClientAddressBlock
v-for="(view, index) in addressViews"
:key="view.draft.id ?? index"
:model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
:category-options="view.categoryOptions"
:site-options="allSiteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
readonly
/>
</div>
</template>
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting>
<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="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
readonly
/>
<MalioInputText
:model-value="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')"
readonly
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label=""
readonly
/>
<MalioInputText
:model-value="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')"
readonly
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label=""
readonly
/>
<MalioSelect
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label=""
readonly
/>
<MalioSelect
v-if="accounting.bankIri"
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
empty-option-label=""
readonly
/>
</div>
</div>
<!-- Blocs RIB (0..n), lecture seule. -->
<div
v-for="(rib, index) in ribs"
:key="rib.id ?? index"
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">
<MalioInputText
:model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
readonly
/>
<MalioInputText
:model-value="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')"
readonly
/>
<MalioInputText
:model-value="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')"
readonly
/>
</div>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
<!-- Modal de confirmation Archiver / Restaurer. -->
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">
{{ isArchived ? t('commercial.clients.consultation.confirmRestore.title') : t('commercial.clients.consultation.confirmArchive.title') }}
</h2>
</template>
<p>{{ isArchived ? t('commercial.clients.consultation.confirmRestore.message') : t('commercial.clients.consultation.confirmArchive.message') }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('commercial.clients.form.confirmDelete.cancel')"
@click="confirmOpen = false"
/>
<MalioButton
:variant="isArchived ? 'primary' : 'danger'"
button-class="flex-1"
:label="t('commercial.clients.form.confirmDelete.confirm')"
:disabled="toggling"
@click="confirmToggleArchive"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
import {
canEditClient,
categoryOptionsOf,
contactOptionsOf,
mapAccountingDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
referentialOptionOf,
relationOf,
showArchiveAction,
showRestoreAction,
type ClientDetail,
type SelectOption,
} from '~/modules/commercial/utils/clientConsultation'
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est
// redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7.
if (!can('commercial.clients.view')) {
await navigateTo('/clients')
}
const clientId = route.params.id as string
const { client, loading, error, load, archive, restore } = useClient(clientId)
// ── Permissions / visibilite des actions ───────────────────────────────────
const canAccountingView = computed(() => can('commercial.clients.accounting.view'))
const canEdit = computed(() => canEditClient(canAny))
const isArchived = computed(() => client.value?.isArchived === true)
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => client.value?.companyName ?? t('commercial.clients.consultation.title'))
// ── Donnees derivees du payload (lecture seule) ────────────────────────────
const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null }))
const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id']))
const information = computed(() => ({
description: client.value?.description ?? null,
competitors: client.value?.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime renvoye.
foundedAt: client.value?.foundedAt ? client.value.foundedAt.slice(0, 10) : null,
employeesCount: client.value?.employeesCount != null ? String(client.value.employeesCount) : null,
revenueAmount: client.value?.revenueAmount ?? null,
profitAmount: client.value?.profitAmount ?? null,
directorName: client.value?.directorName ?? null,
}))
// Chaque bloc reste visible meme vide en consultation : si la collection est
// 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.
const addressViews = computed(() => {
const views = (client.value?.addresses ?? []).map(mapAddressView)
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).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
// ── Options des selects (construites depuis l'EMBED, jamais via un GET de
// referentiel : /categories et /sites sont en 403 pour les roles metier
// non-admin, ce qui laisserait les libelles vides). ───────────────────────
const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories))
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[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
const countryOptions: SelectOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
const paymentDelayOptions = computed(() => referentialOptionOf(client.value?.paymentDelay))
const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
transport: 'mdi:truck-delivery-outline',
accounting: 'mdi:bank-circle-outline',
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`commercial.clients.tab.${key}`),
icon: TAB_ICONS[key],
})))
const activeTab = ref('information')
// ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void {
router.push('/clients')
}
function goEdit(): void {
router.push(`/clients/${clientId}/edit`)
}
// ── Archivage / Restauration ────────────────────────────────────────────────
const confirmOpen = ref(false)
const toggling = ref(false)
function askToggleArchive(): void {
confirmOpen.value = true
}
/**
* Confirme l'archivage ou la restauration (PATCH isArchived seul). Gere le 409
* de conflit d'homonyme actif a la restauration (RG-1.23) avec un message dedie.
*/
async function confirmToggleArchive(): Promise<void> {
if (toggling.value) return
toggling.value = true
const restoring = isArchived.value
try {
if (restoring) {
await restore()
toast.success({ title: t('commercial.clients.toast.restoreSuccess') })
}
else {
await archive()
toast.success({ title: t('commercial.clients.toast.archiveSuccess') })
}
confirmOpen.value = false
}
catch (e) {
const status = (e as { response?: { status?: number } })?.response?.status
toast.error({
title: t('commercial.clients.toast.error'),
message: restoring && status === 409
? t('commercial.clients.toast.restoreConflict')
: t('commercial.clients.toast.error'),
})
}
finally {
toggling.value = false
}
}
useHead({ title: headerTitle })
onMounted(load)
</script>
+157 -182
View File
@@ -22,24 +22,50 @@
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:readonly="mainLocked" :readonly="mainLocked"
:error="mainErrors.errors.companyName" />
<MalioInputText
v-model="main.lastName"
:label="t('commercial.clients.form.main.lastName')"
:readonly="mainLocked"
/>
<MalioInputText
v-model="main.firstName"
:label="t('commercial.clients.form.main.firstName')"
:readonly="mainLocked"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:readonly="mainLocked" :disabled="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<!-- Telephones : 1 par defaut, le bouton « + » revele le 2e (max 2, RG-1.02). -->
<MalioInputPhone
v-for="(_, index) in mainPhones"
:key="index"
v-model="mainPhones[index]"
:label="t('commercial.clients.form.main.phonePrimary')"
:mask="PHONE_MASK"
:required="index === 0"
:readonly="mainLocked"
add-icon-name="mdi:plus"
:addable="mainPhones.length === 1 && !mainLocked"
:add-button-label="t('commercial.clients.form.main.addPhone')"
@add="addMainPhone"
/>
<MalioInputEmail
v-model="main.email"
:label="t('commercial.clients.form.main.email')"
:required="true"
:readonly="mainLocked"
/>
<MalioSelect <MalioSelect
:model-value="main.relationType" :model-value="main.relationType"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :disabled="mainLocked"
:readonly="mainLocked"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -47,9 +73,7 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="referentials.brokers.value" :options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:readonly="mainLocked" :disabled="mainLocked"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
@@ -57,9 +81,7 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="referentials.distributors.value" :options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:readonly="mainLocked" :disabled="mainLocked"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
/> />
<MalioCheckbox <MalioCheckbox
@@ -92,45 +114,38 @@
resize="none" resize="none"
group-class="row-span-2 pt-1" group-class="row-span-2 pt-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.competitors"
/> />
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.foundedAt"
/> />
<MalioInputText <MalioInputText
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.revenueAmount" v-model="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')" :readonly="isValidated('information')"
:error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/> />
</div> </div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center"> <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
@@ -156,7 +171,6 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="index > 0" :removable="index > 0"
:readonly="isValidated('contact')" :readonly="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
@@ -193,7 +207,6 @@
:country-options="countryOptions" :country-options="countryOptions"
:removable="index > 0" :removable="index > 0"
:readonly="isValidated('address')" :readonly="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@degraded="onAddressDegraded" @degraded="onAddressDegraded"
@@ -220,57 +233,45 @@
<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')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange" @update:model-value="onPaymentTypeChange"
/> />
<MalioSelect <MalioSelect
@@ -278,10 +279,8 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
/> />
</div> </div>
@@ -302,27 +301,21 @@
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')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly" :readonly="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/> />
</div> </div>
</div> </div>
@@ -348,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). -->
@@ -378,7 +371,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
buildClientFormTabKeys, buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS, CLIENT_FORM_PLACEHOLDER_TABS,
@@ -396,9 +388,11 @@ import {
type ContactFormDraft, type ContactFormDraft,
type RibFormDraft, type RibFormDraft,
} from '~/modules/commercial/types/clientForm' } from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7). // Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
const EMPLOYEES_MASK = '#######' const EMPLOYEES_MASK = '#######'
@@ -428,22 +422,6 @@ function apiErrorMessage(error: unknown): string {
return extractApiErrorMessage(data) || t('commercial.clients.toast.error') return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
} }
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Etat d'erreurs factorise entre creation et edition (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire (Principal / Information / Comptabilite)
// + un tableau d'erreurs par ligne pour chaque collection (contacts/adresses/RIB).
// `mapRowError` mappe une 422 inline et retourne true ; il ne toaste pas, le
// fallback reste local a la creation (cf. catch des submits de collection).
const {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
} = useClientFormErrors()
useHead({ title: t('commercial.clients.form.title') }) useHead({ title: t('commercial.clients.form.title') })
// Gating de la route : la creation est reservee a `manage`. Compta (accounting // Gating de la route : la creation est reservee a `manage`. Compta (accounting
@@ -466,6 +444,9 @@ const tabSubmitting = ref(false)
// ── Formulaire principal ──────────────────────────────────────────────────── // ── Formulaire principal ────────────────────────────────────────────────────
const main = reactive({ const main = reactive({
companyName: null as string | null, companyName: null as string | null,
firstName: null as string | null,
lastName: null as string | null,
email: null as string | null,
categoryIris: [] as string[], categoryIris: [] as string[],
relationType: null as 'distributeur' | 'courtier' | null, relationType: null as 'distributeur' | 'courtier' | null,
distributorIri: null as string | null, distributorIri: null as string | null,
@@ -473,6 +454,17 @@ const main = reactive({
triageService: false, triageService: false,
}) })
// Telephones du formulaire principal : 1 par defaut, 2 au maximum (RG-1.02).
// L'index 0 alimente phonePrimary, l'index 1 phoneSecondary au POST.
const mainPhones = ref<string[]>([''])
/** Revele le 2e numero (le bouton « + » disparait une fois a 2, RG-1.02). */
function addMainPhone(): void {
if (mainPhones.value.length === 1) {
mainPhones.value.push('')
}
}
// Pas d'option « Aucun » : le select est vide par defaut (relationType = null). // Pas d'option « Aucun » : le select est vide par defaut (relationType = null).
const relationOptions = computed<RefOption[]>(() => [ const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
@@ -480,11 +472,10 @@ const relationOptions = computed<RefOption[]>(() => [
]) ])
// Validation du formulaire principal (gate le bouton « Valider ») : // Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / >= 1 categorie obligatoires ; // - companyName / email / telephone principal / >= 1 categorie obligatoires ;
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant // - RG-1.01 : nom OU prenom du contact principal ;
// devient requis si l'un des deux est choisi (spec fonctionnelle). // - relation Distributeur/Courtier obligatoire (un des deux), ET le nom
// Les coordonnees de contact ne sont plus saisies ici : elles vivent dans // correspondant obligatoire selon le choix (spec fonctionnelle).
// l'onglet Contacts (RG-1.05/1.14 garantissent >= 1 contact valide).
const isMainValid = computed(() => { const isMainValid = computed(() => {
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== '' const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du // Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
@@ -494,6 +485,9 @@ const isMainValid = computed(() => {
|| (main.relationType === 'distributeur' && filled(main.distributorIri)) || (main.relationType === 'distributeur' && filled(main.distributorIri))
|| (main.relationType === 'courtier' && filled(main.brokerIri)) || (main.relationType === 'courtier' && filled(main.brokerIri))
return filled(main.companyName) return filled(main.companyName)
&& filled(main.email)
&& filled(mainPhones.value[0])
&& (filled(main.firstName) || filled(main.lastName))
&& main.categoryIris.length >= 1 && main.categoryIris.length >= 1
&& relationValid && relationValid
}) })
@@ -515,10 +509,14 @@ async function onRelationChange(value: string | number | null): Promise<void> {
async function submitMain(): Promise<void> { async function submitMain(): Promise<void> {
if (!isMainValid.value || mainSubmitting.value) return if (!isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors()
try { try {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
companyName: main.companyName, companyName: main.companyName,
firstName: main.firstName || null,
lastName: main.lastName || null,
email: main.email,
phonePrimary: mainPhones.value[0] || null,
phoneSecondary: mainPhones.value[1] || null,
categories: main.categoryIris, categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null, distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null, broker: main.relationType === 'courtier' ? main.brokerIri : null,
@@ -530,8 +528,18 @@ async function submitMain(): Promise<void> {
}) })
clientId.value = created.id clientId.value = created.id
// Reaffiche la valeur normalisee renvoyee par le serveur. // Reaffiche les valeurs normalisees renvoyees par le serveur.
main.companyName = created.companyName ?? main.companyName main.companyName = created.companyName ?? main.companyName
main.firstName = created.firstName ?? null
main.lastName = created.lastName ?? null
main.email = created.email ?? main.email
// Reaffiche les telephones normalises (reformates via formatPhoneFR).
const normalizedPhones = [formatPhoneFR(created.phonePrimary), formatPhoneFR(created.phoneSecondary)]
.filter(p => p !== '')
mainPhones.value = normalizedPhones.length > 0 ? normalizedPhones : ['']
// Pre-remplit le 1er contact a partir du formulaire principal (editable).
prefillFirstContact()
mainLocked.value = true mainLocked.value = true
unlockedIndex.value = 0 unlockedIndex.value = 0
@@ -539,18 +547,15 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.createSuccess') }) toast.success({ title: t('commercial.clients.toast.createSuccess') })
} }
catch (error) { catch (error) {
// 409 = doublon nom de societe (RG d'unicite) → erreur inline sur le // 409 = doublon nom de societe (RG d'unicite) → message explicite ;
// champ + toast explicite ; 422 → mapping inline par champ (pas de // sinon on remonte le message de validation du serveur (ex: 422).
// toast) ; autre → toast de fallback. Cf. ERP-101.
const status = (error as { response?: { status?: number } })?.response?.status const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) { toast.error({
const message = t('commercial.clients.form.duplicateCompany') title: t('commercial.clients.toast.error'),
mainErrors.setError('companyName', message) message: status === 409
toast.error({ title: t('commercial.clients.toast.error'), message }) ? t('commercial.clients.form.duplicateCompany')
} : apiErrorMessage(error),
else { })
mainErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
}
} }
finally { finally {
mainSubmitting.value = false mainSubmitting.value = false
@@ -625,7 +630,6 @@ const information = reactive({
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
informationErrors.clearErrors()
try { try {
await api.patch(`/clients/${clientId.value}`, { await api.patch(`/clients/${clientId.value}`, {
description: information.description || null, description: information.description || null,
@@ -640,7 +644,7 @@ async function submitInformation(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) { catch (error) {
informationErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') }) toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -648,10 +652,18 @@ async function submitInformation(): Promise<void> {
} }
// ── Onglet Contact ────────────────────────────────────────────────────────── // ── Onglet Contact ──────────────────────────────────────────────────────────
// Au moins un bloc Contact vide au depart : c'est desormais le seul point de
// saisie des coordonnees (le bloc principal ne porte plus de contact inline).
const contacts = ref<ContactFormDraft[]>([emptyContact()]) const contacts = ref<ContactFormDraft[]>([emptyContact()])
/** Pre-remplit le 1er contact depuis le formulaire principal (apres creation). */
function prefillFirstContact(): void {
const first = contacts.value[0]
if (!first) return
first.lastName = main.lastName
first.firstName = main.firstName
first.email = main.email
first.phonePrimary = mainPhones.value[0] ?? null
}
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom. // « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
const canAddContact = computed(() => { const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1] const last = contacts.value[contacts.value.length - 1]
@@ -668,7 +680,6 @@ function addContact(): void {
function askRemoveContact(index: number): void { function askRemoveContact(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => { askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
contacts.value.splice(index, 1) contacts.value.splice(index, 1)
contactErrors.value.splice(index, 1)
}) })
} }
@@ -676,10 +687,8 @@ function askRemoveContact(index: number): void {
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = []
try { try {
for (let index = 0; index < contacts.value.length; index++) { for (const contact of contacts.value) {
const contact = contacts.value[index]
// On ignore les blocs totalement vides (ni nom ni prenom). // On ignore les blocs totalement vides (ni nom ni prenom).
if (!isContactNamed(contact)) continue if (!isContactNamed(contact)) continue
@@ -692,32 +701,25 @@ async function submitContacts(): Promise<void> {
email: contact.email || null, email: contact.email || null,
} }
try { if (contact.id === null) {
if (contact.id === null) { const created = await api.post<ContactResponse>(
const created = await api.post<ContactResponse>( `/clients/${clientId.value}/contacts`,
`/clients/${clientId.value}/contacts`, body,
body, { headers: { Accept: 'application/ld+json' }, toast: false },
{ headers: { Accept: 'application/ld+json' }, toast: false }, )
) contact.id = created.id
contact.id = created.id contact.iri = created['@id'] ?? null
contact.iri = created['@id'] ?? null
}
else {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
}
} }
catch (error) { else {
// 422 → erreurs inline sous les champs de CETTE ligne ; on stoppe await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
// a la premiere ligne en echec (les suivantes ne sont pas tentees).
if (!mapRowError(error, contactErrors, index)) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return
} }
} }
completeTab('contact') completeTab('contact')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
} }
@@ -753,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)
}), }),
) )
@@ -766,7 +766,6 @@ function addAddress(): void {
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => { askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
addressErrors.value.splice(index, 1)
}) })
} }
@@ -784,10 +783,8 @@ function onAddressDegraded(): void {
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = []
try { try {
for (let index = 0; index < addresses.value.length; index++) { for (const address of addresses.value) {
const address = addresses.value[index]
const body = { const body = {
isProspect: address.isProspect, isProspect: address.isProspect,
isDelivery: address.isDelivery, isDelivery: address.isDelivery,
@@ -803,29 +800,24 @@ async function submitAddresses(): Promise<void> {
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null, billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
} }
try { if (address.id === null) {
if (address.id === null) { const created = await api.post<{ id: number }>(
const created = await api.post<{ id: number }>( `/clients/${clientId.value}/addresses`,
`/clients/${clientId.value}/addresses`, body,
body, { headers: { Accept: 'application/ld+json' }, toast: false },
{ headers: { Accept: 'application/ld+json' }, toast: false }, )
) address.id = created.id
address.id = created.id
}
else {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
}
} }
catch (error) { else {
if (!mapRowError(error, addressErrors, index)) { await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return
} }
} }
completeTab('address') completeTab('address')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
} }
@@ -878,9 +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)
ribErrors.value.splice(index, 1)
// Garde au moins un bloc RIB visible (cf. amorce au montage).
if (ribs.value.length === 0) ribs.value.push(emptyRib())
}) })
} }
@@ -892,54 +881,38 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors()
ribErrors.value = []
try { try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs). await api.patch(`/clients/${clientId.value}`, {
try { siren: accounting.siren || null,
await api.patch(`/clients/${clientId.value}`, { accountNumber: accounting.accountNumber || null,
siren: accounting.siren || null, tvaMode: accounting.tvaModeIri,
accountNumber: accounting.accountNumber || null, nTva: accounting.nTva || null,
tvaMode: accounting.tvaModeIri, paymentDelay: accounting.paymentDelayIri,
nTva: accounting.nTva || null, paymentType: accounting.paymentTypeIri,
paymentDelay: accounting.paymentDelayIri, bank: isBankRequired.value ? accounting.bankIri : null,
paymentType: accounting.paymentTypeIri, }, { toast: false })
bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
// 2) POST/PATCH des RIB (erreurs inline par ligne). for (const rib of ribs.value) {
for (let index = 0; index < ribs.value.length; index++) {
const rib = ribs.value[index]
if (!ribIsComplete(rib)) continue if (!ribIsComplete(rib)) continue
try { if (rib.id === null) {
if (rib.id === null) { const created = await api.post<{ id: number }>(
const created = await api.post<{ id: number }>( `/clients/${clientId.value}/ribs`,
`/clients/${clientId.value}/ribs`, { label: rib.label, bic: rib.bic, iban: rib.iban },
{ label: rib.label, bic: rib.bic, iban: rib.iban }, { headers: { Accept: 'application/ld+json' }, toast: false },
{ headers: { Accept: 'application/ld+json' }, toast: false }, )
) rib.id = created.id
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
}
} }
catch (error) { else {
if (!mapRowError(error, ribErrors, index)) { await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
return
} }
} }
completeTab('accounting') completeTab('accounting')
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (error) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
}
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
} }
@@ -968,6 +941,11 @@ function runConfirm(): void {
interface ClientResponse { interface ClientResponse {
id: number id: number
companyName: string | null companyName: string | null
firstName: string | null
lastName: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
} }
interface ContactResponse { interface ContactResponse {
@@ -978,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>
@@ -1,235 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
canEditClient,
categoryOptionsOf,
contactOptionsOf,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
referentialOptionOf,
relationOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
type ClientDetail,
} from '../clientConsultation'
describe('iriOf', () => {
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
expect(iriOf({ '@id': '/api/payment_types/10', code: 'LCR' })).toBe('/api/payment_types/10')
})
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
})
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
})
describe('relationOf', () => {
it('detecte une relation distributeur et expose son nom', () => {
const client = { distributor: { '@id': '/api/clients/15', companyName: 'DISTRIB GRAND SUD-OUEST' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'distributeur', name: 'DISTRIB GRAND SUD-OUEST' })
})
it('detecte une relation courtier et expose son nom', () => {
const client = { broker: { '@id': '/api/clients/16', companyName: 'CABINET LEONARD' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'courtier', name: 'CABINET LEONARD' })
})
it('retourne type null quand aucune relation n\'est posee (cles omises)', () => {
expect(relationOf({} as ClientDetail)).toEqual({ type: null, name: null })
})
})
describe('mapContactToDraft', () => {
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/18',
id: 18,
firstName: 'Sophie',
lastName: 'Léonard',
jobTitle: 'Gérante',
phonePrimary: '0549112233',
email: 'sophie@x.fr',
})
expect(draft.id).toBe(18)
expect(draft.iri).toBe('/api/client_contacts/18')
expect(draft.phonePrimary).toBe('05 49 11 22 33')
expect(draft.hasSecondaryPhone).toBe(false)
})
it('revele le 2e telephone quand phoneSecondary est present', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/19',
id: 19,
phonePrimary: '0600000000',
phoneSecondary: '0611111111',
})
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
})
})
describe('mapAddressToDraft', () => {
it('extrait les iris de sites / categories / contacts (objets ou chaines)', () => {
const draft = mapAddressToDraft({
'@id': '/api/client_addresses/18',
id: 18,
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '5 rue des Courtiers',
billingEmail: 'factures@x.fr',
isProspect: false,
isDelivery: false,
isBilling: true,
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#056CF2' }],
categories: [{ '@id': '/api/categories/3', code: 'SECTEUR' }],
contacts: [{ '@id': '/api/client_contacts/18' }, '/api/client_contacts/20'],
})
expect(draft.siteIris).toEqual(['/api/sites/4'])
expect(draft.categoryIris).toEqual(['/api/categories/3'])
expect(draft.contactIris).toEqual(['/api/client_contacts/18', '/api/client_contacts/20'])
expect(draft.isBilling).toBe(true)
expect(draft.city).toBe('Châtellerault')
expect(draft.country).toBe('France')
})
it('tolere les sous-collections absentes (defaut tableau vide, pays France)', () => {
const draft = mapAddressToDraft({ '@id': '/api/client_addresses/9', id: 9 })
expect(draft.siteIris).toEqual([])
expect(draft.categoryIris).toEqual([])
expect(draft.contactIris).toEqual([])
expect(draft.country).toBe('France')
expect(draft.isBilling).toBe(false)
})
})
describe('mapRibToDraft', () => {
it('mappe label / bic / iban et l\'id serveur', () => {
const draft = mapRibToDraft({ '@id': '/api/client_ribs/3', id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
expect(draft).toEqual({ id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
})
})
describe('mapAccountingDraft', () => {
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
const acc = mapAccountingDraft({
'@id': '/api/clients/1',
id: 1,
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/1' },
paymentDelay: { '@id': '/api/payment_delays/2' },
paymentType: { '@id': '/api/payment_types/10', code: 'LCR' },
bank: { '@id': '/api/banks/3' },
} as ClientDetail)
expect(acc).toEqual({
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/2',
paymentTypeIri: '/api/payment_types/10',
bankIri: '/api/banks/3',
})
})
it('renvoie des null quand les champs comptables sont absents (sans accounting.view)', () => {
const acc = mapAccountingDraft({} as ClientDetail)
expect(acc).toEqual({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
})
})
})
describe('options construites depuis l\'embed (role-independantes)', () => {
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }])).toEqual([
{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' },
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/4', label: 'Chatellerault' },
])
})
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
expect(contactOptionsOf([
{ '@id': '/api/client_contacts/1', id: 1, firstName: 'Jean', lastName: 'Dupont' },
{ '@id': '/api/client_contacts/2', id: 2, email: 'a@b.fr' },
])).toEqual([
{ value: '/api/client_contacts/1', label: 'Jean Dupont' },
{ value: '/api/client_contacts/2', label: 'a@b.fr' },
])
})
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
expect(referentialOptionOf({ '@id': '/api/payment_types/10', label: 'LCR' })).toEqual([
{ value: '/api/payment_types/10', label: 'LCR' },
])
expect(referentialOptionOf('/api/banks/3')).toEqual([])
expect(referentialOptionOf(null)).toEqual([])
})
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
const view = mapAddressView({
'@id': '/api/client_addresses/18',
id: 18,
city: 'Châtellerault',
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault' }],
categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }],
})
expect(view.draft.id).toBe(18)
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }])
})
})
describe('canEditClient', () => {
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('visible pour manage', () => {
expect(canEditClient(can(['commercial.clients.manage']))).toBe(true)
})
it('visible pour accounting.manage (role Compta)', () => {
expect(canEditClient(can(['commercial.clients.accounting.manage']))).toBe(true)
})
it('masque sans aucune des deux permissions (role Usine)', () => {
expect(canEditClient(can(['commercial.clients.view']))).toBe(false)
})
})
describe('showArchiveAction / showRestoreAction', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
it('Archiver : visible avec la permission archive ET client non archive', () => {
expect(showArchiveAction(can(['commercial.clients.archive']), false)).toBe(true)
expect(showArchiveAction(can(['commercial.clients.archive']), true)).toBe(false)
expect(showArchiveAction(can([]), false)).toBe(false)
})
it('Restaurer : visible avec la permission archive ET client archive', () => {
expect(showRestoreAction(can(['commercial.clients.archive']), true)).toBe(true)
expect(showRestoreAction(can(['commercial.clients.archive']), false)).toBe(false)
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
@@ -1,241 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
type AccountingFormDraft,
type InformationFormDraft,
type MainFormDraft,
} from '../clientEdit'
import type { ClientDetail } from '../clientConsultation'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
// ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ─────
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
return {
companyName: 'ACME',
categoryIris: ['/api/categories/1'],
relationType: null,
distributorIri: null,
brokerIri: null,
triageService: false,
...overrides,
}
}
function informationDraft(overrides: Partial<InformationFormDraft> = {}): InformationFormDraft {
return {
description: 'desc',
competitors: 'concurrents',
foundedAt: '2010-05-01',
employeesCount: '42',
revenueAmount: '1000000',
profitAmount: '50000',
directorName: 'PDG',
...overrides,
}
}
function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): AccountingFormDraft {
return {
siren: '123456789',
accountNumber: 'C-001',
nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/1',
paymentTypeIri: '/api/payment_types/1',
bankIri: '/api/banks/1',
...overrides,
}
}
// Champs de chaque groupe de serialisation (miroir back ClientProcessor).
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
const MAIN_KEYS = [
'companyName', 'categories', 'distributor', 'broker', 'triageService',
]
const INFORMATION_KEYS = [
'description', 'competitors', 'foundedAt', 'employeesCount',
'revenueAmount', 'profitAmount', 'directorName',
]
const ACCOUNTING_KEYS = ['siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', 'bank']
describe('buildMainPayload — scoping strict groupe client:write:main', () => {
it('n\'expose QUE les champs du groupe main (aucune fuite information/accounting)', () => {
expect(Object.keys(buildMainPayload(mainDraft())).sort()).toEqual([...MAIN_KEYS].sort())
})
it('relation distributeur : renseigne distributor, force broker a null (RG-1.03)', () => {
const payload = buildMainPayload(mainDraft({
relationType: 'distributeur',
distributorIri: '/api/clients/9',
brokerIri: '/api/clients/7',
}))
expect(payload.distributor).toBe('/api/clients/9')
expect(payload.broker).toBeNull()
})
it('relation courtier : renseigne broker, force distributor a null (RG-1.03)', () => {
const payload = buildMainPayload(mainDraft({
relationType: 'courtier',
distributorIri: '/api/clients/9',
brokerIri: '/api/clients/7',
}))
expect(payload.broker).toBe('/api/clients/7')
expect(payload.distributor).toBeNull()
})
it('sans relation : distributor et broker a null', () => {
const payload = buildMainPayload(mainDraft({ relationType: null }))
expect(payload.distributor).toBeNull()
expect(payload.broker).toBeNull()
})
})
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => {
expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort())
})
it('convertit employeesCount en nombre et vide -> null', () => {
expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42)
expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull()
expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull()
})
it('chaines vides normalisees en null', () => {
const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' }))
expect(payload.description).toBeNull()
expect(payload.directorName).toBeNull()
})
})
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => {
expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort())
})
it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => {
expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1')
expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull()
})
})
describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
it('contact : telephone secondaire ignore si non revele', () => {
const contact: ContactFormDraft = {
id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B',
jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000',
email: null, hasSecondaryPhone: false,
}
expect(buildContactPayload(contact).phoneSecondary).toBeNull()
})
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
const address: AddressFormDraft = {
id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France',
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: 'facturation@acme.fr',
}
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
expect(buildAddressPayload(address, false).billingEmail).toBeNull()
})
it('rib : label / bic / iban transmis tels quels', () => {
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
})
})
describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('resout la relation et extrait les IRI (sans contact inline)', () => {
const client = {
'@id': '/api/clients/1', id: 1,
companyName: 'ACME', triageService: true,
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
} as ClientDetail
const draft = mapMainDraft(client)
expect(draft.companyName).toBe('ACME')
expect(draft.categoryIris).toEqual(['/api/categories/1'])
expect(draft.relationType).toBe('distributeur')
expect(draft.distributorIri).toBe('/api/clients/9')
expect(draft.brokerIri).toBeNull()
expect(draft.triageService).toBe(true)
})
it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail)
expect(draft.companyName).toBeNull()
expect(draft.categoryIris).toEqual([])
expect(draft.relationType).toBeNull()
expect(draft.triageService).toBe(false)
})
})
describe('mapInformationDraft — pre-remplissage onglet Information', () => {
it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => {
const draft = mapInformationDraft({
'@id': '/api/clients/1', id: 1,
foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000',
} as ClientDetail)
expect(draft.foundedAt).toBe('2010-05-01')
expect(draft.employeesCount).toBe('42')
expect(draft.revenueAmount).toBe('1000000')
})
it('cles omises -> null', () => {
const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail)
expect(draft.foundedAt).toBeNull()
expect(draft.employeesCount).toBeNull()
expect(draft.description).toBeNull()
})
})
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
it('extrait les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingFormDraft({
'@id': '/api/clients/1', id: 1,
siren: '123456789', accountNumber: 'C-001', nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' },
paymentType: '/api/payment_types/3',
} as ClientDetail)
expect(draft.siren).toBe('123456789')
expect(draft.tvaModeIri).toBe('/api/tva_modes/2')
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
expect(draft.bankIri).toBeNull()
})
})
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
it('Admin : tout editable', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
})
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
})
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
})
it('Sans permission d\'edition : rien d\'editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
})
})
@@ -1,316 +0,0 @@
/**
* Helpers purs de l'ecran « Consultation client » (M1 Commercial, lecture seule).
*
* Mappent le payload `GET /api/clients/{id}` (relations embarquees, cf. groupe
* `client:item:read` + `client:read:accounting`) vers les brouillons « plats »
* partages avec les blocs reutilisables `ClientContactBlock` / `ClientAddressBlock`
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
* unitairement (cf. clientConsultation.spec.ts).
*
* Rappels de contrat back (verifies sur l'API reelle) :
* - les relations ManyToOne (distributor/broker/tvaMode/paymentType/...) sont
* serialisees en OBJETS embarques (avec @id + companyName/code/label), pas en IRI nu ;
* - les champs nuls sont OMIS du JSON (skip_null_values) toujours lire avec `?? null` ;
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS sans permission
* accounting.view (gate serveur via ClientReadGroupContextBuilder).
*/
import { formatPhoneFR } from '~/shared/utils/phone'
import type {
AddressFormDraft,
ContactFormDraft,
RibFormDraft,
} from '~/modules/commercial/types/clientForm'
/** Reference Hydra embarquee minimale (@id toujours present). */
export interface HydraRef {
'@id': string
[key: string]: unknown
}
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
export type Relation = HydraRef | string | null | undefined
/** Site embarque dans une adresse (groupe site:read). */
export interface SiteRead extends HydraRef {
name?: string
color?: string
}
/** Categorie embarquee (groupe category:read). */
export interface CategoryRead extends HydraRef {
code?: string
name?: string
}
/** Contact embarque (groupe client_contact:read). */
export interface ContactRead extends HydraRef {
id: number
firstName?: string | null
lastName?: string | null
jobTitle?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
}
/** Adresse embarquee (groupe client_address:read). */
export interface AddressRead extends HydraRef {
id: number
country?: string | null
postalCode?: string | null
city?: string | null
street?: string | null
streetComplement?: string | null
billingEmail?: string | null
isProspect?: boolean
isDelivery?: boolean
isBilling?: boolean
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
contacts?: Array<HydraRef | string>
}
/** RIB embarque (groupe client:read:accounting, present ssi accounting.view). */
export interface RibRead extends HydraRef {
id: number
label?: string | null
bic?: string | null
iban?: string | null
}
/** Client relie (distributeur / courtier) embarque (groupe client:read). */
export interface RelatedClientRead extends HydraRef {
companyName?: string | null
}
/**
* Detail d'un client tel que renvoye par `GET /api/clients/{id}`. Tous les
* champs sont optionnels : skip_null_values cote serveur et gating accounting
* peuvent omettre n'importe quelle cle.
*/
export interface ClientDetail extends HydraRef {
id: number
companyName?: string | null
triageService?: boolean
isArchived?: boolean
categories?: CategoryRead[]
distributor?: RelatedClientRead | string | null
broker?: RelatedClientRead | string | null
contacts?: ContactRead[]
addresses?: AddressRead[]
ribs?: RibRead[]
// Onglet Information
description?: string | null
competitors?: string | null
foundedAt?: string | null
employeesCount?: number | null
revenueAmount?: string | null
profitAmount?: string | null
directorName?: string | null
// Onglet Comptabilite (present ssi accounting.view)
siren?: string | null
accountNumber?: string | null
nTva?: string | null
tvaMode?: Relation
paymentDelay?: Relation
paymentType?: Relation
bank?: Relation
}
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire 1.10). */
export interface AccountingDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
bankIri: string | null
}
/** Relation Distributeur/Courtier resolue pour l'affichage en lecture seule. */
export interface ClientRelation {
type: 'distributeur' | 'courtier' | null
name: string | null
}
/** Option de select ({ value, label }) construite a partir de l'embed. */
export interface SelectOption {
value: string
label: string
}
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
export interface CategorySelectOption extends SelectOption {
code: string
}
/**
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
*/
export interface AddressView {
draft: AddressFormDraft
siteOptions: SelectOption[]
categoryOptions: CategorySelectOption[]
}
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
export function iriOf(relation: Relation): string | null {
if (relation === null || relation === undefined) {
return null
}
if (typeof relation === 'string') {
return relation
}
return relation['@id'] ?? null
}
/**
* Resout la relation Distributeur/Courtier (RG-1.03 : mutuellement exclusives).
* Le nom est lu sur l'objet embarque (`companyName`) ; null si la relation est
* un IRI nu ou absente.
*/
export function relationOf(client: ClientDetail): ClientRelation {
const nameOf = (rel: RelatedClientRead | string | null | undefined): string | null =>
rel && typeof rel === 'object' ? (rel.companyName ?? null) : null
if (client.distributor) {
return { type: 'distributeur', name: nameOf(client.distributor) }
}
if (client.broker) {
return { type: 'courtier', name: nameOf(client.broker) }
}
return { type: null, name: null }
}
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
export function mapContactToDraft(contact: ContactRead): ContactFormDraft {
const phoneSecondary = contact.phoneSecondary ?? null
return {
id: contact.id,
iri: contact['@id'] ?? null,
firstName: contact.firstName ?? null,
lastName: contact.lastName ?? null,
jobTitle: contact.jobTitle ?? null,
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
email: contact.email ?? null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
}
}
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
return {
id: address.id,
isProspect: address.isProspect ?? false,
isDelivery: address.isDelivery ?? false,
isBilling: address.isBilling ?? false,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
street: address.street ?? null,
streetComplement: address.streetComplement ?? null,
categoryIris: (address.categories ?? []).map(c => c['@id']),
siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
billingEmail: address.billingEmail ?? null,
}
}
/** Mappe un RIB embarque vers un brouillon. */
export function mapRibToDraft(rib: RibRead): RibFormDraft {
return {
id: rib.id,
label: rib.label ?? null,
bic: rib.bic ?? null,
iban: rib.iban ?? null,
}
}
/** Mappe les champs comptables du client (scalaires + IRI des referentiels). */
export function mapAccountingDraft(client: ClientDetail): AccountingDraft {
return {
siren: client.siren ?? null,
accountNumber: client.accountNumber ?? null,
nTva: client.nTva ?? null,
tvaModeIri: iriOf(client.tvaMode),
paymentDelayIri: iriOf(client.paymentDelay),
paymentTypeIri: iriOf(client.paymentType),
bankIri: iriOf(client.bank),
}
}
/**
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
* roles metier non-admin), qui laisserait les libelles vides.
*/
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
return (categories ?? []).map(c => ({
value: c['@id'],
label: c.name ?? c.code ?? c['@id'],
code: c.code ?? '',
}))
}
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
return (contacts ?? []).map(c => ({
value: c['@id'],
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
}))
}
/**
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
* `GET` de referentiel l'affichage reste correct quel que soit le role.
*/
export function referentialOptionOf(relation: Relation): SelectOption[] {
if (!relation || typeof relation === 'string') {
return []
}
const label = (relation.label as string | undefined)
?? (relation.name as string | undefined)
?? relation['@id']
return [{ value: relation['@id'], label }]
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView {
return {
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
* par onglet est gere sur l'ecran d'edition (1.12).
*/
export function canEditClient(canAny: (codes: string[]) => boolean): boolean {
return canAny(['commercial.clients.manage', 'commercial.clients.accounting.manage'])
}
/** Bouton « Archiver » : permission archive ET client encore actif. */
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.clients.archive') && !isArchived
}
/** Bouton « Restaurer » : permission archive ET client deja archive. */
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.clients.archive') && isArchived
}
@@ -1,247 +0,0 @@
/**
* Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12).
*
* Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) :
* 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed
* contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites
* par la page et les blocs reutilisables (mappers contacts/adresses/ribs/
* comptabilite reutilises depuis clientConsultation).
* 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / ERP-74) : chaque
* onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un
* payload mixte un champ hors-permission = 403 sur l'integralite cote back.
*
* Ces helpers ne touchent ni a l'API ni a l'etat reactif.
*
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON
* miroitee cote front (cf. clientFormRules.ts) /api/me n'expose pas le code de
* role et Bureau partage les permissions de Commerciale. Le back l'applique de
* maniere fiable (422) ; on laisse remonter ce 422 en toast.
*/
import {
iriOf,
relationOf,
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/**
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
* categories, relation, triage), pas sur une sous-ressource ClientContact. Les
* coordonnees de contact (nom, prenom, telephones, email) ne sont plus portees
* par le Client : elles vivent exclusivement dans l'onglet Contacts.
*/
export interface MainFormDraft {
companyName: string | null
/** IRI des categories rattachees (M2M). */
categoryIris: string[]
relationType: 'distributeur' | 'courtier' | null
distributorIri: string | null
brokerIri: string | null
triageService: boolean
}
/** Etat « plat » de l'onglet Information (groupe client:write:information). */
export interface InformationFormDraft {
description: string | null
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
}
/** Etat « plat » de l'onglet Comptabilite (groupe client:write:accounting). */
export interface AccountingFormDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
bankIri: string | null
}
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un client. */
export interface ClientEditAbilities {
/** `commercial.clients.manage` : bloc principal + onglets metier. */
canManage: boolean
/** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */
canAccountingView: boolean
/** `commercial.clients.accounting.manage` : edition de l'onglet Comptabilite. */
canAccountingManage: boolean
}
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
export interface TabEditability {
/** Bloc principal + onglets Information / Contact / Adresse editables. */
businessEditable: boolean
/** Onglet Comptabilite present (affiche). */
accountingVisible: boolean
/** Onglet Comptabilite editable. */
accountingEditable: boolean
}
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
/**
* Mappe le detail client vers le brouillon du bloc principal. La relation
* Distributeur/Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait
* de l'embed.
*/
export function mapMainDraft(client: ClientDetail): MainFormDraft {
const relation = relationOf(client)
return {
companyName: client.companyName ?? null,
categoryIris: (client.categories ?? []).map(c => c['@id']),
relationType: relation.type,
distributorIri: iriOf(client.distributor),
brokerIri: iriOf(client.broker),
triageService: client.triageService === true,
}
}
/** Mappe le detail client vers le brouillon de l'onglet Information. */
export function mapInformationDraft(client: ClientDetail): InformationFormDraft {
return {
description: client.description ?? null,
competitors: client.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
revenueAmount: client.revenueAmount ?? null,
profitAmount: client.profitAmount ?? null,
directorName: client.directorName ?? null,
}
}
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft {
return {
siren: client.siren ?? null,
accountNumber: client.accountNumber ?? null,
nTva: client.nTva ?? null,
tvaModeIri: iriOf(client.tvaMode),
paymentDelayIri: iriOf(client.paymentDelay),
paymentTypeIri: iriOf(client.paymentType),
bankIri: iriOf(client.bank),
}
}
// ── Scoping strict des payloads PATCH ────────────────────────────────────────
/**
* Payload du bloc principal groupe client:write:main UNIQUEMENT. La relation
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
* que la FK correspondant au type choisi, l'autre est forcee a null.
*/
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
return {
companyName: main.companyName,
categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}
}
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
return {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
directorName: information.directorName || null,
}
}
/**
* Payload des scalaires de l'onglet Comptabilite groupe client:write:accounting
* UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque
* n'a de sens que pour un Virement (RG-1.12) : forcee a null sinon.
*/
export function buildAccountingPayload(
accounting: AccountingFormDraft,
isBankRequired: boolean,
): Record<string, unknown> {
return {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired ? accounting.bankIri : null,
}
}
/** Payload d'un contact (sous-ressource client_contact). */
export function buildContactPayload(contact: ContactFormDraft): Record<string, unknown> {
return {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
phonePrimary: contact.phonePrimary || null,
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null,
}
}
/** Payload d'une adresse (sous-ressource client_address). */
export function buildAddressPayload(
address: AddressFormDraft,
isBillingEmailRequired: boolean,
): Record<string, unknown> {
return {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
}
}
/** Payload d'un RIB (sous-ressource client_rib). */
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
return {
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}
}
// ── Gating par permission ────────────────────────────────────────────────────
/**
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
* miroir UI du re-gating champ-par-champ du ClientProcessor) :
* - bloc principal + Information/Contact/Adresse : editables ssi `manage` ;
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
*
* Produit le comportement attendu :
* - Admin : tout editable.
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
*/
export function resolveTabEditability(abilities: ClientEditAbilities): TabEditability {
return {
businessEditable: abilities.canManage,
accountingVisible: abilities.canAccountingView,
accountingEditable: abilities.canAccountingManage,
}
}
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.4", "@malio/layer-ui": "^1.7.3",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.7.4", "version": "1.7.3",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.4/layer-ui-1.7.4.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
"integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==", "integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.4", "@malio/layer-ui": "^1.7.3",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
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,91 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFormErrors } from '../useFormErrors'
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
// useI18n stub : renvoie la cle telle quelle (pour asserter dessus).
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
/**
* Tests du composable `useFormErrors` pendant front de la regle « le back
* renvoie toutes les violations 422 d'un coup » (ERP-101). Centralise l'etat
* d'erreurs par champ (`Record<propertyPath, message>`) et la dispatch d'une
* erreur API : 422 mappee inline, sinon toast de fallback.
*/
describe('useFormErrors', () => {
beforeEach(() => {
mockToastError.mockReset()
})
/** Fabrique une erreur ofetch avec status + payload. */
function fetchError(status: number, data: unknown) {
return { response: { status, _data: data } }
}
it('demarre sans erreur', () => {
const { errors, hasErrors } = useFormErrors()
expect(errors).toEqual({})
expect(hasErrors.value).toBe(false)
})
it('setServerErrors mappe les violations par champ et retourne true', () => {
const { errors, hasErrors, setServerErrors } = useFormErrors()
const mapped = setServerErrors({
violations: [
{ propertyPath: 'companyName', message: 'Obligatoire.' },
{ propertyPath: 'siren', message: 'Deja utilise.' },
],
})
expect(mapped).toBe(true)
expect(errors).toEqual({ companyName: 'Obligatoire.', siren: 'Deja utilise.' })
expect(hasErrors.value).toBe(true)
})
it('setServerErrors retourne false et ne touche rien sans violation', () => {
const { errors, setServerErrors } = useFormErrors()
expect(setServerErrors({})).toBe(false)
expect(errors).toEqual({})
})
it('setError / clearError / clearErrors manipulent l\'etat finement', () => {
const { errors, setError, clearError, clearErrors } = useFormErrors()
setError('iban', 'IBAN invalide.')
expect(errors.iban).toBe('IBAN invalide.')
clearError('iban')
expect(errors.iban).toBeUndefined()
setError('a', 'x')
setError('b', 'y')
clearErrors()
expect(errors).toEqual({})
})
it('handleApiError : 422 avec violations → mappe inline, pas de toast, retourne true', () => {
const { errors, handleApiError } = useFormErrors()
const handled = handleApiError(
fetchError(422, { violations: [{ propertyPath: 'email', message: 'Invalide.' }] }),
)
expect(handled).toBe(true)
expect(errors.email).toBe('Invalide.')
expect(mockToastError).not.toHaveBeenCalled()
})
it('handleApiError : erreur non-422 → toast de fallback, retourne false', () => {
const { errors, handleApiError } = useFormErrors()
const handled = handleApiError(
fetchError(500, { 'hydra:description': 'Erreur serveur.' }),
{ fallbackMessage: 'Oups.' },
)
expect(handled).toBe(false)
expect(errors).toEqual({})
expect(mockToastError).toHaveBeenCalledTimes(1)
// Titre via i18n (cle renvoyee telle quelle par le stub).
expect(mockToastError.mock.calls[0][0]).toMatchObject({ title: 'errors.title', message: 'Erreur serveur.' })
})
it('handleApiError : 422 sans violation mappable → toast de fallback, retourne false', () => {
const { handleApiError } = useFormErrors()
const handled = handleApiError(fetchError(422, { 'hydra:description': 'Donnees invalides.' }))
expect(handled).toBe(false)
expect(mockToastError).toHaveBeenCalledTimes(1)
})
})
@@ -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 throw new AddressAutocompleteUnavailableError()
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()
}
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[]> { throw new AddressAutocompleteUnavailableError()
// 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()
}
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 ?? '',
}
})
}, },
} }
} }
+5 -5
View File
@@ -44,7 +44,7 @@ export function useApi(): ApiClient {
const data = responseData ?? (error as FetchError)?.data const data = responseData ?? (error as FetchError)?.data
const msg = extractApiErrorMessage(data) const msg = extractApiErrorMessage(data)
if (msg) return msg if (msg) return msg
return (error as FetchError)?.message ?? t('errors.unknown') return (error as FetchError)?.message ?? 'Erreur inconnue.'
} }
const methodErrorKeys: Record<string, string> = { const methodErrorKeys: Record<string, string> = {
@@ -76,7 +76,7 @@ export function useApi(): ApiClient {
if (successMessage) { if (successMessage) {
toast.success({ toast.success({
title: t('success.title'), title: 'Succes',
message: successMessage message: successMessage
}) })
} }
@@ -98,10 +98,10 @@ export function useApi(): ApiClient {
apiOptions?.toastErrorMessage || apiOptions?.toastErrorMessage ||
errorMessage || errorMessage ||
extractedMessage || extractedMessage ||
t('errors.generic') 'Une erreur est survenue.'
toast.error({ toast.error({
title: apiOptions?.toastTitle ?? t('errors.title'), title: apiOptions?.toastTitle ?? 'Erreur',
message message
}) })
} }
@@ -139,7 +139,7 @@ export function useApi(): ApiClient {
'Une erreur est survenue.' 'Une erreur est survenue.'
toast.error({ toast.error({
title: apiOptions?.toastTitle ?? t('errors.title'), title: apiOptions?.toastTitle ?? 'Erreur',
message message
}) })
} }
@@ -1,113 +0,0 @@
/**
* Composable d'erreurs de formulaire convention de mapping erreurchamp pour
* tous les forms du projet (ERP-101).
*
* Le back renvoie TOUTES les violations d'une 422 d'un coup (un `propertyPath`
* + `message` par champ fautif). Ce composable centralise leur affichage
* inline : il tient un `Record<propertyPath, message>` reactif que le template
* branche directement sur la prop `:error` des composants `Malio*` (le nom du
* champ cote front = le `propertyPath` cote back, donc aucun mapping manuel).
*
* Chaque appel cree son propre etat (refs internes a la fonction) un form =
* une instance, pas de singleton partage.
*
* Convention d'usage : les appels API qui veulent un retour inline doivent
* passer `{ toast: false }` a `useApi` (sinon le toast natif masque le mapping
* fin), puis router l'erreur via `handleApiError`. Pour les collections (1
* appel par ligne), utiliser directement `mapViolationsToRecord` par ligne.
*/
import { computed, reactive } from 'vue'
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
/**
* Erreur HTTP capturee par ofetch. On n'expose que les champs lus ici (status
* + payload) pour eviter de typer toute la lib.
*/
interface ApiFetchError {
response?: {
status?: number
_data?: unknown
}
}
/** Options de `handleApiError`. */
interface HandleApiErrorOptions {
/** Message de toast si l'erreur n'est pas une 422 exploitable. */
fallbackMessage?: string
}
export function useFormErrors() {
const toast = useToast()
const { t } = useI18n()
// Etat d'erreurs indexe par propertyPath. Reactif : muter une cle suffit a
// rafraichir la prop `:error` du champ correspondant.
const errors = reactive<Record<string, string>>({})
const hasErrors = computed(() => Object.keys(errors).length > 0)
/** Pose une erreur sur un champ. */
function setError(field: string, message: string): void {
errors[field] = message
}
/** Retire l'erreur d'un champ (no-op si absente). */
function clearError(field: string): void {
delete errors[field]
}
/** Vide toutes les erreurs (a appeler en debut de submit). */
function clearErrors(): void {
for (const key of Object.keys(errors)) {
delete errors[key]
}
}
/**
* Mappe les violations 422 d'un payload sur les champs. Retourne true des
* qu'au moins une violation a ete posee, false sinon (payload sans
* violation exploitable).
*/
function setServerErrors(data: unknown): boolean {
const mapped = mapViolationsToRecord(data)
const keys = Object.keys(mapped)
if (keys.length === 0) return false
for (const key of keys) {
errors[key] = mapped[key]
}
return true
}
/**
* Route une erreur API : 422 avec violations exploitables mapping inline
* (pas de toast, l'erreur s'affiche sous le champ) ; sinon toast de
* fallback (message serveur extrait, ou `fallbackMessage`).
*
* Retourne true si l'erreur a ete mappee inline, false si fallback toast.
*/
function handleApiError(e: unknown, opts: HandleApiErrorOptions = {}): boolean {
const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 422 && setServerErrors(data)) {
return true
}
const message
= extractApiErrorMessage(data)
|| opts.fallbackMessage
|| t('errors.generic')
toast.error({ title: t('errors.title'), message })
return false
}
return {
errors,
hasErrors,
setError,
clearError,
clearErrors,
setServerErrors,
handleApiError,
}
}
@@ -1,58 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mapViolationsToRecord } from '../api'
/**
* Tests de `mapViolationsToRecord` fondation du mapping erreurchamp des
* formulaires (ERP-101). Transforme un payload 422 API Platform en
* `Record<propertyPath, message>` directement consommable par la prop `:error`
* des composants `Malio*`.
*/
describe('mapViolationsToRecord', () => {
it('mappe chaque violation par son propertyPath (format `violations`)', () => {
const data = {
violations: [
{ propertyPath: 'companyName', message: 'Obligatoire.' },
{ propertyPath: 'siren', message: 'SIREN deja utilise.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({
companyName: 'Obligatoire.',
siren: 'SIREN deja utilise.',
})
})
it('supporte le format negocie `hydra:violations`', () => {
const data = {
'hydra:violations': [
{ propertyPath: 'email', message: 'Adresse invalide.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({ email: 'Adresse invalide.' })
})
it('renvoie un objet vide quand il n\'y a pas de violation exploitable', () => {
expect(mapViolationsToRecord({})).toEqual({})
expect(mapViolationsToRecord(null)).toEqual({})
expect(mapViolationsToRecord({ violations: [] })).toEqual({})
})
it('ignore les violations sans propertyPath', () => {
const data = {
violations: [
{ propertyPath: '', message: 'Erreur globale.' },
{ propertyPath: 'iban', message: 'IBAN invalide.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({ iban: 'IBAN invalide.' })
})
it('en cas de doublon de propertyPath, la derniere violation gagne', () => {
const data = {
violations: [
{ propertyPath: 'name', message: 'Premier message.' },
{ propertyPath: 'name', message: 'Second message.' },
],
}
expect(mapViolationsToRecord(data)).toEqual({ name: 'Second message.' })
})
})
@@ -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')
})
}) })
-19
View File
@@ -66,25 +66,6 @@ export function extractApiViolations(data: unknown): ApiViolation[] {
return out return out
} }
/**
* Transforme un payload d'erreur 422 d'API Platform en dictionnaire
* `{ propertyPath: message }`, directement consommable par la prop `:error`
* des composants `Malio*` (le nom du champ cote front = le `propertyPath`
* renvoye par le back). Fondation du mapping erreurchamp des formulaires :
* utilise par `useFormErrors` (champs scalaires) et par les boucles de submit
* de collections (erreur par ligne).
*
* Les violations sans `propertyPath` (erreur globale) sont ignorees ; en cas
* de doublon de `propertyPath`, la derniere violation l'emporte.
*/
export function mapViolationsToRecord(data: unknown): Record<string, string> {
const out: Record<string, string> = {}
for (const v of extractApiViolations(data)) {
if (v.propertyPath) out[v.propertyPath] = v.message
}
return out
}
/** /**
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON * Extrait un message d'erreur lisible depuis un payload Hydra / JSON
* d'erreur API Platform. Essaie les champs courants dans l'ordre : * d'erreur API Platform. Essaie les champs courants dans l'ordre :
-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) {
@@ -166,12 +166,14 @@ final class ColumnCommentsCatalog
], ],
'client' => [ 'client' => [
'_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) {
@@ -167,9 +167,8 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
public function testNonBillingAddressAcceptsEmptyBillingEmail(): void public function testNonBillingAddressAcceptsEmptyBillingEmail(): void
{ {
$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).
*/ */
+57 -11
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');
@@ -33,18 +33,23 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$response = $client->request('POST', '/api/clients', [ $response = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'acme sas', 'companyName' => 'acme sas',
'categories' => ['/api/categories/'.$cat->getId()], 'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
'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']);
} }
@@ -55,18 +60,41 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$iri = '/api/categories/'.$cat->getId(); $iri = '/api/categories/'.$cat->getId();
$payload = [ $payload = [
'companyName' => 'Doublon SARL', 'companyName' => 'Doublon SARL',
'categories' => [$iri], 'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'dup@test.fr',
'categories' => [$iri],
]; ];
$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(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();
@@ -74,8 +102,11 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients', [ $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'No Category', 'companyName' => 'No Category',
'categories' => [], 'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'nocat@test.fr',
'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,54 +33,52 @@ 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',
'categories' => ['/api/categories/'.$cat->getId()], 'firstName' => 'A',
], 'phonePrimary' => '06.12.34.56.78',
])->toArray(); 'phoneSecondary' => '05 49 00 11 22',
'email' => 'twophones@test.fr',
self::assertResponseStatusCodeSame(201);
self::assertSame('MAIN FORM SARL', $data['companyName']);
// Les champs de contact inline ont disparu de la representation.
self::assertArrayNotHasKey('firstName', $data);
self::assertArrayNotHasKey('lastName', $data);
self::assertArrayNotHasKey('phonePrimary', $data);
self::assertArrayNotHasKey('phoneSecondary', $data);
self::assertArrayNotHasKey('email', $data);
}
/**
* Les anciens champs de contact envoyes par un appel API direct (payload
* historique) sont ignores par le denormaliseur : ils n'apparaissent pas
* dans la representation et ne creent aucune colonne sur le client.
*/
public function testLegacyContactFieldsAreIgnored(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Legacy Fields SARL',
'firstName' => 'Ignored',
'lastName' => 'Ignored',
'phonePrimary' => '0612345678',
'phoneSecondary' => '0549001122',
'email' => 'ignored@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
])->toArray(); ])->toArray();
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
self::assertArrayNotHasKey('firstName', $data); self::assertSame('0612345678', $data['phonePrimary']);
self::assertArrayNotHasKey('phonePrimary', $data); self::assertSame('0549001122', $data['phoneSecondary']);
self::assertArrayNotHasKey('email', $data); }
// Confirmation cote base : le client cree ne porte aucun contact inline /**
// (les colonnes n'existent plus, l'entite n'a plus les proprietes). * RG-1.02 : maximum 2 telephones le modele n'expose que phonePrimary et
* phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est
* ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero.
*/
public function testThirdPhoneFieldIsIgnored(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Third Phone SARL',
'firstName' => 'A',
'phonePrimary' => '0612345678',
'phoneSecondary' => '0549001122',
'phoneTertiary' => '0700000000',
'email' => 'thirdphone@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// Le champ inconnu est ignore par le denormaliseur : il n'apparait pas
// dans la representation et n'a pas ete persiste.
self::assertArrayNotHasKey('phoneTertiary', $data);
// Confirmation cote base : seules les 2 colonnes telephone existent.
$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>
*/ */
@@ -335,8 +316,11 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
$categoryId ??= $this->createCategory('SECTEUR')->getId(); $categoryId ??= $this->createCategory('SECTEUR')->getId();
return [ return [
'companyName' => $companyName, 'companyName' => $companyName,
'categories' => ['/api/categories/'.$categoryId], 'firstName' => 'Jean',
'phonePrimary' => '0612345678',
'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test',
'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');
@@ -110,10 +110,9 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
public function testPostAddressNormalizesBillingEmail(): void public function testPostAddressNormalizesBillingEmail(): void
{ {
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$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;
} }