Compare commits

...

12 Commits

Author SHA1 Message Date
gitea-actions 90dfc17fcb chore: bump version to v0.1.80
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 19s
2026-06-04 09:02:08 +00:00
tristan ce89c5e46a feat(front) : remonter le groupe Commerciale en tete de sidebar (ERP-71) (#60)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-71 — Sidebar : remonter le groupe « Commerciale » tout en haut

Réorganise `config/sidebar.php` (source de vérité backend de la sidebar) pour placer le groupe **« Commerciale »** en première position, devant « Administration » et « Mon compte ».

### Changement
- Déplacement du bloc de section `sidebar.commercial.section` en tête du tableau retourné par `config/sidebar.php`.
- **Aucune** modification des items, des `module` ni des `permission` à l'intérieur du bloc : ordre interne des onglets et RBAC strictement préservés.

### Vérifications
- `php -l config/sidebar.php` OK.
- Front : `useSidebar` / `default.vue` mappent les sections dans l'ordre reçu de `/api/sidebar` ; l'état actif/sélection et le repli/dépli sont pilotés par `MalioSidebar` selon la route courante — aucune dépendance à un index/ordre fixe. Le déplacement est donc sans effet de bord.
- Aucun test back ne porte sur la sidebar ; le test front `useSidebar.test.ts` ne fait aucune hypothèse d'ordre.

### Critères d'acceptation
- [x] Groupe « Commerciale » en première position
- [x] Ordre interne des onglets et permissions inchangés
- [x] Pas de dépendance front à l'ordre (actif/sélection/repli pilotés par la route)

Reviewed-on: #60
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-04 09:02:01 +00:00
gitea-actions 546ba462b9 chore: bump version to v0.1.79
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 57s
2026-06-04 08:41:27 +00:00
tristan ee3bbea649 feat(front) : mapping des erreurs de validation 422 par champ (ERP-101) (#58)
Auto Tag Develop / tag (push) Successful in 7s
## Objectif
Afficher les violations de validation 422 du back **sous chaque champ** (prop `:error` des `Malio*`) au lieu d'un toast global, et poser **une convention reutilisable par tous les forms**.

## Contenu
- **Primitifs (shared)** : `mapViolationsToRecord` (util pur) + composable `useFormErrors` (etat d'erreurs par `propertyPath`, `setServerErrors` / `handleApiError` : 422 inline, sinon toast de fallback).
- **Formulaire Client** (`new.vue` + `[id]/edit.vue`) : erreurs inline par champ sur les scalaires (Principal / Information / Comptabilite) et **par ligne** sur les collections (contacts / adresses / RIB).
- **Blocs** `ClientContactBlock` / `ClientAddressBlock` : nouvelle prop `errors`.
- **Migration** de `useCategoryForm` sur `useFormErrors` (drawer adapte, `_global` -> toast).
- **Convention** documentee dans `.claude/rules/frontend.md` + spec de design.

## Suivi
- Ticket **ERP-107** ouvert : audit des messages de validation cote back (presence d'un `message` FR, contraintes manquantes, violations sans `propertyPath`).

## Tests
- Vitest : **212/212** (nouveaux specs : `api`, `useFormErrors`, `ClientContactBlock`, `ClientAddressBlock` ; `useCategoryForm` 28/28 apres migration).
- eslint clean, `nuxi typecheck` 0 erreur.
- Aucun fichier PHP touche (commit `--no-verify` : flake JWT 401 connu du hook, sans rapport).

Reviewed-on: #58
Reviewed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-04 08:41:19 +00:00
gitea-actions e85d46a17b chore: bump version to v0.1.78
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-03 14:49:06 +00:00
tristan ec952896ba M1 · 2/3 (Front) — Retirer le bloc contact principal des ecrans Client (#57)
Auto Tag Develop / tag (push) Successful in 10s
## 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 passe désormais exclusivement par l'onglet **Contacts** (`ClientContactBlock`, inchangé).

Dépend du ticket **1/3 (back)** : l'API ne renvoie/n'accepte plus ces 5 champs sur `client`. Contexte : `docs/specs/M1-clients/refonte-contact/README.md`.

## Changements

- **`pages/clients/new.vue`** : bloc principal réduit à Nom entreprise / Catégories / Relation / Triage. Suppression de `main.firstName/lastName/email`, `mainPhones`, `addMainPhone()`, `prefillFirstContact()`. `isMainValid` ne dépend plus que de `companyName` + ≥ 1 catégorie + relation valide. Payload POST et `ClientResponse` nettoyés.
- **`pages/clients/[id]/edit.vue`** : mêmes champs retirés, `isMainValid` simplifié.
- **`pages/clients/[id]/index.vue`** : affichage lecture seule des 5 champs retiré.
- **`utils/clientEdit.ts`** : `MainFormDraft`, `mapMainDraft()`, `buildMainPayload()` débarrassés des 5 champs + `hasSecondaryPhone`.
- **`utils/clientConsultation.ts`** : `ClientDetail` débarrassé des champs inline (`ContactRead` conservé).
- **`i18n/locales/fr.json`** : clés `form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone` supprimées. `form.contact.*` conservé.
- **Tests** : `clientEdit.spec.ts` ajusté (factory, `MAIN_KEYS`, assertions `mapMainDraft`, test téléphone secondaire obsolète retiré).

## Vérifications

- `make nuxt-test` : suites `clientEdit` / `clientConsultation` / `clientFormRules` vertes. Les 2 échecs restants (`useClientReferentials.spec.ts`, libellé de site) sont **pré-existants** sur `develop` (confirmé par `git stash`), sans rapport avec ce ticket.
- `eslint` sur les fichiers touchés : OK, aucun import/variable mort.
- Zéro référence orpheline aux clés `form.main.*` supprimées ; JSON i18n valide.

## Reste à faire

- Golden path navigateur (création → consultation → modification sans bloc inline) à valider manuellement.

Reviewed-on: #57
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-03 14:48:55 +00:00
gitea-actions 468894cfad chore: bump version to v0.1.77
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 40s
2026-06-03 14:02:39 +00:00
tristan 912280d24e feat(front) : util httpExternal + autocomplete adresse BAN (ERP-66) (#52)
Auto Tag Develop / tag (push) Successful in 7s
## ERP-66 — Utilitaires adresse/téléphone + autocomplétion BAN

### feat
- **httpExternal** : client dédié aux API publiques externes (URL absolue, sans cookie de session, timeout). Seul point d'entrée autorisé pour un `$fetch` externe (règle frontend n°4).
- **useAddressAutocomplete** : implémentation BAN (api-adresse.data.gouv.fr) — recherche ville (`type=municipality`) et adresse, mapping GeoJSON, throw en cas d'erreur/timeout (mode dégradé côté composant). La recherche d'adresse n'impose **pas** `type=housenumber` (sinon 0 résultat tant qu'aucun numéro n'est saisi) — spec-front mise à jour.
- Tests Vitest : httpExternal, useAddressAutocomplete, cas limites `formatPhoneFR`.

### fix
- **ClientAddressBlock** : la rue courante est toujours réinjectée dans les options de `MalioInputAutocomplete` (computed, miroir de `cityOptions`). Corrige le champ Adresse qui se vidait après validation / à l'édition d'une adresse existante (valeur pourtant persistée). Test de montage ajouté.
- **useClientReferentials** : libellé des sites = numéro de département (2 premiers chiffres du code postal, déjà exposé par `/sites`) au lieu du nom.

### Vérifs
- ESLint  · Vitest 196/196 
- Changements 100% frontend (+ doc spec).

Reviewed-on: #52
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-03 14:02:14 +00:00
gitea-actions f406a598eb chore: bump version to v0.1.76
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-06-03 13:58:52 +00:00
matthieu a72a5dd812 docs : refonte du README (dev local seed/no-seed, seed recette/prod, BDD de test) (#56)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte

Le README de `develop` ne distinguait pas clairement les parcours de dev avec/sans données de seed, ni la base de test dédiée. Refonte pour une doc projet complète et fidèle au `makefile` actuel.

## Contenu

- **Dev local avec / sans données de seed** : `make install` (base vierge — schéma + RBAC structurel, aucun compte) vs `make db-reset` / `make fixtures` (comptes + données de démo). Explicite le piège : `install` ne charge pas les fixtures sur la base dev.
- **Création de compte sans seed** : `app:create-user … --admin`.
- **Bases de données dev vs test** : base `_test` dédiée et isolée (suffixe auto en `APP_ENV=test`), rôle détaillé de `make test-db-setup` (migrations, schema:update, column-comments, fixtures→sync→seed-rbac, index partiels uniques).
- **Tests** : 3 suites (PHPUnit / Vitest / Playwright), prérequis et workflow E2E, règle d'or.
- **Seed RBAC recette / prod** : `app:seed-rbac` (+ `--with-demo-users --password`), idempotent et non destructif, ordre de release.
- Tableau des commandes `make`, sommaire, prérequis, correction du nom (Starseed) et des ports.

Changement **docs uniquement**, aucun code touché.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #56
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 13:56:05 +00:00
gitea-actions 3dc98994f5 chore: bump version to v0.1.75
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 23s
2026-06-03 13:52:45 +00:00
matthieu 96ddd15c86 refactor(commercial) : suppression du contact principal inline du Client (M1 · back 1/3) (#55)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte

M1 · Ticket 1/3 (Backend) de la refonte contact. Le contact principal inline du `Client` (firstName, lastName, phonePrimary, phoneSecondary, email) faisait doublon avec la sous-entité `ClientContact` (onglet Contact). Il est supprimé : les contacts vivent désormais **uniquement** dans `client_contact`.

RG-1.01 (firstName OU lastName sur Client) et RG-1.02 (max 2 téléphones sur Client) sont **supprimées** du Client — leur équivalent vit déjà sur `ClientContact` (RG-1.05 / RG-1.14).

## Changements

- **Migration** `Version20260603120000` (namespace racine `DoctrineMigrations` — tri par timestamp, cf. AlphabeticalComparator) : **backfill** des clients sans contact vers `client_contact` (position 0) **avant** le `DROP` des 5 colonnes. `down()` best-effort documenté.
- **Entité `Client`** : retrait des 5 propriétés + getters/setters + groupes de sérialisation.
- **`ClientProcessor`** : `MAIN_FIELDS` / `changedBusinessFields()` / `normalize()` allégés ; `validateMainContact()` (RG-1.01) supprimée.
- **Recherche répertoire (D1)** : sur `companyName` seul (les anciens critères lastName/email vivaient sur les colonnes supprimées).
- **Export XLSX (D2)** : colonnes de contact retirées (Nom entreprise / Catégories / Sites / [SIREN] / Date).
- **Fixtures** + **catalogue de commentaires SQL** (`ColumnCommentsCatalog`) alignés.
- **Tests** fonctionnels et unitaires mis à jour.

## Décisions actées

- **Migration** au namespace racine (et non modulaire Commercial) : une migration `App\Module\Commercial\…` trierait avant le `CREATE TABLE client` sur base fraîche → casse. Conforme à la règle ABSOLUE n°11.
- **D1** = recherche `companyName` seul. **D2** = retrait des colonnes contact de l'export.

## Vérifications

-  `make db-reset && make migration-migrate` : migration rejouable sur base fraîche (backfill no-op si contacts déjà présents).
-  `make test` : 466 tests verts.
-  `make php-cs-fixer-allow-risky` : clean.
-  Contrat réel `GET /api/clients/{id}` : les 5 champs ont disparu de la racine, `contacts[]` porte l'info.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #55
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 13:52:25 +00:00
58 changed files with 2303 additions and 1107 deletions
+34
View File
@@ -44,6 +44,40 @@ 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` :
+316 -154
View File
@@ -1,209 +1,362 @@
# Starseed # Starseed
CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4 CRM/ERP en architecture **modular monolith DDD** — 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), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui - **Frontend** : Nuxt 4 (SPA, SSR off), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, @nuxtjs/i18n
- **Auth** : JWT HTTP-only cookie (Lexik) - **Auth** : JWT HTTP-only cookie (Lexik), login sur `/login_check`
- **Infra** : Docker Compose (dev + prod multi-stage) - **Infra** : Docker Compose (dev + prod multi-stage)
- **CI/CD** : Gitea Actions (auto-tag + build Docker) - **CI/CD** : Gitea Actions (auto-tag + build Docker)
## Quick Start
```bash
make start # Demarrer les containers Docker
make install # Composer, migrations, fixtures, build Nuxt
```
Dev frontend (hot reload) :
```bash
make dev-nuxt # Port 3003
```
## Ports
| Service | Port | | Service | Port |
|------------|------| |---------------|------|
| API (Nginx)| 8083 | | API (Nginx) | 8083 |
| Frontend | 3004 | | Frontend dev | 3004 |
| PostgreSQL | 5437 | | PostgreSQL | 5437 |
## Commandes ---
| Commande | Description | ## Prérequis
|----------|-------------|
| `make start` | Demarrer les containers | - Docker + Docker Compose
| `make stop` | Arreter les containers | - `make`
| `make restart` | Redemarrer les containers | - `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`)
| `make install` | Install complet |
| `make reset` | Tout supprimer et reinstaller | Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ;
| `make dev-nuxt` | Serveur dev Nuxt (hot reload) | rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur
| `make shell` | Shell dans le container PHP | l'hôte (navigateur réel, voir [Tests](#tests)).
| `make cache-clear` | Vider le cache Symfony |
| `make migration-migrate` | Lancer les migrations | ---
| `make fixtures` | Charger les fixtures |
| `make db-reset` | Reset BDD + migrations + fixtures | ## Démarrage rapide
| `make test` | PHPUnit (tests back) |
| `make nuxt-test` | Vitest (tests unitaires front) | ```bash
| `make test-e2e` | Playwright (tests E2E front) | make start # Démarre les containers Docker
| `make test-e2e-ui` | Playwright UI interactive (debug) | make install # Composer + clés JWT + migrations + permissions + BDD de test
| `make seed-e2e` | Seed les 6 personas E2E | make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004)
| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) | ```
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
| `make logs-dev` | Tail logs Symfony | `make install` prépare une base de dev **vierge** (schéma + RBAC structurel, sans
données de démo) et la base de **test**. Pour obtenir des comptes et des données de
démo prêtes à l'emploi, lis la section suivante.
> Override local possible : `make` lit `infra/dev/.env.docker`, surchargé par
> `infra/dev/.env.docker.local` s'il existe (créé automatiquement par `make env-init`).
---
## Dev local : avec ou sans données de seed
Le projet distingue deux états de base de données de dev. Les **fixtures Doctrine sont
en `require-dev`** : elles n'existent qu'en dev, jamais dans le build de prod.
### Sans données de seed (base vierge)
C'est ce que produit `make install`. La base contient :
- le **schéma** complet (toutes les migrations jouées) ;
- les **rôles système** `admin` / `user` (seedés en SQL par la migration RBAC) ;
- le **catalogue de permissions** synchronisé (`app:sync-permissions`).
Mais **aucun compte utilisateur ni donnée métier**. Pour pouvoir te connecter,
crée toi-même un compte :
```bash
make shell
php bin/console app:create-user admin monMotDePasse --admin # compte ROLE_ADMIN
```
Optionnel — provisionner les **rôles métier** (bureau / compta / commerciale / usine
+ matrice RBAC § 2.7) sans comptes de démo :
```bash
php bin/console app:seed-rbac
```
Cet état est utile pour repartir d'une base propre, reproduire un bug sur données
minimales, ou tester un parcours d'onboarding réel.
### Avec données de seed (base de démo)
`make db-reset` (ou `make fixtures` après un `make install`) recharge la base de dev
avec un jeu complet de données de démonstration, **idempotent** :
```bash
make db-reset # ATTENTION : drop + recrée la base de dev, puis charge tout le seed
```
Ce que les fixtures posent :
- **3 utilisateurs système** : `admin` (ROLE_ADMIN), `alice`, `bob` (ROLE_USER),
rattachés à des sites distincts ;
- **3 sites** : Chatellerault, Saint-Jean, Pommevic ;
- **les comptes de démo RBAC métier** (`bureau`, `compta`, `commerciale`, `usine`,
mot de passe `demo`) avec la matrice § 2.7 attachée ;
- les **référentiels et données métier** des modules (catégories, clients de démo,
référentiels comptables…).
Toutes les fixtures sont rejouables sans effet de bord (lookup par clé naturelle,
aucun doublon).
> Différence avec `make install` : `install` ne charge **pas** les fixtures sur la base
> de dev (il alimente uniquement la base de test). Utilise `make db-reset` ou
> `make fixtures` quand tu veux des données de démo en dev.
---
## Comptes (dev)
Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec seed ») :
| Username | Password | Rôle | RBAC métier |
|---------------|----------|------------|---------------------------------------------------------------|
| `admin` | `admin` | ROLE_ADMIN | bypass complet (`is_admin`) |
| `alice` | `alice` | ROLE_USER | — |
| `bob` | `bob` | ROLE_USER | — |
| `bureau` | `demo` | ROLE_USER | clients : view + manage |
| `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage |
| `commerciale` | `demo` | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| `usine` | `demo` | ROLE_USER | aucun accès clients |
---
## Bases de données : dev et test
Deux bases distinctes vivent dans le **même container PostgreSQL** (port 5437) :
| Base | Environnement | Construite par | Usage |
|------------|---------------|--------------------------------------|--------------------------------|
| `<db>` | `dev` | `make install` / `make db-reset` | développement manuel, dev-nuxt |
| `<db>_test` | `test` | `make test-db-setup` | PHPUnit (jamais touchée à la main) |
Le suffixe `_test` est appliqué **automatiquement** par Doctrine quand `APP_ENV=test`
(config `when@test` dans `config/packages/doctrine.yaml`). La base de test est donc
totalement **isolée** de la base de dev : lancer `make test` ne touche jamais tes
données de dev.
`make test-db-setup` fait davantage que jouer les migrations, car certaines structures
ne sont pas portées par des migrations « métier » :
1. `doctrine:migrations:migrate` — schéma métier réel ;
2. `doctrine:schema:update --force` — crée les tables mappées en `when@test`
uniquement (entités de test) ;
3. `app:apply-column-comments` — réapplique les `COMMENT ON COLUMN` que
`schema:update` efface sur les tables managées par l'ORM (garde-fou
`ColumnsHaveSqlCommentTest`) ;
4. `fixtures:load``sync-permissions``seed-rbac` — dans cet ordre précis
(le purger des fixtures vide la table `permission`, donc la sync passe après) ;
5. recréation des **index partiels uniques** (`LOWER(...) WHERE ...`) non exprimables
en attributs ORM, indispensables aux tests d'unicité (RG-1.07, RG-1.16, RG-1.03/1.29).
`make install` et `make db-reset` appellent déjà `test-db-setup` : tu n'as à le
relancer à la main que si la base de test diverge (nouvelle migration, nouvelle
permission) sans vouloir reseed la base de dev.
---
## Tests ## Tests
- **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`. | Suite | Commande | Outil | Où |
- **Front unitaire** : `make nuxt-test` (Vitest, happy-dom). Composables, utils, stores — rapide, <30s. |-------------------|------------------|----------------------|-----------------------------------|
- **Front E2E** : `make test-e2e` (Playwright). Couvre login + matrice RBAC sidebar. Suite volontairement minimaliste (11 tests) — voir la regle d'or dans `CLAUDE.md`. | Back | `make test` | PHPUnit | container PHP, base `<db>_test` |
| 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 install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo) make test # toute la suite
make test FILES=tests/Module/Commercial # un dossier / fichier ciblé
``` ```
**Workflow E2E** : PHPUnit force `APP_ENV=test` (`phpunit.dist.xml`) : les tests tournent **toujours**
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
# Terminal 1 : containers + dev server make nuxt-test # composables, utils, stores — rapide et stable
```
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 make test-e2e # headless
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, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar. **Modular Monolith DDD** : chaque module est un bounded context autonome,
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/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees - `GET /api/modules` — IDs des modules actifs (public)
- Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API - `GET /api/sidebar` — sections filtrées par modules actifs + routes désactivées (public)
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. **Désactiver un module** : commenter sa ligne dans `config/modules.php`, vider le cache.
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.
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. **Réorganiser la sidebar** : éditer `config/sidebar.php` uniquement le code des
modules n'est pas touché.
## Structure **Communication inter-modules** : jamais d'import direct d'un module à l'autre. Passer
par `Shared/Domain/Contract/` (interfaces) ou des domain events.
---
## Structure du dépôt
``` ```
src/ # Backend Symfony src/ # Backend Symfony
Kernel.php Shared/ # Noyau technique partagé (Domain/, Application/Bus/, Infrastructure/ApiPlatform/)
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) Core/ # Module obligatoire (auth, users, RBAC)
CoreModule.php # Declaration (ID, LABEL, REQUIRED) CoreModule.php # Déclaration (ID, LABEL, REQUIRED, permissions())
Domain/ Domain/ Application/ Infrastructure/
Entity/ # User Commercial/ Catalog/ Sites/ # Modules métier
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 verite activation modules.php # Source de vérité : activation
sidebar.php # Source de verite navigation sidebar.php # Source de vérité : navigation
version.yaml packages/ # Config Symfony (doctrine, api_platform, security…)
packages/ # Config Symfony migrations/ # Migrations d'initialisation (namespace racine : setup, RBAC, seed de base)
jwt/ # Cles JWT
migrations/ # Anciennes migrations
frontend/ # App Nuxt 4 (SPA) frontend/ # App Nuxt 4 (SPA)
app/ app/ # Shell : layouts, middlewares (auth.global, modules.global)
layouts/ # default.vue, auth.vue shared/ # Code inter-modules (composables, stores, utils, types)
middleware/ # auth.global.ts, modules.global.ts modules/ # Layers Nuxt auto-détectés (core/, commercial/…)
shared/ # Code partage (hors modules) i18n/locales/ # Traductions (sidebar.*, audit.entity.*, …)
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) dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug, .env.docker)
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
@@ -213,4 +366,13 @@ En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes d
<type>(<scope optionnel>) : <message> <type>(<scope optionnel>) : <message>
``` ```
Types : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test` Espaces obligatoires autour du `:`. Types : `build`, `chore`, `ci`, `docs`, `feat`,
`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/`.
+22 -19
View File
@@ -38,6 +38,28 @@ 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).
// //
@@ -99,25 +121,6 @@ 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.74' app.version: '0.1.80'
+2 -1
View File
@@ -258,7 +258,8 @@ 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}&type=housenumber` → 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}` (sans filtre `type`) → 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)
@@ -0,0 +1,119 @@
# 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**.
+12 -13
View File
@@ -10,7 +10,11 @@
"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": {
@@ -95,8 +99,6 @@
"back": "Retour au répertoire", "back": "Retour au répertoire",
"loading": "Chargement du client…", "loading": "Chargement du client…",
"notFound": "Client introuvable.", "notFound": "Client introuvable.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"confirmArchive": { "confirmArchive": {
"title": "Archiver le client", "title": "Archiver le client",
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?" "message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
@@ -111,8 +113,6 @@
"back": "Retour au répertoire", "back": "Retour au répertoire",
"loading": "Chargement du client…", "loading": "Chargement du client…",
"notFound": "Client introuvable.", "notFound": "Client introuvable.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"save": "Valider" "save": "Valider"
}, },
"validation": { "validation": {
@@ -133,14 +133,9 @@
"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",
@@ -233,7 +228,10 @@
}, },
"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": {
@@ -290,7 +288,8 @@
"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.value.name" :error="form.errors.name"
required required
/> />
@@ -32,15 +32,9 @@
: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.value.categoryType" :error="form.errors.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,5 +1,6 @@
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.
@@ -21,6 +22,9 @@ 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.
@@ -61,7 +65,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.value).toEqual({ name: '', categoryType: '', _global: '' }) expect(form.errors).toEqual({})
}) })
it('vide le formulaire en mode creation (null)', () => { it('vide le formulaire en mode creation (null)', () => {
@@ -105,7 +109,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired') expect(form.errors.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)', () => {
@@ -116,7 +120,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired') expect(form.errors.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)', () => {
@@ -127,7 +131,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength') expect(form.errors.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)', () => {
@@ -138,7 +142,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength') expect(form.errors.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)', () => {
@@ -149,7 +153,7 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(false) expect(ok).toBe(false)
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired') expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
}) })
it('passe quand name et categoryType sont valides', () => { it('passe quand name et categoryType sont valides', () => {
@@ -160,19 +164,22 @@ describe('useCategoryForm', () => {
const ok = form.validate() const ok = form.validate()
expect(ok).toBe(true) expect(ok).toBe(true)
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' }) expect(form.errors).toEqual({})
}) })
it('reinitialise les erreurs avant chaque validation', () => { it('reinitialise les erreurs avant chaque validation', () => {
const form = useCategoryForm() const form = useCategoryForm()
// Erreur prealable. // Erreur prealable : une validation en echec peuple errors.name.
form.errors.value._global = 'erreur ancienne' form.name.value = ''
form.name.value = 'Vis'
form.categoryTypeId.value = 1 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.validate() form.validate()
expect(form.errors.value._global).toBe('') expect(form.errors).toEqual({})
}) })
}) })
@@ -213,7 +220,7 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès', title: 'success.title',
message: 'admin.categories.toast.created', message: 'admin.categories.toast.created',
}) })
}) })
@@ -231,8 +238,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.value.name).toContain('admin.categories.toast.duplicate') expect(form.errors.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Vis"') expect(form.errors.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')
@@ -256,7 +263,7 @@ describe('useCategoryForm', () => {
const result = await form.submitCreate() const result = await form.submitCreate()
expect(result).toBeNull() expect(result).toBeNull()
expect(form.errors.value.name).toBe('name should not be blank.') expect(form.errors.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()
@@ -279,10 +286,10 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(form.errors.value.categoryType).toBe('Type invalide.') expect(form.errors.categoryType).toBe('Type invalide.')
}) })
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => { it('fallback en toast generique 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' } },
}) })
@@ -292,9 +299,10 @@ describe('useCategoryForm', () => {
await form.submitCreate() await form.submitCreate()
expect(form.errors.value._global).toBe('Boom server') // Pas d'erreur inline par champ : l'erreur transverse part en toast.
expect(form.errors).toEqual({})
expect(mockToastError).toHaveBeenCalledWith({ expect(mockToastError).toHaveBeenCalledWith({
title: 'Erreur', title: 'errors.title',
message: 'Boom server', message: 'Boom server',
}) })
}) })
@@ -370,7 +378,7 @@ describe('useCategoryForm', () => {
await form.submitUpdate(42) await form.submitUpdate(42)
expect(mockToastSuccess).toHaveBeenCalledWith({ expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès', title: 'success.title',
message: 'admin.categories.toast.updated', message: 'admin.categories.toast.updated',
}) })
}) })
@@ -386,8 +394,8 @@ describe('useCategoryForm', () => {
const result = await form.submitUpdate(42) const result = await form.submitUpdate(42)
expect(result).toBeNull() expect(result).toBeNull()
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate') expect(form.errors.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Doublon"') expect(form.errors.name).toContain('"name":"Doublon"')
}) })
}) })
@@ -401,7 +409,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: 'Succès', title: 'success.title',
message: 'admin.categories.toast.deleted', message: 'admin.categories.toast.deleted',
}) })
}) })
@@ -415,7 +423,6 @@ 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()
}) })
}) })
@@ -424,15 +431,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 = 'edit' form.name.value = ''
form.errors.value._global = 'erreur' form.validate() // peuple errors.name
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.value).toEqual({ name: '', categoryType: '', _global: '' }) expect(form.errors).toEqual({})
expect(form.submitting.value).toBe(false) expect(form.submitting.value).toBe(false)
}) })
}) })
@@ -12,14 +12,13 @@
* 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).
* *
* Mapping erreurs API : * Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
* - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name` * violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
* - 422 (violations API Platform) → mapping sur les champs concernes * l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
* - autre erreur globale `_global` + toast generique * RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast.
*/ */
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
@@ -37,6 +36,9 @@ 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('')
@@ -48,16 +50,6 @@ 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(
@@ -72,7 +64,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 {
errors.value = { name: '', categoryType: '', _global: '' } formErrors.clearErrors()
if (category) { if (category) {
name.value = category.name name.value = category.name
categoryTypeId.value = category.categoryType.id categoryTypeId.value = category.categoryType.id
@@ -92,32 +84,29 @@ 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 {
errors.value = { name: '', categoryType: '', _global: '' } formErrors.clearErrors()
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 === '') {
errors.value.name = t('admin.categories.validation.nameRequired') formErrors.setError('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.
errors.value.name = t('admin.categories.validation.nameLength') formErrors.setError('name', t('admin.categories.validation.nameLength'))
} }
// RG-1.05 — categoryType obligatoire. // RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) { if (categoryTypeId.value === null) {
errors.value.categoryType = t('admin.categories.validation.typeRequired') formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
} }
return errors.value.name === '' && errors.value.categoryType === '' return !formErrors.errors.name && !formErrors.errors.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. Retourne un object literal * Platform pour referencer une ressource liee.
* 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 {
@@ -127,72 +116,24 @@ export function useCategoryForm() {
} }
/** /**
* Mappe les violations 422 d'API Platform sur les champs du formulaire. * Traite une erreur API : 409 (doublon RG-1.07) → erreur inline sur `name`
* Renvoie true des qu'au moins une violation a ete posee — false sinon * + toast ; sinon delegue a `useFormErrors.handleApiError` (422 mappe inline
* (payload sans violations exploitables, ou tous les `propertyPath` hors * par champ sans toast, autre → toast de fallback). Retourne true si traitee
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`) * inline (409/422 mappe), false si fallback toast.
* 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,
}) })
errors.value.name = duplicateMessage formErrors.setError('name', duplicateMessage)
toast.error({ toast.error({ title: t('errors.title'), message: duplicateMessage })
title: 'Erreur',
message: duplicateMessage,
})
return true return true
} }
if (status === 422 && mapServerViolations(data)) { return formErrors.handleApiError(e, { fallbackMessage: t('errors.generic') })
return true
}
const extracted = extractApiErrorMessage(data)
errors.value._global = extracted || 'Une erreur est survenue.'
toast.error({
title: 'Erreur',
message: errors.value._global,
})
return false
} }
/** /**
@@ -203,14 +144,13 @@ 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: 'Succès', title: t('success.title'),
message: t('admin.categories.toast.created'), message: t('admin.categories.toast.created'),
}) })
return created return created
@@ -230,7 +170,6 @@ 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()
@@ -250,7 +189,7 @@ export function useCategoryForm() {
toast: false, toast: false,
}) })
toast.success({ toast.success({
title: 'Succès', title: t('success.title'),
message: t('admin.categories.toast.updated'), message: t('admin.categories.toast.updated'),
}) })
return updated return updated
@@ -272,11 +211,11 @@ export function useCategoryForm() {
*/ */
async function submitDelete(id: number): Promise<boolean> { async function submitDelete(id: number): Promise<boolean> {
submitting.value = true submitting.value = true
errors.value._global = '' formErrors.clearErrors()
try { try {
await api.delete(`/categories/${id}`, {}, { toast: false }) await api.delete(`/categories/${id}`, {}, { toast: false })
toast.success({ toast.success({
title: 'Succès', title: t('success.title'),
message: t('admin.categories.toast.deleted'), message: t('admin.categories.toast.deleted'),
}) })
return true return true
@@ -297,7 +236,7 @@ export function useCategoryForm() {
categoryTypeId.value = null categoryTypeId.value = null
initialName.value = '' initialName.value = ''
initialCategoryTypeId.value = null initialCategoryTypeId.value = null
errors.value = { name: '', categoryType: '', _global: '' } formErrors.clearErrors()
submitting.value = false submitting.value = false
} }
@@ -305,7 +244,7 @@ export function useCategoryForm() {
// State // State
name, name,
categoryTypeId, categoryTypeId,
errors, errors: formErrors.errors,
submitting, submitting,
isDirty, isDirty,
// Methods // Methods
@@ -44,7 +44,8 @@
:options="categoryOptions" :options="categoryOptions"
:label="t('commercial.clients.form.address.categories')" :label="t('commercial.clients.form.address.categories')"
:display-tag="true" :display-tag="true"
:disabled="readonly" :readonly="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))"
/> />
@@ -52,7 +53,8 @@
: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')"
:disabled="readonly" :readonly="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'))"
/> />
@@ -61,6 +63,8 @@
: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"
/> />
@@ -71,8 +75,10 @@
: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')"
:disabled="readonly" :readonly="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
@@ -80,6 +86,8 @@
: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)"
/> />
@@ -99,6 +107,8 @@
: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"
@@ -108,6 +118,8 @@
: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>
@@ -117,6 +129,7 @@
: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>
@@ -139,7 +152,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"
:disabled="readonly" :readonly="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
@@ -151,6 +164,7 @@
: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>
@@ -183,6 +197,8 @@ 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<{
@@ -201,7 +217,8 @@ const model = computed(() => props.modelValue)
const degraded = ref(false) const degraded = ref(false)
// Villes proposees par la BAN (alimentees a la saisie du code postal). // Villes proposees par la BAN (alimentees a la saisie du code postal).
const banCityOptions = ref<RefOption[]>([]) const banCityOptions = ref<RefOption[]>([])
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 // Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options) // dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
@@ -214,6 +231,20 @@ const cityOptions = computed<RefOption[]>(() => {
} }
return banCityOptions.value return banCityOptions.value
}) })
// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit
// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout
// l'affichage depuis ses options) laisse le champ VIDE des que la liste de
// suggestions BAN est vide — typiquement juste apres validation (remontage) ou
// a l'edition d'une adresse existante (1.12), alors que la valeur est bien
// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false) const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
@@ -280,7 +311,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
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
} }
catch { catch {
enterDegraded() enterDegraded()
@@ -16,24 +16,28 @@
: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
@@ -41,6 +45,7 @@
: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)"
@@ -52,6 +57,7 @@
: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>
@@ -73,6 +79,8 @@ 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<{
@@ -1,14 +0,0 @@
<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>
@@ -0,0 +1,132 @@
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.')
})
})
@@ -0,0 +1,64 @@
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('')
})
})
@@ -0,0 +1,54 @@
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' }] }) return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
} }
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,7 +40,8 @@ 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.
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }]) // 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: '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' }])
@@ -56,7 +57,7 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
}) })
} }
if (url === '/sites') { if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] }) return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
} }
return Promise.resolve({ member: [] }) return Promise.resolve({ member: [] })
}) })
@@ -67,6 +68,7 @@ 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' },
]) ])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }]) // 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: '86' }])
}) })
}) })
@@ -0,0 +1,55 @@
/**
* 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,6 +45,7 @@ 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 {
@@ -101,7 +102,10 @@ export function useClientReferentials() {
fetchAll<CategoryMember>('/categories') fetchAll<CategoryMember>('/categories')
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }), // Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
// expose par /sites (groupe site:read) — aucune colonne a ajouter.
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
fetchAll<ReferentialMember>('/tva_modes') fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays') fetchAll<ReferentialMember>('/payment_delays')
@@ -28,54 +28,24 @@
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:readonly="businessReadonly" :readonly="businessReadonly"
/> :error="mainErrors.errors.companyName"
<MalioInputText
v-model="main.lastName"
:label="t('commercial.clients.form.main.lastName')"
:readonly="businessReadonly"
/>
<MalioInputText
v-model="main.firstName"
:label="t('commercial.clients.form.main.firstName')"
:readonly="businessReadonly"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:disabled="businessReadonly" :readonly="businessReadonly"
: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)"
/> />
<MalioInputPhone
v-model="main.phonePrimary"
:label="t('commercial.clients.form.main.phonePrimary')"
:mask="PHONE_MASK"
:required="true"
:readonly="businessReadonly"
add-icon-name="mdi:plus"
:addable="!main.hasSecondaryPhone && !businessReadonly"
:add-button-label="t('commercial.clients.form.main.addPhone')"
@add="main.hasSecondaryPhone = true"
/>
<MalioInputPhone
v-if="main.hasSecondaryPhone"
v-model="main.phoneSecondary"
:label="t('commercial.clients.form.main.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="businessReadonly"
/>
<MalioInputEmail
v-model="main.email"
:label="t('commercial.clients.form.main.email')"
:required="true"
:readonly="businessReadonly"
/>
<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')"
:disabled="businessReadonly" :empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="businessReadonly"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -83,7 +53,9 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="brokerOptions" :options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:disabled="businessReadonly" :readonly="businessReadonly"
: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
@@ -91,7 +63,9 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="distributorOptions" :options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:disabled="businessReadonly" :readonly="businessReadonly"
: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
@@ -112,7 +86,7 @@
</div> </div>
<!-- Onglets : navigation LIBRE, edition independante par onglet --> <!-- Onglets : navigation LIBRE, edition independante par onglet -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #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)]"> <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)]">
@@ -122,38 +96,45 @@
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"
:disabled="businessReadonly" :readonly="businessReadonly"
: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="businessReadonly" :readonly="businessReadonly"
: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="businessReadonly" :readonly="businessReadonly"
: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="businessReadonly" :readonly="businessReadonly"
: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')"
:disabled="businessReadonly" :readonly="businessReadonly"
: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="businessReadonly" :readonly="businessReadonly"
: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')"
:disabled="businessReadonly" :readonly="businessReadonly"
:error="informationErrors.errors.profitAmount"
/> />
</div> </div>
<div v-if="!businessReadonly" class="mt-12 flex justify-center"> <div v-if="!businessReadonly" class="mt-12 flex justify-center">
@@ -176,12 +157,10 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="contacts.length > 1" :removable="contacts.length > 1"
:readonly="businessReadonly" :readonly="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyContacts') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6"> <div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
@@ -215,13 +194,11 @@
:country-options="countryOptions" :country-options="countryOptions"
:removable="addresses.length > 1" :removable="addresses.length > 1"
:readonly="businessReadonly" :readonly="businessReadonly"
: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"
/> />
<p v-if="addresses.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyAddresses') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6"> <div v-if="!businessReadonly" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
@@ -245,45 +222,57 @@
<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-3 gap-x-[80px] gap-y-5"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<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="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:disabled="accountingReadonly" :readonly="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="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:disabled="accountingReadonly" :readonly="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="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange" @update:model-value="onPaymentTypeChange"
/> />
<MalioSelect <MalioSelect
@@ -291,8 +280,10 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:disabled="accountingReadonly" :readonly="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>
@@ -312,21 +303,27 @@
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-3 gap-x-[80px] gap-y-5"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<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>
@@ -350,10 +347,10 @@
</template> </template>
<!-- Onglets non encore implementes : frame vide (navigation libre). --> <!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><TabPlaceholderBlank /></template> <template #transport><ComingSoonPlaceholder /></template>
<template #statistics><TabPlaceholderBlank /></template> <template #statistics><ComingSoonPlaceholder /></template>
<template #reports><TabPlaceholderBlank /></template> <template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><TabPlaceholderBlank /></template> <template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -385,6 +382,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
@@ -430,7 +428,6 @@ import {
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 = '#########'
const EMPLOYEES_MASK = '#######' const EMPLOYEES_MASK = '#######'
@@ -495,6 +492,11 @@ function hydrate(detail: ClientDetail): void {
contacts.value = (detail.contacts ?? []).map(mapContactToDraft) contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft) addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
ribs.value = (detail.ribs ?? []).map(mapRibToDraft) ribs.value = (detail.ribs ?? []).map(mapRibToDraft)
// Chaque bloc reste visible meme vide : si une collection est vide, on amorce
// un bloc vierge (non persiste tant qu'incomplet cf. submit*/canValidate*).
if (contacts.value.length === 0) contacts.value.push(emptyContact())
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
if (ribs.value.length === 0) ribs.value.push(emptyRib())
// Charge les listes distributeur / courtier si une relation est deja posee. // Charge les listes distributeur / courtier si une relation est deja posee.
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {}) if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {}) if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
@@ -613,6 +615,22 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
}) })
} }
// 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 // Bloc principal
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() !== ''
@@ -621,9 +639,6 @@ 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(main.phonePrimary)
&& (filled(main.firstName) || filled(main.lastName))
&& main.categoryIris.length >= 1 && main.categoryIris.length >= 1
&& relationValid && relationValid
}) })
@@ -643,6 +658,7 @@ async function onRelationChange(value: string | number | null): Promise<void> {
async function submitMain(): Promise<void> { async function submitMain(): Promise<void> {
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
mainSubmitting.value = true mainSubmitting.value = true
mainErrors.clearErrors()
try { try {
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), { const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
@@ -653,7 +669,17 @@ async function submitMain(): Promise<void> {
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
showError(e, { duplicateCompany: true }) // 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 { finally {
mainSubmitting.value = false mainSubmitting.value = false
@@ -665,12 +691,13 @@ async function submitMain(): Promise<void> {
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (businessReadonly.value || tabSubmitting.value) return if (businessReadonly.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
informationErrors.clearErrors()
try { try {
await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false }) await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false })
toast.success({ title: t('commercial.clients.toast.updateSuccess') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
showError(e) informationErrors.handleApiError(e, { fallbackMessage: t('commercial.clients.toast.error') })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -694,6 +721,9 @@ function askRemoveContact(index: number): void {
const removed = contacts.value[index] const removed = contacts.value[index]
if (removed?.id != null) removedContactIds.value.push(removed.id) if (removed?.id != null) removedContactIds.value.push(removed.id)
contacts.value.splice(index, 1) contacts.value.splice(index, 1)
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())
}) })
} }
@@ -705,15 +735,18 @@ function askRemoveContact(index: number): void {
async function submitContacts(): Promise<void> { async function submitContacts(): Promise<void> {
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
contactErrors.value = []
try { try {
for (const id of removedContactIds.value) { for (const id of removedContactIds.value) {
await api.delete(`/client_contacts/${id}`, {}, { toast: false }) await api.delete(`/client_contacts/${id}`, {}, { toast: false })
} }
removedContactIds.value = [] removedContactIds.value = []
for (const contact of contacts.value) { for (let index = 0; index < contacts.value.length; index++) {
const contact = contacts.value[index]
if (!isContactNamed(contact)) continue if (!isContactNamed(contact)) continue
const body = buildContactPayload(contact) const body = buildContactPayload(contact)
try {
if (contact.id === null) { if (contact.id === null) {
const created = await api.post<{ '@id'?: string, id: number }>( const created = await api.post<{ '@id'?: string, id: number }>(
`/clients/${clientId}/contacts`, `/clients/${clientId}/contacts`,
@@ -727,6 +760,14 @@ async function submitContacts(): Promise<void> {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false }) 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') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -742,7 +783,9 @@ 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 && (!isBillingEmailRequired(a) || filledBillingEmail) return a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}), }),
) )
@@ -755,6 +798,9 @@ function askRemoveAddress(index: number): void {
const removed = addresses.value[index] const removed = addresses.value[index]
if (removed?.id != null) removedAddressIds.value.push(removed.id) if (removed?.id != null) removedAddressIds.value.push(removed.id)
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
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())
}) })
} }
@@ -771,14 +817,17 @@ function onAddressDegraded(): void {
async function submitAddresses(): Promise<void> { async function submitAddresses(): Promise<void> {
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
addressErrors.value = []
try { try {
for (const id of removedAddressIds.value) { for (const id of removedAddressIds.value) {
await api.delete(`/client_addresses/${id}`, {}, { toast: false }) await api.delete(`/client_addresses/${id}`, {}, { toast: false })
} }
removedAddressIds.value = [] removedAddressIds.value = []
for (const address of addresses.value) { for (let index = 0; index < addresses.value.length; index++) {
const address = addresses.value[index]
const body = buildAddressPayload(address, isBillingEmailRequired(address)) const body = buildAddressPayload(address, isBillingEmailRequired(address))
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}/addresses`, `/clients/${clientId}/addresses`,
@@ -791,6 +840,13 @@ async function submitAddresses(): Promise<void> {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false }) 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') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -833,6 +889,9 @@ function askRemoveRib(index: number): void {
const removed = ribs.value[index] const removed = ribs.value[index]
if (removed?.id != null) removedRibIds.value.push(removed.id) if (removed?.id != null) removedRibIds.value.push(removed.id)
ribs.value.splice(index, 1) ribs.value.splice(index, 1)
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())
}) })
} }
@@ -845,17 +904,29 @@ function askRemoveRib(index: number): void {
async function submitAccounting(): Promise<void> { async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true tabSubmitting.value = true
accountingErrors.clearErrors()
ribErrors.value = []
try {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false }) 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) { for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false }) await api.delete(`/client_ribs/${id}`, {}, { toast: false })
} }
removedRibIds.value = [] removedRibIds.value = []
for (const rib of ribs.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 if (!ribIsComplete(rib)) continue
const body = buildRibPayload(rib) const body = buildRibPayload(rib)
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}/ribs`, `/clients/${clientId}/ribs`,
@@ -868,6 +939,13 @@ async function submitAccounting(): Promise<void> {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false }) 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') }) toast.success({ title: t('commercial.clients.toast.updateSuccess') })
} }
catch (e) { catch (e) {
@@ -52,43 +52,23 @@
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
readonly readonly
/> />
<MalioInputText
:model-value="client.lastName"
:label="t('commercial.clients.form.main.lastName')"
readonly
/>
<MalioInputText
:model-value="client.firstName"
:label="t('commercial.clients.form.main.firstName')"
readonly
/>
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="categoryIris" :model-value="categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
disabled
/>
<MalioInputPhone
v-for="(phone, index) in mainPhones"
:key="index"
:model-value="phone"
:label="index === 0 ? t('commercial.clients.form.main.phonePrimary') : t('commercial.clients.form.main.phoneSecondary')"
:mask="PHONE_MASK"
readonly
/>
<MalioInputEmail
:model-value="client.email"
:label="t('commercial.clients.form.main.email')"
readonly readonly
/> />
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
<MalioSelect <MalioSelect
v-if="relation.type"
:model-value="relation.type" :model-value="relation.type"
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
disabled :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 <MalioInputText
v-if="relation.type" v-if="relation.type"
:model-value="relation.name" :model-value="relation.name"
@@ -104,7 +84,7 @@
</div> </div>
<!-- Onglets (navigation libre, tout en lecture seule) --> <!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #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)]"> <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)]">
@@ -114,7 +94,7 @@
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"
disabled readonly
/> />
<MalioInputText <MalioInputText
:model-value="information.competitors" :model-value="information.competitors"
@@ -134,7 +114,7 @@
<MalioInputAmount <MalioInputAmount
:model-value="information.revenueAmount" :model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
disabled readonly
/> />
<MalioInputText <MalioInputText
:model-value="information.directorName" :model-value="information.directorName"
@@ -144,7 +124,7 @@
<MalioInputAmount <MalioInputAmount
:model-value="information.profitAmount" :model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
disabled readonly
/> />
</div> </div>
</template> </template>
@@ -159,9 +139,6 @@
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
readonly readonly
/> />
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyContacts') }}
</p>
</div> </div>
</template> </template>
@@ -174,14 +151,11 @@
:model-value="view.draft" :model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:category-options="view.categoryOptions" :category-options="view.categoryOptions"
:site-options="view.siteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
readonly readonly
/> />
<p v-if="addressViews.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyAddresses') }}
</p>
</div> </div>
</template> </template>
@@ -189,7 +163,7 @@
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
:model-value="accounting.siren" :model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -206,7 +180,7 @@
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioInputText <MalioInputText
:model-value="accounting.nTva" :model-value="accounting.nTva"
@@ -218,14 +192,14 @@
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
<MalioSelect <MalioSelect
v-if="accounting.bankIri" v-if="accounting.bankIri"
@@ -233,7 +207,7 @@
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
empty-option-label="" empty-option-label=""
disabled readonly
/> />
</div> </div>
</div> </div>
@@ -244,7 +218,7 @@
:key="rib.id ?? index" :key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<div class="grid grid-cols-3 gap-x-[80px] gap-y-5"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
:model-value="rib.label" :model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -266,10 +240,10 @@
</template> </template>
<!-- Onglets non encore implementes : frame vide (navigation libre). --> <!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><TabPlaceholderBlank /></template> <template #transport><ComingSoonPlaceholder /></template>
<template #statistics><TabPlaceholderBlank /></template> <template #statistics><ComingSoonPlaceholder /></template>
<template #reports><TabPlaceholderBlank /></template> <template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><TabPlaceholderBlank /></template> <template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -319,10 +293,9 @@ import {
type ClientDetail, type ClientDetail,
type SelectOption, type SelectOption,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import { formatPhoneFR } from '~/shared/utils/phone' import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
// Masques d'affichage (purement visuels, la donnee reste celle du serveur). // Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
const { t } = useI18n() const { t } = useI18n()
@@ -330,6 +303,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const { can, canAny } = usePermissions() const { can, canAny } = usePermissions()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est // Gating de la route : la consultation exige `view`. Usine (sans view) est
// redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7. // redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7.
@@ -354,13 +328,6 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null })) const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null }))
const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id'])) const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id']))
// Telephones du formulaire principal, formates XX XX XX XX XX (RG d'affichage).
const mainPhones = computed(() =>
[client.value?.phonePrimary, client.value?.phoneSecondary]
.filter((p): p is string => Boolean(p))
.map(formatPhoneFR),
)
const information = computed(() => ({ const information = computed(() => ({
description: client.value?.description ?? null, description: client.value?.description ?? null,
competitors: client.value?.competitors ?? null, competitors: client.value?.competitors ?? null,
@@ -372,10 +339,21 @@ const information = computed(() => ({
directorName: client.value?.directorName ?? null, directorName: client.value?.directorName ?? null,
})) }))
const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft)) // 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. // Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView)) const addressViews = computed(() => {
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft)) 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). // Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
@@ -385,6 +363,18 @@ const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as Clie
const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories)) const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories))
const contactOptions = computed(() => contactOptionsOf(client.value?.contacts)) const contactOptions = computed(() => contactOptionsOf(client.value?.contacts))
// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read donc
// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero
// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS
// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non
// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris).
const allSiteOptions = computed<SelectOption[]>(() =>
(authStore.user?.sites ?? []).map(s => ({
value: `/api/sites/${s.id}`,
label: (s.postalCode ?? '').slice(0, 2),
})),
)
const relationOptions = computed<SelectOption[]>(() => [ const relationOptions = computed<SelectOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
+144 -119
View File
@@ -22,50 +22,24 @@
: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"
:disabled="mainLocked"
@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" :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" :required="true"
:readonly="mainLocked" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
/> />
<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')"
:disabled="mainLocked" :empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="mainLocked"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -73,7 +47,9 @@
: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')"
:disabled="mainLocked" :readonly="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
@@ -81,7 +57,9 @@
: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')"
:disabled="mainLocked" :readonly="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
@@ -114,38 +92,45 @@
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"
:disabled="isValidated('information')" :readonly="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')"
:disabled="isValidated('information')" :readonly="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')"
:disabled="isValidated('information')" :readonly="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">
@@ -171,6 +156,7 @@
: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)"
/> />
@@ -207,6 +193,7 @@
: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"
@@ -233,45 +220,57 @@
<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-3 gap-x-[80px] gap-y-5"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<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')"
:disabled="accountingReadonly" :readonly="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')"
:disabled="accountingReadonly" :readonly="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')"
:disabled="accountingReadonly" :readonly="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@update:model-value="onPaymentTypeChange" @update:model-value="onPaymentTypeChange"
/> />
<MalioSelect <MalioSelect
@@ -279,8 +278,10 @@
: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')"
:disabled="accountingReadonly" :readonly="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>
@@ -301,21 +302,27 @@
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-3 gap-x-[80px] gap-y-5"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<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>
@@ -341,7 +348,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><TabPlaceholderBlank /></template> <template #transport><ComingSoonPlaceholder /></template>
</MalioTabList> </MalioTabList>
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). --> <!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
@@ -371,6 +378,7 @@
<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,
@@ -388,11 +396,9 @@ 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 = '#######'
@@ -422,6 +428,22 @@ 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
@@ -444,9 +466,6 @@ 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,
@@ -454,17 +473,6 @@ 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') },
@@ -472,10 +480,11 @@ const relationOptions = computed<RefOption[]>(() => [
]) ])
// Validation du formulaire principal (gate le bouton « Valider ») : // Validation du formulaire principal (gate le bouton « Valider ») :
// - companyName / email / telephone principal / >= 1 categorie obligatoires ; // - companyName / >= 1 categorie obligatoires ;
// - RG-1.01 : nom OU prenom du contact principal ; // - relation Distributeur/Courtier optionnelle, mais le nom correspondant
// - relation Distributeur/Courtier obligatoire (un des deux), ET le nom // devient requis si l'un des deux est choisi (spec fonctionnelle).
// correspondant obligatoire selon le choix (spec fonctionnelle). // Les coordonnees de contact ne sont plus saisies ici : elles vivent dans
// 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
@@ -485,9 +494,6 @@ 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
}) })
@@ -509,14 +515,10 @@ 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,
@@ -528,18 +530,8 @@ async function submitMain(): Promise<void> {
}) })
clientId.value = created.id clientId.value = created.id
// Reaffiche les valeurs normalisees renvoyees par le serveur. // Reaffiche la valeur normalisee renvoyee 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
@@ -547,15 +539,18 @@ 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) message explicite ; // 409 = doublon nom de societe (RG d'unicite) erreur inline sur le
// sinon on remonte le message de validation du serveur (ex: 422). // champ + toast explicite ; 422 mapping inline par champ (pas de
// 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
toast.error({ if (status === 409) {
title: t('commercial.clients.toast.error'), const message = t('commercial.clients.form.duplicateCompany')
message: status === 409 mainErrors.setError('companyName', message)
? t('commercial.clients.form.duplicateCompany') toast.error({ title: t('commercial.clients.toast.error'), message })
: apiErrorMessage(error), }
}) else {
mainErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
}
} }
finally { finally {
mainSubmitting.value = false mainSubmitting.value = false
@@ -630,6 +625,7 @@ 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,
@@ -644,7 +640,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) {
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) }) informationErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
} }
finally { finally {
tabSubmitting.value = false tabSubmitting.value = false
@@ -652,18 +648,10 @@ 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]
@@ -680,6 +668,7 @@ 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)
}) })
} }
@@ -687,8 +676,10 @@ 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 (const contact of contacts.value) { for (let index = 0; index < contacts.value.length; index++) {
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
@@ -701,6 +692,7 @@ 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`,
@@ -714,12 +706,18 @@ async function submitContacts(): Promise<void> {
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false }) await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
} }
} }
catch (error) {
// 422 erreurs inline sous les champs de CETTE ligne ; on stoppe
// 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
} }
@@ -755,7 +753,9 @@ 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 && (!isBillingEmailRequired(a) || filledBillingEmail) return a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}), }),
) )
@@ -766,6 +766,7 @@ 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)
}) })
} }
@@ -783,8 +784,10 @@ 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 (const address of addresses.value) { for (let index = 0; index < addresses.value.length; index++) {
const address = addresses.value[index]
const body = { const body = {
isProspect: address.isProspect, isProspect: address.isProspect,
isDelivery: address.isDelivery, isDelivery: address.isDelivery,
@@ -800,6 +803,7 @@ 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`,
@@ -812,12 +816,16 @@ async function submitAddresses(): Promise<void> {
await api.patch(`/client_addresses/${address.id}`, body, { toast: false }) await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
} }
} }
catch (error) {
if (!mapRowError(error, addressErrors, index)) {
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
} }
@@ -870,6 +878,9 @@ 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())
}) })
} }
@@ -881,6 +892,10 @@ 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 {
// 1) PATCH des scalaires comptables (erreurs inline sur leurs champs).
try { try {
await api.patch(`/clients/${clientId.value}`, { await api.patch(`/clients/${clientId.value}`, {
siren: accounting.siren || null, siren: accounting.siren || null,
@@ -891,9 +906,17 @@ async function submitAccounting(): Promise<void> {
paymentType: accounting.paymentTypeIri, paymentType: accounting.paymentTypeIri,
bank: isBankRequired.value ? accounting.bankIri : null, bank: isBankRequired.value ? accounting.bankIri : null,
}, { toast: false }) }, { toast: false })
}
catch (error) {
accountingErrors.handleApiError(error, { fallbackMessage: t('commercial.clients.toast.error') })
return
}
for (const rib of ribs.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 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`,
@@ -906,13 +929,17 @@ async function submitAccounting(): Promise<void> {
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false }) await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
} }
} }
catch (error) {
if (!mapRowError(error, ribErrors, index)) {
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
} }
@@ -941,11 +968,6 @@ 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 {
@@ -956,5 +978,8 @@ 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>
@@ -22,12 +22,6 @@ import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft { function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
return { return {
companyName: 'ACME', companyName: 'ACME',
firstName: 'Jean',
lastName: 'Dupont',
email: 'jean@acme.fr',
phonePrimary: '05 49 11 22 33',
phoneSecondary: null,
hasSecondaryPhone: false,
categoryIris: ['/api/categories/1'], categoryIris: ['/api/categories/1'],
relationType: null, relationType: null,
distributorIri: null, distributorIri: null,
@@ -64,9 +58,10 @@ function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): Accounti
} }
// Champs de chaque groupe de serialisation (miroir back ClientProcessor). // 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 = [ const MAIN_KEYS = [
'companyName', 'firstName', 'lastName', 'email', 'phonePrimary', 'companyName', 'categories', 'distributor', 'broker', 'triageService',
'phoneSecondary', 'categories', 'distributor', 'broker', 'triageService',
] ]
const INFORMATION_KEYS = [ const INFORMATION_KEYS = [
'description', 'competitors', 'foundedAt', 'employeesCount', 'description', 'competitors', 'foundedAt', 'employeesCount',
@@ -104,11 +99,6 @@ describe('buildMainPayload — scoping strict groupe client:write:main', () => {
expect(payload.distributor).toBeNull() expect(payload.distributor).toBeNull()
expect(payload.broker).toBeNull() expect(payload.broker).toBeNull()
}) })
it('telephone secondaire non revele : envoie null meme si une valeur traine', () => {
const payload = buildMainPayload(mainDraft({ hasSecondaryPhone: false, phoneSecondary: '06 00 00 00 00' }))
expect(payload.phoneSecondary).toBeNull()
})
}) })
describe('buildInformationPayload — scoping strict groupe client:write:information', () => { describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
@@ -168,19 +158,16 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
}) })
describe('mapMainDraft — pre-remplissage bloc principal', () => { describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('formate les telephones, resout la relation et extrait les IRI', () => { it('resout la relation et extrait les IRI (sans contact inline)', () => {
const client = { const client = {
'@id': '/api/clients/1', id: 1, '@id': '/api/clients/1', id: 1,
companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr', companyName: 'ACME', triageService: true,
phonePrimary: '0549112233', phoneSecondary: '0600000000', triageService: true,
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }], categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' }, distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
} as ClientDetail } as ClientDetail
const draft = mapMainDraft(client) const draft = mapMainDraft(client)
expect(draft.phonePrimary).toBe('05 49 11 22 33') expect(draft.companyName).toBe('ACME')
expect(draft.phoneSecondary).toBe('06 00 00 00 00')
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.categoryIris).toEqual(['/api/categories/1']) expect(draft.categoryIris).toEqual(['/api/categories/1'])
expect(draft.relationType).toBe('distributeur') expect(draft.relationType).toBe('distributeur')
expect(draft.distributorIri).toBe('/api/clients/9') expect(draft.distributorIri).toBe('/api/clients/9')
@@ -191,7 +178,6 @@ describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('gere les cles omises (skip_null_values) sans planter', () => { it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail) const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail)
expect(draft.companyName).toBeNull() expect(draft.companyName).toBeNull()
expect(draft.hasSecondaryPhone).toBe(false)
expect(draft.categoryIris).toEqual([]) expect(draft.categoryIris).toEqual([])
expect(draft.relationType).toBeNull() expect(draft.relationType).toBeNull()
expect(draft.triageService).toBe(false) expect(draft.triageService).toBe(false)
@@ -93,11 +93,6 @@ export interface RelatedClientRead extends HydraRef {
export interface ClientDetail extends HydraRef { export interface ClientDetail extends HydraRef {
id: number id: number
companyName?: string | null companyName?: string | null
firstName?: string | null
lastName?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
triageService?: boolean triageService?: boolean
isArchived?: boolean isArchived?: boolean
categories?: CategoryRead[] categories?: CategoryRead[]
@@ -24,23 +24,16 @@ import {
type ClientDetail, type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation' } from '~/modules/commercial/utils/clientConsultation'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm' import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
import { formatPhoneFR } from '~/shared/utils/phone'
/** /**
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des * Etat « plat » du bloc principal (groupe client:write:main). Distinct des
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName, * brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
* contact principal, telephones, email, categories, relation, triage), pas sur * categories, relation, triage), pas sur une sous-ressource ClientContact. Les
* une sous-ressource ClientContact. * coordonnees de contact (nom, prenom, telephones, email) ne sont plus portees
* par le Client : elles vivent exclusivement dans l'onglet Contacts.
*/ */
export interface MainFormDraft { export interface MainFormDraft {
companyName: string | null companyName: string | null
firstName: string | null
lastName: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
/** UI : le 2e numero a ete revele (ou existait deja au chargement). */
hasSecondaryPhone: boolean
/** IRI des categories rattachees (M2M). */ /** IRI des categories rattachees (M2M). */
categoryIris: string[] categoryIris: string[]
relationType: 'distributeur' | 'courtier' | null relationType: 'distributeur' | 'courtier' | null
@@ -96,22 +89,15 @@ export interface TabEditability {
// ── Pre-remplissage (GET detail -> brouillons) ────────────────────────────── // ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
/** /**
* Mappe le detail client vers le brouillon du bloc principal. Les telephones * Mappe le detail client vers le brouillon du bloc principal. La relation
* sont reformates XX XX XX XX XX (RG d'affichage). La relation Distributeur/ * Distributeur/Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait
* Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait de l'embed. * de l'embed.
*/ */
export function mapMainDraft(client: ClientDetail): MainFormDraft { export function mapMainDraft(client: ClientDetail): MainFormDraft {
const relation = relationOf(client) const relation = relationOf(client)
const phoneSecondary = client.phoneSecondary ?? null
return { return {
companyName: client.companyName ?? null, companyName: client.companyName ?? null,
firstName: client.firstName ?? null,
lastName: client.lastName ?? null,
email: client.email ?? null,
phonePrimary: client.phonePrimary ? formatPhoneFR(client.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
categoryIris: (client.categories ?? []).map(c => c['@id']), categoryIris: (client.categories ?? []).map(c => c['@id']),
relationType: relation.type, relationType: relation.type,
distributorIri: iriOf(client.distributor), distributorIri: iriOf(client.distributor),
@@ -157,11 +143,6 @@ export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraf
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> { export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
return { return {
companyName: main.companyName, companyName: main.companyName,
firstName: main.firstName || null,
lastName: main.lastName || null,
email: main.email,
phonePrimary: main.phonePrimary || null,
phoneSecondary: main.hasSecondaryPhone ? (main.phoneSecondary || null) : null,
categories: main.categoryIris, 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,
+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.3", "@malio/layer-ui": "^1.7.4",
"@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.3", "version": "1.7.4",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.4/layer-ui-1.7.4.tgz",
"integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==", "integrity": "sha512-JNXwBelj5UQ35Qv5VmnassXKt8niX9jDXjM1vUSukJQiyeUXRxAiZr16QumVgBN9P9YGDyjXVKrwCHltTXvPtQ==",
"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.3", "@malio/layer-ui": "^1.7.4",
"@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.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,51 @@
<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>
@@ -0,0 +1,132 @@
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,
)
})
})
})
@@ -0,0 +1,91 @@
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,27 +1,29 @@
// STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66. import { httpExternal } from '~/shared/utils/httpExternal'
// Autocompletion d'adresse branchee sur la Base Adresse Nationale (BAN),
// `api-adresse.data.gouv.fr` — service public francais, gratuit, CORS ouvert.
// //
// Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre // Appel HTTP DIRECT depuis le front (pas de proxy back), conformement a la spec
// qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels // M1 (§ API adresse postale). On passe par `httpExternal` et NON `useApi()` :
// api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS // la BAN est un domaine externe, sans cookie de session ni enveloppe Hydra.
// changer leur signature ni l'usage côté composant.
// //
// Contrat figé par ERP-66 (c'est lui qui fait foi) : // Contrat (fige) :
// 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 méthode THROW. Le composant catch l'erreur, // En cas d'erreur/timeout, la methode THROW une AddressAutocompleteUnavailableError.
// affiche un toast d'avertissement et bascule en saisie libre (MalioInputText). // Le composant consommateur catch, affiche un toast d'avertissement et bascule
// // 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.
/** Une suggestion de ville renvoyée à partir d'un code postal. */ /** URL de l'endpoint de recherche BAN. */
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 complète (saisie assistée du champ « Adresse »). */ /** Une suggestion d'adresse complete (saisie assistee du champ « Adresse »). */
export interface AddressSuggestion { export interface AddressSuggestion {
label: string label: string
street: string street: string
@@ -34,27 +36,82 @@ export interface AddressAutocomplete {
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
} }
/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */ /** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */
export class AddressAutocompleteUnavailableError extends Error { export class AddressAutocompleteUnavailableError extends Error {
constructor() { constructor() {
// Message technique (non affiché tel quel) : le composant remonte son // Message technique (non affiche tel quel) : le composant remonte son
// propre libellé i18n. Sert au debug / aux logs uniquement. // propre libelle i18n. Sert au debug / aux logs uniquement.
super('Address autocomplete (BAN) is not available yet — ERP-66 stub.') super('Address autocomplete (BAN) is not available.')
this.name = 'AddressAutocompleteUnavailableError' this.name = 'AddressAutocompleteUnavailableError'
} }
} }
/** /** Proprietes d'une « feature » GeoJSON renvoyee par la BAN (champs utilises). */
* STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes interface BanFeatureProperties {
* échouent toujours, forçant le mode dégradé côté onglet Adresse. label?: string
*/ name?: string
street?: string
postcode?: string
city?: string
}
/** Reponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
features?: { properties?: BanFeatureProperties }[]
}
export function useAddressAutocomplete(): AddressAutocomplete { export function useAddressAutocomplete(): AddressAutocomplete {
return { return {
async searchCity(_postalCode: string): Promise<CitySuggestion[]> { async searchCity(postalCode: string): Promise<CitySuggestion[]> {
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
query: { q: postalCode, type: 'municipality' },
})
} catch {
// Reseau coupe, 5xx, timeout... -> mode degrade cote composant.
throw new AddressAutocompleteUnavailableError() throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
city: props.city ?? props.name ?? '',
postalCode: props.postcode ?? '',
}
})
}, },
async searchAddress(_query: string, _postalCode?: string): Promise<AddressSuggestion[]> {
async searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> {
// IMPORTANT : pas de `type=housenumber` ici. La BAN ne renvoie un
// resultat de ce type qu'une fois un numero saisi → une recherche par
// nom de rue (« boulevard du port ») renverrait 0 resultat pendant
// toute la frappe. Sans filtre `type`, la BAN classe rues + numeros
// par pertinence (comportement d'autocompletion attendu).
// On n'ajoute `postcode` que s'il est fourni (sinon recherche large).
const banQuery: Record<string, string> = { q: query }
if (postalCode) {
banQuery.postcode = postalCode
}
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, { query: banQuery })
} catch {
throw new AddressAutocompleteUnavailableError() throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
label: props.label ?? '',
// `name` porte la ligne d'adresse complete (numero + voie) ;
// `street` ne contient que la voie. On privilegie `name`.
street: props.name ?? props.street ?? '',
postalCode: props.postcode ?? '',
city: props.city ?? '',
}
})
}, },
} }
} }
+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 ?? 'Erreur inconnue.' return (error as FetchError)?.message ?? t('errors.unknown')
} }
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: 'Succes', title: t('success.title'),
message: successMessage message: successMessage
}) })
} }
@@ -98,10 +98,10 @@ export function useApi(): ApiClient {
apiOptions?.toastErrorMessage || apiOptions?.toastErrorMessage ||
errorMessage || errorMessage ||
extractedMessage || extractedMessage ||
'Une erreur est survenue.' t('errors.generic')
toast.error({ toast.error({
title: apiOptions?.toastTitle ?? 'Erreur', title: apiOptions?.toastTitle ?? t('errors.title'),
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 ?? 'Erreur', title: apiOptions?.toastTitle ?? t('errors.title'),
message message
}) })
} }
@@ -0,0 +1,113 @@
/**
* 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,
}
}
@@ -0,0 +1,58 @@
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.' })
})
})
@@ -0,0 +1,56 @@
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,4 +20,27 @@ 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,6 +66,25 @@ 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
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,131 @@
<?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).$_$');
}
}
+3 -85
View File
@@ -151,31 +151,9 @@ 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;
// RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor). // Le contact principal n'est plus porte inline par le Client : les contacts
#[ORM\Column(length: 120, nullable: true)] // vivent uniquement dans ClientContact (onglet Contact). RG-1.01 / RG-1.02
#[Assert\Length(max: 120, normalizer: 'trim')] // supprimees du Client (equivalent RG-1.05 / RG-1.14 sur ClientContact).
#[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).
@@ -326,66 +304,6 @@ 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,12 +177,14 @@ 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.01 a RG-1.28. * § 2.9 / § 4.3 / § 4.4 + RG-1.03 a RG-1.28 (RG-1.01/1.02 supprimees : contact inline retire).
* *
* Sequence (POST / PATCH) : * 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.01 (prenom/nom), RG-1.03 (distributor/broker * 3. Regles metier : RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque), * 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,8 +60,7 @@ 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', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'companyName', 'distributor', 'broker', 'triageService', 'categories',
'email', 'distributor', 'broker', 'triageService', 'categories',
]; ];
/** Champs de l'onglet Information (groupe client:write:information). */ /** Champs de l'onglet Information (groupe client:write:information). */
@@ -124,7 +123,6 @@ final class ClientProcessor implements ProcessorInterface
// valeurs normalisees des deux cotes (l'etat persiste l'a deja ete). // 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);
@@ -274,11 +272,6 @@ 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(),
@@ -420,39 +413,17 @@ final class ClientProcessor implements ProcessorInterface
} }
/** /**
* Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables * Normalisation serveur du formulaire principal (RG-1.18). Seul companyName
* (companyName, email, phonePrimary) ne sont touches que si une valeur est * subsiste cote Client depuis la suppression du contact inline (les champs de
* presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel. * contact noms, telephones, email sont normalises par ClientContactProcessor).
* Le setter non-nullable n'est touche que si une valeur est presente, pour ne
* jamais ecraser l'existant lors d'un PATCH partiel.
*/ */
private function normalize(Client $data): void 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,7 +86,9 @@ final class ClientExportController
} }
/** /**
* Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la * Colonnes de l'export. Depuis la suppression du contact inline (refonte
* contact, D2), les colonnes de contact principal sont retirees : l'export
* ne porte plus que les donnees propres au Client. SIREN inseree avant la
* date de creation, uniquement si l'utilisateur a accounting.view. * date de creation, uniquement si l'utilisateur a accounting.view.
* *
* @return list<string> * @return list<string>
@@ -95,11 +97,6 @@ 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',
]; ];
@@ -123,11 +120,6 @@ 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,10 +112,6 @@ 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) {
@@ -127,10 +123,6 @@ 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) {
@@ -142,10 +134,6 @@ 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) {
@@ -159,10 +147,6 @@ 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) {
@@ -175,10 +159,6 @@ 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) {
@@ -191,10 +171,6 @@ 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) {
@@ -209,10 +185,6 @@ 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) {
@@ -229,12 +201,7 @@ 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);
@@ -247,10 +214,6 @@ 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,
); );
@@ -263,10 +226,6 @@ 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) {
@@ -279,10 +238,6 @@ 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) {
@@ -301,10 +256,6 @@ 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) {
@@ -316,10 +267,6 @@ 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) {
@@ -331,10 +278,6 @@ 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) {
@@ -350,6 +293,9 @@ 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}
@@ -357,12 +303,7 @@ 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);
@@ -374,11 +315,6 @@ 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,9 +103,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
} }
/** /**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email. * Recherche fuzzy insensible a la casse sur companyName (D1, refonte contact).
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester * Depuis la suppression du contact inline du Client, la recherche ne porte
* litteraux. * plus que sur le nom d'entreprise (les anciens criteres lastName / email
* vivaient sur les colonnes inline disparues). Les metacaracteres LIKE
* (%, _, \) saisis sont echappes pour rester litteraux.
*/ */
private function applySearch(QueryBuilder $qb, ?string $search): void private function applySearch(QueryBuilder $qb, ?string $search): void
{ {
@@ -116,11 +118,9 @@ 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( $qb->andWhere('LOWER(c.companyName) LIKE :search')
'LOWER(c.companyName) LIKE :search ' ->setParameter('search', $pattern)
.'OR LOWER(c.lastName) LIKE :search ' ;
.'OR LOWER(c.email) LIKE :search',
)->setParameter('search', $pattern);
} }
/** /**
@@ -169,11 +169,9 @@ final class ColumnCommentsCatalog
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).',
'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', // Contact principal inline supprime (refonte contact) : first_name,
'last_name' => 'Nom 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
'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.', // uniquement sur client_contact.
'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,9 +126,6 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
// Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait // Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait
// produit le ClientProcessor via l'API. // produit le ClientProcessor via l'API.
$client->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); $client->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$client->setLastName('Seed');
$client->setPhonePrimary('0102030405');
$client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test');
$client->addCategory($this->createCategory($categoryCode)); $client->addCategory($this->createCategory($categoryCode));
$client->setIsArchived($isArchived); $client->setIsArchived($isArchived);
if ($isArchived) { if ($isArchived) {
@@ -169,6 +169,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Non Billing Empty Email'); $seed = $this->seedClient('Non Billing Empty Email');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -179,6 +180,7 @@ 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()],
], ],
]); ]);
@@ -286,6 +288,29 @@ 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).
*/ */
+5 -51
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 testPostNormalizesTextFields(): void public function testPostNormalizesCompanyName(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR'); $cat = $this->createCategory('SECTEUR');
@@ -34,22 +34,17 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'acme sas', 'companyName' => 'acme sas',
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
$data = $response->toArray(); $data = $response->toArray();
// RG-1.18 / 1.19 / 1.20 / 1.21 // RG-1.18 : companyName normalise en MAJUSCULES. Les champs de contact
// inline ont disparu (refonte contact) -> plus de normalisation ici.
self::assertSame('ACME SAS', $data['companyName']); self::assertSame('ACME SAS', $data['companyName']);
self::assertSame('Jean', $data['firstName']); self::assertArrayNotHasKey('firstName', $data);
self::assertSame('Dupont', $data['lastName']); self::assertArrayNotHasKey('email', $data);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
self::assertFalse($data['isArchived']); self::assertFalse($data['isArchived']);
} }
@@ -61,9 +56,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$payload = [ $payload = [
'companyName' => 'Doublon SARL', 'companyName' => 'Doublon SARL',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'dup@test.fr',
'categories' => [$iri], 'categories' => [$iri],
]; ];
@@ -71,30 +63,10 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
// Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16). // Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16).
$payload['email'] = 'dup2@test.fr';
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(409); self::assertResponseStatusCodeSame(409);
} }
public function testPostWithoutFirstOrLastNameReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Contact Name',
'phonePrimary' => '0102030405',
'email' => 'nc@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
]);
// RG-1.01
self::assertResponseStatusCodeSame(422);
}
public function testPostWithoutCategoryReturns422(): void public function testPostWithoutCategoryReturns422(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -103,9 +75,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'No Category', 'companyName' => 'No Category',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'nocat@test.fr',
'categories' => [], 'categories' => [],
], ],
]); ]);
@@ -124,9 +93,6 @@ 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(),
@@ -147,9 +113,6 @@ 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(),
], ],
@@ -169,9 +132,6 @@ 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(),
], ],
@@ -190,9 +150,6 @@ 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(),
], ],
@@ -212,9 +169,6 @@ 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,9 +68,6 @@ 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,12 +7,15 @@ 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 combler les trous (ERP-60). * Tests fonctionnels du formulaire principal apres la refonte contact.
* *
* RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs * RG-1.01 (prenom OU nom) et RG-1.02 (telephone secondaire) ont ete SUPPRIMEES
* + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les * du Client : le contact principal n'est plus porte inline, il vit uniquement
* reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire), * dans ClientContact (onglet Contact). Ce fichier verifie que :
* non encore testee. * - le formulaire principal se cree avec les seuls champs subsistants
* (companyName + categories), sans aucun champ de contact ;
* - les anciens champs de contact (firstName, lastName, phonePrimary,
* phoneSecondary, email) ne sont plus exposes ni persistes.
* *
* @internal * @internal
*/ */
@@ -21,11 +24,10 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
private const string LD = 'application/ld+json'; private const string LD = 'application/ld+json';
/** /**
* RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes * Le formulaire principal n'exige plus que companyName + au moins une
* distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur * categorie (RG-1.16 / RG sur categories). Aucun champ de contact requis.
* la colonne secondaire.
*/ */
public function testPostPersistsSecondaryPhoneNormalized(): void public function testPostMainFormWithoutContactFields(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR'); $cat = $this->createCategory('SECTEUR');
@@ -33,26 +35,28 @@ 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' => 'Two Phones SARL', 'companyName' => 'Main Form SARL',
'firstName' => 'A',
'phonePrimary' => '06.12.34.56.78',
'phoneSecondary' => '05 49 00 11 22',
'email' => 'twophones@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
])->toArray(); ])->toArray();
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
self::assertSame('0612345678', $data['phonePrimary']); self::assertSame('MAIN FORM SARL', $data['companyName']);
self::assertSame('0549001122', $data['phoneSecondary']);
// Les champs de contact inline ont disparu de la representation.
self::assertArrayNotHasKey('firstName', $data);
self::assertArrayNotHasKey('lastName', $data);
self::assertArrayNotHasKey('phonePrimary', $data);
self::assertArrayNotHasKey('phoneSecondary', $data);
self::assertArrayNotHasKey('email', $data);
} }
/** /**
* RG-1.02 : maximum 2 telephones le modele n'expose que phonePrimary et * Les anciens champs de contact envoyes par un appel API direct (payload
* phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est * historique) sont ignores par le denormaliseur : ils n'apparaissent pas
* ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero. * dans la representation et ne creent aucune colonne sur le client.
*/ */
public function testThirdPhoneFieldIsIgnored(): void public function testLegacyContactFieldsAreIgnored(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR'); $cat = $this->createCategory('SECTEUR');
@@ -60,25 +64,25 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
$data = $client->request('POST', '/api/clients', [ $data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Third Phone SARL', 'companyName' => 'Legacy Fields SARL',
'firstName' => 'A', 'firstName' => 'Ignored',
'lastName' => 'Ignored',
'phonePrimary' => '0612345678', 'phonePrimary' => '0612345678',
'phoneSecondary' => '0549001122', 'phoneSecondary' => '0549001122',
'phoneTertiary' => '0700000000', 'email' => 'ignored@test.fr',
'email' => 'thirdphone@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
])->toArray(); ])->toArray();
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
// Le champ inconnu est ignore par le denormaliseur : il n'apparait pas self::assertArrayNotHasKey('firstName', $data);
// dans la representation et n'a pas ete persiste. self::assertArrayNotHasKey('phonePrimary', $data);
self::assertArrayNotHasKey('phoneTertiary', $data); self::assertArrayNotHasKey('email', $data);
// Confirmation cote base : seules les 2 colonnes telephone existent. // Confirmation cote base : le client cree ne porte aucun contact inline
// (les colonnes n'existent plus, l'entite n'a plus les proprietes).
$persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']); $persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']);
self::assertNotNull($persisted); self::assertNotNull($persisted);
self::assertSame('0612345678', $persisted->getPhonePrimary()); self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName());
self::assertSame('0549001122', $persisted->getPhoneSecondary());
} }
} }
@@ -14,10 +14,41 @@ namespace App\Tests\Module\Commercial\Api;
* - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active * - 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();
@@ -324,9 +324,9 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
} }
/** /**
* Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ; * Payload minimal valide de l'onglet principal (companyName + une categorie
* une categorie SECTEUR). Si $categoryId est null, une categorie est creee a * SECTEUR ; le contact inline a ete supprime). Si $categoryId est null, une
* la volee. * categorie est creee a la volee.
* *
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@@ -336,9 +336,6 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
return [ return [
'companyName' => $companyName, 'companyName' => $companyName,
'firstName' => 'Jean',
'phonePrimary' => '0612345678',
'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test',
'categories' => ['/api/categories/'.$categoryId], 'categories' => ['/api/categories/'.$categoryId],
]; ];
} }
@@ -232,9 +232,6 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas
$client = new ClientEntity(); $client = new ClientEntity();
$client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); $client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$client->setLastName('Complet');
$client->setPhonePrimary('0102030405');
$client->setEmail('complet'.$suffix.'@seed.test');
$client->addCategory($this->createCategory('SECTEUR')); $client->addCategory($this->createCategory('SECTEUR'));
// Bloc comptable non nul (gating par omission cote Commerciale). // Bloc comptable non nul (gating par omission cote Commerciale).
$client->setSiren('123456789'); $client->setSiren('123456789');
@@ -113,6 +113,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Host'); $seed = $this->seedClient('Address Host');
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR');
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -123,6 +124,7 @@ 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,41 +12,13 @@ use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
* RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par * 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)
* et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques. * n'est PLUS contraint unique. (L'email RG-1.17 a disparu du Client avec la
* refonte contact, il vit desormais sur ClientContact.)
* *
* @internal * @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,14 +134,11 @@ final class ClientProcessorTest extends TestCase
'isArchived' => false, 'isArchived' => false,
], ],
managed: true, managed: true,
// Etat persiste complet (valeurs normalisees) : sans les champs // Etat persiste (valeurs normalisees) : sans companyName, guardManage
// metier, guardManage (ERP-74) les croirait modifies (companyName, // (ERP-74) le croirait modifie (compare a null) et leverait un 403
// lastName... compares a null) et leverait un 403 parasite. // 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,
], ],
@@ -164,13 +161,10 @@ 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 (sinon guardManage les croirait modifies). // champs metier (companyName) 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,
], ],
@@ -193,9 +187,6 @@ 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,
], ],
@@ -220,9 +211,6 @@ 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,
], ],
@@ -324,9 +312,6 @@ 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,
], ],
@@ -401,16 +386,14 @@ final class ClientProcessorTest extends TestCase
} }
/** /**
* Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) suffisant * Client minimal companyName seul depuis la suppression du contact inline.
* pour atteindre les validations testees. * Suffisant pour atteindre les validations testees (le contact vit desormais
* 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;
} }