Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e85d46a17b | |||
| ec952896ba | |||
| 468894cfad | |||
| 912280d24e | |||
| f406a598eb | |||
| a72a5dd812 | |||
| 3dc98994f5 | |||
| 96ddd15c86 | |||
| 1b924ba0fd | |||
| 8fae987e15 | |||
| 6f977d387d | |||
| 1888b70623 | |||
| 1961bc62c8 | |||
| bc7c8f6f83 | |||
| 7833ff32e6 | |||
| 6fee9f6bd6 | |||
| 276f242b10 | |||
| 97301dcd6c | |||
| daeb8b3003 | |||
| 9c311cb58b | |||
| 5a33815584 | |||
| 052a39092b | |||
| 19148800ba | |||
| fc063c725d | |||
| 583d634a83 | |||
| ee1521384e | |||
| 79dffccc79 | |||
| 1ff335b3fe |
@@ -98,6 +98,24 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
|
||||
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
||||
- Spec complete : @doc/audit-log.md
|
||||
|
||||
### Libelle i18n du type d'entite (obligatoire avec `#[Auditable]`)
|
||||
|
||||
**Toute entite `#[Auditable]` doit avoir son libelle FR dans le bloc `audit.entity` de `frontend/i18n/locales/fr.json`.** C'est la contrepartie i18n de l'attribut : sans elle, le filtre « Type d'entite » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un libelle lisible.
|
||||
|
||||
Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents en base) ; des qu'un module audite une entite, son type y apparait. Le front (`formatEntityType`, `audit-log.vue`) construit la cle `audit.entity.<module>_<entity>` et, faute de traduction, **retombe silencieusement** sur le type brut.
|
||||
|
||||
Derivation de la cle (emplacement centralise + schema flat — decision ERP-99) :
|
||||
|
||||
| FQCN entite | `entity_type` (back) | Cle i18n (flat) |
|
||||
|---|---|---|
|
||||
| `App\Module\Commercial\Domain\Entity\Client` | `commercial.Client` | `commercial_client` |
|
||||
| `App\Module\Commercial\Domain\Entity\ClientAddress` | `commercial.ClientAddress` | `commercial_clientaddress` |
|
||||
| `App\Module\Catalog\Domain\Entity\Category` | `catalog.Category` | `catalog_category` |
|
||||
|
||||
Regle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa cle de libelle audit fait partie de la **definition de fini** d'une entite metier auditee.
|
||||
|
||||
**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entites `#[Auditable]` et echoue si une seule n'a pas sa cle `audit.entity.*`. Conclusion : creer une entite `#[Auditable]` sans son libelle i18n casse `make test`.
|
||||
|
||||
## Timestampable + Blamable (obligatoire pour entites metier)
|
||||
|
||||
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
|
||||
|
||||
@@ -1,209 +1,362 @@
|
||||
# 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
|
||||
|
||||
- **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
|
||||
- **Auth** : JWT HTTP-only cookie (Lexik)
|
||||
- **Frontend** : Nuxt 4 (SPA, SSR off), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, @nuxtjs/i18n
|
||||
- **Auth** : JWT HTTP-only cookie (Lexik), login sur `/login_check`
|
||||
- **Infra** : Docker Compose (dev + prod multi-stage)
|
||||
- **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 |
|
||||
|------------|------|
|
||||
| API (Nginx)| 8083 |
|
||||
| Frontend | 3004 |
|
||||
|---------------|------|
|
||||
| API (Nginx) | 8083 |
|
||||
| Frontend dev | 3004 |
|
||||
| PostgreSQL | 5437 |
|
||||
|
||||
## Commandes
|
||||
---
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `make start` | Demarrer les containers |
|
||||
| `make stop` | Arreter les containers |
|
||||
| `make restart` | Redemarrer les containers |
|
||||
| `make install` | Install complet |
|
||||
| `make reset` | Tout supprimer et reinstaller |
|
||||
| `make dev-nuxt` | Serveur dev Nuxt (hot reload) |
|
||||
| `make shell` | Shell dans le container PHP |
|
||||
| `make cache-clear` | Vider le cache Symfony |
|
||||
| `make migration-migrate` | Lancer les migrations |
|
||||
| `make fixtures` | Charger les fixtures |
|
||||
| `make db-reset` | Reset BDD + migrations + fixtures |
|
||||
| `make test` | PHPUnit (tests back) |
|
||||
| `make nuxt-test` | Vitest (tests unitaires front) |
|
||||
| `make test-e2e` | Playwright (tests E2E front) |
|
||||
| `make test-e2e-ui` | Playwright UI interactive (debug) |
|
||||
| `make seed-e2e` | Seed les 6 personas E2E |
|
||||
| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) |
|
||||
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
|
||||
| `make logs-dev` | Tail logs Symfony |
|
||||
## Prérequis
|
||||
|
||||
- Docker + Docker Compose
|
||||
- `make`
|
||||
- `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`)
|
||||
|
||||
Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ;
|
||||
rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur
|
||||
l'hôte (navigateur réel, voir [Tests](#tests)).
|
||||
|
||||
---
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
```bash
|
||||
make start # Démarre les containers Docker
|
||||
make install # Composer + clés JWT + migrations + permissions + BDD de test
|
||||
make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004)
|
||||
```
|
||||
|
||||
`make install` prépare une base de dev **vierge** (schéma + RBAC structurel, sans
|
||||
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
|
||||
|
||||
- **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`.
|
||||
- **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`.
|
||||
| Suite | Commande | Outil | Où |
|
||||
|-------------------|------------------|----------------------|-----------------------------------|
|
||||
| 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
|
||||
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
|
||||
# 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
|
||||
|
||||
# Terminal 2 : tests
|
||||
make test-e2e
|
||||
# Terminal 2 — tests
|
||||
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
|
||||
|
||||
**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/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
|
||||
- Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
|
||||
- `GET /api/modules` — IDs des modules actifs (public)
|
||||
- `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
|
||||
Kernel.php
|
||||
Shared/ # Noyau technique partage
|
||||
Domain/
|
||||
ValueObject/ # Email, ...
|
||||
Event/ # DomainEventInterface
|
||||
Contract/ # Interfaces inter-modules
|
||||
Application/
|
||||
Bus/ # CommandBusInterface, QueryBusInterface
|
||||
Infrastructure/
|
||||
ApiPlatform/
|
||||
Resource/ # AppVersion, ModulesResource, SidebarResource
|
||||
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
|
||||
Shared/ # Noyau technique partagé (Domain/, Application/Bus/, Infrastructure/ApiPlatform/)
|
||||
Module/
|
||||
Core/ # Module obligatoire (auth, users)
|
||||
CoreModule.php # Declaration (ID, LABEL, REQUIRED)
|
||||
Domain/
|
||||
Entity/ # User
|
||||
Repository/ # UserRepositoryInterface
|
||||
Event/ # UserCreated
|
||||
Application/
|
||||
DTO/ # UserOutput
|
||||
Infrastructure/
|
||||
Doctrine/ # DoctrineUserRepository, Migrations/
|
||||
ApiPlatform/State/
|
||||
Provider/ # MeProvider
|
||||
Processor/ # UserPasswordHasherProcessor
|
||||
Console/ # CreateUserCommand
|
||||
DataFixtures/ # AppFixtures
|
||||
Commercial/ # Autre module (exemple)
|
||||
CommercialModule.php
|
||||
Core/ # Module obligatoire (auth, users, RBAC)
|
||||
CoreModule.php # Déclaration (ID, LABEL, REQUIRED, permissions())
|
||||
Domain/ Application/ Infrastructure/
|
||||
Commercial/ Catalog/ Sites/ # Modules métier
|
||||
config/
|
||||
modules.php # Source de verite activation
|
||||
sidebar.php # Source de verite navigation
|
||||
version.yaml
|
||||
packages/ # Config Symfony
|
||||
jwt/ # Cles JWT
|
||||
migrations/ # Anciennes migrations
|
||||
modules.php # Source de vérité : activation
|
||||
sidebar.php # Source de vérité : navigation
|
||||
packages/ # Config Symfony (doctrine, api_platform, security…)
|
||||
migrations/ # Migrations d'initialisation (namespace racine : setup, RBAC, seed de base)
|
||||
frontend/ # App Nuxt 4 (SPA)
|
||||
app/
|
||||
layouts/ # default.vue, auth.vue
|
||||
middleware/ # auth.global.ts, modules.global.ts
|
||||
shared/ # Code partage (hors modules)
|
||||
composables/ # useApi, useAppVersion, useSidebar
|
||||
components/ui/ # AppTopNav, ...
|
||||
stores/ # auth, ui
|
||||
services/ # auth
|
||||
types/ # SidebarSection, UserData
|
||||
utils/ # api (Hydra)
|
||||
modules/ # Modules auto-detectes comme layers Nuxt
|
||||
core/
|
||||
nuxt.config.ts # Marqueur layer
|
||||
pages/ # index, login, logout
|
||||
commercial/
|
||||
nuxt.config.ts
|
||||
pages/ # commercial.vue
|
||||
app.vue
|
||||
nuxt.config.ts # Scanne modules/*/ automatiquement
|
||||
i18n/locales/ # Traductions (sidebar.*, etc.)
|
||||
assets/ # CSS, images
|
||||
public/ # Fichiers statiques
|
||||
app/ # Shell : layouts, middlewares (auth.global, modules.global)
|
||||
shared/ # Code inter-modules (composables, stores, utils, types)
|
||||
modules/ # Layers Nuxt auto-détectés (core/, commercial/…)
|
||||
i18n/locales/ # Traductions (sidebar.*, audit.entity.*, …)
|
||||
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)
|
||||
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
|
||||
.claude/
|
||||
skills/create-module/ # Skill Claude Code pour scaffolder un module
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD
|
||||
|
||||
- **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
|
||||
|
||||
Secrets requis dans Gitea :
|
||||
|
||||
- `RELEASE_TOKEN` — PAT avec droits `write:repository`
|
||||
- `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
|
||||
|
||||
@@ -213,4 +366,13 @@ En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes d
|
||||
<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/`.
|
||||
|
||||
@@ -3,6 +3,14 @@ lexik_jwt_authentication:
|
||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
token_ttl: '%env(int:JWT_TOKEN_TTL)%'
|
||||
# Tolerance d'horloge (en secondes) appliquee a la validation des claims
|
||||
# temporels iat / nbf / exp (LooseValidAt cote lcobucci). Sans cette marge
|
||||
# (defaut 0), un recul d'horloge entre la signature (/login_check) et la
|
||||
# requete suivante rend iat/nbf « dans le futur » -> « Invalid JWT Token »
|
||||
# (401). Observe en dev sous WSL2/Docker (horloge CLOCK_REALTIME non
|
||||
# monotone) : flakes intermittents de la suite PHPUnit (ERP-98). Benefice
|
||||
# aussi en prod si les noeuds derivent legerement entre eux.
|
||||
clock_skew: 15
|
||||
remove_token_from_body_when_cookies_used: true
|
||||
token_extractors:
|
||||
authorization_header:
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.64'
|
||||
app.version: '0.1.78'
|
||||
|
||||
@@ -18,8 +18,8 @@ merge de la stack.
|
||||
|
||||
| RG | Intitulé | Test(s) | Source |
|
||||
|----|----------|---------|--------|
|
||||
| RG-1.01 | Prénom OU nom obligatoire → 422 | `ClientApiTest::testPostWithoutFirstOrLastNameReturns422` ; `ClientProcessorTest` (unit) | ERP-55 |
|
||||
| RG-1.02 | phoneSecondary persisté ; max 2 téléphones | `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` ; `::testThirdPhoneFieldIsIgnored` | **ERP-60** |
|
||||
| ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact |
|
||||
| ~~RG-1.02~~ | _(supprimée du Client V1)_ téléphones inline retirés du Client (testés sur `ClientContact`) | — | refonte-contact |
|
||||
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
|
||||
| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
|
||||
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# M1 · Ticket 1/3 (Backend) — Supprimer le contact inline du `Client`
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Retirer de l'entité `Client` (et de la table `client`) les **5 champs du contact
|
||||
principal inline** : `firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`.
|
||||
La gestion des contacts passe désormais **exclusivement** par la sous-entité
|
||||
`ClientContact` (onglet « Contacts »), déjà en place et déjà porteuse des mêmes champs.
|
||||
|
||||
Le code M1 est **déjà livré en prod** : ce ticket inclut donc une **migration de données**
|
||||
(backfill) pour ne perdre aucune information de contact existante avant de supprimer les
|
||||
colonnes.
|
||||
|
||||
Contexte et justification : voir `README.md` du dossier `refonte-contact`.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### IN
|
||||
|
||||
- Migration Doctrine : **backfill puis suppression** des 5 colonnes de `client`.
|
||||
- `Client` (entité) : supprimer les 5 propriétés, getters/setters, annotations ORM /
|
||||
`Assert` / `Groups`.
|
||||
- `ClientProcessor` : retirer les 5 champs de `MAIN_FIELDS`, `changedBusinessFields()`,
|
||||
`normalize()` ; supprimer `validateMainContact()` (RG-1.01 — n'a plus d'objet).
|
||||
- `DoctrineClientRepository::applySearch()` : trancher D1 (recherche) et l'appliquer.
|
||||
- `ClientExportController` : trancher D2 (colonnes export) et l'appliquer.
|
||||
- `ClientFixtures` : retirer les 5 paramètres inline de `ensureClient()` ; garantir que
|
||||
chaque client seedé possède au moins 1 `ClientContact` (déjà géré par `addContact()`).
|
||||
- Tests PHPUnit : mettre à jour / retirer les cas qui exercent ces 5 champs sur `Client`.
|
||||
|
||||
### OUT
|
||||
|
||||
- Toute modification de `ClientContact` / `ClientContactProcessor` : **inchangés** (c'est la
|
||||
cible, les champs y restent). `ClientFieldNormalizer` reste tel quel (toujours appelé par
|
||||
`ClientContactProcessor`).
|
||||
- Le front (formulaires, vues, types, i18n) → **ticket 2/3**.
|
||||
- Les specs (`spec-back.md`, `spec-front.md`, cahier de test) → **ticket 3/3**.
|
||||
|
||||
## 3. Fichiers à modifier
|
||||
|
||||
| Fichier | Action |
|
||||
|---|---|
|
||||
| `src/Module/Commercial/Domain/Entity/Client.php` | Supprimer props `firstName` (~l.158), `lastName` (~l.163), `phonePrimary` (~l.168), `phoneSecondary` (~l.172), `email` (~l.178) + leurs getters/setters (~l.329-382) + groupes `client:read`/`client:write:main` + `Assert\*`. |
|
||||
| `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php` | Retirer les 5 clés de `MAIN_FIELDS` (~l.63) ; de `changedBusinessFields()` (~l.277-281) ; les 6 lignes de `normalize()` qui touchent email/phone/first/last/secondary (~l.433-441) ; supprimer `validateMainContact()` (~l.447-456) et son appel. |
|
||||
| `src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php` | `applySearch()` (~l.110-124) : appliquer **D1**. |
|
||||
| `src/Module/Commercial/Infrastructure/Controller/ClientExportController.php` | `buildHeaders()` (~l.94-114) + `buildRows()` (~l.121-143) : appliquer **D2**. |
|
||||
| `src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php` | `ensureClient()` (~l.357-395) : retirer firstName/lastName/phonePrimary/phoneSecondary/email ; conserver `addContact()`. |
|
||||
| `migrations/Version<timestamp>.php` (NOUVELLE) | Backfill + `DROP COLUMN` (cf. § 4). |
|
||||
| `tests/Module/Commercial/**` | Voir § 5. |
|
||||
|
||||
## 4. Migration Doctrine — backfill puis suppression
|
||||
|
||||
> Migration **modulaire** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : ce
|
||||
> n'est PAS une migration d'initialisation, le schéma `client` / `client_contact` existe
|
||||
> déjà (règle ABSOLUE n°11).
|
||||
|
||||
### `up()`
|
||||
|
||||
1. **Backfill — ne créer un contact que pour les clients qui n'en ont aucun**, afin de ne
|
||||
pas dupliquer le contact déjà recopié à la création (`prefillFirstContact`) :
|
||||
|
||||
```sql
|
||||
INSERT INTO client_contact
|
||||
(client_id, first_name, last_name, phone_primary, phone_secondary, email, position, created_at, updated_at)
|
||||
SELECT c.id, c.first_name, c.last_name, c.phone_primary, c.phone_secondary, c.email, 0, NOW(), NOW()
|
||||
FROM client c
|
||||
WHERE NOT EXISTS (SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id)
|
||||
AND (c.first_name IS NOT NULL OR c.last_name IS NOT NULL);
|
||||
```
|
||||
|
||||
> Le `WHERE ... first_name OU last_name IS NOT NULL` respecte le CHECK
|
||||
> `chk_client_contact_name`. Les rares clients sans nom de contact ET sans contact
|
||||
> existant ne reçoivent pas de ligne (cas théorique : `phone_primary`/`email` étaient
|
||||
> `NOT NULL` mais les noms nullables).
|
||||
|
||||
2. **Supprimer les 5 colonnes** :
|
||||
|
||||
```sql
|
||||
ALTER TABLE client
|
||||
DROP COLUMN first_name,
|
||||
DROP COLUMN last_name,
|
||||
DROP COLUMN phone_primary,
|
||||
DROP COLUMN phone_secondary,
|
||||
DROP COLUMN email;
|
||||
```
|
||||
|
||||
> Pas de `COMMENT ON COLUMN` à poser (on supprime). Vérifier qu'aucun index ne portait
|
||||
> sur `email` (l'index unique `uq_client_email_active` a déjà été supprimé — décision Q4 /
|
||||
> RG-1.17, cf. `ClientMigrationTest`).
|
||||
|
||||
### `down()` (best-effort)
|
||||
|
||||
1. Recréer les 5 colonnes (`phone_primary`/`email` en `NOT NULL` impose un défaut transitoire
|
||||
ou un re-remplissage depuis le contact `position = 0`).
|
||||
2. Re-remplir depuis `client_contact` (`position = 0`) si possible.
|
||||
3. Reposer les `COMMENT ON COLUMN` d'origine (textes RG-1.19/1.20/1.21/1.01/1.17 — cf.
|
||||
`migrations/Version20260601000000.php` l.251-255).
|
||||
|
||||
> `down()` ne peut pas restaurer parfaitement les données (ambiguïté si plusieurs contacts).
|
||||
> Documenter cette limite dans le docblock de la migration.
|
||||
|
||||
## 5. Tests à mettre à jour
|
||||
|
||||
| Fichier | Action |
|
||||
|---|---|
|
||||
| `tests/Module/Commercial/Api/ClientApiTest.php` | Retirer firstName/lastName/phone/email des payloads POST/PATCH `client` et des assertions JSON. |
|
||||
| `tests/.../ClientFormulaireMainTest.php` | Supprimer les tests RG-1.01 (firstName/lastName) et RG-1.02 (téléphones) **côté Client** — ils basculent côté `ClientContact` (couverts ailleurs). |
|
||||
| `tests/.../ClientExportControllerTest.php` | Aligner les en-têtes/lignes attendus sur **D2**. |
|
||||
| `tests/.../ClientMigrationTest.php` | Asserter que les 5 colonnes **n'existent plus** sur `client` ; vérifier le backfill (un client sans contact obtient bien 1 `client_contact`). |
|
||||
| `tests/.../ClientFieldNormalizerTest.php` | Conserver les tests du normalizer (toujours utilisé par `ClientContact`) ; retirer les cas spécifiques aux champs `Client` s'il y en a. |
|
||||
| RG-1.01/1.02 (matrice) | Ne plus tester sur `Client` ; vérifier qu'ils restent couverts sur `ClientContact` (RG-1.05). |
|
||||
|
||||
## 6. Décisions à trancher (cf. README § 3)
|
||||
|
||||
- **D1 — recherche** : recommandé = `LEFT JOIN client_contact` (fuzzy sur
|
||||
`companyName` + contact `first_name`/`last_name`/`email`). Attention au `DISTINCT` /
|
||||
risque de doublons de lignes si plusieurs contacts matchent (grouper par `client.id`).
|
||||
- **D2 — export** : recommandé = alimenter les colonnes contact depuis le contact de plus
|
||||
petit `position` (fetch-join `contacts` pour éviter le N+1).
|
||||
|
||||
## 7. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] Les colonnes `first_name`, `last_name`, `phone_primary`, `phone_secondary`, `email`
|
||||
n'existent plus sur la table `client`.
|
||||
- [ ] La migration est jouable sur une base seedée sans perte de contact (backfill vérifié)
|
||||
et `down()` documenté comme best-effort.
|
||||
- [ ] `Client`, `ClientProcessor`, `DoctrineClientRepository`, `ClientExportController`,
|
||||
`ClientFixtures` ne référencent plus les 5 champs.
|
||||
- [ ] D1 et D2 implémentées conformément à la décision validée.
|
||||
- [ ] `ClientContact` / `ClientContactProcessor` / `ClientFieldNormalizer` inchangés.
|
||||
- [ ] `make test` vert (notamment `tests/Architecture/ColumnsHaveSqlCommentTest` et
|
||||
`EntitiesAreTimestampableBlamableTest`).
|
||||
- [ ] `make php-cs-fixer-allow-risky` ne signale rien sur les fichiers touchés.
|
||||
- [ ] Aucune régression du contrat de sérialisation : capturer le JSON réel de
|
||||
`GET /api/clients/{id}` et vérifier l'absence des 5 champs (réflexe RETEX M1).
|
||||
@@ -0,0 +1,58 @@
|
||||
# Prompt d'implémentation — M1 · Ticket 1/3 (Backend)
|
||||
|
||||
Tu travailles sur le projet **Starseed** (Symfony 8 / API Platform 4 / Doctrine / PostgreSQL).
|
||||
Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Commentaires en français,
|
||||
code en anglais, `declare(strict_types=1);` partout.
|
||||
|
||||
## Mission
|
||||
|
||||
Supprimer le **contact principal inline** de l'entité `Client` : les 5 champs
|
||||
`firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`. Les contacts sont gérés
|
||||
uniquement via la sous-entité `ClientContact` (onglet Contacts), déjà en place. Le code est
|
||||
déjà en prod → migration avec **backfill** avant `DROP`.
|
||||
|
||||
La spec détaillée du ticket est dans `docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.md`.
|
||||
Lis-la en entier, ainsi que le `README.md` du même dossier (décision + RG impactées + D1/D2).
|
||||
|
||||
## Étapes
|
||||
|
||||
1. **Explorer** : `Client.php`, `ClientProcessor.php`, `DoctrineClientRepository.php`,
|
||||
`ClientExportController.php`, `ClientFixtures.php`, et `ClientContact.php` (pour confirmer
|
||||
que la cible porte bien les mêmes champs).
|
||||
2. **Demander la validation des décisions D1 (recherche) et D2 (export)** avant de coder —
|
||||
défauts recommandés : D1 = LEFT JOIN sur `client_contact`, D2 = colonnes export depuis le
|
||||
contact `position` minimal. Ne pas inventer un autre comportement.
|
||||
3. **Migration** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : backfill
|
||||
`INSERT INTO client_contact ... WHERE NOT EXISTS(...)` puis `ALTER TABLE client DROP COLUMN ...`
|
||||
(les 5). `down()` best-effort documenté. Voir le SQL exact dans la spec § 4.
|
||||
4. **Entité** : retirer les 5 props + getters/setters + `#[ORM\Column]` + `#[Assert\*]` +
|
||||
`#[Groups(['client:read','client:write:main'])]`.
|
||||
5. **Processor** : retirer de `MAIN_FIELDS`, `changedBusinessFields()`, `normalize()` ;
|
||||
supprimer `validateMainContact()` et son appel.
|
||||
6. **Repository** : `applySearch()` selon D1.
|
||||
7. **Export** : `buildHeaders()` / `buildRows()` selon D2.
|
||||
8. **Fixtures** : alléger `ensureClient()` ; garder `addContact()`.
|
||||
9. **Tests** : mettre à jour `ClientApiTest`, `ClientFormulaireMainTest`,
|
||||
`ClientExportControllerTest`, `ClientMigrationTest`, `ClientFieldNormalizerTest`
|
||||
(cf. spec § 5). Ajouter une assertion que le backfill crée bien un contact pour un client
|
||||
qui n'en avait pas.
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- Ne touche **pas** `ClientContact`, `ClientContactProcessor`, `ClientFieldNormalizer`.
|
||||
- Respecte les règles ABSOLUES : pagination, `#[Auditable]`, COMMENT ON COLUMN (ici on
|
||||
supprime → pas de commentaire à poser, mais ne pas casser le garde-fou).
|
||||
- Les RG-1.01 et RG-1.02 disparaissent **du Client** : leur équivalent (RG-1.05 / RG-1.14)
|
||||
vit déjà sur `ClientContact`, ne le duplique pas.
|
||||
|
||||
## Vérification finale (obligatoire avant de dire « fini »)
|
||||
|
||||
```bash
|
||||
make db-reset && make migration-migrate # migration rejouable sur base fraîche
|
||||
make test # PHPUnit vert
|
||||
make php-cs-fixer-allow-risky # lint
|
||||
```
|
||||
|
||||
Puis capture le JSON réel de `GET /api/clients/{id}` (avec un JWT) et confirme que les 5
|
||||
champs ont disparu de la réponse et que `contacts[]` porte bien l'info (réflexe RETEX M1 :
|
||||
on valide sur le contrat réel, pas sur les annotations).
|
||||
@@ -0,0 +1,74 @@
|
||||
# M1 · Ticket 2/3 (Frontend) — Retirer le bloc contact principal des écrans Client
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
|
||||
trois écrans Client — **création**, **consultation**, **modification** — ainsi que des
|
||||
types, mappeurs, validations et clés i18n associés. La saisie des contacts se fait
|
||||
désormais uniquement dans l'**onglet « Contacts »** (composant `ClientContactBlock`, déjà
|
||||
en place et inchangé).
|
||||
|
||||
Dépend du **ticket 1/3 (back)** : l'API ne renvoie/n'accepte plus ces 5 champs sur `client`.
|
||||
Contexte : voir `README.md` du dossier `refonte-contact`.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### IN — fichiers `frontend/modules/commercial/`
|
||||
|
||||
| Fichier | Action |
|
||||
|---|---|
|
||||
| `pages/clients/new.vue` | Supprimer le bloc principal Nom/Prénom/Téléphones/Email (~l.27-63), l'état `main.firstName/lastName/email`, `mainPhones` (~l.445-459), la fonction `prefillFirstContact()` (~l.658-665) et son appel, le mapping payload POST `phonePrimary/phoneSecondary` (~l.513-524). Adapter `isMainValid` (~l.479-493) : la validation principale ne porte plus que sur `companyName` (+ relation/catégories selon RG existantes). L'onglet **Contacts** devient le point de saisie des coordonnées ; garantir au moins un `ClientContactBlock` vide au départ. |
|
||||
| `pages/clients/[id]/edit.vue` | Supprimer les 5 champs du bloc principal (~l.32-73). `mapMainDraft()` et `buildMainPayload()` ne portent plus ces champs. L'onglet Contacts reste éditable. |
|
||||
| `pages/clients/[id]/index.vue` | Supprimer l'affichage lecture seule des 5 champs du bloc principal (~l.49-104, partie contact). Conserver l'onglet Contacts (lecture seule). |
|
||||
| `types/clientForm.ts` | `MainFormDraft` : retirer `firstName`, `lastName`, `email`, `phonePrimary`, `phoneSecondary`, `hasSecondaryPhone`. Garder `ContactFormDraft` (inchangé). |
|
||||
| `types/clientConsultation.ts` | `ClientDetail` : retirer `firstName/lastName/phonePrimary/phoneSecondary/email` (les commentaires « Contact principal »). Garder `ContactRead`. |
|
||||
| `utils/clientEdit.ts` | `mapMainDraft()` et `buildMainPayload()` : retirer les 5 champs. Garder `buildContactPayload()`. |
|
||||
| `utils/clientConsultation.ts` | Retirer toute lecture des 5 champs inline du client (garder `mapContactToDraft`, `contactOptionsOf`). |
|
||||
| `i18n/locales/fr.json` | Retirer `commercial.clients.form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone`. **Conserver** tout le bloc `commercial.clients.form.contact.*`. Vérifier qu'aucune autre vue ne référence les clés retirées. |
|
||||
| `**/__tests__/*.spec.ts` | Mettre à jour `clientFormRules.spec.ts`, `clientEdit.spec.ts`, `clientConsultation.spec.ts` (cf. § 4). |
|
||||
|
||||
### OUT
|
||||
|
||||
- `ClientContactBlock.vue`, l'onglet Contacts, `useClient`, la liste/répertoire
|
||||
(`pages/clients/index.vue` — ses colonnes n'affichent déjà pas le contact inline) :
|
||||
**inchangés**.
|
||||
- Le back → ticket 1/3. Les specs → ticket 3/3.
|
||||
|
||||
## 3. Comportement attendu après modification
|
||||
|
||||
- **Création** : le formulaire principal demande l'entreprise (et relation/catégories selon
|
||||
l'existant), plus de Nom/Prénom/Téléphone/Email inline. L'utilisateur renseigne les
|
||||
coordonnées dans l'onglet **Contacts**. La création reste valide tant qu'il y a
|
||||
`companyName` **et** ≥ 1 bloc Contact valide (Nom OU Prénom) — RG-1.05/RG-1.14 inchangées.
|
||||
- **Consultation** : plus de bloc contact principal ; l'onglet Contacts affiche les
|
||||
contacts.
|
||||
- **Modification** : idem ; le PATCH du groupe `client:write:main` n'envoie plus les 5
|
||||
champs.
|
||||
|
||||
## 4. Tests Vitest à mettre à jour
|
||||
|
||||
- `clientFormRules.spec.ts` : la validité du « principal » ne dépend plus de
|
||||
firstName/email/phone ; conserver `isContactNamed()` (RG-1.05) sur les blocs Contacts.
|
||||
- `clientEdit.spec.ts` : `buildMainPayload()` ne contient plus les 5 champs ; `mapMainDraft()`
|
||||
non plus.
|
||||
- `clientConsultation.spec.ts` : retirer les assertions sur les 5 champs inline.
|
||||
|
||||
## 5. Tips & rappels projet
|
||||
|
||||
- `useApi()` obligatoire (jamais `$fetch`/`ofetch`). Composants `Malio*` obligatoires.
|
||||
- État de tableau jamais dans l'URL (règle inchangée).
|
||||
- Les valeurs sont **normalisées côté serveur** (Capitalize / chiffres / lowercase) : le
|
||||
front envoie la saisie et réaffiche la valeur renvoyée — ne pas réintroduire de
|
||||
normalisation front.
|
||||
- Ne pas créer de clé i18n orpheline ni laisser de clé `form.main.*` morte.
|
||||
|
||||
## 6. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] Les 3 écrans n'affichent plus Nom/Prénom/Téléphone/Téléphone 2/Email en bloc principal.
|
||||
- [ ] Le parcours de création fonctionne avec `companyName` + onglet Contacts (≥ 1 contact).
|
||||
- [ ] `MainFormDraft` / `ClientDetail` ne déclarent plus les 5 champs ; `mapMainDraft` /
|
||||
`buildMainPayload` non plus.
|
||||
- [ ] Aucune clé i18n `form.main.firstName/lastName/email/phone*` restante ni référencée.
|
||||
- [ ] `make nuxt-test` vert.
|
||||
- [ ] Vérification visuelle du golden path (`make dev-nuxt`, port 3004) : création →
|
||||
consultation → modification d'un client sans bloc contact inline.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Prompt d'implémentation — M1 · Ticket 2/3 (Frontend)
|
||||
|
||||
Projet **Starseed** (Nuxt 4 / Vue 3 Composition API / TypeScript / @malio/layer-ui).
|
||||
Lis `CLAUDE.md` et `.claude/rules/frontend.md` avant de coder. Commentaires en français,
|
||||
code en anglais, 4 espaces d'indentation.
|
||||
|
||||
## Mission
|
||||
|
||||
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
|
||||
écrans Client (création / consultation / modification) et de tout le code associé (types,
|
||||
mappeurs, validations, i18n). Les contacts restent gérés par l'onglet **Contacts**
|
||||
(`ClientContactBlock`, inchangé).
|
||||
|
||||
Spec détaillée : `docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.md` (lis-la en
|
||||
entier + le `README.md` du dossier). Ce ticket dépend du ticket back (l'API ne porte plus
|
||||
les 5 champs sur `client`).
|
||||
|
||||
## Étapes
|
||||
|
||||
1. Explorer `frontend/modules/commercial/` : `pages/clients/new.vue`, `[id]/edit.vue`,
|
||||
`[id]/index.vue`, `types/clientForm.ts`, `types/clientConsultation.ts`,
|
||||
`utils/clientEdit.ts`, `utils/clientConsultation.ts`, `i18n/locales/fr.json`.
|
||||
2. Supprimer le bloc principal des 3 écrans + l'état réactif `main.firstName/lastName/email`,
|
||||
`mainPhones`, `prefillFirstContact()`.
|
||||
3. Adapter `isMainValid` : ne dépend plus que de `companyName` (+ relation/catégories selon
|
||||
l'existant). La garantie « ≥ 1 contact valide » reste portée par l'onglet Contacts.
|
||||
4. Nettoyer les types (`MainFormDraft`, `ClientDetail`) et les mappeurs (`mapMainDraft`,
|
||||
`buildMainPayload`, `clientConsultation`).
|
||||
5. Retirer les clés i18n `form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone` ;
|
||||
vérifier par recherche qu'aucune vue ne les utilise plus. **Garder** `form.contact.*`.
|
||||
6. Mettre à jour les specs Vitest (`clientFormRules`, `clientEdit`, `clientConsultation`).
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- `useApi()` uniquement ; composants `Malio*` uniquement ; pas d'état tableau dans l'URL.
|
||||
- Ne touche pas `ClientContactBlock.vue`, l'onglet Contacts, ni la liste/répertoire.
|
||||
- Pas de normalisation front (le serveur normalise).
|
||||
|
||||
## Vérification finale
|
||||
|
||||
```bash
|
||||
make nuxt-test # Vitest vert
|
||||
make dev-nuxt # port 3004 — golden path manuel
|
||||
```
|
||||
|
||||
Golden path à vérifier dans le navigateur : créer un client (entreprise + 1 contact dans
|
||||
l'onglet Contacts), le consulter, le modifier — sans aucun bloc contact inline.
|
||||
@@ -0,0 +1,51 @@
|
||||
# M1 · Ticket 3/3 (Specs) — Acter la suppression du contact inline dans les specs M1
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Mettre à jour la **documentation fonctionnelle/technique M1** pour refléter la décision :
|
||||
le contact principal inline est supprimé du `Client`, les contacts vivent uniquement dans
|
||||
`ClientContact`. Les specs sont la **source de vérité** du projet (cf. `workflow.md`) : elles
|
||||
doivent décrire le modèle cible, pas l'ancien.
|
||||
|
||||
> Idéalement réalisé **avant** les tickets 1 et 2 (la spec guide le code), mais peut être
|
||||
> fait en parallèle. À minima, ne pas merger le code sans aligner la spec.
|
||||
|
||||
## 2. Fichiers à modifier
|
||||
|
||||
| Fichier | Sections concernées |
|
||||
|---|---|
|
||||
| `docs/specs/M1-clients/spec-back.md` | § 3.1 diagramme E-R (retirer les 5 colonnes du bloc `client`) ; § 3.2 migration SQL `CREATE TABLE client` (retirer `first_name`/`last_name`/`phone_primary`/`phone_secondary`/`email` + leurs COMMENT) ; § 3.4 squelette entité `Client` (retirer les 5 props) ; § 4.3 exemple payload `POST /api/clients` (retirer les 5 champs) ; § 4.1 filtre `?search=` (refléter D1) ; § 4.6 export (refléter D2) ; § 7 RG (voir § 3 ci-dessous) ; § 8 cahier de tests (déplacer RG-1.01/1.02 vers ClientContact). |
|
||||
| `docs/specs/M1-clients/spec-front.md` | « Formulaire principal » (l.85-103) : retirer les lignes Nom/Prénom/Téléphone/Téléphone 2/Email ; écrans Consultation / Modification ; règles de formatage. Préciser que les coordonnées se saisissent dans l'onglet Contact. |
|
||||
| `docs/specs/M1-clients/cahier-test-back-M1.md` | Retirer / requalifier les lignes RG-1.01 et RG-1.02 (désormais couvertes par RG-1.05 sur `ClientContact`). |
|
||||
|
||||
## 3. Traitement des règles de gestion
|
||||
|
||||
- **RG-1.01** (firstName OU lastName obligatoire sur Client) → marquer **supprimée** :
|
||||
« Remplacée par RG-1.05 (≥ 1 contact valide) + RG-1.14 (≥ 1 bloc Contact). Le contact
|
||||
principal inline n'existe plus. »
|
||||
- **RG-1.02** (max 2 téléphones sur Client) → marquer **supprimée du Client** (reste
|
||||
applicable aux blocs `ClientContact`).
|
||||
- **RG-1.19 / RG-1.20 / RG-1.21** (normalisation) → préciser que le **scope `Client`
|
||||
disparaît** ; la normalisation reste sur `ClientContact` (et `ClientAddress.billingEmail`
|
||||
pour RG-1.21).
|
||||
- Ne **pas renuméroter** les RG existantes (éviter le drift avec le code/tests) : marquer
|
||||
« supprimée / requalifiée » en place, avec date et renvoi à la décision.
|
||||
|
||||
## 4. Forme
|
||||
|
||||
- Bumper la version des deux specs (`version: V0` → `V1`) dans le frontmatter, avec une
|
||||
entrée d'historique : date `2026-06-03`, motif « Suppression du contact inline du Client
|
||||
(refonte-contact) », auteur.
|
||||
- Ajouter un encadré « Décision » en tête de la section modèle de données, renvoyant au
|
||||
`README.md` du dossier `refonte-contact`.
|
||||
- Conserver le style des specs (sections numérotées, tableaux RG, exemples JSON).
|
||||
|
||||
## 5. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] `spec-back.md` : aucune mention des 5 colonnes inline dans le modèle `client`
|
||||
(E-R + SQL + entité + payload) ; RG-1.01/1.02 marquées supprimées ; D1/D2 décrites.
|
||||
- [ ] `spec-front.md` : le formulaire principal ne liste plus les champs de contact ;
|
||||
l'onglet Contact est présenté comme seul lieu de saisie des coordonnées.
|
||||
- [ ] `cahier-test-back-M1.md` : RG-1.01/1.02 retirées/requalifiées.
|
||||
- [ ] Versions bumpées (V1) + historique daté dans les deux specs.
|
||||
- [ ] Cohérence vérifiée avec les tickets 1 et 2 (mêmes décisions D1/D2).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Prompt d'implémentation — M1 · Ticket 3/3 (Specs)
|
||||
|
||||
Projet **Starseed**. Tâche **documentaire** : mettre à jour les specs M1 Clients pour acter
|
||||
la suppression du contact principal inline du `Client`. Les specs sont la source de vérité ;
|
||||
elles doivent décrire le modèle cible.
|
||||
|
||||
## Mission
|
||||
|
||||
Modifier `docs/specs/M1-clients/spec-back.md`, `spec-front.md` et `cahier-test-back-M1.md`
|
||||
pour retirer le contact inline du `Client` (5 champs `firstName/lastName/phonePrimary/
|
||||
phoneSecondary/email`) — les contacts vivent uniquement dans `ClientContact`.
|
||||
|
||||
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.md` (lis-la + le
|
||||
`README.md` du dossier, qui contient la décision, les RG impactées et les décisions D1/D2).
|
||||
|
||||
## Étapes
|
||||
|
||||
1. Lire les 3 fichiers de specs M1 visés, repérer toutes les occurrences des 5 champs
|
||||
(diagramme E-R, CREATE TABLE client, squelette entité, payload POST, filtre search,
|
||||
export, RG, cahier de test).
|
||||
2. Retirer les 5 colonnes du modèle `client` (E-R + SQL + entité + exemple JSON).
|
||||
3. Marquer **supprimées** RG-1.01 et RG-1.02 (renvoi à RG-1.05/RG-1.14 sur `ClientContact`),
|
||||
restreindre le scope de RG-1.19/1.20/1.21 à `ClientContact`. **Ne pas renuméroter** les RG.
|
||||
4. Refléter les décisions D1 (recherche) et D2 (export) une fois tranchées.
|
||||
5. Côté `spec-front.md` : retirer les champs de contact du formulaire principal ; présenter
|
||||
l'onglet Contact comme seul lieu de saisie.
|
||||
6. Bumper `version: V0 → V1` + ajouter une entrée d'historique datée (2026-06-03).
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- Ne touche pas au code, uniquement aux `.md` de specs.
|
||||
- Garde le style existant (sections numérotées, tableaux RG, exemples JSON).
|
||||
- Cohérence stricte avec les tickets 1 (back) et 2 (front) : mêmes décisions D1/D2.
|
||||
|
||||
## Vérification
|
||||
|
||||
Relire les 3 fichiers : plus aucune mention des 5 champs inline dans le modèle `client` ;
|
||||
RG-1.01/1.02 marquées supprimées ; versions à V1 avec historique.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Amendement des tickets M2 existants — suppression du contact inline du `Supplier`
|
||||
|
||||
Les 14 tickets M2 (n° 84–97, groupe Lesstime « M2 — Répertoire fournisseurs ») ont été
|
||||
rédigés sur le modèle initial **avec** contact inline. La décision `refonte-contact` les
|
||||
amende : `Supplier` ne porte **plus** les 5 champs `firstName/lastName/phonePrimary/
|
||||
phoneSecondary/email` ; les contacts vivent uniquement dans `SupplierContact` (onglet
|
||||
Contacts). Comme M2 n'est pas codé, il suffit de **ne jamais créer** ces colonnes/champs.
|
||||
|
||||
## Bandeau injecté en tête des tickets impactés
|
||||
|
||||
> ⚠️ **AMENDEMENT 2026-06-03 — refonte-contact.** Le contact principal inline est
|
||||
> **supprimé** du `Supplier` : ne pas créer/saisir les colonnes ni les champs `firstName`,
|
||||
> `lastName`, `phonePrimary`, `phoneSecondary`, `email` sur l'entité/le formulaire
|
||||
> `Supplier`. Les contacts sont gérés **uniquement** via `SupplierContact` (onglet
|
||||
> Contacts). RG-2.01 et RG-2.02 sont supprimées (équivalent assuré par RG-2.04 / RG-2.13).
|
||||
> RG-2.12 ne s'applique qu'à `companyName` + `SupplierContact`. Décisions transverses
|
||||
> recherche (D1) et export (D2) : cf. `docs/specs/M1-clients/refonte-contact/README.md`.
|
||||
|
||||
## Tickets à amender
|
||||
|
||||
### Back
|
||||
|
||||
| Ticket | n° | Impact |
|
||||
|---|---|---|
|
||||
| migration BDD M2 (supplier + sous-collections) | #85 | Retirer `first_name/last_name/phone_primary/phone_secondary/email` du `CREATE TABLE supplier` et leurs `COMMENT ON COLUMN`. `supplier_contact` inchangé. |
|
||||
| entités + repositories M2 | #86 | `Supplier` : retirer les 5 props + `Assert\Callback` RG-2.01. `SupplierContact` inchangé. |
|
||||
| SupplierProvider + SupplierProcessor | #87 | Retirer la validation RG-2.01, la normalisation des champs inline, leur présence dans `MAIN_FIELDS` / changedFields. Recherche selon D1. |
|
||||
| export XLSX fournisseurs | #91 | Colonnes contact selon D2 (depuis le contact principal, ou supprimées). |
|
||||
| tests PHPUnit M2 | #92 | RG-2.01/2.02 testées sur `SupplierContact` (pas `Supplier`) ; contrat de sérialisation sans les 5 champs inline sur le supplier. |
|
||||
|
||||
### Front
|
||||
|
||||
| Ticket | n° | Impact |
|
||||
|---|---|---|
|
||||
| page Ajouter un fournisseur (`/suppliers/new`) + `useSupplierForm` | #94 | Retirer le bloc contact principal du formulaire + le pré-remplissage du 1er contact. Saisie des coordonnées dans l'onglet Contacts. |
|
||||
| page Consultation fournisseur (`/suppliers/{id}`) | #95 | Retirer l'affichage du bloc contact principal. |
|
||||
| page Modification fournisseur (`/suppliers/{id}/edit`) | #96 | Retirer les 5 champs du bloc principal ; payload `supplier:write:main` sans ces champs. |
|
||||
|
||||
### Léger
|
||||
|
||||
| Ticket | n° | Impact |
|
||||
|---|---|---|
|
||||
| page Répertoire fournisseurs + datatable | #93 | Recherche « nom / contact / email » selon D1. Datatable : colonnes inchangées (pas de contact inline en colonne). |
|
||||
| i18n + sidebar fournisseurs | #97 | Ne pas créer les clés i18n `form.main.firstName/lastName/email/phone*` (garder `form.contact.*`). |
|
||||
|
||||
## Tickets NON impactés
|
||||
|
||||
- #84 (taxonomie FOURNISSEUR), #88 (sous-ressources contacts/adresses/ribs —
|
||||
`SupplierContact` est la cible, inchangé), #89 (validators Information Commerciale /
|
||||
catégorie / RG-2.07-2.08), #90 (RBAC fournisseurs).
|
||||
|
||||
## Méthode d'amendement
|
||||
|
||||
Pour chaque ticket impacté : **préfixer** la description existante du bandeau ci-dessus
|
||||
(sans rien supprimer du contenu d'origine), via `mcp__lesstime__update-task`
|
||||
(`description` = bandeau + description actuelle). La méthode préserve l'historique et reste
|
||||
réversible (retirer le bandeau).
|
||||
@@ -0,0 +1,55 @@
|
||||
# M2 · Ticket Specs — Retirer le contact inline du `Supplier` dans les specs M2
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Mettre à jour les specs **M2 Fournisseurs** déjà rédigées pour **ne plus inclure** le contact
|
||||
principal inline sur le `Supplier`. M2 est le jumeau strict de M1 (`Supplier` /
|
||||
`SupplierContact` / `SupplierAddress` / `SupplierRib`) et n'est **pas encore codé** : il faut
|
||||
donc corriger la conception **en amont**, pour que les 14 tickets M2 « prêts à dev » soient
|
||||
implémentés directement sans les 5 colonnes inline.
|
||||
|
||||
> Pendant de M1 ticket 3/3, mais côté M2 : **aucune migration de suppression ni backfill** —
|
||||
> on retire simplement le contact inline du modèle cible. Contexte : `README.md` du dossier
|
||||
> `refonte-contact`.
|
||||
|
||||
## 2. Fichiers à modifier
|
||||
|
||||
| Fichier | Sections concernées |
|
||||
|---|---|
|
||||
| `docs/specs/M2-suppliers/spec-back.md` | § 3.1 diagramme E-R (l.175-179 : retirer les 5 colonnes du bloc `supplier`) ; § 3.2 `CREATE TABLE supplier` (l.227-231) ; § 3.4 squelette entité `Supplier` (l.496-517 : props + `Assert\Callback` RG-2.01) ; § 4 exemples payload POST/GET (l.782-805, 867-871) ; recherche `?search=` (l.847 : refléter D1) ; export (refléter D2) ; § contrat de sérialisation (l.725, 729) ; § 7 RG (voir § 3). |
|
||||
| `docs/specs/M2-suppliers/spec-front.md` | « Formulaire principal » (l.105-117 : retirer Nom/Prénom/Téléphone/Téléphone 2/Email) ; onglet « Contact » (l.140-157 : retirer la phrase de pré-remplissage depuis le formulaire principal, l.142) ; écrans Consultation/Modification ; règles de formatage (l.283-285) ; recherche (l.76 : refléter D1). |
|
||||
|
||||
## 3. Traitement des règles de gestion M2
|
||||
|
||||
- **RG-2.01** (firstName OU lastName obligatoire sur Supplier) → **supprimée** : remplacée
|
||||
par RG-2.04 (≥ 1 contact valide) + RG-2.13 (≥ 1 bloc Contact). Le contact inline n'existe
|
||||
plus sur `Supplier`.
|
||||
- **RG-2.02** (max 2 téléphones sur Supplier) → **supprimée du Supplier** (reste sur
|
||||
`SupplierContact`).
|
||||
- **RG-2.12** (normalisation Capitalize / chiffres / lowercase) → restreindre le scope :
|
||||
s'applique à `companyName` (UPPERCASE) et aux champs de `SupplierContact` ; **plus** aux
|
||||
champs inline du `Supplier` (qui disparaissent).
|
||||
- Ne pas renuméroter les RG : marquer « supprimée / requalifiée » en place, avec date.
|
||||
|
||||
## 4. Forme
|
||||
|
||||
- Bumper la version des deux specs M2 + entrée d'historique datée (2026-06-03, motif
|
||||
« Suppression du contact inline du Supplier — alignement refonte-contact M1 »).
|
||||
- Encadré « Décision » renvoyant au `README.md` du dossier `refonte-contact`.
|
||||
- Garder le style des specs M2.
|
||||
|
||||
## 5. Lien avec les tickets M2 existants
|
||||
|
||||
La mise à jour des specs doit être cohérente avec l'**amendement des tickets M2** (voir
|
||||
`M2-amendement-tickets.md`) : tickets back #85/#86/#87/#91/#92 et front #94/#95/#96 (+ #93/#97
|
||||
légers). Specs et tickets décrivent le **même** modèle cible (sans contact inline).
|
||||
|
||||
## 6. Critères d'acceptation (DoD)
|
||||
|
||||
- [ ] `spec-back.md` M2 : aucune mention des 5 colonnes inline dans le modèle `supplier`
|
||||
(E-R + SQL + entité + payloads + sérialisation) ; RG-2.01/2.02 marquées supprimées ;
|
||||
D1/D2 décrites.
|
||||
- [ ] `spec-front.md` M2 : formulaire principal sans champs de contact ; onglet Contact
|
||||
présenté comme seul lieu de saisie (sans pré-remplissage depuis le principal).
|
||||
- [ ] Versions bumpées + historique daté.
|
||||
- [ ] Cohérence avec l'amendement des tickets M2.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Prompt d'implémentation — M2 · Ticket Specs
|
||||
|
||||
Projet **Starseed**. Tâche **documentaire**. Mettre à jour les specs M2 Fournisseurs
|
||||
(`docs/specs/M2-suppliers/spec-back.md` + `spec-front.md`) pour retirer le contact principal
|
||||
inline du `Supplier` (5 champs `firstName/lastName/phonePrimary/phoneSecondary/email`).
|
||||
|
||||
M2 n'est **pas encore codé** : on corrige la conception en amont, **sans** migration ni
|
||||
backfill (contrairement à M1). Les contacts vivent uniquement dans `SupplierContact`.
|
||||
|
||||
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M2-ticket-specs.md` (lis-la + le
|
||||
`README.md` du dossier).
|
||||
|
||||
## Étapes
|
||||
|
||||
1. Lire `spec-back.md` et `spec-front.md` M2 ; repérer toutes les occurrences des 5 champs
|
||||
(E-R l.175-179, CREATE TABLE supplier l.227-231, entité l.496-517, payloads l.782-805 /
|
||||
867-871, sérialisation l.725-729, RG-2.01/2.02/2.12, recherche, export, formulaire
|
||||
principal front l.105-117, pré-remplissage onglet Contact l.142).
|
||||
2. Retirer les 5 colonnes du modèle `supplier`.
|
||||
3. Marquer **supprimées** RG-2.01 et RG-2.02 (renvoi RG-2.04/RG-2.13) ; restreindre RG-2.12
|
||||
à `companyName` + `SupplierContact`. Ne pas renuméroter.
|
||||
4. Refléter D1 (recherche : LEFT JOIN supplier_contact recommandé) et D2 (export depuis le
|
||||
contact principal recommandé).
|
||||
5. Front : retirer les champs de contact du formulaire principal ; retirer la phrase de
|
||||
pré-remplissage du 1er bloc Contact ; présenter l'onglet Contact comme seul lieu de saisie.
|
||||
6. Bumper la version + historique daté (2026-06-03).
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- Uniquement les `.md` de specs M2. Style existant conservé.
|
||||
- Cohérence stricte avec l'amendement des tickets M2 et avec la décision M1 (jumeau).
|
||||
|
||||
## Vérification
|
||||
|
||||
Relire les 2 specs : plus aucune mention des 5 champs inline dans le modèle `supplier` ;
|
||||
RG-2.01/2.02 supprimées ; versions bumpées.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Refonte « contact » — suppression du contact inline des tiers (Client M1 + Supplier M2)
|
||||
|
||||
> Dossier de tickets transverse. Source de vérité de la décision et de son découpage.
|
||||
> Rédigé le 2026-06-03. Owner : Matthieu.
|
||||
|
||||
## 1. Décision
|
||||
|
||||
Le **contact « principal » inline** (les 5 colonnes plates `first_name`, `last_name`,
|
||||
`phone_primary`, `phone_secondary`, `email`) est **supprimé de l'entité tier** (`Client`,
|
||||
puis `Supplier`). La gestion des contacts passe **exclusivement** par la sous-entité
|
||||
dédiée (`ClientContact` / `SupplierContact`), c.-à-d. l'**onglet « Contacts »**.
|
||||
|
||||
### Pourquoi
|
||||
|
||||
- **Modèle unique, zéro duplication.** Aujourd'hui le contact est saisi deux fois : une
|
||||
fois dans le bloc principal (inline sur le tier) et une fois dans l'onglet Contacts
|
||||
(sous-entité). À la création, le front recopie même l'un dans l'autre
|
||||
(`prefillFirstContact`). Deux sources pour la même information = risque de divergence.
|
||||
- **Cohérence métier.** Un tier peut avoir plusieurs contacts ; il n'y a pas de raison
|
||||
qu'un seul soit « privilégié » au niveau de la table tier. La notion de contact
|
||||
appartient à la collection de contacts.
|
||||
- **Garantie préservée.** L'invariant « il y a toujours au moins un contact » est déjà
|
||||
assuré par la sous-entité : RG-1.05/RG-1.14 (M1) et RG-2.04/RG-2.13 (M2) imposent
|
||||
**≥ 1 bloc Contact valide** (Nom OU Prénom). Supprimer le contact inline ne crée donc
|
||||
aucun trou : le contact reste obligatoire, mais au bon endroit.
|
||||
|
||||
### Règles de gestion impactées
|
||||
|
||||
| RG | Avant | Après |
|
||||
|---|---|---|
|
||||
| RG-1.01 / RG-2.01 (firstName OU lastName obligatoire **sur le tier**) | sur `Client` / `Supplier` | **supprimée** du tier — équivalent assuré par RG-1.05 / RG-2.04 sur la sous-entité |
|
||||
| RG-1.02 / RG-2.02 (max 2 téléphones **sur le tier**) | sur le tier | **supprimée** du tier — reste sur la sous-entité |
|
||||
| RG-1.19/1.20/1.21 — RG-2.12 (normalisation Capitalize / chiffres / lowercase) | appliquée aux champs **du tier ET** de la sous-entité | ne s'applique plus aux champs du tier (qui n'existent plus) — **inchangée** sur la sous-entité |
|
||||
|
||||
## 2. Périmètre & découpage
|
||||
|
||||
### M1 — Clients (code DÉJÀ livré → suppression + migration de données)
|
||||
|
||||
| # | Ticket | Tag | Effort |
|
||||
|---|--------|-----|--------|
|
||||
| 1 | `M1-ticket-01-back` — supprimer le contact inline du `Client` (migration + backfill + entité + processor + provider + export + fixtures + tests) | Backend | M |
|
||||
| 2 | `M1-ticket-02-front` — retirer le bloc contact principal des écrans création / consultation / modification | Frontend | M |
|
||||
| 3 | `M1-ticket-03-specs` — acter la décision dans les specs M1 (back + front + cahier de test) | Maintenance | S |
|
||||
|
||||
### M2 — Fournisseurs (NON codé → on retire le contact inline dès la conception)
|
||||
|
||||
| # | Action | Tag | Effort |
|
||||
|---|--------|-----|--------|
|
||||
| 4 | `M2-ticket-specs` — mettre à jour les specs M2 déjà écrites (back + front) pour retirer le contact inline du `Supplier` | Maintenance | S |
|
||||
| — | `M2-amendement-tickets` — amender les tickets M2 existants (n° 84–97) impactés (migration, entités, processor, export, front, tests, i18n) | — | — |
|
||||
|
||||
> M2 ne nécessite **pas** de migration de suppression ni de backfill : il suffit de **ne
|
||||
> jamais créer** les 5 colonnes inline sur `supplier`. Le travail M2 est donc un
|
||||
> ajustement de specs + un amendement des tickets « prêts à dev ».
|
||||
|
||||
## 3. Décisions transverses à trancher (mêmes pour M1 et M2)
|
||||
|
||||
Deux comportements s'appuyaient sur les colonnes inline du tier. À la suppression, il faut
|
||||
choisir leur nouvelle source. Recommandation par défaut entre parenthèses.
|
||||
|
||||
- **D1 — Recherche serveur** (`?search=`). Aujourd'hui : fuzzy sur `companyName` +
|
||||
`lastName` + `email` **du tier**. Après suppression, deux options :
|
||||
- (a) restreindre la recherche à `companyName` seul (simple, mais perte de la recherche
|
||||
par contact) ;
|
||||
- (b) **[recommandé]** étendre la recherche en `LEFT JOIN` sur la sous-entité contact
|
||||
(`first_name` / `last_name` / `email` du contact), pour préserver l'UX « recherche par
|
||||
nom / contact / email » annoncée dans la barre de recherche.
|
||||
- **D2 — Colonnes de l'export XLSX** (Nom contact / Prénom / Téléphone / Téléphone 2 /
|
||||
Email). Après suppression :
|
||||
- (a) supprimer ces colonnes ;
|
||||
- (b) **[recommandé]** les alimenter depuis le **contact principal** (le contact de plus
|
||||
petit `position`), pour garder un export utile.
|
||||
|
||||
Ces deux décisions sont à valider par le métier (Matthieu) avant implémentation et sont
|
||||
rappelées dans chaque ticket concerné.
|
||||
|
||||
## 4. Fichiers de ce dossier
|
||||
|
||||
- `README.md` (ce fichier) — décision + découpage.
|
||||
- `M1-ticket-01-back.md` / `.prompt.md` — description + prompt d'implémentation.
|
||||
- `M1-ticket-02-front.md` / `.prompt.md`.
|
||||
- `M1-ticket-03-specs.md` / `.prompt.md`.
|
||||
- `M2-ticket-specs.md` / `.prompt.md`.
|
||||
- `M2-amendement-tickets.md` — bandeau d'amendement + liste des tickets M2 à mettre à jour.
|
||||
@@ -5,8 +5,11 @@ nom: "Répertoire clients"
|
||||
ecran: repertoire-clients
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0
|
||||
version: V1
|
||||
date_redaction: 2026-05-28
|
||||
# Historique : V1 (2026-06-03) — Refonte contact : suppression du contact principal inline
|
||||
# du Client (firstName/lastName/phonePrimary/phoneSecondary/email retirés de la table client).
|
||||
# Les contacts vivent uniquement dans ClientContact. Cf. docs/specs/M1-clients/refonte-contact/README.md
|
||||
|
||||
# === LIENS ===
|
||||
spec_front: ./spec-front.md
|
||||
@@ -203,11 +206,11 @@ Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter V
|
||||
| | +-----------------------+ | (Catalog) |
|
||||
| id (PK) | +--------------+
|
||||
| company_name |
|
||||
| first_name | +-----------------------+ +--------------+
|
||||
| last_name |--1:n-->| client_contact | | site |
|
||||
| phone_primary | +-----------------------+ | (Sites) |
|
||||
| phone_secondary | +--------------+
|
||||
| email | +-----------------------+ ^
|
||||
| (contact inline | +-----------------------+ +--------------+
|
||||
| retiré V1 — |--1:n-->| client_contact | | site |
|
||||
| firstName, | +-----------------------+ | (Sites) |
|
||||
| lastName, phones,| +--------------+
|
||||
| email) | +-----------------------+ ^
|
||||
| distributor_id |--1:n-->| client_address |--n:m---------+
|
||||
| broker_id | +-----------------------+
|
||||
| triage_service | |
|
||||
@@ -302,11 +305,8 @@ CREATE TABLE client (
|
||||
id SERIAL PRIMARY KEY,
|
||||
-- Formulaire principal
|
||||
company_name VARCHAR(180) NOT NULL,
|
||||
first_name VARCHAR(120),
|
||||
last_name VARCHAR(120),
|
||||
phone_primary VARCHAR(20) NOT NULL,
|
||||
phone_secondary VARCHAR(20),
|
||||
email VARCHAR(180) NOT NULL,
|
||||
-- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary /
|
||||
-- phone_secondary / email vivent désormais uniquement dans client_contact (onglet Contacts).
|
||||
distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
|
||||
broker_id INT REFERENCES client(id) ON DELETE SET NULL,
|
||||
triage_service BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
@@ -580,32 +580,9 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
// RG-1.01 — first_name OU last_name obligatoire (validation Assert\Callback
|
||||
// au niveau de l'entite, levee dans le Processor).
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $phonePrimary = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $phoneSecondary = null;
|
||||
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $email = null;
|
||||
// Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary /
|
||||
// phoneSecondary / email ne sont plus portés par Client — ils vivent dans ClientContact
|
||||
// (onglet Contacts). La garantie « ≥ 1 contact nommé » est portée par RG-1.05 + RG-1.14.
|
||||
|
||||
// RG-1.03 — distributor / broker auto-references (mutuellement exclusives,
|
||||
// contrainte CHECK en base).
|
||||
@@ -749,7 +726,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
- **Query params** :
|
||||
- `includeArchived=true|false` (default `false`)
|
||||
- `categoryCode=<code>` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`)
|
||||
- `search=<text>` (recherche fuzzy sur companyName + lastName + email)
|
||||
- `search=<text>` (recherche fuzzy sur companyName + contacts liés `client_contact` (firstName / lastName / email) via LEFT JOIN groupé par `client.id` — décision D1, refonte-contact)
|
||||
- **Tri par défaut** : `companyName ASC`
|
||||
- **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible). Pas de pagination serveur au M1.
|
||||
- **Réponse 200** (JSON-LD Hydra) : items avec champs `client:read` UNIQUEMENT (pas les champs `client:read:accounting` sauf si l'user a la permission `accounting.view`).
|
||||
@@ -768,10 +745,6 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
```json
|
||||
{
|
||||
"companyName": "ACME SAS",
|
||||
"firstName": "Jean",
|
||||
"lastName": "Dupont",
|
||||
"phonePrimary": "0612345678",
|
||||
"email": "jean.dupont@acme.fr",
|
||||
"categories": ["/api/categories/3", "/api/categories/7"],
|
||||
"distributor": null,
|
||||
"broker": null,
|
||||
@@ -783,7 +756,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
- `201` / `400` / `401` / `403`
|
||||
- `409 Conflict` si doublon de nom de société (`companyName` — RG-1.16). SIREN et email ne sont pas uniques (cf. Q4, § 2.4).
|
||||
- `422 Unprocessable Entity` :
|
||||
- RG-1.01 : ni firstName ni lastName
|
||||
- (RG-1.01 supprimée V1 — la complétude du contact est portée par l'onglet Contacts : RG-1.05 / RG-1.14)
|
||||
- RG-1.03 : distributor + broker remplis simultanément
|
||||
- Catégories vides (Assert\Count min=1)
|
||||
|
||||
@@ -885,8 +858,8 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
|
||||
### Formulaire principal
|
||||
|
||||
- **RG-1.01** : Au moins l'un des champs `firstName` (Prénom du contact principal) ou `lastName` (Nom du contact principal) doit être renseigné. Sinon → 422.
|
||||
- **RG-1.02** : Le champ `phoneSecondary` est optionnel et apparaît au clic sur un bouton `+` côté front. Maximum 2 téléphones (primary + secondary). Comportement purement front au niveau UI ; côté serveur, les 2 colonnes existent et sont distinctes.
|
||||
- ~~**RG-1.01**~~ _(SUPPRIMÉE — V1, 2026-06-03, refonte-contact)_ : le contact principal inline est retiré du `Client`. La garantie « au moins un contact nommé » est désormais portée par **RG-1.05** (bloc Contact valide) + **RG-1.14** (≥ 1 bloc Contact) sur `ClientContact`.
|
||||
- ~~**RG-1.02**~~ _(SUPPRIMÉE du Client — V1, refonte-contact)_ : plus de téléphones inline sur le `Client`. Le « maximum 2 téléphones » reste applicable aux blocs `ClientContact` (normalisation RG-1.20).
|
||||
- **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. Un `distributor` référencé doit porter une **`Category` de code `DISTRIBUTEUR`** ; un `broker` une **`Category` de code `COURTIER`** — sinon 422. _(Refonte ERP-78 : le filtrage se fait sur le `code` de la `Category`, plus sur le type — `ClientProcessor::hasCategoryCode`.)_ La liste front de `distributor` = clients ayant une catégorie de code `DISTRIBUTEUR`, via `GET /api/clients?categoryCode=DISTRIBUTEUR` ; idem `broker` avec `COURTIER`.
|
||||
|
||||
### Onglet Information
|
||||
@@ -929,9 +902,9 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
### Normalisation serveur (formatage)
|
||||
|
||||
- **RG-1.18** : `companyName` est **upper-cased** intégralement côté serveur avant validation et persistance (`mb_strtoupper(trim($v), 'UTF-8')`). Le client n'a pas besoin de saisir en majuscules ; la BDD stocke en majuscules.
|
||||
- **RG-1.19** : `firstName`, `lastName` (sur `Client` et `ClientContact`) sont **capitalize**-és serveur (`mb_convert_case(trim($v), MB_CASE_TITLE, 'UTF-8')`). Exemple : `JEAN dupont` → `Jean Dupont`.
|
||||
- **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `Client`, et idem sur `ClientContact`) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front).
|
||||
- **RG-1.21** : `email` (`Client.email`, `ClientAddress.billingEmail`, `ClientContact.email`) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`).
|
||||
- **RG-1.19** : `firstName`, `lastName` (sur `ClientContact` ; scope `Client` retiré en V1) sont **capitalize**-és serveur (`mb_convert_case(trim($v), MB_CASE_TITLE, 'UTF-8')`). Exemple : `JEAN dupont` → `Jean Dupont`.
|
||||
- **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `ClientContact` ; scope `Client` retiré en V1) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front).
|
||||
- **RG-1.21** : `email` (`ClientAddress.billingEmail`, `ClientContact.email` ; `Client.email` retiré en V1) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`).
|
||||
|
||||
### Archivage
|
||||
|
||||
@@ -960,8 +933,8 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
|
||||
### 8.1 Cas à couvrir (back — PHPUnit)
|
||||
|
||||
- [ ] **RG-1.01** : POST sans firstName ni lastName → 422
|
||||
- [ ] **RG-1.02** : POST avec phoneSecondary rempli → persistance OK ; PATCH ajoutant un 3e téléphone → côté API, 2 colonnes uniquement (test que le payload ne peut pas créer un 3e)
|
||||
- [ ] ~~RG-1.01~~ _(supprimée V1)_ : la complétude du contact est couverte par RG-1.05 / RG-1.14 sur `ClientContact`
|
||||
- [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`)
|
||||
- [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201
|
||||
- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`)
|
||||
- [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200
|
||||
@@ -975,9 +948,9 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
- [ ] **RG-1.14** : front-driven uniquement, pas de test back
|
||||
- [ ] **RG-1.16** : POST avec `companyName` déjà pris → 409 ; POST avec même `companyName` après archivage de l'ancien → 201. SIREN et email dupliqués → 201 (plus d'unicité — RG-1.15/1.17 supprimées, Q4).
|
||||
- [ ] **RG-1.18** : POST `companyName="acme sas"` → BDD persiste `"ACME SAS"`
|
||||
- [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` → persiste `"Jean"`, `"Dupont"`
|
||||
- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` → persiste `"0612345678"`
|
||||
- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` → persiste `"jean.dupont@acme.fr"`
|
||||
- [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` (via un bloc `ClientContact`) → persiste `"Jean"`, `"Dupont"`
|
||||
- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` (via un bloc `ClientContact`) → persiste `"0612345678"`
|
||||
- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` (via `ClientContact` ou `ClientAddress.billingEmail`) → persiste `"jean.dupont@acme.fr"`
|
||||
- [ ] **RG-1.22/23** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; PATCH isArchived=false sur un client archivé dont le SIREN a été repris → 409
|
||||
- [ ] **RG-1.24/25** : GET liste sans flag → exclut archivés ; avec `?includeArchived=true` → inclut
|
||||
- [ ] **RG-1.26** : GET liste → tri companyName ASC
|
||||
|
||||
@@ -5,7 +5,10 @@ nom: "Répertoire clients"
|
||||
ecran: repertoire-clients
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0
|
||||
version: V1
|
||||
# Historique : V1 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
|
||||
# (Nom/Prénom/Téléphone/Téléphone 2/Email retirés du formulaire principal et des écrans).
|
||||
# Saisie via l'onglet Contacts uniquement. Cf. docs/specs/M1-clients/refonte-contact/README.md
|
||||
date_redaction: 2026-05-28
|
||||
|
||||
# === LIENS ===
|
||||
@@ -68,9 +71,6 @@ Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maqu
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom entreprise** | `client.companyName` | ASC par défaut |
|
||||
| **Contact principal** | `firstName + lastName` | Oui |
|
||||
| **Téléphone principal** | `phonePrimary` (formaté `XX XX XX XX XX`) | Non |
|
||||
| **Email principal** | `email` | Oui |
|
||||
| **Catégories** | liste des codes catégories séparés par `,` | Non |
|
||||
| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
|
||||
|
||||
@@ -86,15 +86,12 @@ Création par **onglets successifs avec validation incrémentale** : pour pouvoi
|
||||
|
||||
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
|
||||
|
||||
> **V1 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent désormais dans l'onglet **Contacts** (RG-1.05 / RG-1.14). Le formulaire principal ne contient plus que Entreprise + Catégorie + relation Distributeur/Courtier.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
|
||||
| **Nom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Prénom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
|
||||
| **Téléphone principal** | `<MalioInputText>` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) |
|
||||
| **Téléphone secondaire** | `<MalioInputText>` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. |
|
||||
| **Email** | `<MalioInputText>` type email | Oui | RG-1.21 (lowercase) |
|
||||
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
|
||||
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de **code** `DISTRIBUTEUR` (ERP-78), via `GET /api/clients?categoryCode=DISTRIBUTEUR`. RG-1.03. |
|
||||
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de **code** `COURTIER` (ERP-78), via `GET /api/clients?categoryCode=COURTIER`. RG-1.03. |
|
||||
@@ -120,7 +117,7 @@ Saisir les informations de l'entreprise.
|
||||
|
||||
### Onglet « Contact »
|
||||
|
||||
Saisir un ou plusieurs contacts associés au client. Le 1er bloc est **pré-rempli** depuis les champs du formulaire principal (Nom, Prénom, Téléphone, Email — édition autorisée).
|
||||
Saisir un ou plusieurs contacts associés au client. **(V1 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-1.14).
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
@@ -250,7 +247,7 @@ Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.
|
||||
|---|---|---|
|
||||
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
|
||||
| Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
|
||||
| Téléphone (téléphones des blocs `ClientContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
|
||||
> **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`.
|
||||
@@ -261,7 +258,8 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.
|
||||
|
||||
- Composable dédié `useAddressAutocomplete()` (à créer en M1).
|
||||
- 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.
|
||||
|
||||
## Points laissés ouverts par la V0 (résolus côté back)
|
||||
@@ -275,7 +273,7 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.
|
||||
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
|
||||
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
|
||||
| 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. |
|
||||
| 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. |
|
||||
| 8 | Téléphones (max 2) | Sur les blocs `ClientContact` (`phone_primary` + `phone_secondary`). _(V1 : retirés du Client — refonte-contact.)_ |
|
||||
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. |
|
||||
| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). |
|
||||
| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,331 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M2
|
||||
nom: "Répertoire fournisseurs"
|
||||
ecran: repertoire-fournisseurs
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.2
|
||||
date_redaction: 2026-06-02
|
||||
# Historique : V0.2 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
|
||||
# du formulaire Supplier (Nom/Prénom/Téléphone/Téléphone 2/Email). Saisie via l'onglet Contacts.
|
||||
# Aligné sur M1. Cf. docs/specs/M1-clients/refonte-contact/README.md
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev"
|
||||
regles_metier: [RG-2.01, RG-2.02, RG-2.03, RG-2.04, RG-2.05, RG-2.06, RG-2.07, RG-2.08, RG-2.09, RG-2.10, RG-2.11, RG-2.12, RG-2.13, RG-2.14, RG-2.15, RG-2.16, RG-2.17]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-22
|
||||
version: V0
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
client_validation_2:
|
||||
statut: validee
|
||||
date: 2026-06-01
|
||||
version: V0.1
|
||||
valide_par: "Matthieu (CP MALIO)"
|
||||
resume: "Module 2 — Répertoire fournisseurs. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders 'À venir')."
|
||||
trace_archivee: "uploads/M2-reportoire-fournisseurs.docx (V0.1) + M2-reportoire-fournisseurs-V01.pdf"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 26
|
||||
lesstime_project_id: 6
|
||||
statut_global: a_dev
|
||||
---
|
||||
|
||||
# Module 2 — Répertoire fournisseurs (V0.1 front)
|
||||
|
||||
> **Origine** : spec front livrée le 22/05/2026 (V0), amendée le 01/06/2026 (V0.1) — `M2-reportoire-fournisseurs.docx`. Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié ; toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M2 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md).
|
||||
|
||||
## But
|
||||
|
||||
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des fournisseurs** de l'organisation : consultation, création, modification, archivage. C'est la **deuxième porte d'entrée du module Commercial** (aux côtés des Clients).
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire fournisseurs » (route `/suppliers`).
|
||||
- **Rôles autorisés** :
|
||||
|
||||
| Rôle | Consultation | Création / Modification | Archivage |
|
||||
|---|---|---|---|
|
||||
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
|
||||
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
|
||||
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
|
||||
| **Usine** | ❌ (pas d'accès) | ❌ | ❌ |
|
||||
|
||||
> **Note** : RBAC identique au M1, transposée sur `commercial.suppliers.*`. Compta édite uniquement l'onglet Comptabilité (SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) d'un fournisseur existant ; Compta ne peut pas **créer** un fournisseur. **L'archivage est réservé à Admin** (cf. tableau du docx).
|
||||
|
||||
## Navigation
|
||||
|
||||
Page d'entrée du module **Commercial** (route `/suppliers`). Titre : « **Répertoire fournisseurs** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les fournisseurs **actifs** (les archivés sont masqués par défaut — toggle UI dédié).
|
||||
- **Clic sur une ligne** → écran **Consultation fournisseur** (page dédiée).
|
||||
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un fournisseur**.
|
||||
- **Bouton « Filtrer »** (haut droite, **à côté de « + Ajouter »**) → ouvre le **panneau de filtres** (cf. ci-dessous). Un badge/compteur indique le nombre de filtres actifs ; un bouton « Réinitialiser » les vide.
|
||||
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des fournisseurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
|
||||
|
||||
### Panneau de filtres (bouton « Filtrer »)
|
||||
|
||||
Ouvre un drawer/popover (composant à confirmer côté équipe front — réutiliser le pattern M1 s'il existe). Filtres proposés, branchés sur les query params de `GET /api/suppliers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
|
||||
|
||||
| Filtre | Composant | Query param back |
|
||||
|---|---|---|
|
||||
| **Recherche** (nom entreprise / contact / email — recherche contact via `supplier_contact`, décision D1) | `<MalioInputText>` | `?search=` |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type FOURNISSEUR) | `?categoryCode=` |
|
||||
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
|
||||
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
|
||||
|
||||
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/suppliers` avec les params.
|
||||
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6). Le bouton « Filtrer » + son panneau remplacent/regroupent l'ancien toggle « archivés » isolé.
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Supplier>({ url: '/suppliers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes **conformes à la maquette Figma** (4 colonnes) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom** | `supplier.companyName` | ASC par défaut |
|
||||
| **Catégories** | `supplier.categories[].name` (embarquées en liste — cohérence M1/ERP-62 ; libellé = `name`, pas `label`) | Non |
|
||||
| **Site** | `supplier.sites[].name` (agrégat des adresses via `getSites()` ; `Site` n'a pas de `code`) | Non |
|
||||
| **Dernière activité** | `supplier.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `supplier:read` | Oui |
|
||||
|
||||
> **Clic sur une ligne** (texte en bleu lien) → écran Consultation.
|
||||
> **Filtres** : regroupés dans le panneau du bouton « Filtrer » (cf. section précédente), dont l'inclusion des archivés (désactivée par défaut). **État local** (jamais dans l'URL — règle ABSOLUE n°6).
|
||||
> **Pagination** : `<MalioDataTable>` + `usePaginatedList`, options **standard Starseed 10 / 25 / 50 (défaut 10)** — on **n'applique pas** le « Ligne : 20 » de la maquette (décision Matthieu : on reste sur le standard). Tri serveur `companyName ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un fournisseur »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
|
||||
|
||||
**Barre d'onglets en création (5 onglets, conforme maquette)** : `Information` · `Contacts` · `Adresses` · `Transport` · `Comptabilité`. L'onglet `Information` est actif par défaut juste après validation du formulaire principal. Les onglets `Statistiques`, `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification.
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/suppliers`, puis bascule sur l'onglet Information ; les champs passent en readonly.
|
||||
|
||||
> **V0.2 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent dans l'onglet **Contacts** (RG-2.04 / RG-2.13). Le formulaire principal ne contient plus que Entreprise + Catégorie.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du fournisseur (Entreprise)** | `<MalioInputText>` | Oui | RG-2.12 (UPPERCASE serveur) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type FOURNISSEUR** via `GET /api/categories?typeCode=FOURNISSEUR` (RG-2.10). Libellé affiché = `category.name`. ⚠️ Le type + le filtre `?typeCode=` sont **à créer** côté back (n'existent pas en prod — cf. spec-back § 2.4). |
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/suppliers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Information ».
|
||||
|
||||
### Onglet « Information »
|
||||
|
||||
Saisir les informations du fournisseur.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-2.03 (obligatoire rôle Commerciale) |
|
||||
| **Concurrent** | `<MalioInputText>` | Conditionnel | RG-2.03 |
|
||||
| **Date création** (entreprise) | `<input type="date">` (exception Malio — `// TODO migrer`) | Conditionnel | RG-2.03 |
|
||||
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-2.03 |
|
||||
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
|
||||
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-2.03 |
|
||||
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
|
||||
| **Volume Prévisionnel** | `<MalioInputNumber>` | Conditionnel | RG-2.03 (champ spécifique fournisseur) |
|
||||
|
||||
> **Disposition maquette** : 3 colonnes — ligne 1 (Description / Concurrent / Date création), ligne 2 (Nombre de salariés / CA / Dirigeant), ligne 3 (Résultat / Volume Prévisionnel).
|
||||
|
||||
**Action** : « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:information`).
|
||||
|
||||
### Onglet « Contact »
|
||||
|
||||
Saisir un ou plusieurs contacts. **(V0.2 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-2.13).
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-2.12 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-2.12 (lowercase) |
|
||||
|
||||
**RG-2.04 / RG-2.13** : au moins 1 bloc Contact valide (Nom OU Prénom rempli) pour valider l'onglet — l'onglet Contact ne peut pas être finalisé vide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas Prénom OU Nom** (RG-2.04).
|
||||
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
|
||||
- « Valider » → PATCH `/api/suppliers/{id}/contacts`.
|
||||
|
||||
### Onglet « Adresse »
|
||||
|
||||
Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
|
||||
|
||||
**Bloc Adresse** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Type d'adresse** | `<MalioRadioButton>` — `Prospect` / `Départ` / `Rendu` | Oui | RG-2.09 (exclusif, enum `PROSPECT`/`DEPART`/`RENDU`) |
|
||||
| **Pays** | `<MalioSelect>` (saisie assistée — préremplie « France ») | Oui | — |
|
||||
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — déclenche autocomplete ville (BAN) |
|
||||
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-2.05 — alimentée par api-adresse.data.gouv.fr suivant le CP |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — autocomplete BAN |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-2.06 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100/17400/82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M). |
|
||||
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type FOURNISSEUR (RG-2.10), liées aux catégories du fournisseur |
|
||||
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||
| **Benne(s)** | `<MalioInputNumber>` (stepper −/+ , défaut 0) | Non | Champ spécifique fournisseur |
|
||||
| **Prestation de triage** | `<MalioCheckbox>` | Non | Champ spécifique fournisseur (porté par l'adresse — colonne back `triage_provider`) |
|
||||
|
||||
> **Disposition maquette par bloc** : ligne 1 = radio (Prospect / Départ / Rendu) + Pays + Code postal ; ligne 2 = Ville + Adresse + Adresse complémentaire ; ligne 3 = sites (86 / 17 / 82) + Catégories + Contact ; ligne 4 = Benne(s) + Prestation de triage. Icône corbeille en haut à droite de chaque bloc pour le supprimer.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
|
||||
- « Supprimer » : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/suppliers/{id}/addresses`.
|
||||
|
||||
### Onglet « Transport »
|
||||
|
||||
🚧 **Onglet placeholder minimal au M2.** Conforme à la maquette : la frame est **vide** (aucun champ, aucun bouton de validation, aucune API back). L'onglet reste navigable. Un libellé discret « À venir » est toléré mais non requis (la maquette ne l'affiche pas). Cet onglet **fait partie de la barre de création** (entre Adresses et Comptabilité).
|
||||
|
||||
### Onglet « Comptabilité »
|
||||
|
||||
⚠ **Accessible aux rôles avec `commercial.suppliers.accounting.view`** (Admin + Compta au M2). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un fournisseur (pas de `manage` global).
|
||||
|
||||
**Champs comptables** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. § 2.6) |
|
||||
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel M1) |
|
||||
| **N° de TVA** | `<MalioInputText>` | Oui | — |
|
||||
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
|
||||
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
|
||||
| **Banque** | `<MalioSelect>` | Conditionnel | RG-2.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
|
||||
|
||||
**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-2.08) :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
|
||||
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
|
||||
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
|
||||
|
||||
**Actions** :
|
||||
- « + RIB » : ajoute un bloc.
|
||||
- « Supprimer » (icône) : modal de confirmation.
|
||||
- « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:accounting`) + sous-ressource RIBs.
|
||||
|
||||
### Onglets « Statistiques » / « Rapports » / « Échanges »
|
||||
|
||||
🚧 **Placeholders minimaux au M2 — uniquement en Consultation / Modification** (ils n'apparaissent **pas** dans le flux de création, cf. maquette). Frames vides, pas de validation, pas d'API.
|
||||
|
||||
## Écran « Consultation fournisseur »
|
||||
|
||||
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
|
||||
|
||||
- **Flèche retour** (gauche) → revient au Répertoire.
|
||||
- **Bouton « Modifier »** (droite, visible si `commercial.suppliers.manage`) → écran Modification.
|
||||
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `commercial.suppliers.archive`) → modal de confirmation, puis PATCH `/api/suppliers/{id}` `{ "isArchived": true }`.
|
||||
|
||||
> Un fournisseur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
|
||||
|
||||
### Onglets affichés en consultation
|
||||
|
||||
Information / Contacts / Adresses / Transport / Statistiques / Rapports / Échanges / Comptabilité (les 4 derniers métiers en placeholder « À venir », Comptabilité selon permission). L'utilisateur navigue **librement** entre les onglets (pas de séquence forcée en consultation).
|
||||
|
||||
## Écran « Modification fournisseur »
|
||||
|
||||
Comportement identique à l'écran Ajouter sauf :
|
||||
- **Pas de formulaire principal** réaffiché (champs principaux édités via les onglets correspondants).
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
|
||||
- Les onglets placeholders « À venir » restent non éditables.
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input numérique** : `<MalioInputNumber>` (Nombre de salariés, Volume prévisionnel, Bennes)
|
||||
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
|
||||
- **TextArea** : `<MalioInputTextArea>` (Description)
|
||||
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||
- **Radio** : `<MalioRadioButton>` (Type d'adresse Prospect / Départ / Rendu — RG-2.09)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Prestataire de triage)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
|
||||
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
|
||||
- `<input type="date">` pour « Date Création » (`MalioDate` non couvert).
|
||||
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1 si présent).
|
||||
|
||||
## Composables & appels API
|
||||
|
||||
- `usePaginatedList<Supplier>({ url: '/suppliers' })` — liste paginée (obligatoire, règle frontend). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cohérence M1/ERP-62, cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)). Côté back, fetch-joins anti-N+1.
|
||||
- `useSupplier(id)` — charge le détail via `GET /api/suppliers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Les écrans Consultation et Modification se peuplent depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1 d'appels). **DoD avant intégration** : vérifier que le JSON réel contient bien ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
|
||||
- `useSupplierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useClientForm()`.
|
||||
- `useAddressAutocomplete()` — **réutilisé du M1** (BAN), pas de réécriture.
|
||||
- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
|
||||
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
|
||||
- Filter `formatPhoneFR()` — **réutilisé du M1** pour l'affichage `XX XX XX XX XX`.
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (RG-2.12 — cf. [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom fournisseur (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize | identique |
|
||||
| Téléphones (blocs `SupplierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
|
||||
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche. Cohérent avec `useApi()`.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1** (réutilisé tel quel) :
|
||||
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville.
|
||||
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
|
||||
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
|
||||
|
||||
## Différences notables avec le M1 (clients)
|
||||
|
||||
| Zone | M1 clients | M2 fournisseurs |
|
||||
|---|---|---|
|
||||
| Distributeur / Courtier | Auto-référence Client (RG-1.03) | **Absent** |
|
||||
| Prestation de triage | Booléen sur le client (formulaire principal) | **Booléen sur l'adresse** (`triage_provider`) |
|
||||
| Type d'adresse | 3 checkboxes Prospect / Livraison / Facturation | **Radio exclusif** Prospect / Départ / Rendu (RG-2.09) |
|
||||
| Email facturation sur adresse | Oui (conditionnel) | **Absent** |
|
||||
| Champ adresse « Bennes » | — | **Présent** (nombre) |
|
||||
| Onglet Information | 7 champs | **8 champs** (ajout « Volume prévisionnel ») |
|
||||
| Catégories | type unique `CLIENT` (codes ERP-78) | **nouveau type `FOURNISSEUR`** |
|
||||
| Archivage | Admin | **Admin uniquement** (idem) |
|
||||
| Onglets « À venir » | frames blanches | **placeholder « À venir »** (minimal) |
|
||||
|
||||
## Points résolus côté back
|
||||
|
||||
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Catégorie multi-select | M2M `supplier_category`, `Category` de type **FOURNISSEUR** (RG-2.10) |
|
||||
| 2 | Type d'adresse Prospect/Départ/Rendu | Enum exclusif `address_type` (RG-2.09) |
|
||||
| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
|
||||
| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
|
||||
| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Transport / Stats / Rapports / Échanges) |
|
||||
| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
|
||||
| 7 | Unicité métier | Nom de fournisseur uniquement (à valider — § 2.6). SIREN/email non uniques |
|
||||
| 8 | Référentiels comptables | Réutilisés du M1 (zéro duplication) |
|
||||
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1 |
|
||||
| 10 | Format export | XLSX uniquement (CSV = HP) |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M2 — Répertoire fournisseurs` (projet `ERP / Starseed`, projectId=6).
|
||||
|
||||
> Détail complet et action manuelle → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
|
||||
@@ -10,7 +10,11 @@
|
||||
"confirm": "Confirmer",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"comingSoon": {
|
||||
"title": "En cours de dev",
|
||||
"subtitle": "Cette fonctionnalité arrive bientôt."
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"administration": {
|
||||
@@ -51,12 +55,20 @@
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun client pour l'instant.",
|
||||
"column": {
|
||||
"companyName": "Nom entreprise",
|
||||
"contact": "Contact principal",
|
||||
"phone": "Téléphone principal",
|
||||
"email": "Email principal",
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site(s)"
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"search": "Recherche",
|
||||
"categories": "Catégories",
|
||||
"sites": "Sites",
|
||||
"status": "Statut",
|
||||
"archivedOnly": "Voir les archivés",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser"
|
||||
},
|
||||
"tab": {
|
||||
"information": "Information",
|
||||
@@ -78,7 +90,30 @@
|
||||
"updateSuccess": "Client mis à jour avec succès",
|
||||
"archiveSuccess": "Client archivé avec succès",
|
||||
"restoreSuccess": "Client restauré avec succès",
|
||||
"error": "Une erreur est survenue. Réessayez."
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export du répertoire clients a échoué. Réessayez.",
|
||||
"restoreConflict": "Impossible de restaurer : un client actif portant ce nom existe déjà."
|
||||
},
|
||||
"consultation": {
|
||||
"title": "Consultation client",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"confirmArchive": {
|
||||
"title": "Archiver le client",
|
||||
"message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
|
||||
},
|
||||
"confirmRestore": {
|
||||
"title": "Restaurer le client",
|
||||
"message": "Ce client réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le client",
|
||||
"back": "Retour au répertoire",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"save": "Valider"
|
||||
},
|
||||
"validation": {
|
||||
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
||||
@@ -90,6 +125,85 @@
|
||||
"phoneFormat": "Format de téléphone invalide (attendu : XX XX XX XX XX).",
|
||||
"emailFormat": "Format d'email invalide.",
|
||||
"addressCategoryForbidden": "Une catégorie « Distributeur » ou « Courtier » ne peut pas qualifier une adresse."
|
||||
},
|
||||
"form": {
|
||||
"title": "Ajouter un client",
|
||||
"back": "Précédent",
|
||||
"submit": "Valider",
|
||||
"duplicateCompany": "Un client portant ce nom de société existe déjà.",
|
||||
"main": {
|
||||
"companyName": "Nom du client (Entreprise)",
|
||||
"categories": "Catégorie",
|
||||
"relation": "Distributeur / Courtier",
|
||||
"relationNone": "Aucun",
|
||||
"relationDistributor": "Dépend du distributeur",
|
||||
"relationBroker": "Dépend du courtier",
|
||||
"distributorName": "Nom du distributeur",
|
||||
"brokerName": "Nom du courtier",
|
||||
"triageService": "Prestation de triage"
|
||||
},
|
||||
"information": {
|
||||
"description": "Description",
|
||||
"competitors": "Concurrent",
|
||||
"foundedAt": "Date de création",
|
||||
"employeesCount": "Nombre de salariés",
|
||||
"revenueAmount": "CA",
|
||||
"profitAmount": "Résultat",
|
||||
"directorName": "Dirigeant"
|
||||
},
|
||||
"contact": {
|
||||
"title": "Contact {n}",
|
||||
"lastName": "Nom",
|
||||
"firstName": "Prénom",
|
||||
"jobTitle": "Fonction",
|
||||
"email": "Email",
|
||||
"phonePrimary": "Téléphone",
|
||||
"phoneSecondary": "Téléphone (2)",
|
||||
"addPhone": "Ajouter un numéro",
|
||||
"remove": "Supprimer le contact",
|
||||
"add": "Nouveau contact"
|
||||
},
|
||||
"address": {
|
||||
"title": "Adresse {n}",
|
||||
"prospect": "Prospect",
|
||||
"delivery": "Adresse de livraison",
|
||||
"billing": "Facturation",
|
||||
"categories": "Catégorie",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"street": "Adresse",
|
||||
"streetComplement": "Adresse complémentaire",
|
||||
"sites": "Sites Starseed",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"billingEmail": "Email de facturation",
|
||||
"remove": "Supprimer l'adresse",
|
||||
"add": "Nouvelle adresse",
|
||||
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||
},
|
||||
"accounting": {
|
||||
"siren": "SIREN",
|
||||
"accountNumber": "Numéro de compte",
|
||||
"tvaMode": "Mode de TVA",
|
||||
"nTva": "N° de TVA",
|
||||
"paymentDelay": "Délai de règlement",
|
||||
"paymentType": "Type de règlement",
|
||||
"bank": "Banque",
|
||||
"ribTitle": "RIB {n}",
|
||||
"ribLabel": "Libellé",
|
||||
"ribBic": "BIC",
|
||||
"ribIban": "IBAN",
|
||||
"addRib": "Ajouter un RIB",
|
||||
"removeRib": "Supprimer le RIB"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Confirmer la suppression",
|
||||
"contact": "Supprimer ce contact ?",
|
||||
"address": "Supprimer cette adresse ?",
|
||||
"rib": "Supprimer ce RIB ?",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -132,7 +246,12 @@
|
||||
"core_user": "Utilisateur",
|
||||
"core_role": "Rôle",
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site"
|
||||
"sites_site": "Site",
|
||||
"catalog_category": "Catégorie",
|
||||
"commercial_client": "Client",
|
||||
"commercial_clientaddress": "Adresse client",
|
||||
"commercial_clientcontact": "Contact client",
|
||||
"commercial_clientrib": "RIB client"
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div class="relative 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)]">
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation
|
||||
(RG-1.06/07/08). L'exclusivite est appliquee au toggle (cocher l'un
|
||||
decoche l'autre) plutot qu'en masquant les options. -->
|
||||
<MalioCheckbox
|
||||
:model-value="model.isProspect"
|
||||
:label="t('commercial.clients.form.address.prospect')"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
:model-value="model.isDelivery"
|
||||
:label="t('commercial.clients.form.address.delivery')"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
:model-value="model.isBilling"
|
||||
:label="t('commercial.clients.form.address.billing')"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
|
||||
/>
|
||||
|
||||
<!-- Cellule vide : laisse un trou en position 4 (ligne 1) pour que
|
||||
Categorie reparte au debut de la ligne suivante. -->
|
||||
<div aria-hidden="true" />
|
||||
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('commercial.clients.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:disabled="readonly"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.clients.form.address.country')"
|
||||
:disabled="readonly"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('commercial.clients.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
|
||||
degrade (service indisponible), bascule en saisie libre. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:disabled="readonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
|
||||
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
|
||||
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
|
||||
le col-span-2, le champ le remplit (w-full). -->
|
||||
<div class="col-span-2">
|
||||
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple en
|
||||
mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche
|
||||
pas sa valeur liee, il n'afficherait rien en readonly). -->
|
||||
<MalioInputAutocomplete
|
||||
v-if="!degraded && !readonly"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.clients.form.address.streetComplement')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Sites Starseed : cases a cocher inline (>= 1 obligatoire, RG-1.10). -->
|
||||
<div class="flex justify-between">
|
||||
<MalioCheckbox
|
||||
v-for="site in siteOptions"
|
||||
:key="site.value"
|
||||
:model-value="model.siteIris.includes(site.value)"
|
||||
:label="site.label"
|
||||
group-class="w-auto self-center"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: boolean) => toggleSite(site.value, v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.clients.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:disabled="readonly"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Email de facturation : visible/obligatoire seulement si Facturation
|
||||
est coche (RG-1.11). -->
|
||||
<MalioInputText
|
||||
v-if="isBillingEmailRequired(model)"
|
||||
:model-value="model.billingEmail"
|
||||
:label="t('commercial.clients.form.address.billingEmail')"
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
applyProspectExclusivity,
|
||||
isBillingEmailRequired,
|
||||
type AddressFlagsDraft,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: AddressFormDraft
|
||||
title: string
|
||||
/** Categories autorisees sur une adresse (DISTRIBUTEUR/COURTIER exclus, RG-1.29). */
|
||||
categoryOptions: CategoryOption[]
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
contactOptions: RefOption[]
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: AddressFormDraft]
|
||||
'remove': []
|
||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||
'degraded': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
|
||||
const degraded = ref(false)
|
||||
// Villes proposees par la BAN (alimentees a la saisie du code postal).
|
||||
const banCityOptions = ref<RefOption[]>([])
|
||||
// Adresses proposees par la BAN (alimentees a la saisie d'adresse).
|
||||
const banAddressOptions = ref<RefOption[]>([])
|
||||
|
||||
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||
// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
|
||||
// afficherait un champ vide en lecture seule (consultation 1.11) ou en edition
|
||||
// d'une adresse existante (1.12), ou la BAN n'a pas (re)peuple les suggestions.
|
||||
const cityOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.city
|
||||
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banCityOptions.value]
|
||||
}
|
||||
return banCityOptions.value
|
||||
})
|
||||
|
||||
// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit
|
||||
// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout
|
||||
// l'affichage depuis ses options) laisse le champ VIDE des que la liste de
|
||||
// suggestions BAN est vide — typiquement juste apres validation (remontage) ou
|
||||
// a l'edition d'une adresse existante (1.12), alors que la valeur est bien
|
||||
// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee.
|
||||
const addressOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.street
|
||||
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banAddressOptions.value]
|
||||
}
|
||||
return banAddressOptions.value
|
||||
})
|
||||
const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Coche/decoche un site Starseed rattache a l'adresse (M2M par IRI, RG-1.10). */
|
||||
function toggleSite(siteIri: string, selected: boolean): void {
|
||||
const current = props.modelValue.siteIris
|
||||
const next = selected
|
||||
? [...current, siteIri]
|
||||
: current.filter(iri => iri !== siteIri)
|
||||
update('siteIris', next)
|
||||
}
|
||||
|
||||
/** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */
|
||||
function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void {
|
||||
const flags = applyProspectExclusivity(
|
||||
{ isProspect: model.value.isProspect, isDelivery: model.value.isDelivery, isBilling: model.value.isBilling },
|
||||
field,
|
||||
value,
|
||||
)
|
||||
emit('update:modelValue', { ...props.modelValue, ...flags })
|
||||
}
|
||||
|
||||
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
|
||||
function enterDegraded(): void {
|
||||
if (!degraded.value) {
|
||||
degraded.value = true
|
||||
emit('degraded')
|
||||
}
|
||||
}
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
if (degraded.value) {
|
||||
return
|
||||
}
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const suggestions = await autocomplete.searchCity(digits)
|
||||
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||
}
|
||||
catch {
|
||||
enterDegraded()
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||
async function onAddressSearch(query: string): Promise<void> {
|
||||
if (degraded.value) {
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||
lastAddressSuggestions = suggestions
|
||||
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
}
|
||||
catch {
|
||||
enterDegraded()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une suggestion d'adresse → remplit rue + ville + CP.
|
||||
* Le type d'option suit le contrat MalioInputAutocomplete ({ label, value }).
|
||||
*/
|
||||
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
||||
if (option === null) {
|
||||
return
|
||||
}
|
||||
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||
if (!suggestion) {
|
||||
update('street', String(option.value))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
street: suggestion.street,
|
||||
city: suggestion.city,
|
||||
postalCode: suggestion.postalCode,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="relative 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)]">
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
|
||||
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.clients.form.contact.lastName')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.clients.form.contact.firstName')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
<MalioInputEmail
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.clients.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
|
||||
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon du contact (v-model). */
|
||||
modelValue: ContactFormDraft
|
||||
/** Titre du bloc (ex: « Contact 1 »). */
|
||||
title: string
|
||||
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ContactFormDraft]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Revele le 2e numero (RG-1.02/1.20 : max 1 secondaire, le « + » disparait). */
|
||||
function revealSecondaryPhone(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
const { useClient } = await import('../useClient')
|
||||
|
||||
const SAMPLE = { '@id': '/api/clients/42', id: 42, companyName: 'ACME', isArchived: false }
|
||||
|
||||
describe('useClient', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockGet.mockResolvedValue(SAMPLE)
|
||||
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
|
||||
})
|
||||
|
||||
it('charge le detail via GET /clients/{id} en Hydra, sans toast', async () => {
|
||||
const { client, load } = useClient(42)
|
||||
await load()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/clients/42',
|
||||
{},
|
||||
expect.objectContaining({
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
}),
|
||||
)
|
||||
expect(client.value).toEqual(SAMPLE)
|
||||
})
|
||||
|
||||
it('bascule loading pendant le chargement et le retombe a false', async () => {
|
||||
const { loading, load } = useClient(42)
|
||||
const promise = load()
|
||||
expect(loading.value).toBe(true)
|
||||
await promise
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('marque error et laisse client null si le GET echoue (404...)', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('not found'))
|
||||
const { client, error, load } = useClient(99)
|
||||
await load()
|
||||
expect(error.value).toBe(true)
|
||||
expect(client.value).toBeNull()
|
||||
})
|
||||
|
||||
it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
|
||||
// 1er GET = chargement initial, 2e GET = rechargement post-archivage.
|
||||
mockGet.mockResolvedValueOnce(SAMPLE)
|
||||
mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
|
||||
const { client, load, archive } = useClient(42)
|
||||
await load()
|
||||
await archive()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/clients/42',
|
||||
{ isArchived: true },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
|
||||
expect(mockGet).toHaveBeenCalledTimes(2)
|
||||
expect(client.value?.isArchived).toBe(true)
|
||||
})
|
||||
|
||||
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
|
||||
const { load, restore } = useClient(42)
|
||||
await load()
|
||||
await restore()
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/clients/42',
|
||||
{ isArchived: false },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('propage l\'erreur (ex: 409 conflit homonyme RG-1.23) au lieu de l\'avaler', async () => {
|
||||
const conflict = { response: { status: 409 } }
|
||||
mockPatch.mockRejectedValueOnce(conflict)
|
||||
const { load, restore } = useClient(42)
|
||||
await load()
|
||||
await expect(restore()).rejects.toBe(conflict)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||
// les appels de chargement des referentiels et simuler un endpoint en echec
|
||||
// (ex: 403 sur /categories pour un role sans la permission de lecture).
|
||||
// Meme pattern que useClientsRepository.spec.ts.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
|
||||
const { useClientReferentials } = await import('../useClientReferentials')
|
||||
|
||||
describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
})
|
||||
|
||||
it('un referentiel en echec (403) ne vide QUE son select, pas les autres', async () => {
|
||||
// /categories rejette (simulateur d'un 403), tous les autres repondent.
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/categories') {
|
||||
return Promise.reject(new Error('403 Forbidden'))
|
||||
}
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
|
||||
}
|
||||
return Promise.resolve({
|
||||
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
|
||||
})
|
||||
})
|
||||
|
||||
const refs = useClientReferentials()
|
||||
// loadCommon ne doit JAMAIS rejeter : l'echec d'un referentiel est isole.
|
||||
await refs.loadCommon()
|
||||
|
||||
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories.
|
||||
// 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.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
||||
|
||||
// Seul le select en echec reste vide.
|
||||
expect(refs.categories.value).toEqual([])
|
||||
})
|
||||
|
||||
it('charge tous les referentiels quand tout repond', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/categories') {
|
||||
return Promise.resolve({
|
||||
member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }],
|
||||
})
|
||||
}
|
||||
if (url === '/sites') {
|
||||
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
|
||||
}
|
||||
return Promise.resolve({ member: [] })
|
||||
})
|
||||
|
||||
const refs = useClientReferentials()
|
||||
await refs.loadCommon()
|
||||
|
||||
expect(refs.categories.value).toEqual([
|
||||
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
|
||||
])
|
||||
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
|
||||
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
import type { Client } from '../useClientsRepository'
|
||||
|
||||
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||
// les appels declenches par usePaginatedList (que useClientsRepository enveloppe)
|
||||
// et controler les reponses. Meme pattern que useCategoriesAdmin.spec.ts.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
|
||||
const { useClientsRepository } = await import('../useClientsRepository')
|
||||
|
||||
/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */
|
||||
function makeHydra(total: number): HydraCollection<Client> {
|
||||
return { totalItems: total, member: [] }
|
||||
}
|
||||
|
||||
describe('useClientsRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(25))
|
||||
})
|
||||
|
||||
it('cible la ressource /clients en page 1 par defaut', async () => {
|
||||
const repo = useClientsRepository()
|
||||
await repo.fetch()
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('pousse les filtres du drawer (categories multi, sites, archives) et retombe en page 1', async () => {
|
||||
const repo = useClientsRepository()
|
||||
await repo.fetch()
|
||||
await repo.goToPage(2)
|
||||
expect(repo.currentPage.value).toBe(2)
|
||||
|
||||
await repo.setFilters(
|
||||
{
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'],
|
||||
'siteId[]': ['1', '2'],
|
||||
archivedOnly: true,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
|
||||
expect(repo.currentPage.value).toBe(1)
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{
|
||||
search: 'acme',
|
||||
'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'],
|
||||
'siteId[]': ['1', '2'],
|
||||
archivedOnly: true,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||
const repo = useClientsRepository()
|
||||
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
|
||||
await repo.setFilters({}, { replace: true })
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ref } from 'vue'
|
||||
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
|
||||
|
||||
/**
|
||||
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
|
||||
* client », 1.11). Lit le detail embarque via `GET /api/clients/{id}` (contacts /
|
||||
* adresses / ribs sous `client:item:read` / `client:read:accounting`) et expose
|
||||
* les bascules d'archivage (PATCH `isArchived` SEUL — tout autre champ => 422).
|
||||
*
|
||||
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
|
||||
* Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs
|
||||
* d'archivage/restauration (notamment le 409 RG-1.23 : homonyme actif a la
|
||||
* restauration) sont PROPAGEES a l'appelant, qui decide du toast a afficher.
|
||||
*/
|
||||
export function useClient(id: number | string) {
|
||||
const api = useApi()
|
||||
|
||||
const client = ref<ClientDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
|
||||
function fetchDetail(): Promise<ClientDetail> {
|
||||
return api.get<ClientDetail>(
|
||||
`/clients/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/** Charge le detail du client. En cas d'echec : `error = true`, `client = null`. */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
client.value = await fetchDetail()
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
client.value = null
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422),
|
||||
* puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
|
||||
* `client:read` (ni l'embed contacts/adresses/ribs ni les libelles des
|
||||
* referentiels comptables), un simple merge laisserait l'affichage incoherent.
|
||||
* Toute erreur (notamment le 409 d'homonyme actif a la restauration, RG-1.23)
|
||||
* est propagee a l'appelant AVANT le rechargement.
|
||||
*/
|
||||
async function setArchived(isArchived: boolean): Promise<void> {
|
||||
await api.patch(`/clients/${id}`, { isArchived }, { toast: false })
|
||||
client.value = await fetchDetail()
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
archive: () => setArchived(true),
|
||||
restore: () => setArchived(false),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
|
||||
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
|
||||
* reglement, banques, et les listes distributeurs / courtiers.
|
||||
*
|
||||
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
|
||||
* l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir
|
||||
* l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`)
|
||||
* pour pouvoir etre renvoyees telles quelles dans les payloads POST/PATCH
|
||||
* (relations ManyToOne / ManyToMany).
|
||||
*
|
||||
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||
*/
|
||||
|
||||
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Option de type de reglement enrichie de son code stable (RG-1.12 / RG-1.13). */
|
||||
export interface PaymentTypeOption extends RefOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Option de categorie enrichie de son code stable (filtrage RG-1.29 cote adresse). */
|
||||
export interface CategoryOption extends RefOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Option de client (distributeur / courtier) — value = IRI du client lie. */
|
||||
export type ClientOption = RefOption
|
||||
|
||||
interface HydraMember {
|
||||
'@id': string
|
||||
}
|
||||
|
||||
interface CategoryMember extends HydraMember {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface SiteMember extends HydraMember {
|
||||
name: string
|
||||
postalCode: string
|
||||
}
|
||||
|
||||
interface ReferentialMember extends HydraMember {
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ClientMember extends HydraMember {
|
||||
companyName: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
export function useClientReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const categories = ref<CategoryOption[]>([])
|
||||
const sites = ref<RefOption[]>([])
|
||||
const tvaModes = ref<RefOption[]>([])
|
||||
const paymentDelays = ref<RefOption[]>([])
|
||||
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||
const banks = ref<RefOption[]>([])
|
||||
const distributors = ref<ClientOption[]>([])
|
||||
const brokers = ref<ClientOption[]>([])
|
||||
|
||||
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||
async function fetchAll<T extends HydraMember>(
|
||||
url: string,
|
||||
query: Record<string, string | string[]> = {},
|
||||
): Promise<T[]> {
|
||||
const res = await api.get<{ member?: T[] }>(
|
||||
url,
|
||||
{ pagination: 'false', ...query },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge en parallele les referentiels communs (hors distributeurs/courtiers,
|
||||
* charges a la demande selon la relation choisie).
|
||||
*
|
||||
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole.
|
||||
* Necessaire pour les roles metier qui n'ont pas toutes les permissions de
|
||||
* lecture — ex. Compta a `commercial.clients.view` (donc /tva_modes, /banks...
|
||||
* accessibles) mais PAS `catalog.categories.view` ni `sites.view` : sans
|
||||
* isolation, le 403 sur /categories ferait echouer tout le bloc et viderait
|
||||
* les selects comptables dont Compta a besoin sur l'ecran de modification.
|
||||
* Un referentiel en echec reste simplement vide (l'ecran d'edition complete
|
||||
* l'affichage des valeurs courantes depuis l'embed du detail client).
|
||||
*/
|
||||
async function loadCommon(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll<CategoryMember>('/categories')
|
||||
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
|
||||
fetchAll<SiteMember>('/sites')
|
||||
// Libelle = numero de departement (2 premiers chiffres du code
|
||||
// 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')
|
||||
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||
fetchAll<ReferentialMember>('/payment_delays')
|
||||
.then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }),
|
||||
fetchAll<ReferentialMember>('/payment_types')
|
||||
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
|
||||
fetchAll<ReferentialMember>('/banks')
|
||||
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||
])
|
||||
}
|
||||
|
||||
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
|
||||
async function loadDistributors(): Promise<void> {
|
||||
if (distributors.value.length > 0) {
|
||||
return
|
||||
}
|
||||
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'DISTRIBUTEUR' })
|
||||
distributors.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||
}
|
||||
|
||||
/** Liste des clients pouvant etre choisis comme courtier (code COURTIER). */
|
||||
async function loadBrokers(): Promise<void> {
|
||||
if (brokers.value.length > 0) {
|
||||
return
|
||||
}
|
||||
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'COURTIER' })
|
||||
brokers.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
sites,
|
||||
tvaModes,
|
||||
paymentDelays,
|
||||
paymentTypes,
|
||||
banks,
|
||||
distributors,
|
||||
brokers,
|
||||
loadCommon,
|
||||
loadDistributors,
|
||||
loadBrokers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Site Starseed rattache a une adresse du client, tel qu'embarque en LISTE
|
||||
* (groupe site:read) pour la colonne « Site(s) » du Repertoire (badges colores).
|
||||
*/
|
||||
export interface ClientSite {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie rattachee au client, embarquee en LISTE (groupe category:read).
|
||||
* Seul le `code` (stable, MAJUSCULE — ERP-78) est affiche dans la colonne
|
||||
* « Catégories ». Les autres champs sont presents mais non utilises ici.
|
||||
*/
|
||||
export interface ClientCategory {
|
||||
code: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un client pour le Repertoire (datatable). Volontairement
|
||||
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-62).
|
||||
*/
|
||||
export interface Client {
|
||||
id: number
|
||||
companyName: string
|
||||
categories: ClientCategory[]
|
||||
sites: ClientSite[]
|
||||
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||
updatedAt: string | null
|
||||
isArchived: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Repertoire clients (ERP-62) — simple enveloppe de `usePaginatedList<Client>`
|
||||
* sur la ressource `/clients` (RG-13 : pagination serveur obligatoire ; jamais
|
||||
* de chargement integral en memoire).
|
||||
*
|
||||
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
||||
*
|
||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||
* `usePaginatedList` (cf. sites.vue / categories.vue). Aucun reset au logout a
|
||||
* gerer.
|
||||
*/
|
||||
export function useClientsRepository() {
|
||||
return usePaginatedList<Client>({ url: '/clients' })
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour repertoire + nom du client. -->
|
||||
<div class="flex items-center gap-3">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Etats de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.edit.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.edit.notFound') }}</p>
|
||||
|
||||
<template v-else-if="client">
|
||||
<!-- ── Bloc principal (pre-rempli, editable si `manage`) ──────────────
|
||||
Decision Tristan : on conserve le bloc principal en modification
|
||||
(« pour ne pas tout casser »), edite via son propre PATCH scope
|
||||
sur le groupe client:write:main. Readonly pour les roles sans
|
||||
`manage` (ex. Compta). -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="businessReadonly"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:disabled="businessReadonly"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="main.relationType"
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
:disabled="businessReadonly"
|
||||
@update:model-value="onRelationChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="main.relationType === 'courtier'"
|
||||
:model-value="main.brokerIri"
|
||||
:options="brokerOptions"
|
||||
:label="t('commercial.clients.form.main.brokerName')"
|
||||
:disabled="businessReadonly"
|
||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="main.relationType === 'distributeur'"
|
||||
:model-value="main.distributorIri"
|
||||
:options="distributorOptions"
|
||||
:label="t('commercial.clients.form.main.distributorName')"
|
||||
:disabled="businessReadonly"
|
||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
v-model="main.triageService"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
:readonly="businessReadonly"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.edit.save')"
|
||||
:disabled="!isMainValid || mainSubmitting"
|
||||
@click="submitMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<MalioInputTextArea
|
||||
v-model="information.description"
|
||||
:label="t('commercial.clients.form.information.description')"
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1"
|
||||
text-input="h-full text-lg"
|
||||
:disabled="businessReadonly"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
:readonly="businessReadonly"
|
||||
/>
|
||||
<MalioDate
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
:readonly="businessReadonly"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="businessReadonly"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
:disabled="businessReadonly"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
:readonly="businessReadonly"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
:disabled="businessReadonly"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.edit.save')"
|
||||
:disabled="tabSubmitting"
|
||||
@click="submitInformation"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="contact.id ?? `new-${index}`"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="contacts.length > 1"
|
||||
:readonly="businessReadonly"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.form.contact.add')"
|
||||
:disabled="!canAddContact"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.edit.save')"
|
||||
:disabled="!canValidateContacts || tabSubmitting"
|
||||
@click="submitContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresse -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ClientAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="address.id ?? `new-${index}`"
|
||||
:model-value="address"
|
||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||
:category-options="addressCategoryOptions"
|
||||
:site-options="siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="addresses.length > 1"
|
||||
:readonly="businessReadonly"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.form.address.add')"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.edit.save')"
|
||||
:disabled="!canValidateAddresses || tabSubmitting"
|
||||
@click="submitAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view ;
|
||||
editable uniquement si accounting.manage). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="onPaymentTypeChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isBankRequired"
|
||||
:model-value="accounting.bankIri"
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR (RG-1.13). -->
|
||||
<div
|
||||
v-for="(rib, index) in ribs"
|
||||
:key="rib.id ?? `new-${index}`"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.form.accounting.addRib')"
|
||||
@click="addRib"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.edit.save')"
|
||||
:disabled="!canValidateAccounting || tabSubmitting"
|
||||
@click="submitAccounting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
||||
<template #transport><ComingSoonPlaceholder /></template>
|
||||
<template #statistics><ComingSoonPlaceholder /></template>
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
|
||||
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmModal.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('commercial.clients.form.confirmDelete.cancel')"
|
||||
@click="confirmModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="t('commercial.clients.form.confirmDelete.confirm')"
|
||||
@click="runConfirm"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||
import { useClientReferentials, type CategoryOption, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||
import {
|
||||
canEditClient,
|
||||
categoryOptionsOf,
|
||||
referentialOptionOf,
|
||||
siteOptionsOf,
|
||||
mapContactToDraft,
|
||||
mapAddressToDraft,
|
||||
mapRibToDraft,
|
||||
type ClientDetail,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
buildContactPayload,
|
||||
buildInformationPayload,
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
mapAccountingFormDraft,
|
||||
mapInformationDraft,
|
||||
mapMainDraft,
|
||||
resolveTabEditability,
|
||||
type AccountingFormDraft,
|
||||
type ClientEditAbilities,
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
} from '~/modules/commercial/utils/clientEdit'
|
||||
import {
|
||||
buildClientFormTabKeys,
|
||||
hasAtLeastOneValidContact,
|
||||
isBankRequiredForPaymentType,
|
||||
isBillingEmailRequired,
|
||||
isContactNamed,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
emptyRib,
|
||||
type AddressFormDraft,
|
||||
type ContactFormDraft,
|
||||
type RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
const EMPLOYEES_MASK = '#######'
|
||||
|
||||
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
||||
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { can, canAny } = usePermissions()
|
||||
|
||||
// Gating de la route : l'edition exige de pouvoir editer au moins un onglet
|
||||
// (`manage` OU `accounting.manage`). Usine et roles en lecture seule sont
|
||||
// rediriges vers le repertoire (lui-meme protege).
|
||||
if (!canEditClient(canAny)) {
|
||||
await navigateTo('/clients')
|
||||
}
|
||||
|
||||
const clientId = route.params.id as string
|
||||
|
||||
const { client, loading, error, load } = useClient(clientId)
|
||||
const referentials = useClientReferentials()
|
||||
|
||||
// ── Permissions / editabilite par zone (option 1 ERP-74) ────────────────────
|
||||
const abilities = computed<ClientEditAbilities>(() => ({
|
||||
canManage: can('commercial.clients.manage'),
|
||||
canAccountingView: can('commercial.clients.accounting.view'),
|
||||
canAccountingManage: can('commercial.clients.accounting.manage'),
|
||||
}))
|
||||
const editability = computed(() => resolveTabEditability(abilities.value))
|
||||
// Bloc principal + onglets Information / Contact / Adresse.
|
||||
const businessReadonly = computed(() => !editability.value.businessEditable)
|
||||
const canAccountingView = computed(() => editability.value.accountingVisible)
|
||||
const accountingReadonly = computed(() => !editability.value.accountingEditable)
|
||||
|
||||
const headerTitle = computed(() => client.value?.companyName ?? t('commercial.clients.edit.title'))
|
||||
|
||||
// ── Brouillons editables (pre-remplis depuis le detail) ─────────────────────
|
||||
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
|
||||
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
|
||||
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
|
||||
const contacts = ref<ContactFormDraft[]>([])
|
||||
const addresses = ref<AddressFormDraft[]>([])
|
||||
const ribs = ref<RibFormDraft[]>([])
|
||||
|
||||
// Ids des sous-ressources existantes supprimees (DELETE differe au « Valider »).
|
||||
const removedContactIds = ref<number[]>([])
|
||||
const removedAddressIds = ref<number[]>([])
|
||||
const removedRibIds = ref<number[]>([])
|
||||
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
const addressDegradedNotified = ref(false)
|
||||
|
||||
/** Recopie le detail charge dans les brouillons editables. */
|
||||
function hydrate(detail: ClientDetail): void {
|
||||
Object.assign(main, mapMainDraft(detail))
|
||||
Object.assign(information, mapInformationDraft(detail))
|
||||
Object.assign(accounting, mapAccountingFormDraft(detail))
|
||||
contacts.value = (detail.contacts ?? []).map(mapContactToDraft)
|
||||
addresses.value = (detail.addresses ?? []).map(mapAddressToDraft)
|
||||
ribs.value = (detail.ribs ?? []).map(mapRibToDraft)
|
||||
// Chaque bloc reste visible meme vide : si une collection est vide, on amorce
|
||||
// un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*).
|
||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
// Charge les listes distributeur / courtier si une relation est deja posee.
|
||||
if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {})
|
||||
if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {})
|
||||
}
|
||||
|
||||
// ── Options de selects (referentiels UNION valeurs courantes de l'embed) ─────
|
||||
// L'union garantit que les valeurs deja posees s'affichent meme quand le
|
||||
// referentiel complet n'est pas chargeable (roles metier sans
|
||||
// catalog.categories.view / sites.view → 403, cf. matrice § 2.7).
|
||||
function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[] {
|
||||
const seen = new Set(primary.map(o => o.value))
|
||||
return [...primary, ...extra.filter(o => !seen.has(o.value))]
|
||||
}
|
||||
|
||||
const embedCategoryOptions = computed<CategoryOption[]>(() => {
|
||||
const fromClient = categoryOptionsOf(client.value?.categories)
|
||||
const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
|
||||
return mergeOptions(fromClient, fromAddresses)
|
||||
})
|
||||
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
|
||||
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
||||
const addressCategoryOptions = computed(() =>
|
||||
mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
||||
)
|
||||
|
||||
const embedSiteOptions = computed<RefOption[]>(() =>
|
||||
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
|
||||
)
|
||||
const siteOptions = computed(() => mergeOptions(referentials.sites.value, embedSiteOptions.value))
|
||||
|
||||
// Contacts deja persistes (iri non null), rattachables a une adresse (M2M).
|
||||
const contactOptions = computed<RefOption[]>(() =>
|
||||
contacts.value
|
||||
.filter(c => c.iri !== null)
|
||||
.map(c => ({
|
||||
value: c.iri as string,
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||
})),
|
||||
)
|
||||
|
||||
const countryOptions: RefOption[] = [
|
||||
{ value: 'France', label: 'France' },
|
||||
{ value: 'Espagne', label: 'Espagne' },
|
||||
]
|
||||
|
||||
const relationOptions = computed<RefOption[]>(() => [
|
||||
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
||||
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
||||
])
|
||||
|
||||
// Distributeur / courtier : referentiel charge a la demande UNION valeur courante.
|
||||
const currentDistributorOption = computed<RefOption[]>(() => {
|
||||
const d = client.value?.distributor
|
||||
return d && typeof d === 'object' ? [{ value: d['@id'], label: d.companyName ?? d['@id'] }] : []
|
||||
})
|
||||
const currentBrokerOption = computed<RefOption[]>(() => {
|
||||
const b = client.value?.broker
|
||||
return b && typeof b === 'object' ? [{ value: b['@id'], label: b.companyName ?? b['@id'] }] : []
|
||||
})
|
||||
const distributorOptions = computed(() => mergeOptions(referentials.distributors.value, currentDistributorOption.value))
|
||||
const brokerOptions = computed(() => mergeOptions(referentials.brokers.value, currentBrokerOption.value))
|
||||
|
||||
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
|
||||
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(client.value?.tvaMode)))
|
||||
const paymentDelayOptions = computed(() => mergeOptions(referentials.paymentDelays.value, referentialOptionOf(client.value?.paymentDelay)))
|
||||
const paymentTypeOptions = computed(() => mergeOptions(
|
||||
referentials.paymentTypes.value.map(p => ({ value: p.value, label: p.label })),
|
||||
referentialOptionOf(client.value?.paymentType),
|
||||
))
|
||||
const bankOptions = computed(() => mergeOptions(referentials.banks.value, referentialOptionOf(client.value?.bank)))
|
||||
|
||||
// ── Onglets : navigation libre (4 actifs + 4 coquilles, comme la consultation) ─
|
||||
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
||||
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
information: 'mdi:account-outline',
|
||||
contact: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
transport: 'mdi:truck-delivery-outline',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
statistics: 'mdi:finance',
|
||||
reports: 'mdi:file-document-edit-outline',
|
||||
exchanges: 'mdi:account-group-outline',
|
||||
}
|
||||
|
||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`commercial.clients.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
const activeTab = ref('information')
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
router.push(`/clients/${clientId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Message d'erreur a afficher : violation 422 / detail renvoye par le serveur,
|
||||
* sinon un libelle generique. Le 409 d'unicite de nom (bloc principal) est
|
||||
* traduit explicitement par l'appelant.
|
||||
*/
|
||||
function apiErrorMessage(e: unknown): string {
|
||||
const data = (e as { data?: unknown })?.data
|
||||
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
|
||||
}
|
||||
|
||||
function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status
|
||||
toast.error({
|
||||
title: t('commercial.clients.toast.error'),
|
||||
message: opts.duplicateCompany && status === 409
|
||||
? t('commercial.clients.form.duplicateCompany')
|
||||
: apiErrorMessage(e),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Bloc principal ───────────────────────────────────────────────────────────
|
||||
const isMainValid = computed(() => {
|
||||
const filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
|
||||
const relationValid
|
||||
= main.relationType === null
|
||||
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|
||||
|| (main.relationType === 'courtier' && filled(main.brokerIri))
|
||||
return filled(main.companyName)
|
||||
&& main.categoryIris.length >= 1
|
||||
&& relationValid
|
||||
})
|
||||
|
||||
async function onRelationChange(value: string | number | null): Promise<void> {
|
||||
const relation = (value === null || value === '') ? null : (String(value) as 'distributeur' | 'courtier')
|
||||
main.relationType = relation
|
||||
// Une seule FK remplie a la fois (RG-1.03).
|
||||
if (relation !== 'distributeur') main.distributorIri = null
|
||||
if (relation !== 'courtier') main.brokerIri = null
|
||||
|
||||
if (relation === 'distributeur') await referentials.loadDistributors().catch(() => {})
|
||||
if (relation === 'courtier') await referentials.loadBrokers().catch(() => {})
|
||||
}
|
||||
|
||||
/** PATCH /clients/{id} — groupe client:write:main UNIQUEMENT (mode strict). */
|
||||
async function submitMain(): Promise<void> {
|
||||
if (businessReadonly.value || !isMainValid.value || mainSubmitting.value) return
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const updated = await api.patch<ClientDetail>(`/clients/${clientId}`, buildMainPayload(main), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
// Reaffiche les valeurs normalisees renvoyees par le serveur.
|
||||
Object.assign(main, mapMainDraft(updated))
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
showError(e, { duplicateCompany: true })
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Information ───────────────────────────────────────────────────────
|
||||
/** PATCH /clients/{id} — groupe client:write:information UNIQUEMENT. */
|
||||
async function submitInformation(): Promise<void> {
|
||||
if (businessReadonly.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
await api.patch(`/clients/${clientId}`, buildInformationPayload(information), { toast: false })
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
showError(e)
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Contact ───────────────────────────────────────────────────────────
|
||||
const canAddContact = computed(() => {
|
||||
const last = contacts.value[contacts.value.length - 1]
|
||||
return last === undefined || isContactNamed(last)
|
||||
})
|
||||
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
|
||||
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
|
||||
|
||||
function addContact(): void {
|
||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||
}
|
||||
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
||||
const removed = contacts.value[index]
|
||||
if (removed?.id != null) removedContactIds.value.push(removed.id)
|
||||
contacts.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (contacts.value.length === 0) contacts.value.push(emptyContact())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contact : DELETE des contacts retires (existants), puis
|
||||
* POST/PATCH des blocs restants sur la sous-ressource. Strictement scope a la
|
||||
* collection contacts (endpoints client_contact dedies).
|
||||
*/
|
||||
async function submitContacts(): Promise<void> {
|
||||
if (businessReadonly.value || !canValidateContacts.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
for (const id of removedContactIds.value) {
|
||||
await api.delete(`/client_contacts/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedContactIds.value = []
|
||||
|
||||
for (const contact of contacts.value) {
|
||||
if (!isContactNamed(contact)) continue
|
||||
const body = buildContactPayload(contact)
|
||||
if (contact.id === null) {
|
||||
const created = await api.post<{ '@id'?: string, id: number }>(
|
||||
`/clients/${clientId}/contacts`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
contact.id = created.id
|
||||
contact.iri = created['@id'] ?? null
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
||||
}
|
||||
}
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
showError(e)
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Adresse ───────────────────────────────────────────────────────────
|
||||
const canValidateAddresses = computed(() =>
|
||||
addresses.value.length > 0
|
||||
&& addresses.value.every((a) => {
|
||||
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
|
||||
return a.siteIris.length >= 1
|
||||
&& a.categoryIris.length >= 1
|
||||
&& (!isBillingEmailRequired(a) || filledBillingEmail)
|
||||
}),
|
||||
)
|
||||
|
||||
function addAddress(): void {
|
||||
addresses.value.push(emptyAddress())
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
||||
const removed = addresses.value[index]
|
||||
if (removed?.id != null) removedAddressIds.value.push(removed.id)
|
||||
addresses.value.splice(index, 1)
|
||||
// Garde au moins un bloc visible (cf. amorce a l'hydratation).
|
||||
if (addresses.value.length === 0) addresses.value.push(emptyAddress())
|
||||
})
|
||||
}
|
||||
|
||||
function onAddressDegraded(): void {
|
||||
if (addressDegradedNotified.value) return
|
||||
addressDegradedNotified.value = true
|
||||
toast.warning({
|
||||
title: t('commercial.clients.toast.error'),
|
||||
message: t('commercial.clients.form.address.degraded'),
|
||||
})
|
||||
}
|
||||
|
||||
/** Valide l'onglet Adresse : DELETE des adresses retirees puis POST/PATCH. */
|
||||
async function submitAddresses(): Promise<void> {
|
||||
if (businessReadonly.value || !canValidateAddresses.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
for (const id of removedAddressIds.value) {
|
||||
await api.delete(`/client_addresses/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedAddressIds.value = []
|
||||
|
||||
for (const address of addresses.value) {
|
||||
const body = buildAddressPayload(address, isBillingEmailRequired(address))
|
||||
if (address.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId}/addresses`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
address.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
||||
}
|
||||
}
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
showError(e)
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Comptabilite ──────────────────────────────────────────────────────
|
||||
const selectedPaymentTypeCode = computed(() =>
|
||||
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||
)
|
||||
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
|
||||
function onPaymentTypeChange(value: string | number | null): void {
|
||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||
if (!isBankRequired.value) accounting.bankIri = null
|
||||
}
|
||||
|
||||
function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean {
|
||||
const filled = (v: string | null) => v !== null && v.trim() !== ''
|
||||
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
|
||||
}
|
||||
|
||||
const canValidateAccounting = computed(() => {
|
||||
if (isBankRequired.value && accounting.bankIri === null) return false
|
||||
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function addRib(): void {
|
||||
ribs.value.push(emptyRib())
|
||||
}
|
||||
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
||||
const removed = ribs.value[index]
|
||||
if (removed?.id != null) removedRibIds.value.push(removed.id)
|
||||
ribs.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (cf. amorce a l'hydratation).
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting,
|
||||
* exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la
|
||||
* sous-ressource. Aucun champ main/information dans le payload (mode strict
|
||||
* RG-1.28 : sinon 403 sur tout le payload).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
|
||||
|
||||
for (const id of removedRibIds.value) {
|
||||
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
|
||||
}
|
||||
removedRibIds.value = []
|
||||
|
||||
for (const rib of ribs.value) {
|
||||
if (!ribIsComplete(rib)) continue
|
||||
const body = buildRibPayload(rib)
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId}/ribs`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
|
||||
}
|
||||
}
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (e) {
|
||||
showError(e)
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal de confirmation generique ──────────────────────────────────────────
|
||||
const confirmModal = reactive({
|
||||
open: false,
|
||||
message: '',
|
||||
action: null as null | (() => void),
|
||||
})
|
||||
|
||||
function askConfirm(message: string, action: () => void): void {
|
||||
confirmModal.message = message
|
||||
confirmModal.action = action
|
||||
confirmModal.open = true
|
||||
}
|
||||
|
||||
function runConfirm(): void {
|
||||
confirmModal.action?.()
|
||||
confirmModal.action = null
|
||||
confirmModal.open = false
|
||||
}
|
||||
|
||||
useHead({ title: headerTitle })
|
||||
|
||||
onMounted(async () => {
|
||||
// Referentiels en best-effort (echec non bloquant : l'embed alimente les
|
||||
// libelles des valeurs courantes).
|
||||
referentials.loadCommon().catch(() => {})
|
||||
await load()
|
||||
if (client.value) hydrate(client.value)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,471 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
|
||||
<div class="flex items-center gap-3">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
|
||||
|
||||
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
|
||||
<div class="ml-auto flex items-center gap-12">
|
||||
<MalioButton
|
||||
v-if="canEdit"
|
||||
variant="secondary"
|
||||
icon-name="mdi:pencil-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.action.edit')"
|
||||
@click="goEdit"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.action.archive')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showRestore"
|
||||
variant="secondary"
|
||||
icon-name="mdi:archive-arrow-up-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.action.restore')"
|
||||
@click="askToggleArchive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Etats de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.consultation.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.consultation.notFound') }}</p>
|
||||
|
||||
<template v-else-if="client">
|
||||
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
:model-value="client.companyName"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
disabled
|
||||
/>
|
||||
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
|
||||
<MalioSelect
|
||||
:model-value="relation.type"
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
disabled
|
||||
/>
|
||||
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
|
||||
aucune valeur sans relation — meme comportement qu'en edition). -->
|
||||
<MalioInputText
|
||||
v-if="relation.type"
|
||||
:model-value="relation.name"
|
||||
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
|
||||
readonly
|
||||
/>
|
||||
<MalioCheckbox
|
||||
:model-value="client.triageService === true"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<MalioInputTextArea
|
||||
:model-value="information.description"
|
||||
:label="t('commercial.clients.form.information.description')"
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1"
|
||||
text-input="h-full text-lg"
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="information.competitors"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
readonly
|
||||
/>
|
||||
<MalioDate
|
||||
:model-value="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputAmount
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="information.directorName"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputAmount
|
||||
:model-value="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="contact.id ?? index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresse -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ClientAddressBlock
|
||||
v-for="(view, index) in addressViews"
|
||||
:key="view.draft.id ?? index"
|
||||
:model-value="view.draft"
|
||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="allSiteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
:model-value="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="accounting.accountNumber"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
empty-option-label=""
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="accounting.nTva"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
readonly
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
empty-option-label=""
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
empty-option-label=""
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="accounting.bankIri"
|
||||
:model-value="accounting.bankIri"
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
empty-option-label=""
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (0..n), lecture seule. -->
|
||||
<div
|
||||
v-for="(rib, index) in ribs"
|
||||
:key="rib.id ?? index"
|
||||
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
:model-value="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="rib.bic"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
readonly
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="rib.iban"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
||||
<template #transport><ComingSoonPlaceholder /></template>
|
||||
<template #statistics><ComingSoonPlaceholder /></template>
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
<!-- Modal de confirmation Archiver / Restaurer. -->
|
||||
<MalioModal v-model="confirmOpen" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ isArchived ? t('commercial.clients.consultation.confirmRestore.title') : t('commercial.clients.consultation.confirmArchive.title') }}
|
||||
</h2>
|
||||
</template>
|
||||
<p>{{ isArchived ? t('commercial.clients.consultation.confirmRestore.message') : t('commercial.clients.consultation.confirmArchive.message') }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('commercial.clients.form.confirmDelete.cancel')"
|
||||
@click="confirmOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
:variant="isArchived ? 'primary' : 'danger'"
|
||||
button-class="flex-1"
|
||||
:label="t('commercial.clients.form.confirmDelete.confirm')"
|
||||
:disabled="toggling"
|
||||
@click="confirmToggleArchive"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useClient } from '~/modules/commercial/composables/useClient'
|
||||
import { buildClientFormTabKeys } from '~/modules/commercial/utils/clientFormRules'
|
||||
import {
|
||||
canEditClient,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressView,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
referentialOptionOf,
|
||||
relationOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
type ClientDetail,
|
||||
type SelectOption,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can, canAny } = usePermissions()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Gating de la route : la consultation exige `view`. Usine (sans view) est
|
||||
// redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7.
|
||||
if (!can('commercial.clients.view')) {
|
||||
await navigateTo('/clients')
|
||||
}
|
||||
|
||||
const clientId = route.params.id as string
|
||||
|
||||
const { client, loading, error, load, archive, restore } = useClient(clientId)
|
||||
|
||||
// ── Permissions / visibilite des actions ───────────────────────────────────
|
||||
const canAccountingView = computed(() => can('commercial.clients.accounting.view'))
|
||||
const canEdit = computed(() => canEditClient(canAny))
|
||||
const isArchived = computed(() => client.value?.isArchived === true)
|
||||
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
|
||||
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
|
||||
|
||||
const headerTitle = computed(() => client.value?.companyName ?? t('commercial.clients.consultation.title'))
|
||||
|
||||
// ── Donnees derivees du payload (lecture seule) ────────────────────────────
|
||||
const relation = computed(() => (client.value ? relationOf(client.value) : { type: null, name: null }))
|
||||
const categoryIris = computed(() => (client.value?.categories ?? []).map(c => c['@id']))
|
||||
|
||||
const information = computed(() => ({
|
||||
description: client.value?.description ?? null,
|
||||
competitors: client.value?.competitors ?? null,
|
||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime renvoye.
|
||||
foundedAt: client.value?.foundedAt ? client.value.foundedAt.slice(0, 10) : null,
|
||||
employeesCount: client.value?.employeesCount != null ? String(client.value.employeesCount) : null,
|
||||
revenueAmount: client.value?.revenueAmount ?? null,
|
||||
profitAmount: client.value?.profitAmount ?? null,
|
||||
directorName: client.value?.directorName ?? null,
|
||||
}))
|
||||
|
||||
// Chaque bloc reste visible meme vide en consultation : si la collection est
|
||||
// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun … »).
|
||||
const contacts = computed(() => {
|
||||
const list = (client.value?.contacts ?? []).map(mapContactToDraft)
|
||||
return list.length ? list : [emptyContact()]
|
||||
})
|
||||
// Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
|
||||
const addressViews = computed(() => {
|
||||
const views = (client.value?.addresses ?? []).map(mapAddressView)
|
||||
return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }]
|
||||
})
|
||||
const ribs = computed(() => {
|
||||
const list = (client.value?.ribs ?? []).map(mapRibToDraft)
|
||||
return list.length ? list : [emptyRib()]
|
||||
})
|
||||
// Draft comptable (tout null si l'utilisateur n'a pas accounting.view).
|
||||
const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail)))
|
||||
|
||||
// ── Options des selects (construites depuis l'EMBED, jamais via un GET de
|
||||
// referentiel : /categories et /sites sont en 403 pour les roles metier
|
||||
// non-admin, ce qui laisserait les libelles vides). ───────────────────────
|
||||
const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories))
|
||||
const contactOptions = computed(() => contactOptionsOf(client.value?.contacts))
|
||||
|
||||
// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read — donc
|
||||
// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero
|
||||
// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS
|
||||
// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non
|
||||
// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris).
|
||||
const allSiteOptions = computed<SelectOption[]>(() =>
|
||||
(authStore.user?.sites ?? []).map(s => ({
|
||||
value: `/api/sites/${s.id}`,
|
||||
label: (s.postalCode ?? '').slice(0, 2),
|
||||
})),
|
||||
)
|
||||
|
||||
const relationOptions = computed<SelectOption[]>(() => [
|
||||
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
||||
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
||||
])
|
||||
|
||||
const countryOptions: SelectOption[] = [
|
||||
{ value: 'France', label: 'France' },
|
||||
{ value: 'Espagne', label: 'Espagne' },
|
||||
]
|
||||
|
||||
// Selects comptables : libelle issu de l'embed (option unique ou vide).
|
||||
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
|
||||
const paymentDelayOptions = computed(() => referentialOptionOf(client.value?.paymentDelay))
|
||||
const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paymentType))
|
||||
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
|
||||
|
||||
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
||||
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
|
||||
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
|
||||
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
||||
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
information: 'mdi:account-outline',
|
||||
contact: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
transport: 'mdi:truck-delivery-outline',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
statistics: 'mdi:finance',
|
||||
reports: 'mdi:file-document-edit-outline',
|
||||
exchanges: 'mdi:account-group-outline',
|
||||
}
|
||||
|
||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`commercial.clients.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
const activeTab = ref('information')
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
router.push('/clients')
|
||||
}
|
||||
|
||||
function goEdit(): void {
|
||||
router.push(`/clients/${clientId}/edit`)
|
||||
}
|
||||
|
||||
// ── Archivage / Restauration ────────────────────────────────────────────────
|
||||
const confirmOpen = ref(false)
|
||||
const toggling = ref(false)
|
||||
|
||||
function askToggleArchive(): void {
|
||||
confirmOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirme l'archivage ou la restauration (PATCH isArchived seul). Gere le 409
|
||||
* de conflit d'homonyme actif a la restauration (RG-1.23) avec un message dedie.
|
||||
*/
|
||||
async function confirmToggleArchive(): Promise<void> {
|
||||
if (toggling.value) return
|
||||
toggling.value = true
|
||||
const restoring = isArchived.value
|
||||
try {
|
||||
if (restoring) {
|
||||
await restore()
|
||||
toast.success({ title: t('commercial.clients.toast.restoreSuccess') })
|
||||
}
|
||||
else {
|
||||
await archive()
|
||||
toast.success({ title: t('commercial.clients.toast.archiveSuccess') })
|
||||
}
|
||||
confirmOpen.value = false
|
||||
}
|
||||
catch (e) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status
|
||||
toast.error({
|
||||
title: t('commercial.clients.toast.error'),
|
||||
message: restoring && status === 409
|
||||
? t('commercial.clients.toast.restoreConflict')
|
||||
: t('commercial.clients.toast.error'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useHead({ title: headerTitle })
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('commercial.clients.title') }}
|
||||
<template #actions>
|
||||
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. -->
|
||||
<div class="flex items-center gap-12">
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('commercial.clients.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
<!-- Bouton Filtres a DROITE d'Ajouter : meme design que
|
||||
l'audit-log. Le compteur reflete les filtres actifs. -->
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="tertiary"
|
||||
:label="filterButtonLabel"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
button-class="w-[184px] justify-start gap-4 text-black"
|
||||
@click="openFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList via useClientsRepository :
|
||||
pagination serveur, tri companyName ASC par defaut (cote back). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
table-class="table-fixed"
|
||||
:empty-message="t('commercial.clients.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : codes stables separes par une virgule (ERP-78). -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
|
||||
<!-- Sites : badges colores (name + color), agreges des adresses. -->
|
||||
<template #cell-sites="{ item }">
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="site in (item.sites as ClientSite[])"
|
||||
:key="site.id"
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white"
|
||||
:style="{ backgroundColor: site.color }"
|
||||
>
|
||||
{{ site.name }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Derniere activite : date de derniere modification (updatedAt). -->
|
||||
<template #cell-lastActivity="{ item }">
|
||||
{{ formatLastActivity(item) }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<div class="flex justify-center mt-6">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||
« Appliquer ». Meme pattern que l'audit-log. Etat 100 % local, jamais
|
||||
dans l'URL (regle ABSOLUE n°6). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('commercial.clients.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Recherche : nom societe + contact + email (param `search`). -->
|
||||
<MalioAccordionItem :title="t('commercial.clients.filters.search')" value="search">
|
||||
<MalioInputText
|
||||
v-model="draftSearch"
|
||||
icon-name="mdi:magnify"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Categories : cases a cocher (multi). Valeur = code stable. -->
|
||||
<MalioAccordionItem :title="t('commercial.clients.filters.categories')" value="categories">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in categoryOptions"
|
||||
:id="`filter-category-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftCategoryCodes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
|
||||
<MalioAccordionItem :title="t('commercial.clients.filters.sites')" value="sites">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in siteOptions"
|
||||
:id="`filter-site-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftSiteIds.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
|
||||
<MalioAccordionItem :title="t('commercial.clients.filters.status')" value="status">
|
||||
<MalioCheckbox
|
||||
id="filter-archived-only"
|
||||
:label="t('commercial.clients.filters.archivedOnly')"
|
||||
:model-value="draftArchivedOnly"
|
||||
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('commercial.clients.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Client, ClientSite } from '~/modules/commercial/composables/useClientsRepository'
|
||||
|
||||
interface FilterOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('commercial.clients.title') })
|
||||
|
||||
// Bouton « Ajouter » reserve a `manage` (POST /clients garde manage seul →
|
||||
// Compta / Usine ne creent pas). « Exporter » et « Filtres » suivent `view`.
|
||||
const canManage = computed(() => can('commercial.clients.manage'))
|
||||
const canView = computed(() => can('commercial.clients.view'))
|
||||
|
||||
const {
|
||||
items: clients,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadClients,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
} = useClientsRepository()
|
||||
|
||||
// Mappe les clients en objets « plats » pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||
// implicite, contrairement a l'interface Client. Meme pattern que sites.vue.
|
||||
const rows = computed(() => clients.value.map(client => ({
|
||||
id: client.id,
|
||||
companyName: client.companyName,
|
||||
categories: client.categories,
|
||||
sites: client.sites,
|
||||
updatedAt: client.updatedAt,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'companyName', label: t('commercial.clients.column.companyName') },
|
||||
{ key: 'categories', label: t('commercial.clients.column.categories') },
|
||||
{ key: 'sites', label: t('commercial.clients.column.sites') },
|
||||
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Codes des categories du client, separes par une virgule (ERP-78). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Client['categories']) ?? []
|
||||
return categories.map(c => c.code).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Derniere activite : faute de suivi d'activite metier au M1, on affiche la
|
||||
* date de derniere modification de la fiche (updatedAt, expose en liste via
|
||||
* default:read). Format court francais jj/mm/aaaa.
|
||||
*/
|
||||
function formatLastActivity(item: Record<string, unknown>): string {
|
||||
const value = item.updatedAt as string | null | undefined
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('fr-FR')
|
||||
}
|
||||
|
||||
/** Clic sur une ligne → ecran Consultation (route a plat /clients/{id}). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/clients/${item.id}`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/clients/new')
|
||||
}
|
||||
|
||||
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||
// Deux niveaux d'etat (pattern audit-log) :
|
||||
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||
// uniquement au clic « Appliquer » / « Réinitialiser ».
|
||||
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||
const filterDrawerOpen = ref(false)
|
||||
|
||||
const draftSearch = ref('')
|
||||
const draftCategoryCodes = ref<string[]>([])
|
||||
const draftSiteIds = ref<string[]>([])
|
||||
const draftArchivedOnly = ref(false)
|
||||
|
||||
const appliedSearch = ref('')
|
||||
const appliedCategoryCodes = ref<string[]>([])
|
||||
const appliedSiteIds = ref<string[]>([])
|
||||
const appliedArchivedOnly = ref(false)
|
||||
|
||||
// Options des selects multi, chargees une fois (referentiels courts).
|
||||
const categoryOptions = ref<FilterOption[]>([])
|
||||
const siteOptions = ref<FilterOption[]>([])
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
let count = 0
|
||||
if (appliedSearch.value.trim() !== '') count++
|
||||
if (appliedCategoryCodes.value.length > 0) count++
|
||||
if (appliedSiteIds.value.length > 0) count++
|
||||
if (appliedArchivedOnly.value) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const filterButtonLabel = computed(() => {
|
||||
const base = t('commercial.clients.filters.title')
|
||||
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||
})
|
||||
|
||||
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
|
||||
// reouverture reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftSearch.value = appliedSearch.value
|
||||
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||
draftSiteIds.value = [...appliedSiteIds.value]
|
||||
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function toggleCategory(code: string, selected: boolean): void {
|
||||
draftCategoryCodes.value = selected
|
||||
? [...draftCategoryCodes.value, code]
|
||||
: draftCategoryCodes.value.filter(c => c !== code)
|
||||
}
|
||||
|
||||
function toggleSite(id: string, selected: boolean): void {
|
||||
draftSiteIds.value = selected
|
||||
? [...draftSiteIds.value, id]
|
||||
: draftSiteIds.value.filter(s => s !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
|
||||
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
|
||||
* Les filtres vides sont omis pour une query propre.
|
||||
*/
|
||||
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||
const payload: Record<string, string | string[] | boolean> = {}
|
||||
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||
return payload
|
||||
}
|
||||
|
||||
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
|
||||
// page 1 via usePaginatedList) et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
appliedSearch.value = draftSearch.value.trim()
|
||||
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||
appliedSiteIds.value = [...draftSiteIds.value]
|
||||
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||
|
||||
setFilters(buildFilterPayload(), { replace: true })
|
||||
filterDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftSearch.value = ''
|
||||
draftCategoryCodes.value = []
|
||||
draftSiteIds.value = []
|
||||
draftArchivedOnly.value = false
|
||||
|
||||
appliedSearch.value = ''
|
||||
appliedCategoryCodes.value = []
|
||||
appliedSiteIds.value = []
|
||||
appliedArchivedOnly.value = false
|
||||
|
||||
setFilters({}, { replace: true })
|
||||
}
|
||||
|
||||
/** Charge les referentiels du drawer (categories + sites) via ?pagination=false. */
|
||||
async function loadFilterOptions(): Promise<void> {
|
||||
const [cats, sites] = await Promise.all([
|
||||
api.get<{ member?: Array<{ code: string, name: string }> }>(
|
||||
'/categories',
|
||||
{ pagination: 'false' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
api.get<{ member?: Array<{ id: number, name: string }> }>(
|
||||
'/sites',
|
||||
{ pagination: 'false' },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
),
|
||||
])
|
||||
|
||||
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
|
||||
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
|
||||
}
|
||||
|
||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
|
||||
// l'utilisateur a accounting.view (gere cote back).
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportXlsx(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||
// contenu faute d'overload blob sur le client partage — a generaliser via
|
||||
// un ticket dedie si d'autres exports binaires arrivent.
|
||||
const blob = await api.get<Blob>('/clients/export.xlsx', buildFilterPayload(), {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'repertoire-clients.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('commercial.clients.toast.error'),
|
||||
message: t('commercial.clients.toast.exportError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadClients()
|
||||
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
|
||||
// l'utilisateur perd juste les options de filtre.
|
||||
loadFilterOptions().catch(() => {
|
||||
categoryOptions.value = []
|
||||
siteOptions.value = []
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,892 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete : retour vers le repertoire + titre. -->
|
||||
<div class="flex items-center gap-3">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[32px] font-bold text-m-primary">{{ t('commercial.clients.form.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
||||
Sans validation de ce bloc, les onglets restent inaccessibles. Au
|
||||
succes du POST, les champs passent en lecture seule et on bascule
|
||||
automatiquement sur l'onglet Information. -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="referentials.categories.value"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:disabled="mainLocked"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="main.relationType"
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
:disabled="mainLocked"
|
||||
@update:model-value="onRelationChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="main.relationType === 'courtier'"
|
||||
:model-value="main.brokerIri"
|
||||
:options="referentials.brokers.value"
|
||||
:label="t('commercial.clients.form.main.brokerName')"
|
||||
:disabled="mainLocked"
|
||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="main.relationType === 'distributeur'"
|
||||
:model-value="main.distributorIri"
|
||||
:options="referentials.distributors.value"
|
||||
:label="t('commercial.clients.form.main.distributorName')"
|
||||
:disabled="mainLocked"
|
||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioCheckbox
|
||||
v-model="main.triageService"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
:readonly="mainLocked"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!mainLocked" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.form.submit')"
|
||||
:disabled="!isMainValid || mainSubmitting"
|
||||
@click="submitMain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Onglets a validation incrementale ─────────────────────────────-->
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||
<!-- Onglet Information -->
|
||||
<template #information>
|
||||
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- pt-1 : aligne le bord superieur du textarea sur celui des
|
||||
inputs (centres dans un conteneur h-12, soit ~4px de retrait haut). -->
|
||||
<MalioInputTextArea
|
||||
v-model="information.description"
|
||||
:label="t('commercial.clients.form.information.description')"
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1"
|
||||
text-input="h-full text-lg"
|
||||
:disabled="isValidated('information')"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
:readonly="isValidated('information')"
|
||||
/>
|
||||
<MalioDate
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
:readonly="isValidated('information')"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="isValidated('information')"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
:disabled="isValidated('information')"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
:readonly="isValidated('information')"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
:disabled="isValidated('information')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
||||
<!-- Desactive tant que le client n'est pas cree : evite un PATCH
|
||||
avant le POST si l'utilisateur clique trop tot (le panneau
|
||||
Information est l'onglet actif par defaut). -->
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.form.submit')"
|
||||
:disabled="tabSubmitting || clientId === null"
|
||||
@click="submitInformation"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Contact -->
|
||||
<template #contact>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ClientContactBlock
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('contact')"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
/>
|
||||
<div v-if="!isValidated('contact')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.form.contact.add')"
|
||||
:disabled="!canAddContact"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.form.submit')"
|
||||
:disabled="!canValidateContacts || tabSubmitting"
|
||||
@click="submitContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Adresse -->
|
||||
<template #address>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<ClientAddressBlock
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||
:category-options="addressCategoryOptions"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="index > 0"
|
||||
:readonly="isValidated('address')"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@degraded="onAddressDegraded"
|
||||
/>
|
||||
<div v-if="!isValidated('address')" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.form.address.add')"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.form.submit')"
|
||||
:disabled="!canValidateAddresses || tabSubmitting"
|
||||
@click="submitAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="onPaymentTypeChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isBankRequired"
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR. -->
|
||||
<div
|
||||
v-for="(rib, index) in ribs"
|
||||
:key="index"
|
||||
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||
>
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="!accountingReadonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||
@click="askRemoveRib(index)"
|
||||
/>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.form.accounting.addRib')"
|
||||
@click="addRib"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.form.submit')"
|
||||
:disabled="!canValidateAccounting || tabSubmitting"
|
||||
@click="submitAccounting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet non encore implemente : frame vide, passage automatique.
|
||||
Statistiques / Rapports / Echanges sont edit-only (absents a la
|
||||
creation) — cf. buildClientFormTabKeys. -->
|
||||
<template #transport><ComingSoonPlaceholder /></template>
|
||||
</MalioTabList>
|
||||
|
||||
<!-- Modal de confirmation generique (suppression contact/adresse/RIB). -->
|
||||
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('commercial.clients.form.confirmDelete.title') }}</h2>
|
||||
</template>
|
||||
<p>{{ confirmModal.message }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('commercial.clients.form.confirmDelete.cancel')"
|
||||
@click="confirmModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
button-class="flex-1"
|
||||
:label="t('commercial.clients.form.confirmDelete.confirm')"
|
||||
@click="runConfirm"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||
import {
|
||||
buildClientFormTabKeys,
|
||||
CLIENT_FORM_PLACEHOLDER_TABS,
|
||||
hasAtLeastOneValidContact,
|
||||
isBankRequiredForPaymentType,
|
||||
isBillingEmailRequired,
|
||||
isContactNamed,
|
||||
isRibRequiredForPaymentType,
|
||||
} from '~/modules/commercial/utils/clientFormRules'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
emptyRib,
|
||||
type AddressFormDraft,
|
||||
type ContactFormDraft,
|
||||
type RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
|
||||
const EMPLOYEES_MASK = '#######'
|
||||
|
||||
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
||||
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
/** Retour vers le repertoire clients (fleche d'en-tete). */
|
||||
function goBack(): void {
|
||||
router.push('/clients')
|
||||
}
|
||||
|
||||
/**
|
||||
* Message d'erreur a afficher dans un toast a partir d'une erreur d'API.
|
||||
* Retourne TOUJOURS une chaine (le composant de toast plante sur `undefined`) :
|
||||
* le message de validation renvoye par le serveur (violations 422 / detail),
|
||||
* sinon un libelle generique.
|
||||
*/
|
||||
function apiErrorMessage(error: unknown): string {
|
||||
const data = (error as { data?: unknown })?.data
|
||||
return extractApiErrorMessage(data) || t('commercial.clients.toast.error')
|
||||
}
|
||||
|
||||
useHead({ title: t('commercial.clients.form.title') })
|
||||
|
||||
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
|
||||
// seul) et Usine sont rediriges vers le repertoire (cf. §0 du ticket).
|
||||
if (!can('commercial.clients.manage')) {
|
||||
await navigateTo('/clients')
|
||||
}
|
||||
|
||||
const canAccountingView = computed(() => can('commercial.clients.accounting.view'))
|
||||
const canAccountingManage = computed(() => can('commercial.clients.accounting.manage'))
|
||||
|
||||
const referentials = useClientReferentials()
|
||||
|
||||
// ── Etat du client cree ────────────────────────────────────────────────────
|
||||
const clientId = ref<number | null>(null)
|
||||
const mainLocked = ref(false)
|
||||
const mainSubmitting = ref(false)
|
||||
const tabSubmitting = ref(false)
|
||||
|
||||
// ── Formulaire principal ────────────────────────────────────────────────────
|
||||
const main = reactive({
|
||||
companyName: null as string | null,
|
||||
categoryIris: [] as string[],
|
||||
relationType: null as 'distributeur' | 'courtier' | null,
|
||||
distributorIri: null as string | null,
|
||||
brokerIri: null as string | null,
|
||||
triageService: false,
|
||||
})
|
||||
|
||||
// Pas d'option « Aucun » : le select est vide par defaut (relationType = null).
|
||||
const relationOptions = computed<RefOption[]>(() => [
|
||||
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
||||
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
||||
])
|
||||
|
||||
// Validation du formulaire principal (gate le bouton « Valider ») :
|
||||
// - companyName / >= 1 categorie obligatoires ;
|
||||
// - relation Distributeur/Courtier optionnelle, mais le nom correspondant
|
||||
// devient requis si l'un des deux est choisi (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 filled = (v: string | null | undefined) => v !== null && v !== undefined && v.trim() !== ''
|
||||
// Relation Distributeur/Courtier OPTIONNELLE ; mais si « Depend du
|
||||
// distributeur/courtier » est choisi, le nom correspondant devient requis.
|
||||
const relationValid
|
||||
= main.relationType === null
|
||||
|| (main.relationType === 'distributeur' && filled(main.distributorIri))
|
||||
|| (main.relationType === 'courtier' && filled(main.brokerIri))
|
||||
return filled(main.companyName)
|
||||
&& main.categoryIris.length >= 1
|
||||
&& relationValid
|
||||
})
|
||||
|
||||
async function onRelationChange(value: string | number | null): Promise<void> {
|
||||
const relation = (value === null || value === '')
|
||||
? null
|
||||
: (String(value) as 'distributeur' | 'courtier')
|
||||
main.relationType = relation
|
||||
// Reinitialise la FK non concernee (une seule remplie a la fois, RG-1.03).
|
||||
if (relation !== 'distributeur') main.distributorIri = null
|
||||
if (relation !== 'courtier') main.brokerIri = null
|
||||
|
||||
if (relation === 'distributeur') await referentials.loadDistributors()
|
||||
if (relation === 'courtier') await referentials.loadBrokers()
|
||||
}
|
||||
|
||||
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
|
||||
async function submitMain(): Promise<void> {
|
||||
if (!isMainValid.value || mainSubmitting.value) return
|
||||
mainSubmitting.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
companyName: main.companyName,
|
||||
categories: main.categoryIris,
|
||||
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
||||
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
||||
triageService: main.triageService,
|
||||
}
|
||||
const created = await api.post<ClientResponse>('/clients', payload, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
|
||||
clientId.value = created.id
|
||||
// Reaffiche la valeur normalisee renvoyee par le serveur.
|
||||
main.companyName = created.companyName ?? main.companyName
|
||||
|
||||
mainLocked.value = true
|
||||
unlockedIndex.value = 0
|
||||
activeTab.value = 'information'
|
||||
toast.success({ title: t('commercial.clients.toast.createSuccess') })
|
||||
}
|
||||
catch (error) {
|
||||
// 409 = doublon nom de societe (RG d'unicite) → message explicite ;
|
||||
// sinon on remonte le message de validation du serveur (ex: 422).
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
toast.error({
|
||||
title: t('commercial.clients.toast.error'),
|
||||
message: status === 409
|
||||
? t('commercial.clients.form.duplicateCompany')
|
||||
: apiErrorMessage(error),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
mainSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglets : ordre + gating progressif ─────────────────────────────────────
|
||||
const activeTab = ref('information')
|
||||
// Index du dernier onglet deverrouille (-1 tant que le client n'est pas cree).
|
||||
const unlockedIndex = ref(-1)
|
||||
// Onglets valides (passent en lecture seule).
|
||||
const validated = reactive<Record<string, boolean>>({})
|
||||
|
||||
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
|
||||
|
||||
// Icone (Iconify) affichee dans l'onglet, par cle. A ajuster librement.
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
information: 'mdi:account-outline',
|
||||
contact: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
transport: 'mdi:truck-delivery-outline',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
statistics: 'mdi:finance',
|
||||
reports: 'mdi:file-document-edit-outline',
|
||||
exchanges: 'mdi:account-group-outline',
|
||||
}
|
||||
|
||||
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||
key,
|
||||
label: t(`commercial.clients.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
disabled: index > unlockedIndex.value,
|
||||
})))
|
||||
|
||||
function isValidated(key: string): boolean {
|
||||
return validated[key] === true
|
||||
}
|
||||
|
||||
function tabIndex(key: string): number {
|
||||
return tabKeys.value.indexOf(key)
|
||||
}
|
||||
|
||||
/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */
|
||||
function completeTab(key: string): void {
|
||||
validated[key] = true
|
||||
const next = tabKeys.value[tabIndex(key) + 1]
|
||||
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
||||
if (next) activeTab.value = next
|
||||
}
|
||||
|
||||
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
|
||||
watch(activeTab, (key) => {
|
||||
if ((CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key)) {
|
||||
const next = tabKeys.value[tabIndex(key) + 1]
|
||||
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
||||
if (next) activeTab.value = next
|
||||
}
|
||||
})
|
||||
|
||||
// ── Onglet Information ──────────────────────────────────────────────────────
|
||||
const information = reactive({
|
||||
description: null as string | null,
|
||||
competitors: null as string | null,
|
||||
foundedAt: null as string | null,
|
||||
employeesCount: null as string | null,
|
||||
revenueAmount: null as string | null,
|
||||
profitAmount: null as string | null,
|
||||
directorName: null as string | null,
|
||||
})
|
||||
|
||||
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
||||
async function submitInformation(): Promise<void> {
|
||||
if (clientId.value === null || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
await api.patch(`/clients/${clientId.value}`, {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
foundedAt: information.foundedAt || null,
|
||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||
revenueAmount: information.revenueAmount || null,
|
||||
profitAmount: information.profitAmount || null,
|
||||
directorName: information.directorName || null,
|
||||
}, { toast: false })
|
||||
completeTab('information')
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (error) {
|
||||
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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()])
|
||||
|
||||
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
|
||||
const canAddContact = computed(() => {
|
||||
const last = contacts.value[contacts.value.length - 1]
|
||||
return last !== undefined && isContactNamed(last)
|
||||
})
|
||||
|
||||
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
|
||||
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
|
||||
|
||||
function addContact(): void {
|
||||
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||
}
|
||||
|
||||
function askRemoveContact(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
||||
contacts.value.splice(index, 1)
|
||||
})
|
||||
}
|
||||
|
||||
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
|
||||
async function submitContacts(): Promise<void> {
|
||||
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
for (const contact of contacts.value) {
|
||||
// On ignore les blocs totalement vides (ni nom ni prenom).
|
||||
if (!isContactNamed(contact)) continue
|
||||
|
||||
const body = {
|
||||
firstName: contact.firstName || null,
|
||||
lastName: contact.lastName || null,
|
||||
jobTitle: contact.jobTitle || null,
|
||||
phonePrimary: contact.phonePrimary || null,
|
||||
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||
email: contact.email || null,
|
||||
}
|
||||
|
||||
if (contact.id === null) {
|
||||
const created = await api.post<ContactResponse>(
|
||||
`/clients/${clientId.value}/contacts`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
contact.id = created.id
|
||||
contact.iri = created['@id'] ?? null
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
||||
}
|
||||
}
|
||||
completeTab('contact')
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (error) {
|
||||
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Adresse ──────────────────────────────────────────────────────────
|
||||
const addresses = ref<AddressFormDraft[]>([emptyAddress()])
|
||||
const addressDegradedNotified = ref(false)
|
||||
|
||||
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
||||
const addressCategoryOptions = computed(() =>
|
||||
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
||||
)
|
||||
|
||||
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
|
||||
const contactOptions = computed<RefOption[]>(() =>
|
||||
contacts.value
|
||||
.filter(c => c.iri !== null)
|
||||
.map(c => ({
|
||||
value: c.iri as string,
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||
})),
|
||||
)
|
||||
|
||||
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
|
||||
const countryOptions: RefOption[] = [
|
||||
{ value: 'France', label: 'France' },
|
||||
{ value: 'Espagne', label: 'Espagne' },
|
||||
]
|
||||
|
||||
// RG-1.10 (>= 1 site) + RG-1.11 (email facturation si Facturation) sur chaque adresse.
|
||||
const canValidateAddresses = computed(() =>
|
||||
addresses.value.length > 0
|
||||
&& addresses.value.every((a) => {
|
||||
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
|
||||
return a.siteIris.length >= 1
|
||||
&& a.categoryIris.length >= 1
|
||||
&& (!isBillingEmailRequired(a) || filledBillingEmail)
|
||||
}),
|
||||
)
|
||||
|
||||
function addAddress(): void {
|
||||
addresses.value.push(emptyAddress())
|
||||
}
|
||||
|
||||
function askRemoveAddress(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
||||
addresses.value.splice(index, 1)
|
||||
})
|
||||
}
|
||||
|
||||
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade. */
|
||||
function onAddressDegraded(): void {
|
||||
if (addressDegradedNotified.value) return
|
||||
addressDegradedNotified.value = true
|
||||
toast.warning({
|
||||
title: t('commercial.clients.toast.error'),
|
||||
message: t('commercial.clients.form.address.degraded'),
|
||||
})
|
||||
}
|
||||
|
||||
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
|
||||
async function submitAddresses(): Promise<void> {
|
||||
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
for (const address of addresses.value) {
|
||||
const body = {
|
||||
isProspect: address.isProspect,
|
||||
isDelivery: address.isDelivery,
|
||||
isBilling: address.isBilling,
|
||||
country: address.country,
|
||||
postalCode: address.postalCode || null,
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
categories: address.categoryIris,
|
||||
sites: address.siteIris,
|
||||
contacts: address.contactIris,
|
||||
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
|
||||
}
|
||||
|
||||
if (address.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId.value}/addresses`,
|
||||
body,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
address.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
||||
}
|
||||
}
|
||||
completeTab('address')
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (error) {
|
||||
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Onglet Comptabilite ─────────────────────────────────────────────────────
|
||||
const accounting = reactive({
|
||||
siren: null as string | null,
|
||||
accountNumber: null as string | null,
|
||||
tvaModeIri: null as string | null,
|
||||
nTva: null as string | null,
|
||||
paymentDelayIri: null as string | null,
|
||||
paymentTypeIri: null as string | null,
|
||||
bankIri: null as string | null,
|
||||
})
|
||||
const ribs = ref<RibFormDraft[]>([])
|
||||
|
||||
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
|
||||
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
|
||||
|
||||
// Code du type de reglement selectionne (pour RG-1.12 / RG-1.13).
|
||||
const selectedPaymentTypeCode = computed(() =>
|
||||
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||
)
|
||||
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||
|
||||
function onPaymentTypeChange(value: string | number | null): void {
|
||||
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
|
||||
if (!isBankRequired.value) accounting.bankIri = null
|
||||
}
|
||||
|
||||
function ribIsComplete(rib: RibFormDraft): boolean {
|
||||
const filled = (v: string | null) => v !== null && v.trim() !== ''
|
||||
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
|
||||
}
|
||||
|
||||
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
|
||||
const canValidateAccounting = computed(() => {
|
||||
if (isBankRequired.value && (accounting.bankIri === null)) return false
|
||||
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function addRib(): void {
|
||||
ribs.value.push(emptyRib())
|
||||
}
|
||||
|
||||
function askRemoveRib(index: number): void {
|
||||
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
||||
ribs.value.splice(index, 1)
|
||||
// Garde au moins un bloc RIB visible (cf. amorce au montage).
|
||||
if (ribs.value.length === 0) ribs.value.push(emptyRib())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting)
|
||||
* PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict
|
||||
* RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back).
|
||||
*/
|
||||
async function submitAccounting(): Promise<void> {
|
||||
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
await api.patch(`/clients/${clientId.value}`, {
|
||||
siren: accounting.siren || null,
|
||||
accountNumber: accounting.accountNumber || null,
|
||||
tvaMode: accounting.tvaModeIri,
|
||||
nTva: accounting.nTva || null,
|
||||
paymentDelay: accounting.paymentDelayIri,
|
||||
paymentType: accounting.paymentTypeIri,
|
||||
bank: isBankRequired.value ? accounting.bankIri : null,
|
||||
}, { toast: false })
|
||||
|
||||
for (const rib of ribs.value) {
|
||||
if (!ribIsComplete(rib)) continue
|
||||
if (rib.id === null) {
|
||||
const created = await api.post<{ id: number }>(
|
||||
`/clients/${clientId.value}/ribs`,
|
||||
{ label: rib.label, bic: rib.bic, iban: rib.iban },
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
rib.id = created.id
|
||||
}
|
||||
else {
|
||||
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
|
||||
}
|
||||
}
|
||||
|
||||
completeTab('accounting')
|
||||
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||
}
|
||||
catch (error) {
|
||||
toast.error({ title: t('commercial.clients.toast.error'), message: apiErrorMessage(error) })
|
||||
}
|
||||
finally {
|
||||
tabSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal de confirmation generique ─────────────────────────────────────────
|
||||
const confirmModal = reactive({
|
||||
open: false,
|
||||
message: '',
|
||||
action: null as null | (() => void),
|
||||
})
|
||||
|
||||
function askConfirm(message: string, action: () => void): void {
|
||||
confirmModal.message = message
|
||||
confirmModal.action = action
|
||||
confirmModal.open = true
|
||||
}
|
||||
|
||||
function runConfirm(): void {
|
||||
confirmModal.action?.()
|
||||
confirmModal.action = null
|
||||
confirmModal.open = false
|
||||
}
|
||||
|
||||
// ── Types de reponse API ────────────────────────────────────────────────────
|
||||
interface ClientResponse {
|
||||
id: number
|
||||
companyName: string | null
|
||||
}
|
||||
|
||||
interface ContactResponse {
|
||||
'@id'?: string
|
||||
id: number
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||
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>
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Types « brouillon » de l'ecran « Ajouter un client » (M1 Commercial).
|
||||
*
|
||||
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
|
||||
* DTO de l'API : elles portent en plus des champs purement UI (`hasSecondaryPhone`)
|
||||
* et l'`iri` Hydra des entites creees (necessaire pour rattacher une adresse a
|
||||
* des contacts deja persistes, M2M). Partage par la page et les blocs reutilisables
|
||||
* `ClientContactBlock` / `ClientAddressBlock` (reutilises par 1.11/1.12).
|
||||
*/
|
||||
|
||||
/** Un contact du client (onglet Contact). */
|
||||
export interface ContactFormDraft {
|
||||
/** Id serveur une fois le contact cree (null tant que non persiste). */
|
||||
id: number | null
|
||||
/** IRI Hydra du contact cree — utilise pour le rattachement M2M cote adresse. */
|
||||
iri: string | null
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
jobTitle: string | null
|
||||
phonePrimary: string | null
|
||||
phoneSecondary: string | null
|
||||
email: string | null
|
||||
/** UI : le 2e numero a ete revele via le bouton « + ». */
|
||||
hasSecondaryPhone: boolean
|
||||
}
|
||||
|
||||
/** Une adresse du client (onglet Adresse). */
|
||||
export interface AddressFormDraft {
|
||||
id: number | null
|
||||
isProspect: boolean
|
||||
isDelivery: boolean
|
||||
isBilling: boolean
|
||||
country: string
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
/** IRI des categories rattachees (hors DISTRIBUTEUR/COURTIER — RG-1.29). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites Starseed rattaches (>= 1 obligatoire — RG-1.10). */
|
||||
siteIris: string[]
|
||||
/** IRI des contacts rattaches (= blocs Contact deja crees). */
|
||||
contactIris: string[]
|
||||
/** Email de facturation (obligatoire si isBilling — RG-1.11). */
|
||||
billingEmail: string | null
|
||||
}
|
||||
|
||||
/** Un RIB du client (onglet Comptabilite). */
|
||||
export interface RibFormDraft {
|
||||
id: number | null
|
||||
label: string | null
|
||||
bic: string | null
|
||||
iban: string | null
|
||||
}
|
||||
|
||||
/** Fabrique un contact vierge. */
|
||||
export function emptyContact(): ContactFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
iri: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
jobTitle: null,
|
||||
phonePrimary: null,
|
||||
phoneSecondary: null,
|
||||
email: null,
|
||||
hasSecondaryPhone: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fabrique une adresse vierge (pays prerempli « France »). */
|
||||
export function emptyAddress(): AddressFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
isProspect: false,
|
||||
isDelivery: false,
|
||||
isBilling: false,
|
||||
country: 'France',
|
||||
postalCode: null,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
contactIris: [],
|
||||
billingEmail: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fabrique un RIB vierge. */
|
||||
export function emptyRib(): RibFormDraft {
|
||||
return {
|
||||
id: null,
|
||||
label: null,
|
||||
bic: null,
|
||||
iban: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canEditClient,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
iriOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
mapAddressView,
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
referentialOptionOf,
|
||||
relationOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
siteOptionsOf,
|
||||
type ClientDetail,
|
||||
} from '../clientConsultation'
|
||||
|
||||
describe('iriOf', () => {
|
||||
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
|
||||
expect(iriOf({ '@id': '/api/payment_types/10', code: 'LCR' })).toBe('/api/payment_types/10')
|
||||
})
|
||||
|
||||
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
|
||||
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
|
||||
})
|
||||
|
||||
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
|
||||
expect(iriOf(null)).toBeNull()
|
||||
expect(iriOf(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('relationOf', () => {
|
||||
it('detecte une relation distributeur et expose son nom', () => {
|
||||
const client = { distributor: { '@id': '/api/clients/15', companyName: 'DISTRIB GRAND SUD-OUEST' } } as ClientDetail
|
||||
expect(relationOf(client)).toEqual({ type: 'distributeur', name: 'DISTRIB GRAND SUD-OUEST' })
|
||||
})
|
||||
|
||||
it('detecte une relation courtier et expose son nom', () => {
|
||||
const client = { broker: { '@id': '/api/clients/16', companyName: 'CABINET LEONARD' } } as ClientDetail
|
||||
expect(relationOf(client)).toEqual({ type: 'courtier', name: 'CABINET LEONARD' })
|
||||
})
|
||||
|
||||
it('retourne type null quand aucune relation n\'est posee (cles omises)', () => {
|
||||
expect(relationOf({} as ClientDetail)).toEqual({ type: null, name: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapContactToDraft', () => {
|
||||
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
|
||||
const draft = mapContactToDraft({
|
||||
'@id': '/api/client_contacts/18',
|
||||
id: 18,
|
||||
firstName: 'Sophie',
|
||||
lastName: 'Léonard',
|
||||
jobTitle: 'Gérante',
|
||||
phonePrimary: '0549112233',
|
||||
email: 'sophie@x.fr',
|
||||
})
|
||||
expect(draft.id).toBe(18)
|
||||
expect(draft.iri).toBe('/api/client_contacts/18')
|
||||
expect(draft.phonePrimary).toBe('05 49 11 22 33')
|
||||
expect(draft.hasSecondaryPhone).toBe(false)
|
||||
})
|
||||
|
||||
it('revele le 2e telephone quand phoneSecondary est present', () => {
|
||||
const draft = mapContactToDraft({
|
||||
'@id': '/api/client_contacts/19',
|
||||
id: 19,
|
||||
phonePrimary: '0600000000',
|
||||
phoneSecondary: '0611111111',
|
||||
})
|
||||
expect(draft.hasSecondaryPhone).toBe(true)
|
||||
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapAddressToDraft', () => {
|
||||
it('extrait les iris de sites / categories / contacts (objets ou chaines)', () => {
|
||||
const draft = mapAddressToDraft({
|
||||
'@id': '/api/client_addresses/18',
|
||||
id: 18,
|
||||
country: 'France',
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
street: '5 rue des Courtiers',
|
||||
billingEmail: 'factures@x.fr',
|
||||
isProspect: false,
|
||||
isDelivery: false,
|
||||
isBilling: true,
|
||||
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#056CF2' }],
|
||||
categories: [{ '@id': '/api/categories/3', code: 'SECTEUR' }],
|
||||
contacts: [{ '@id': '/api/client_contacts/18' }, '/api/client_contacts/20'],
|
||||
})
|
||||
expect(draft.siteIris).toEqual(['/api/sites/4'])
|
||||
expect(draft.categoryIris).toEqual(['/api/categories/3'])
|
||||
expect(draft.contactIris).toEqual(['/api/client_contacts/18', '/api/client_contacts/20'])
|
||||
expect(draft.isBilling).toBe(true)
|
||||
expect(draft.city).toBe('Châtellerault')
|
||||
expect(draft.country).toBe('France')
|
||||
})
|
||||
|
||||
it('tolere les sous-collections absentes (defaut tableau vide, pays France)', () => {
|
||||
const draft = mapAddressToDraft({ '@id': '/api/client_addresses/9', id: 9 })
|
||||
expect(draft.siteIris).toEqual([])
|
||||
expect(draft.categoryIris).toEqual([])
|
||||
expect(draft.contactIris).toEqual([])
|
||||
expect(draft.country).toBe('France')
|
||||
expect(draft.isBilling).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapRibToDraft', () => {
|
||||
it('mappe label / bic / iban et l\'id serveur', () => {
|
||||
const draft = mapRibToDraft({ '@id': '/api/client_ribs/3', id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
|
||||
expect(draft).toEqual({ id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapAccountingDraft', () => {
|
||||
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
|
||||
const acc = mapAccountingDraft({
|
||||
'@id': '/api/clients/1',
|
||||
id: 1,
|
||||
siren: '123456789',
|
||||
accountNumber: '411000',
|
||||
nTva: 'FR123',
|
||||
tvaMode: { '@id': '/api/tva_modes/1' },
|
||||
paymentDelay: { '@id': '/api/payment_delays/2' },
|
||||
paymentType: { '@id': '/api/payment_types/10', code: 'LCR' },
|
||||
bank: { '@id': '/api/banks/3' },
|
||||
} as ClientDetail)
|
||||
expect(acc).toEqual({
|
||||
siren: '123456789',
|
||||
accountNumber: '411000',
|
||||
nTva: 'FR123',
|
||||
tvaModeIri: '/api/tva_modes/1',
|
||||
paymentDelayIri: '/api/payment_delays/2',
|
||||
paymentTypeIri: '/api/payment_types/10',
|
||||
bankIri: '/api/banks/3',
|
||||
})
|
||||
})
|
||||
|
||||
it('renvoie des null quand les champs comptables sont absents (sans accounting.view)', () => {
|
||||
const acc = mapAccountingDraft({} as ClientDetail)
|
||||
expect(acc).toEqual({
|
||||
siren: null,
|
||||
accountNumber: null,
|
||||
nTva: null,
|
||||
tvaModeIri: null,
|
||||
paymentDelayIri: null,
|
||||
paymentTypeIri: null,
|
||||
bankIri: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('options construites depuis l\'embed (role-independantes)', () => {
|
||||
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
|
||||
expect(categoryOptionsOf([{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }])).toEqual([
|
||||
{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' },
|
||||
])
|
||||
})
|
||||
|
||||
it('siteOptionsOf expose value=IRI, label=nom', () => {
|
||||
expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([
|
||||
{ value: '/api/sites/4', label: 'Chatellerault' },
|
||||
])
|
||||
})
|
||||
|
||||
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
|
||||
expect(contactOptionsOf([
|
||||
{ '@id': '/api/client_contacts/1', id: 1, firstName: 'Jean', lastName: 'Dupont' },
|
||||
{ '@id': '/api/client_contacts/2', id: 2, email: 'a@b.fr' },
|
||||
])).toEqual([
|
||||
{ value: '/api/client_contacts/1', label: 'Jean Dupont' },
|
||||
{ value: '/api/client_contacts/2', label: 'a@b.fr' },
|
||||
])
|
||||
})
|
||||
|
||||
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
|
||||
expect(referentialOptionOf({ '@id': '/api/payment_types/10', label: 'LCR' })).toEqual([
|
||||
{ value: '/api/payment_types/10', label: 'LCR' },
|
||||
])
|
||||
expect(referentialOptionOf('/api/banks/3')).toEqual([])
|
||||
expect(referentialOptionOf(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
|
||||
const view = mapAddressView({
|
||||
'@id': '/api/client_addresses/18',
|
||||
id: 18,
|
||||
city: 'Châtellerault',
|
||||
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault' }],
|
||||
categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }],
|
||||
})
|
||||
expect(view.draft.id).toBe(18)
|
||||
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }])
|
||||
expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('canEditClient', () => {
|
||||
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
|
||||
|
||||
it('visible pour manage', () => {
|
||||
expect(canEditClient(can(['commercial.clients.manage']))).toBe(true)
|
||||
})
|
||||
|
||||
it('visible pour accounting.manage (role Compta)', () => {
|
||||
expect(canEditClient(can(['commercial.clients.accounting.manage']))).toBe(true)
|
||||
})
|
||||
|
||||
it('masque sans aucune des deux permissions (role Usine)', () => {
|
||||
expect(canEditClient(can(['commercial.clients.view']))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showArchiveAction / showRestoreAction', () => {
|
||||
const can = (granted: string[]) => (code: string) => granted.includes(code)
|
||||
|
||||
it('Archiver : visible avec la permission archive ET client non archive', () => {
|
||||
expect(showArchiveAction(can(['commercial.clients.archive']), false)).toBe(true)
|
||||
expect(showArchiveAction(can(['commercial.clients.archive']), true)).toBe(false)
|
||||
expect(showArchiveAction(can([]), false)).toBe(false)
|
||||
})
|
||||
|
||||
it('Restaurer : visible avec la permission archive ET client archive', () => {
|
||||
expect(showRestoreAction(can(['commercial.clients.archive']), true)).toBe(true)
|
||||
expect(showRestoreAction(can(['commercial.clients.archive']), false)).toBe(false)
|
||||
expect(showRestoreAction(can([]), true)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildAccountingPayload,
|
||||
buildAddressPayload,
|
||||
buildContactPayload,
|
||||
buildInformationPayload,
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
mapAccountingFormDraft,
|
||||
mapInformationDraft,
|
||||
mapMainDraft,
|
||||
resolveTabEditability,
|
||||
type AccountingFormDraft,
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
} from '../clientEdit'
|
||||
import type { ClientDetail } from '../clientConsultation'
|
||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
// ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ─────
|
||||
|
||||
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
|
||||
return {
|
||||
companyName: 'ACME',
|
||||
categoryIris: ['/api/categories/1'],
|
||||
relationType: null,
|
||||
distributorIri: null,
|
||||
brokerIri: null,
|
||||
triageService: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function informationDraft(overrides: Partial<InformationFormDraft> = {}): InformationFormDraft {
|
||||
return {
|
||||
description: 'desc',
|
||||
competitors: 'concurrents',
|
||||
foundedAt: '2010-05-01',
|
||||
employeesCount: '42',
|
||||
revenueAmount: '1000000',
|
||||
profitAmount: '50000',
|
||||
directorName: 'PDG',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): AccountingFormDraft {
|
||||
return {
|
||||
siren: '123456789',
|
||||
accountNumber: 'C-001',
|
||||
nTva: 'FR123',
|
||||
tvaModeIri: '/api/tva_modes/1',
|
||||
paymentDelayIri: '/api/payment_delays/1',
|
||||
paymentTypeIri: '/api/payment_types/1',
|
||||
bankIri: '/api/banks/1',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// Champs de chaque groupe de serialisation (miroir back ClientProcessor).
|
||||
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
|
||||
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
|
||||
const MAIN_KEYS = [
|
||||
'companyName', 'categories', 'distributor', 'broker', 'triageService',
|
||||
]
|
||||
const INFORMATION_KEYS = [
|
||||
'description', 'competitors', 'foundedAt', 'employeesCount',
|
||||
'revenueAmount', 'profitAmount', 'directorName',
|
||||
]
|
||||
const ACCOUNTING_KEYS = ['siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', 'bank']
|
||||
|
||||
describe('buildMainPayload — scoping strict groupe client:write:main', () => {
|
||||
it('n\'expose QUE les champs du groupe main (aucune fuite information/accounting)', () => {
|
||||
expect(Object.keys(buildMainPayload(mainDraft())).sort()).toEqual([...MAIN_KEYS].sort())
|
||||
})
|
||||
|
||||
it('relation distributeur : renseigne distributor, force broker a null (RG-1.03)', () => {
|
||||
const payload = buildMainPayload(mainDraft({
|
||||
relationType: 'distributeur',
|
||||
distributorIri: '/api/clients/9',
|
||||
brokerIri: '/api/clients/7',
|
||||
}))
|
||||
expect(payload.distributor).toBe('/api/clients/9')
|
||||
expect(payload.broker).toBeNull()
|
||||
})
|
||||
|
||||
it('relation courtier : renseigne broker, force distributor a null (RG-1.03)', () => {
|
||||
const payload = buildMainPayload(mainDraft({
|
||||
relationType: 'courtier',
|
||||
distributorIri: '/api/clients/9',
|
||||
brokerIri: '/api/clients/7',
|
||||
}))
|
||||
expect(payload.broker).toBe('/api/clients/7')
|
||||
expect(payload.distributor).toBeNull()
|
||||
})
|
||||
|
||||
it('sans relation : distributor et broker a null', () => {
|
||||
const payload = buildMainPayload(mainDraft({ relationType: null }))
|
||||
expect(payload.distributor).toBeNull()
|
||||
expect(payload.broker).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
|
||||
it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => {
|
||||
expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort())
|
||||
})
|
||||
|
||||
it('convertit employeesCount en nombre et vide -> null', () => {
|
||||
expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42)
|
||||
expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull()
|
||||
expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull()
|
||||
})
|
||||
|
||||
it('chaines vides normalisees en null', () => {
|
||||
const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' }))
|
||||
expect(payload.description).toBeNull()
|
||||
expect(payload.directorName).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
|
||||
it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => {
|
||||
expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort())
|
||||
})
|
||||
|
||||
it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => {
|
||||
expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1')
|
||||
expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
|
||||
it('contact : telephone secondaire ignore si non revele', () => {
|
||||
const contact: ContactFormDraft = {
|
||||
id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B',
|
||||
jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000',
|
||||
email: null, hasSecondaryPhone: false,
|
||||
}
|
||||
expect(buildContactPayload(contact).phoneSecondary).toBeNull()
|
||||
})
|
||||
|
||||
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
|
||||
const address: AddressFormDraft = {
|
||||
id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France',
|
||||
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
|
||||
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
|
||||
billingEmail: 'facturation@acme.fr',
|
||||
}
|
||||
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
|
||||
expect(buildAddressPayload(address, false).billingEmail).toBeNull()
|
||||
})
|
||||
|
||||
it('rib : label / bic / iban transmis tels quels', () => {
|
||||
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
|
||||
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapMainDraft — pre-remplissage bloc principal', () => {
|
||||
it('resout la relation et extrait les IRI (sans contact inline)', () => {
|
||||
const client = {
|
||||
'@id': '/api/clients/1', id: 1,
|
||||
companyName: 'ACME', triageService: true,
|
||||
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
|
||||
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
|
||||
} as ClientDetail
|
||||
|
||||
const draft = mapMainDraft(client)
|
||||
expect(draft.companyName).toBe('ACME')
|
||||
expect(draft.categoryIris).toEqual(['/api/categories/1'])
|
||||
expect(draft.relationType).toBe('distributeur')
|
||||
expect(draft.distributorIri).toBe('/api/clients/9')
|
||||
expect(draft.brokerIri).toBeNull()
|
||||
expect(draft.triageService).toBe(true)
|
||||
})
|
||||
|
||||
it('gere les cles omises (skip_null_values) sans planter', () => {
|
||||
const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail)
|
||||
expect(draft.companyName).toBeNull()
|
||||
expect(draft.categoryIris).toEqual([])
|
||||
expect(draft.relationType).toBeNull()
|
||||
expect(draft.triageService).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapInformationDraft — pre-remplissage onglet Information', () => {
|
||||
it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => {
|
||||
const draft = mapInformationDraft({
|
||||
'@id': '/api/clients/1', id: 1,
|
||||
foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000',
|
||||
} as ClientDetail)
|
||||
expect(draft.foundedAt).toBe('2010-05-01')
|
||||
expect(draft.employeesCount).toBe('42')
|
||||
expect(draft.revenueAmount).toBe('1000000')
|
||||
})
|
||||
|
||||
it('cles omises -> null', () => {
|
||||
const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail)
|
||||
expect(draft.foundedAt).toBeNull()
|
||||
expect(draft.employeesCount).toBeNull()
|
||||
expect(draft.description).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
|
||||
it('extrait les scalaires et les IRI des referentiels embarques', () => {
|
||||
const draft = mapAccountingFormDraft({
|
||||
'@id': '/api/clients/1', id: 1,
|
||||
siren: '123456789', accountNumber: 'C-001', nTva: 'FR123',
|
||||
tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' },
|
||||
paymentType: '/api/payment_types/3',
|
||||
} as ClientDetail)
|
||||
expect(draft.siren).toBe('123456789')
|
||||
expect(draft.tvaModeIri).toBe('/api/tva_modes/2')
|
||||
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
|
||||
expect(draft.bankIri).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
|
||||
it('Admin : tout editable', () => {
|
||||
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
|
||||
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
|
||||
})
|
||||
|
||||
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
|
||||
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
|
||||
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
|
||||
})
|
||||
|
||||
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
|
||||
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
|
||||
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
|
||||
})
|
||||
|
||||
it('Sans permission d\'edition : rien d\'editable', () => {
|
||||
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
|
||||
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
applyProspectExclusivity,
|
||||
buildClientFormTabKeys,
|
||||
canSelectDeliveryOrBilling,
|
||||
canSelectProspect,
|
||||
hasAtLeastOneValidContact,
|
||||
isBankRequiredForPaymentType,
|
||||
isBillingEmailRequired,
|
||||
isContactNamed,
|
||||
isRibRequiredForPaymentType,
|
||||
type ContactDraft,
|
||||
} from '../clientFormRules'
|
||||
|
||||
describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => {
|
||||
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
|
||||
expect(buildClientFormTabKeys(true)).toContain('accounting')
|
||||
})
|
||||
|
||||
it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => {
|
||||
expect(buildClientFormTabKeys(false)).not.toContain('accounting')
|
||||
})
|
||||
|
||||
it('a la creation, exclut Statistiques / Rapports / Echanges', () => {
|
||||
const keys = buildClientFormTabKeys(true)
|
||||
expect(keys).toEqual(['information', 'contact', 'address', 'transport', 'accounting'])
|
||||
expect(keys).not.toContain('statistics')
|
||||
expect(keys).not.toContain('reports')
|
||||
expect(keys).not.toContain('exchanges')
|
||||
})
|
||||
|
||||
it('en modification (includeEditOnlyTabs), ajoute les onglets edit-only en fin', () => {
|
||||
const keys = buildClientFormTabKeys(true, { includeEditOnlyTabs: true })
|
||||
expect(keys).toEqual([
|
||||
'information',
|
||||
'contact',
|
||||
'address',
|
||||
'transport',
|
||||
'accounting',
|
||||
'statistics',
|
||||
'reports',
|
||||
'exchanges',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isContactNamed (RG-1.05)', () => {
|
||||
it('vrai si le prenom est renseigne', () => {
|
||||
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
|
||||
})
|
||||
|
||||
it('vrai si le nom est renseigne', () => {
|
||||
expect(isContactNamed({ firstName: null, lastName: 'Martin' })).toBe(true)
|
||||
})
|
||||
|
||||
it('faux si les deux sont vides ou espaces uniquement', () => {
|
||||
expect(isContactNamed({ firstName: null, lastName: null })).toBe(false)
|
||||
expect(isContactNamed({ firstName: ' ', lastName: '' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasAtLeastOneValidContact (RG-1.14)', () => {
|
||||
it('faux sur une liste vide', () => {
|
||||
expect(hasAtLeastOneValidContact([])).toBe(false)
|
||||
})
|
||||
|
||||
it('faux si aucun contact n a de nom ni prenom', () => {
|
||||
const contacts: ContactDraft[] = [
|
||||
{ firstName: null, lastName: null },
|
||||
{ firstName: '', lastName: ' ' },
|
||||
]
|
||||
expect(hasAtLeastOneValidContact(contacts)).toBe(false)
|
||||
})
|
||||
|
||||
it('vrai des qu un contact a un nom ou un prenom', () => {
|
||||
const contacts: ContactDraft[] = [
|
||||
{ firstName: null, lastName: null },
|
||||
{ firstName: 'Bob', lastName: null },
|
||||
]
|
||||
expect(hasAtLeastOneValidContact(contacts)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
|
||||
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
|
||||
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
|
||||
expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
|
||||
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
|
||||
expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
|
||||
expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('cocher Prospect efface Livraison et Facturation', () => {
|
||||
const next = applyProspectExclusivity(
|
||||
{ isProspect: false, isDelivery: true, isBilling: true },
|
||||
'isProspect',
|
||||
true,
|
||||
)
|
||||
expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
|
||||
})
|
||||
|
||||
it('cocher Livraison efface Prospect', () => {
|
||||
const next = applyProspectExclusivity(
|
||||
{ isProspect: true, isDelivery: false, isBilling: false },
|
||||
'isDelivery',
|
||||
true,
|
||||
)
|
||||
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
||||
})
|
||||
|
||||
it('cocher Facturation efface Prospect mais conserve Livraison', () => {
|
||||
const next = applyProspectExclusivity(
|
||||
{ isProspect: true, isDelivery: true, isBilling: false },
|
||||
'isBilling',
|
||||
true,
|
||||
)
|
||||
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
|
||||
})
|
||||
|
||||
it('decocher un drapeau ne reactive rien d autre', () => {
|
||||
const next = applyProspectExclusivity(
|
||||
{ isProspect: false, isDelivery: true, isBilling: true },
|
||||
'isBilling',
|
||||
false,
|
||||
)
|
||||
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBillingEmailRequired (RG-1.11)', () => {
|
||||
it('obligatoire uniquement si Facturation est coche', () => {
|
||||
expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true)
|
||||
expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
|
||||
it('banque obligatoire si VIREMENT', () => {
|
||||
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
||||
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
|
||||
expect(isBankRequiredForPaymentType(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('RIB obligatoire si LCR', () => {
|
||||
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
|
||||
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
|
||||
expect(isRibRequiredForPaymentType(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Helpers purs de l'ecran « Consultation client » (M1 Commercial, lecture seule).
|
||||
*
|
||||
* Mappent le payload `GET /api/clients/{id}` (relations embarquees, cf. groupe
|
||||
* `client:item:read` + `client:read:accounting`) vers les brouillons « plats »
|
||||
* partages avec les blocs reutilisables `ClientContactBlock` / `ClientAddressBlock`
|
||||
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
|
||||
* unitairement (cf. clientConsultation.spec.ts).
|
||||
*
|
||||
* Rappels de contrat back (verifies sur l'API reelle) :
|
||||
* - les relations ManyToOne (distributor/broker/tvaMode/paymentType/...) sont
|
||||
* serialisees en OBJETS embarques (avec @id + companyName/code/label), pas en IRI nu ;
|
||||
* - les champs nuls sont OMIS du JSON (skip_null_values) → toujours lire avec `?? null` ;
|
||||
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS sans permission
|
||||
* accounting.view (gate serveur via ClientReadGroupContextBuilder).
|
||||
*/
|
||||
|
||||
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||
import type {
|
||||
AddressFormDraft,
|
||||
ContactFormDraft,
|
||||
RibFormDraft,
|
||||
} from '~/modules/commercial/types/clientForm'
|
||||
|
||||
/** Reference Hydra embarquee minimale (@id toujours present). */
|
||||
export interface HydraRef {
|
||||
'@id': string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
|
||||
export type Relation = HydraRef | string | null | undefined
|
||||
|
||||
/** Site embarque dans une adresse (groupe site:read). */
|
||||
export interface SiteRead extends HydraRef {
|
||||
name?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** Categorie embarquee (groupe category:read). */
|
||||
export interface CategoryRead extends HydraRef {
|
||||
code?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
/** Contact embarque (groupe client_contact:read). */
|
||||
export interface ContactRead extends HydraRef {
|
||||
id: number
|
||||
firstName?: string | null
|
||||
lastName?: string | null
|
||||
jobTitle?: string | null
|
||||
phonePrimary?: string | null
|
||||
phoneSecondary?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/** Adresse embarquee (groupe client_address:read). */
|
||||
export interface AddressRead extends HydraRef {
|
||||
id: number
|
||||
country?: string | null
|
||||
postalCode?: string | null
|
||||
city?: string | null
|
||||
street?: string | null
|
||||
streetComplement?: string | null
|
||||
billingEmail?: string | null
|
||||
isProspect?: boolean
|
||||
isDelivery?: boolean
|
||||
isBilling?: boolean
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
contacts?: Array<HydraRef | string>
|
||||
}
|
||||
|
||||
/** RIB embarque (groupe client:read:accounting, present ssi accounting.view). */
|
||||
export interface RibRead extends HydraRef {
|
||||
id: number
|
||||
label?: string | null
|
||||
bic?: string | null
|
||||
iban?: string | null
|
||||
}
|
||||
|
||||
/** Client relie (distributeur / courtier) embarque (groupe client:read). */
|
||||
export interface RelatedClientRead extends HydraRef {
|
||||
companyName?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail d'un client tel que renvoye par `GET /api/clients/{id}`. Tous les
|
||||
* champs sont optionnels : skip_null_values cote serveur et gating accounting
|
||||
* peuvent omettre n'importe quelle cle.
|
||||
*/
|
||||
export interface ClientDetail extends HydraRef {
|
||||
id: number
|
||||
companyName?: string | null
|
||||
triageService?: boolean
|
||||
isArchived?: boolean
|
||||
categories?: CategoryRead[]
|
||||
distributor?: RelatedClientRead | string | null
|
||||
broker?: RelatedClientRead | string | null
|
||||
contacts?: ContactRead[]
|
||||
addresses?: AddressRead[]
|
||||
ribs?: RibRead[]
|
||||
// Onglet Information
|
||||
description?: string | null
|
||||
competitors?: string | null
|
||||
foundedAt?: string | null
|
||||
employeesCount?: number | null
|
||||
revenueAmount?: string | null
|
||||
profitAmount?: string | null
|
||||
directorName?: string | null
|
||||
// Onglet Comptabilite (present ssi accounting.view)
|
||||
siren?: string | null
|
||||
accountNumber?: string | null
|
||||
nTva?: string | null
|
||||
tvaMode?: Relation
|
||||
paymentDelay?: Relation
|
||||
paymentType?: Relation
|
||||
bank?: Relation
|
||||
}
|
||||
|
||||
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire 1.10). */
|
||||
export interface AccountingDraft {
|
||||
siren: string | null
|
||||
accountNumber: string | null
|
||||
nTva: string | null
|
||||
tvaModeIri: string | null
|
||||
paymentDelayIri: string | null
|
||||
paymentTypeIri: string | null
|
||||
bankIri: string | null
|
||||
}
|
||||
|
||||
/** Relation Distributeur/Courtier resolue pour l'affichage en lecture seule. */
|
||||
export interface ClientRelation {
|
||||
type: 'distributeur' | 'courtier' | null
|
||||
name: string | null
|
||||
}
|
||||
|
||||
/** Option de select ({ value, label }) construite a partir de l'embed. */
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
|
||||
export interface CategorySelectOption extends SelectOption {
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
|
||||
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
|
||||
*/
|
||||
export interface AddressView {
|
||||
draft: AddressFormDraft
|
||||
siteOptions: SelectOption[]
|
||||
categoryOptions: CategorySelectOption[]
|
||||
}
|
||||
|
||||
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
|
||||
export function iriOf(relation: Relation): string | null {
|
||||
if (relation === null || relation === undefined) {
|
||||
return null
|
||||
}
|
||||
if (typeof relation === 'string') {
|
||||
return relation
|
||||
}
|
||||
return relation['@id'] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout la relation Distributeur/Courtier (RG-1.03 : mutuellement exclusives).
|
||||
* Le nom est lu sur l'objet embarque (`companyName`) ; null si la relation est
|
||||
* un IRI nu ou absente.
|
||||
*/
|
||||
export function relationOf(client: ClientDetail): ClientRelation {
|
||||
const nameOf = (rel: RelatedClientRead | string | null | undefined): string | null =>
|
||||
rel && typeof rel === 'object' ? (rel.companyName ?? null) : null
|
||||
|
||||
if (client.distributor) {
|
||||
return { type: 'distributeur', name: nameOf(client.distributor) }
|
||||
}
|
||||
if (client.broker) {
|
||||
return { type: 'courtier', name: nameOf(client.broker) }
|
||||
}
|
||||
return { type: null, name: null }
|
||||
}
|
||||
|
||||
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
|
||||
export function mapContactToDraft(contact: ContactRead): ContactFormDraft {
|
||||
const phoneSecondary = contact.phoneSecondary ?? null
|
||||
return {
|
||||
id: contact.id,
|
||||
iri: contact['@id'] ?? null,
|
||||
firstName: contact.firstName ?? null,
|
||||
lastName: contact.lastName ?? null,
|
||||
jobTitle: contact.jobTitle ?? null,
|
||||
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
|
||||
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
|
||||
email: contact.email ?? null,
|
||||
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
|
||||
export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
|
||||
return {
|
||||
id: address.id,
|
||||
isProspect: address.isProspect ?? false,
|
||||
isDelivery: address.isDelivery ?? false,
|
||||
isBilling: address.isBilling ?? false,
|
||||
country: address.country ?? 'France',
|
||||
postalCode: address.postalCode ?? null,
|
||||
city: address.city ?? null,
|
||||
street: address.street ?? null,
|
||||
streetComplement: address.streetComplement ?? null,
|
||||
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||
billingEmail: address.billingEmail ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe un RIB embarque vers un brouillon. */
|
||||
export function mapRibToDraft(rib: RibRead): RibFormDraft {
|
||||
return {
|
||||
id: rib.id,
|
||||
label: rib.label ?? null,
|
||||
bic: rib.bic ?? null,
|
||||
iban: rib.iban ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe les champs comptables du client (scalaires + IRI des referentiels). */
|
||||
export function mapAccountingDraft(client: ClientDetail): AccountingDraft {
|
||||
return {
|
||||
siren: client.siren ?? null,
|
||||
accountNumber: client.accountNumber ?? null,
|
||||
nTva: client.nTva ?? null,
|
||||
tvaModeIri: iriOf(client.tvaMode),
|
||||
paymentDelayIri: iriOf(client.paymentDelay),
|
||||
paymentTypeIri: iriOf(client.paymentType),
|
||||
bankIri: iriOf(client.bank),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
|
||||
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
|
||||
* roles metier non-admin), qui laisserait les libelles vides.
|
||||
*/
|
||||
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
|
||||
return (categories ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: c.name ?? c.code ?? c['@id'],
|
||||
code: c.code ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
|
||||
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
|
||||
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
|
||||
}
|
||||
|
||||
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
|
||||
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
|
||||
return (contacts ?? []).map(c => ({
|
||||
value: c['@id'],
|
||||
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
|
||||
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
|
||||
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
|
||||
* `GET` de referentiel — l'affichage reste correct quel que soit le role.
|
||||
*/
|
||||
export function referentialOptionOf(relation: Relation): SelectOption[] {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return []
|
||||
}
|
||||
const label = (relation.label as string | undefined)
|
||||
?? (relation.name as string | undefined)
|
||||
?? relation['@id']
|
||||
return [{ value: relation['@id'], label }]
|
||||
}
|
||||
|
||||
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
|
||||
export function mapAddressView(address: AddressRead): AddressView {
|
||||
return {
|
||||
draft: mapAddressToDraft(address),
|
||||
siteOptions: siteOptionsOf(address.sites),
|
||||
categoryOptions: categoryOptionsOf(address.categories),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
|
||||
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
||||
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
|
||||
* par onglet est gere sur l'ecran d'edition (1.12).
|
||||
*/
|
||||
export function canEditClient(canAny: (codes: string[]) => boolean): boolean {
|
||||
return canAny(['commercial.clients.manage', 'commercial.clients.accounting.manage'])
|
||||
}
|
||||
|
||||
/** Bouton « Archiver » : permission archive ET client encore actif. */
|
||||
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('commercial.clients.archive') && !isArchived
|
||||
}
|
||||
|
||||
/** Bouton « Restaurer » : permission archive ET client deja archive. */
|
||||
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
|
||||
return can('commercial.clients.archive') && isArchived
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12).
|
||||
*
|
||||
* Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) :
|
||||
* 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed
|
||||
* contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites
|
||||
* par la page et les blocs reutilisables (mappers contacts/adresses/ribs/
|
||||
* comptabilite reutilises depuis clientConsultation).
|
||||
* 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / ERP-74) : chaque
|
||||
* onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un
|
||||
* payload mixte — un champ hors-permission = 403 sur l'integralite cote back.
|
||||
*
|
||||
* Ces helpers ne touchent ni a l'API ni a l'etat reactif.
|
||||
*
|
||||
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON
|
||||
* miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de
|
||||
* role et Bureau partage les permissions de Commerciale. Le back l'applique de
|
||||
* maniere fiable (422) ; on laisse remonter ce 422 en toast.
|
||||
*/
|
||||
|
||||
import {
|
||||
iriOf,
|
||||
relationOf,
|
||||
type ClientDetail,
|
||||
} from '~/modules/commercial/utils/clientConsultation'
|
||||
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
|
||||
/**
|
||||
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des
|
||||
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
|
||||
* categories, relation, triage), pas sur une sous-ressource ClientContact. Les
|
||||
* coordonnees de contact (nom, prenom, telephones, email) ne sont plus portees
|
||||
* par le Client : elles vivent exclusivement dans l'onglet Contacts.
|
||||
*/
|
||||
export interface MainFormDraft {
|
||||
companyName: string | null
|
||||
/** IRI des categories rattachees (M2M). */
|
||||
categoryIris: string[]
|
||||
relationType: 'distributeur' | 'courtier' | null
|
||||
distributorIri: string | null
|
||||
brokerIri: string | null
|
||||
triageService: boolean
|
||||
}
|
||||
|
||||
/** Etat « plat » de l'onglet Information (groupe client:write:information). */
|
||||
export interface InformationFormDraft {
|
||||
description: string | null
|
||||
competitors: string | null
|
||||
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
|
||||
foundedAt: string | null
|
||||
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
|
||||
employeesCount: string | null
|
||||
revenueAmount: string | null
|
||||
profitAmount: string | null
|
||||
directorName: string | null
|
||||
}
|
||||
|
||||
/** Etat « plat » de l'onglet Comptabilite (groupe client:write:accounting). */
|
||||
export interface AccountingFormDraft {
|
||||
siren: string | null
|
||||
accountNumber: string | null
|
||||
nTva: string | null
|
||||
tvaModeIri: string | null
|
||||
paymentDelayIri: string | null
|
||||
paymentTypeIri: string | null
|
||||
bankIri: string | null
|
||||
}
|
||||
|
||||
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un client. */
|
||||
export interface ClientEditAbilities {
|
||||
/** `commercial.clients.manage` : bloc principal + onglets metier. */
|
||||
canManage: boolean
|
||||
/** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */
|
||||
canAccountingView: boolean
|
||||
/** `commercial.clients.accounting.manage` : edition de l'onglet Comptabilite. */
|
||||
canAccountingManage: boolean
|
||||
}
|
||||
|
||||
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
|
||||
export interface TabEditability {
|
||||
/** Bloc principal + onglets Information / Contact / Adresse editables. */
|
||||
businessEditable: boolean
|
||||
/** Onglet Comptabilite present (affiche). */
|
||||
accountingVisible: boolean
|
||||
/** Onglet Comptabilite editable. */
|
||||
accountingEditable: boolean
|
||||
}
|
||||
|
||||
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Mappe le detail client vers le brouillon du bloc principal. La relation
|
||||
* Distributeur/Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait
|
||||
* de l'embed.
|
||||
*/
|
||||
export function mapMainDraft(client: ClientDetail): MainFormDraft {
|
||||
const relation = relationOf(client)
|
||||
|
||||
return {
|
||||
companyName: client.companyName ?? null,
|
||||
categoryIris: (client.categories ?? []).map(c => c['@id']),
|
||||
relationType: relation.type,
|
||||
distributorIri: iriOf(client.distributor),
|
||||
brokerIri: iriOf(client.broker),
|
||||
triageService: client.triageService === true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe le detail client vers le brouillon de l'onglet Information. */
|
||||
export function mapInformationDraft(client: ClientDetail): InformationFormDraft {
|
||||
return {
|
||||
description: client.description ?? null,
|
||||
competitors: client.competitors ?? null,
|
||||
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
|
||||
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
|
||||
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
|
||||
revenueAmount: client.revenueAmount ?? null,
|
||||
profitAmount: client.profitAmount ?? null,
|
||||
directorName: client.directorName ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
|
||||
export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft {
|
||||
return {
|
||||
siren: client.siren ?? null,
|
||||
accountNumber: client.accountNumber ?? null,
|
||||
nTva: client.nTva ?? null,
|
||||
tvaModeIri: iriOf(client.tvaMode),
|
||||
paymentDelayIri: iriOf(client.paymentDelay),
|
||||
paymentTypeIri: iriOf(client.paymentType),
|
||||
bankIri: iriOf(client.bank),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scoping strict des payloads PATCH ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation
|
||||
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
|
||||
* que la FK correspondant au type choisi, l'autre est forcee a null.
|
||||
*/
|
||||
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
companyName: main.companyName,
|
||||
categories: main.categoryIris,
|
||||
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
||||
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
||||
triageService: main.triageService,
|
||||
}
|
||||
}
|
||||
|
||||
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
|
||||
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
description: information.description || null,
|
||||
competitors: information.competitors || null,
|
||||
foundedAt: information.foundedAt || null,
|
||||
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||
revenueAmount: information.revenueAmount || null,
|
||||
profitAmount: information.profitAmount || null,
|
||||
directorName: information.directorName || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload des scalaires de l'onglet Comptabilite — groupe client:write:accounting
|
||||
* UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque
|
||||
* n'a de sens que pour un Virement (RG-1.12) : forcee a null sinon.
|
||||
*/
|
||||
export function buildAccountingPayload(
|
||||
accounting: AccountingFormDraft,
|
||||
isBankRequired: boolean,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
siren: accounting.siren || null,
|
||||
accountNumber: accounting.accountNumber || null,
|
||||
tvaMode: accounting.tvaModeIri,
|
||||
nTva: accounting.nTva || null,
|
||||
paymentDelay: accounting.paymentDelayIri,
|
||||
paymentType: accounting.paymentTypeIri,
|
||||
bank: isBankRequired ? accounting.bankIri : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Payload d'un contact (sous-ressource client_contact). */
|
||||
export function buildContactPayload(contact: ContactFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
firstName: contact.firstName || null,
|
||||
lastName: contact.lastName || null,
|
||||
jobTitle: contact.jobTitle || null,
|
||||
phonePrimary: contact.phonePrimary || null,
|
||||
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||
email: contact.email || null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Payload d'une adresse (sous-ressource client_address). */
|
||||
export function buildAddressPayload(
|
||||
address: AddressFormDraft,
|
||||
isBillingEmailRequired: boolean,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
isProspect: address.isProspect,
|
||||
isDelivery: address.isDelivery,
|
||||
isBilling: address.isBilling,
|
||||
country: address.country,
|
||||
postalCode: address.postalCode || null,
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
categories: address.categoryIris,
|
||||
sites: address.siteIris,
|
||||
contacts: address.contactIris,
|
||||
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Payload d'un RIB (sous-ressource client_rib). */
|
||||
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
|
||||
return {
|
||||
label: rib.label,
|
||||
bic: rib.bic,
|
||||
iban: rib.iban,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gating par permission ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
|
||||
* miroir UI du re-gating champ-par-champ du ClientProcessor) :
|
||||
* - bloc principal + Information/Contact/Adresse : editables ssi `manage` ;
|
||||
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
|
||||
*
|
||||
* Produit le comportement attendu :
|
||||
* - Admin : tout editable.
|
||||
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
|
||||
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
|
||||
*/
|
||||
export function resolveTabEditability(abilities: ClientEditAbilities): TabEditability {
|
||||
return {
|
||||
businessEditable: abilities.canManage,
|
||||
accountingVisible: abilities.canAccountingView,
|
||||
accountingEditable: abilities.canAccountingManage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Regles metier pures de l'ecran « Ajouter un client » (M1 Commercial).
|
||||
*
|
||||
* Centralisees ici (hors composant) pour rester testables unitairement et
|
||||
* partagees entre la page de creation et les futurs ecrans d'edition (1.11/1.12).
|
||||
* Ces helpers ne touchent ni a l'API ni a l'etat reactif : ils prennent des
|
||||
* brouillons « plats » et retournent des booleens / nouveaux objets.
|
||||
*
|
||||
* Le back reste la source de verite (les RG sont re-validees serveur) ; ces
|
||||
* regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite).
|
||||
*
|
||||
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement
|
||||
* NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code
|
||||
* de role (roles = IRIs opaques) et Bureau partage les memes permissions que
|
||||
* Commerciale : aucun signal fiable pour distinguer le role cote front. Le back
|
||||
* (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ;
|
||||
* a rebrancher ici des qu'un code de role sera expose dans /api/me.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Onglets « coquille » (non encore implementes) : frame vide, passage
|
||||
* automatique a l'onglet suivant (decision Tristan 28/05).
|
||||
*/
|
||||
export const CLIENT_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const
|
||||
|
||||
/**
|
||||
* Onglets affiches uniquement en MODIFICATION (selon le role), jamais a la
|
||||
* creation : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans
|
||||
* d'edition (1.11/1.12) via l'option `includeEditOnlyTabs`.
|
||||
*/
|
||||
export const CLIENT_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const
|
||||
|
||||
/**
|
||||
* Construit l'ordre des onglets du formulaire client.
|
||||
* - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view`
|
||||
* (Bureau / Commerciale ne le voient pas).
|
||||
* - Les onglets edit-only (Statistiques / Rapports / Echanges) sont exclus par
|
||||
* defaut (creation) ; passer `includeEditOnlyTabs: true` pour les afficher en
|
||||
* modification.
|
||||
* Ordre aligne sur la spec M1 § Ecran « Ajouter un client ».
|
||||
*/
|
||||
export function buildClientFormTabKeys(
|
||||
canAccountingView: boolean,
|
||||
options: { includeEditOnlyTabs?: boolean } = {},
|
||||
): string[] {
|
||||
const keys = ['information', 'contact', 'address', 'transport']
|
||||
if (canAccountingView) {
|
||||
keys.push('accounting')
|
||||
}
|
||||
if (options.includeEditOnlyTabs) {
|
||||
keys.push(...CLIENT_FORM_EDIT_ONLY_TABS)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
|
||||
export interface ContactDraft {
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
}
|
||||
|
||||
/** Drapeaux d'usage d'une adresse (RG-1.06/07/08/11). */
|
||||
export interface AddressFlagsDraft {
|
||||
isProspect: boolean
|
||||
isDelivery: boolean
|
||||
isBilling: boolean
|
||||
}
|
||||
|
||||
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||
function isFilled(value: string | null | undefined): boolean {
|
||||
return value !== null && value !== undefined && value.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.05 : un contact est valide des qu'il porte un nom OU un prenom.
|
||||
*/
|
||||
export function isContactNamed(contact: ContactDraft): boolean {
|
||||
return isFilled(contact.firstName) || isFilled(contact.lastName)
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.14 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
|
||||
* contact nomme (nom ou prenom).
|
||||
*/
|
||||
export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
|
||||
return contacts.some(isContactNamed)
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
|
||||
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
|
||||
* Facturation ne sont coches.
|
||||
*/
|
||||
export function canSelectProspect(flags: AddressFlagsDraft): boolean {
|
||||
return !flags.isDelivery && !flags.isBilling
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.06/07/08 : Livraison et Facturation ne sont selectionnables que si
|
||||
* Prospect n'est pas coche.
|
||||
*/
|
||||
export function canSelectDeliveryOrBilling(flags: AddressFlagsDraft): boolean {
|
||||
return !flags.isProspect
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique l'exclusivite Prospect / (Livraison|Facturation) au changement d'un
|
||||
* drapeau. Cocher Prospect efface Livraison + Facturation ; cocher Livraison ou
|
||||
* Facturation efface Prospect. Decocher n'a aucun effet de bord. Retourne un
|
||||
* nouvel objet (pas de mutation de l'entree).
|
||||
*/
|
||||
export function applyProspectExclusivity(
|
||||
flags: AddressFlagsDraft,
|
||||
field: keyof AddressFlagsDraft,
|
||||
value: boolean,
|
||||
): AddressFlagsDraft {
|
||||
const next: AddressFlagsDraft = { ...flags, [field]: value }
|
||||
|
||||
if (value && field === 'isProspect') {
|
||||
next.isDelivery = false
|
||||
next.isBilling = false
|
||||
}
|
||||
else if (value && (field === 'isDelivery' || field === 'isBilling')) {
|
||||
next.isProspect = false
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 : l'email de facturation n'est visible/obligatoire que si l'adresse
|
||||
* est une adresse de facturation.
|
||||
*/
|
||||
export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
|
||||
return flags.isBilling
|
||||
}
|
||||
|
||||
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
|
||||
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
|
||||
|
||||
/** Code stable du type de reglement « lettre de change » (RG-1.13). */
|
||||
const PAYMENT_TYPE_LCR = 'LCR'
|
||||
|
||||
/**
|
||||
* RG-1.12 : la banque est obligatoire lorsque le type de reglement est un
|
||||
* virement.
|
||||
*/
|
||||
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||
return code === PAYMENT_TYPE_TRANSFER
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.13 : au moins un RIB complet est obligatoire lorsque le type de reglement
|
||||
* est une LCR.
|
||||
*/
|
||||
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||
return code === PAYMENT_TYPE_LCR
|
||||
}
|
||||
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,117 @@
|
||||
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.
|
||||
//
|
||||
// Appel HTTP DIRECT depuis le front (pas de proxy back), conformement a la spec
|
||||
// M1 (§ API adresse postale). On passe par `httpExternal` et NON `useApi()` :
|
||||
// la BAN est un domaine externe, sans cookie de session ni enveloppe Hydra.
|
||||
//
|
||||
// Contrat (fige) :
|
||||
// searchCity(postalCode) -> liste { city, postalCode }
|
||||
// searchAddress(query, cp?) -> liste { label, street, postalCode, city }
|
||||
// En cas d'erreur/timeout, la methode THROW une AddressAutocompleteUnavailableError.
|
||||
// Le composant consommateur catch, affiche un toast d'avertissement et bascule
|
||||
// en saisie libre (MalioInputText).
|
||||
|
||||
/** 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 {
|
||||
city: string
|
||||
postalCode: string
|
||||
}
|
||||
|
||||
/** Une suggestion d'adresse complete (saisie assistee du champ « Adresse »). */
|
||||
export interface AddressSuggestion {
|
||||
label: string
|
||||
street: string
|
||||
postalCode: string
|
||||
city: string
|
||||
}
|
||||
|
||||
export interface AddressAutocomplete {
|
||||
searchCity(postalCode: string): Promise<CitySuggestion[]>
|
||||
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
|
||||
}
|
||||
|
||||
/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */
|
||||
export class AddressAutocompleteUnavailableError extends Error {
|
||||
constructor() {
|
||||
// Message technique (non affiche tel quel) : le composant remonte son
|
||||
// propre libelle i18n. Sert au debug / aux logs uniquement.
|
||||
super('Address autocomplete (BAN) is not available.')
|
||||
this.name = 'AddressAutocompleteUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Proprietes d'une « feature » GeoJSON renvoyee par la BAN (champs utilises). */
|
||||
interface BanFeatureProperties {
|
||||
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 {
|
||||
return {
|
||||
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()
|
||||
}
|
||||
|
||||
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[]> {
|
||||
// IMPORTANT : pas de `type=housenumber` ici. La BAN ne renvoie un
|
||||
// resultat de ce type qu'une fois un numero saisi → une recherche par
|
||||
// nom de rue (« boulevard du port ») renverrait 0 resultat pendant
|
||||
// toute la frappe. Sans filtre `type`, la BAN classe rues + numeros
|
||||
// par pertinence (comportement d'autocompletion attendu).
|
||||
// On n'ajoute `postcode` que s'il est fourni (sinon recherche large).
|
||||
const banQuery: Record<string, string> = { q: query }
|
||||
if (postalCode) {
|
||||
banQuery.postcode = postalCode
|
||||
}
|
||||
|
||||
let res: BanResponse
|
||||
try {
|
||||
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, { query: banQuery })
|
||||
} catch {
|
||||
throw new AddressAutocompleteUnavailableError()
|
||||
}
|
||||
|
||||
return (res.features ?? []).map((feature) => {
|
||||
const props = feature.properties ?? {}
|
||||
return {
|
||||
label: props.label ?? '',
|
||||
// `name` porte la ligne d'adresse complete (numero + voie) ;
|
||||
// `street` ne contient que la voie. On privilegie `name`.
|
||||
street: props.name ?? props.street ?? '',
|
||||
postalCode: props.postcode ?? '',
|
||||
city: props.city ?? '',
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatPhoneFR } from '../phone'
|
||||
|
||||
describe('formatPhoneFR', () => {
|
||||
it('formate un numero 10 chiffres en XX XX XX XX XX', () => {
|
||||
expect(formatPhoneFR('0612345678')).toBe('06 12 34 56 78')
|
||||
})
|
||||
|
||||
it('tolere une saisie deja pointee ou espacee', () => {
|
||||
expect(formatPhoneFR('06.12.34.56.78')).toBe('06 12 34 56 78')
|
||||
expect(formatPhoneFR('06 12 34 56 78')).toBe('06 12 34 56 78')
|
||||
})
|
||||
|
||||
it('retourne une chaine vide pour une valeur vide ou nulle', () => {
|
||||
expect(formatPhoneFR('')).toBe('')
|
||||
expect(formatPhoneFR(null)).toBe('')
|
||||
expect(formatPhoneFR(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Formatage d'un numero de telephone francais en groupes de 2 chiffres
|
||||
* (`XX XX XX XX XX`).
|
||||
*
|
||||
* Helper PARTAGE volontaire : les telephones sont presents un peu partout dans
|
||||
* l'app (fiches clients, contacts, fournisseurs, prestataires...). Introduit ici
|
||||
* comme util transverse stable plutot que duplique a chaque ecran. La signature
|
||||
* `formatPhoneFR(value): string` est coordonnee avec ERP-66, qui pourra enrichir
|
||||
* l'implementation (validation, indicatif international) sans casser les appelants.
|
||||
*
|
||||
* - Ne garde que les chiffres puis groupe par 2 (tolere une saisie deja espacee
|
||||
* ou pointee, ex: `06.12.34.56.78` ou `0612345678`).
|
||||
* - Retourne une chaine vide si la valeur est vide/nulle (cellule vide propre).
|
||||
*/
|
||||
export function formatPhoneFR(value: string | null | undefined): string {
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
if (digits.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Groupe par paquets de 2 ; un dernier groupe impair reste tel quel.
|
||||
return digits.match(/.{1,2}/g)?.join(' ') ?? digits
|
||||
}
|
||||
@@ -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).$_$');
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ final class CatalogModule
|
||||
return [
|
||||
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
|
||||
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
|
||||
// Lecture-referentiel transverse (ERP-102) : permet de LISTER les categories
|
||||
// pour alimenter les selects des modules Tiers (clients, fournisseurs...),
|
||||
// sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue
|
||||
// dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
|
||||
['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,13 +42,19 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
// Lecture (liste + item) : permission d'administration `view` OU permission
|
||||
// de lecture-referentiel transverse `read_ref` (ERP-102). Les referentiels
|
||||
// categories sont consommes par les modules Tiers (selects creation/filtre
|
||||
// client) : tout role qui gere des tiers doit pouvoir les lire sans porter
|
||||
// l'acces admin du Catalogue. `read_ref` est une permission Catalog (pas un
|
||||
// code d'un autre module) -> isolement inter-module preserve.
|
||||
new GetCollection(
|
||||
security: "is_granted('catalog.categories.view')",
|
||||
security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
|
||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||
provider: CategoryProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.categories.view')",
|
||||
security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
|
||||
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||
provider: CategoryProvider::class,
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
@@ -58,27 +59,39 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
// La liste embarque les categories (avec leur code, groupe
|
||||
// category:read) et les sites agreges des adresses (groupe
|
||||
// site:read) pour alimenter les colonnes « Catégories » et
|
||||
// « Site(s) » du Repertoire (ERP-62). Cf. getSites() plus bas.
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||
provider: ClientProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
// Detail : client + sous-collections embarquees. Le groupe
|
||||
// client:read:accounting est ajoute par le context builder selon la
|
||||
// permission, donc absent ici volontairement.
|
||||
// Detail : client + sous-collections embarquees.
|
||||
// - client:read:accounting est ajoute par le context builder selon la
|
||||
// permission (gate les scalaires comptables ET les RIB embarques),
|
||||
// donc absent ici volontairement.
|
||||
// - client_rib:read N'EST PLUS dans le contexte : le contenu des RIB
|
||||
// embarques est desormais porte par client:read:accounting (gate),
|
||||
// ce qui retire la fuite IBAN/BIC vers les users sans accounting.view.
|
||||
// - category:read et site:read sont indispensables pour embarquer le
|
||||
// code/libelle des categories et des sites (sinon stub IRI nu) :
|
||||
// Category.code/name vivent sous category:read, Site.name sous site:read.
|
||||
normalizationContext: ['groups' => [
|
||||
'client:read',
|
||||
'client:item:read',
|
||||
'client_contact:read',
|
||||
'client_address:read',
|
||||
'client_rib:read',
|
||||
'category:read',
|
||||
'site:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: ClientProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||
denormalizationContext: ['groups' => ['client:write:main']],
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
@@ -96,7 +109,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
|
||||
// champs accounting exigent accounting.manage, isArchived exige
|
||||
// archive, le reste (main/information) exige manage.
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||
denormalizationContext: ['groups' => [
|
||||
'client:write:main',
|
||||
'client:write:information',
|
||||
@@ -138,31 +151,9 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
// RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor).
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $phonePrimary = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $phoneSecondary = null;
|
||||
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $email = null;
|
||||
// Le contact principal n'est plus porte inline par le Client : les contacts
|
||||
// vivent uniquement dans ClientContact (onglet Contact). RG-1.01 / RG-1.02
|
||||
// supprimees du Client (equivalent RG-1.05 / RG-1.14 sur ClientContact).
|
||||
|
||||
// RG-1.03 : distributor / broker auto-references mutuellement exclusives
|
||||
// (CHECK chk_client_distrib_or_broker en base).
|
||||
@@ -313,66 +304,6 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(?string $firstName): static
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(?string $lastName): static
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhonePrimary(): ?string
|
||||
{
|
||||
return $this->phonePrimary;
|
||||
}
|
||||
|
||||
public function setPhonePrimary(string $phonePrimary): static
|
||||
{
|
||||
$this->phonePrimary = $phonePrimary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhoneSecondary(): ?string
|
||||
{
|
||||
return $this->phoneSecondary;
|
||||
}
|
||||
|
||||
public function setPhoneSecondary(?string $phoneSecondary): static
|
||||
{
|
||||
$this->phoneSecondary = $phoneSecondary;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDistributor(): ?Client
|
||||
{
|
||||
return $this->distributor;
|
||||
@@ -651,8 +582,38 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sites distincts rattaches a au moins une adresse du client (RG-1.10).
|
||||
* Le Client ne porte pas de sites en propre : ils vivent sur les adresses.
|
||||
* Agrege en lecture seule pour la colonne « Site(s) » du Repertoire (badges
|
||||
* colores) — expose en LISTE via le groupe client:read (les adresses
|
||||
* completes restent reservees au detail, client:item:read).
|
||||
*
|
||||
* @return list<SiteInterface>
|
||||
*/
|
||||
#[Groups(['client:read'])]
|
||||
public function getSites(): array
|
||||
{
|
||||
$sites = [];
|
||||
foreach ($this->addresses as $address) {
|
||||
foreach ($address->getSites() as $site) {
|
||||
// Deduplication par identite d'objet : un meme site peut etre
|
||||
// rattache a plusieurs adresses du client.
|
||||
$sites[spl_object_id($site)] = $site;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($sites);
|
||||
}
|
||||
|
||||
// Embed gate sur le groupe COMPTABLE (et non client:item:read comme contacts/
|
||||
// adresses) : client:read:accounting n'est ajoute au contexte que si l'user a
|
||||
// accounting.view (ClientReadGroupContextBuilder). Resultat : la cle `ribs` est
|
||||
// TOTALEMENT ABSENTE du detail pour un user sans accounting.view (ex. Commerciale),
|
||||
// au meme titre que les scalaires comptables — corrige la fuite de RIB ou la
|
||||
// Commerciale recevait IBAN/BIC en clair.
|
||||
/** @return Collection<int, ClientRib> */
|
||||
#[Groups(['client:item:read'])]
|
||||
#[Groups(['client:read:accounting'])]
|
||||
public function getRibs(): Collection
|
||||
{
|
||||
return $this->ribs;
|
||||
|
||||
@@ -22,6 +22,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
@@ -104,16 +105,23 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Client $client = null;
|
||||
|
||||
// Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH/POST).
|
||||
// Le groupe de LECTURE est porte par le getter isProspect()/isDelivery()/
|
||||
// isBilling() avec SerializedName : sans cela, Symfony strip le prefixe "is"
|
||||
// des getters booleens et exposerait les cles "prospect"/"delivery"/"billing"
|
||||
// — en pratique le #[Groups] etant sur la propriete `isX` et le getter
|
||||
// derivant l'attribut `x`, la cle etait totalement DROPPEE du JSON (meme bug
|
||||
// que Client::isArchived). Pattern corrige : Groups + SerializedName sur le getter.
|
||||
#[ORM\Column(name: 'is_prospect', options: ['default' => false])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
#[Groups(['client_address:write'])]
|
||||
private bool $isProspect = false;
|
||||
|
||||
#[ORM\Column(name: 'is_delivery', options: ['default' => false])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
#[Groups(['client_address:write'])]
|
||||
private bool $isDelivery = false;
|
||||
|
||||
#[ORM\Column(name: 'is_billing', options: ['default' => false])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
#[Groups(['client_address:write'])]
|
||||
private bool $isBilling = false;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
@@ -169,12 +177,14 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
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).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'client_address_category')]
|
||||
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[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'])]
|
||||
private Collection $categories;
|
||||
|
||||
@@ -276,6 +286,12 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
|
||||
// sans SerializedName, Symfony exposerait la cle "prospect" (strip du prefixe
|
||||
// "is" sur les getters) et, le groupe etant declare sur la propriete `isProspect`,
|
||||
// droppait silencieusement la cle du JSON.
|
||||
#[Groups(['client_address:read'])]
|
||||
#[SerializedName('isProspect')]
|
||||
public function isProspect(): bool
|
||||
{
|
||||
return $this->isProspect;
|
||||
@@ -288,6 +304,8 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['client_address:read'])]
|
||||
#[SerializedName('isDelivery')]
|
||||
public function isDelivery(): bool
|
||||
{
|
||||
return $this->isDelivery;
|
||||
@@ -300,6 +318,8 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['client_address:read'])]
|
||||
#[SerializedName('isBilling')]
|
||||
public function isBilling(): bool
|
||||
{
|
||||
return $this->isBilling;
|
||||
|
||||
@@ -79,10 +79,17 @@ class ClientRib implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
// Double groupe de lecture :
|
||||
// - `client_rib:read` : sous-ressource autonome GET /api/client_ribs/{id}
|
||||
// (deja securisee par commercial.clients.accounting.view).
|
||||
// - `client:read:accounting` : embed des RIB sous le detail Client, ajoute
|
||||
// DYNAMIQUEMENT par ClientReadGroupContextBuilder uniquement si l'user a
|
||||
// accounting.view. Ce double marquage gate les RIB embarques au meme titre
|
||||
// que les scalaires comptables (RG : la Commerciale ne voit aucun RIB).
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client_rib:read'])]
|
||||
#[Groups(['client_rib:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')]
|
||||
@@ -92,23 +99,23 @@ class ClientRib implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Bic]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
|
||||
private ?string $bic = null;
|
||||
|
||||
#[ORM\Column(length: 34)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Iban]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
|
||||
private ?string $iban = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
|
||||
@@ -16,21 +16,52 @@ interface ClientRepositoryInterface
|
||||
/**
|
||||
* Construit un QueryBuilder de liste pour le repertoire clients.
|
||||
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
||||
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
||||
* - Archivage (RG-1.25) :
|
||||
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
|
||||
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
|
||||
* - sinon (defaut) -> uniquement les actifs (is_archived = false).
|
||||
* $archivedOnly a la priorite sur $includeArchived.
|
||||
* - Tri par defaut : companyName ASC (RG-1.26).
|
||||
* - $search : recherche fuzzy insensible a la casse sur companyName +
|
||||
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
|
||||
* - $categoryCode : restreint aux clients possedant au moins une categorie
|
||||
* du code donne (ERP-78 : filtrage par code de Category, plus par type).
|
||||
* Ignore si null/vide.
|
||||
* - $categoryCodes : restreint aux clients possedant au moins une categorie
|
||||
* dont le code est dans la liste (OR — ERP-78). Liste vide = pas de filtre.
|
||||
* - $siteIds : restreint aux clients ayant au moins une adresse rattachee a
|
||||
* l'un des sites donnes (OR — RG-1.10). Liste vide = pas de filtre.
|
||||
*
|
||||
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
|
||||
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
||||
* partagent strictement la meme logique de selection.
|
||||
*
|
||||
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
|
||||
* l'hydratation des collections affichees est une decision de l'appelant
|
||||
* (cf. {@see self::hydrateListCollections()}), pour ne pas imposer le cout
|
||||
* d'un produit cartesien a un consommateur qui ne filtrerait/compterait que
|
||||
* (ERP-100).
|
||||
*
|
||||
* @param list<string> $categoryCodes
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
?string $categoryCode = null,
|
||||
array $categoryCodes = [],
|
||||
array $siteIds = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder;
|
||||
|
||||
/**
|
||||
* Hydrate en lot les collections affichees par le repertoire (categories,
|
||||
* adresses et leurs sites) sur un jeu de clients DEJA charges, via l'identity
|
||||
* map Doctrine (memes instances). A appeler apres une selection bornee (page
|
||||
* courante ou jeu d'export) pour eviter le N+1 a la serialisation, sans
|
||||
* imposer de fetch-join au QueryBuilder de selection (ERP-100).
|
||||
*
|
||||
* Charge les categories et les adresses/sites en DEUX requetes distinctes
|
||||
* (et non un triple fetch-join) pour ne pas multiplier categories x adresses
|
||||
* x sites en un seul produit cartesien.
|
||||
*
|
||||
* @param list<Client> $clients
|
||||
*/
|
||||
public function hydrateListCollections(array $clients): void;
|
||||
}
|
||||
|
||||
+8
-37
@@ -29,7 +29,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 /
|
||||
* § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28.
|
||||
* § 2.9 / § 4.3 / § 4.4 + RG-1.03 a RG-1.28 (RG-1.01/1.02 supprimees : contact inline retire).
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Autorisation additionnelle par groupe d'onglet. La security d'operation
|
||||
@@ -41,7 +41,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
|
||||
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
|
||||
* interdit toute autre modification dans la meme requete (RG-1.22, 422).
|
||||
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
|
||||
* 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker
|
||||
* 3. Regles metier : RG-1.03 (distributor/broker
|
||||
* exclusifs + type de categorie), RG-1.12 (Virement -> banque),
|
||||
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
|
||||
* et tout PATCH pour le role Commerciale).
|
||||
@@ -60,8 +60,7 @@ final class ClientProcessor implements ProcessorInterface
|
||||
{
|
||||
/** Champs de l'onglet principal (groupe client:write:main). */
|
||||
private const array MAIN_FIELDS = [
|
||||
'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary',
|
||||
'email', 'distributor', 'broker', 'triageService', 'categories',
|
||||
'companyName', 'distributor', 'broker', 'triageService', 'categories',
|
||||
];
|
||||
|
||||
/** Champs de l'onglet Information (groupe client:write:information). */
|
||||
@@ -124,7 +123,6 @@ final class ClientProcessor implements ProcessorInterface
|
||||
// valeurs normalisees des deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
$this->validateMainContact($data);
|
||||
$this->validateDistributorBroker($data);
|
||||
$this->validateAccountingConsistency($data);
|
||||
$this->validateInformationCompleteness($data);
|
||||
@@ -274,11 +272,6 @@ final class ClientProcessor implements ProcessorInterface
|
||||
{
|
||||
$newValues = [
|
||||
'companyName' => $data->getCompanyName(),
|
||||
'firstName' => $data->getFirstName(),
|
||||
'lastName' => $data->getLastName(),
|
||||
'phonePrimary' => $data->getPhonePrimary(),
|
||||
'phoneSecondary' => $data->getPhoneSecondary(),
|
||||
'email' => $data->getEmail(),
|
||||
'distributor' => $data->getDistributor(),
|
||||
'broker' => $data->getBroker(),
|
||||
'triageService' => $data->isTriageService(),
|
||||
@@ -420,39 +413,17 @@ final class ClientProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables
|
||||
* (companyName, email, phonePrimary) ne sont touches que si une valeur est
|
||||
* presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
|
||||
* Normalisation serveur du formulaire principal (RG-1.18). Seul companyName
|
||||
* subsiste cote Client depuis la suppression du contact inline (les champs de
|
||||
* contact — noms, telephones, email — sont normalises par ClientContactProcessor).
|
||||
* Le setter non-nullable n'est touche que si une valeur est presente, pour ne
|
||||
* jamais ecraser l'existant lors d'un PATCH partiel.
|
||||
*/
|
||||
private function normalize(Client $data): void
|
||||
{
|
||||
if (null !== $data->getCompanyName()) {
|
||||
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
|
||||
}
|
||||
if (null !== $data->getEmail()) {
|
||||
$data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail()));
|
||||
}
|
||||
if (null !== $data->getPhonePrimary()) {
|
||||
$data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary()));
|
||||
}
|
||||
|
||||
$data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName()));
|
||||
$data->setLastName($this->normalizer->normalizePersonName($data->getLastName()));
|
||||
$data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary()));
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.01 : au moins le prenom OU le nom du contact principal.
|
||||
*/
|
||||
private function validateMainContact(Client $data): void
|
||||
{
|
||||
if (null === $data->getFirstName() && null === $data->getLastName()) {
|
||||
$this->throwViolation(
|
||||
'firstName',
|
||||
'Le prénom ou le nom du contact principal est obligatoire.',
|
||||
$data,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,21 +64,32 @@ final class ClientProvider implements ProviderInterface
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
|
||||
$search = $filters['search'] ?? null;
|
||||
$categoryCode = $filters['categoryCode'] ?? null;
|
||||
// categoryCode accepte un code unique (?categoryCode=DISTRIBUTEUR, selects
|
||||
// RG-1.03) OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
|
||||
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($filters['siteId'] ?? []);
|
||||
|
||||
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
$includeArchived,
|
||||
is_string($search) ? $search : null,
|
||||
is_string($categoryCode) ? $categoryCode : null,
|
||||
$categoryCodes,
|
||||
$siteIds,
|
||||
$archivedOnly,
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
// @var list<Client> $result
|
||||
return $qb->getQuery()->getResult();
|
||||
/** @var list<Client> $clients */
|
||||
$clients = $qb->getQuery()->getResult();
|
||||
// Hydratation batchee des collections affichees (cf. ERP-100) : evite
|
||||
// le N+1 si la serialisation touche categories/sites, sans cartesien.
|
||||
$this->repository->hydrateListCollections($clients);
|
||||
|
||||
return $clients;
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
@@ -87,9 +98,13 @@ final class ClientProvider implements ProviderInterface
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// fetchJoinCollection: true pour un COUNT correct des que des JOINs
|
||||
// to-many seront ajoutes (sous-collections embarquees en detail).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||
// Le QB de selection ne porte plus de fetch-join to-many (ERP-100) : le
|
||||
// COUNT est simple, fetchJoinCollection inutile. On materialise la page
|
||||
// puis on hydrate ses collections en lot (memes entites managees).
|
||||
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
$this->repository->hydrateListCollections(iterator_to_array($paginator));
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,4 +142,44 @@ final class ClientProvider implements ProviderInterface
|
||||
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines. Tolere un code unique (string)
|
||||
* ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
|
||||
* valeur unique ou une liste (?key[]=1&key[]=2).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,16 +52,28 @@ final class ClientExportController
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
$categoryCode = $request->query->getString('categoryCode') ?: null;
|
||||
|
||||
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
|
||||
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
|
||||
// ne pas lever d'exception sur une valeur scalaire.
|
||||
$query = $request->query->all();
|
||||
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
|
||||
$siteIds = $this->readIntList($query['siteId'] ?? []);
|
||||
|
||||
/** @var list<Client> $clients */
|
||||
$clients = $this->repository
|
||||
->createListQueryBuilder($includeArchived, $search, $categoryCode)
|
||||
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
// Hydratation batchee des categories + adresses/sites (ERP-100) : le QB de
|
||||
// selection ne fetch-join plus, on remplit les collections en 2 requetes
|
||||
// IN bornees plutot que d'hydrater un produit cartesien sur tout le jeu.
|
||||
$this->repository->hydrateListCollections($clients);
|
||||
|
||||
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
@@ -74,7 +86,9 @@ final class ClientExportController
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la
|
||||
* Colonnes de l'export. Depuis la suppression du contact inline (refonte
|
||||
* contact, D2), les colonnes de contact principal sont retirees : l'export
|
||||
* ne porte plus que les donnees propres au Client. SIREN inseree avant la
|
||||
* date de creation, uniquement si l'utilisateur a accounting.view.
|
||||
*
|
||||
* @return list<string>
|
||||
@@ -83,11 +97,6 @@ final class ClientExportController
|
||||
{
|
||||
$headers = [
|
||||
'Nom entreprise',
|
||||
'Nom contact principal',
|
||||
'Prénom',
|
||||
'Téléphone principal',
|
||||
'Téléphone secondaire',
|
||||
'Email',
|
||||
'Catégories',
|
||||
'Sites',
|
||||
];
|
||||
@@ -111,11 +120,6 @@ final class ClientExportController
|
||||
foreach ($clients as $client) {
|
||||
$row = [
|
||||
$client->getCompanyName(),
|
||||
$client->getLastName(),
|
||||
$client->getFirstName(),
|
||||
$client->getPhonePrimary(),
|
||||
$client->getPhoneSecondary(),
|
||||
$client->getEmail(),
|
||||
$this->formatCategories($client),
|
||||
$this->formatSites($client),
|
||||
];
|
||||
@@ -198,4 +202,44 @@ final class ClientExportController
|
||||
{
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste de chaines (valeur unique ou liste).
|
||||
* Aligne sur ClientProvider pour un comportement identique a la liste.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readStringList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && '' !== trim($value)) {
|
||||
$out[] = trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||
* ou liste). Aligne sur ClientProvider.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +112,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$gso, $gsoIsNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Distrib Grand Sud-Ouest',
|
||||
firstName: 'Paul',
|
||||
lastName: 'Garnier',
|
||||
phonePrimary: '05 56 10 20 30',
|
||||
email: 'contact@distrib-gso.fr',
|
||||
categoryNames: ['Distributeur'],
|
||||
);
|
||||
if ($gsoIsNew) {
|
||||
@@ -127,10 +123,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$leonard, $leonardIsNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Cabinet Léonard Assurances',
|
||||
firstName: 'Sophie',
|
||||
lastName: 'Léonard',
|
||||
phonePrimary: '05 49 11 22 33',
|
||||
email: 'contact@cabinet-leonard.fr',
|
||||
categoryNames: ['Courtier'],
|
||||
);
|
||||
if ($leonardIsNew) {
|
||||
@@ -142,10 +134,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$dubois, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Menuiserie Dubois',
|
||||
firstName: 'Jean',
|
||||
lastName: 'Dubois',
|
||||
phonePrimary: '05 49 00 00 01',
|
||||
email: 'contact@menuiserie-dubois.fr',
|
||||
categoryNames: ['BTP'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -159,10 +147,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$garage, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Garage Martin',
|
||||
firstName: 'Luc',
|
||||
lastName: 'Martin',
|
||||
phonePrimary: '05 56 44 55 66',
|
||||
email: 'accueil@garage-martin.fr',
|
||||
categoryNames: ['Services'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -175,10 +159,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$boulangerie, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Boulangerie Lemoine',
|
||||
firstName: 'Marie',
|
||||
lastName: 'Lemoine',
|
||||
phonePrimary: '05 49 77 88 99',
|
||||
email: 'bonjour@boulangerie-lemoine.fr',
|
||||
categoryNames: ['Agro-alimentaire'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -191,10 +171,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$transports, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Transports Rapides',
|
||||
firstName: null,
|
||||
lastName: 'Bernard',
|
||||
phonePrimary: '05 56 12 13 14',
|
||||
email: 'exploitation@transports-rapides.fr',
|
||||
categoryNames: ['Transport/Logistique'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -209,10 +185,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$industries, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Industries Vertes',
|
||||
firstName: 'Claire',
|
||||
lastName: 'Moreau',
|
||||
phonePrimary: '05 49 21 22 23',
|
||||
email: 'contact@industries-vertes.fr',
|
||||
categoryNames: ['Industrie'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -229,12 +201,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$agro, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Agro Distribution Sud',
|
||||
firstName: 'Thomas',
|
||||
lastName: 'Petit',
|
||||
phonePrimary: '05 56 31 32 33',
|
||||
email: 'contact@agro-sud.fr',
|
||||
categoryNames: ['Agro-alimentaire'],
|
||||
phoneSecondary: '06 01 02 03 04',
|
||||
);
|
||||
if ($isNew) {
|
||||
$this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0);
|
||||
@@ -247,10 +214,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$ancienne, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Ancienne Société Oubliée',
|
||||
firstName: null,
|
||||
lastName: 'Durand',
|
||||
phonePrimary: '05 49 99 99 99',
|
||||
email: 'contact@ancienne-societe.fr',
|
||||
categoryNames: ['Association'],
|
||||
isArchived: true,
|
||||
);
|
||||
@@ -263,10 +226,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$services, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Services Pro Conseil',
|
||||
firstName: 'Nadia',
|
||||
lastName: 'Benali',
|
||||
phonePrimary: '05 49 41 42 43',
|
||||
email: 'contact@services-pro.fr',
|
||||
categoryNames: ['Services'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -279,10 +238,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$holding, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Holding Premium Invest',
|
||||
firstName: 'Antoine',
|
||||
lastName: 'Lefèvre',
|
||||
phonePrimary: '05 56 51 52 53',
|
||||
email: 'direction@holding-premium.fr',
|
||||
categoryNames: ['Industrie'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -301,10 +256,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$conglo, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Conglomérat Multi Activités',
|
||||
firstName: 'Hélène',
|
||||
lastName: 'Faure',
|
||||
phonePrimary: '05 49 61 62 63',
|
||||
email: 'contact@conglomerat-multi.fr',
|
||||
categoryNames: ['BTP', 'Industrie', 'Services'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -316,10 +267,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$prospect, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Prospect Futur Client',
|
||||
firstName: 'Olivier',
|
||||
lastName: 'Renard',
|
||||
phonePrimary: '05 56 71 72 73',
|
||||
email: 'olivier.renard@prospect-futur.fr',
|
||||
categoryNames: ['BTP'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -331,10 +278,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
[$association, $isNew] = $this->ensureClient(
|
||||
$manager,
|
||||
companyName: 'Association des Riverains',
|
||||
firstName: null,
|
||||
lastName: 'Caron',
|
||||
phonePrimary: '05 49 81 82 83',
|
||||
email: 'contact@asso-riverains.fr',
|
||||
categoryNames: ['Association'],
|
||||
);
|
||||
if ($isNew) {
|
||||
@@ -350,6 +293,9 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
* sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la
|
||||
* reconstruction des sous-collections (idempotence sans doublon).
|
||||
*
|
||||
* Le contact principal n'est plus porte par le Client (refonte contact) : les
|
||||
* coordonnees de contact sont fournies via addContact() dans le bloc isNew.
|
||||
*
|
||||
* @param list<string> $categoryNames
|
||||
*
|
||||
* @return array{0: Client, 1: bool}
|
||||
@@ -357,12 +303,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
private function ensureClient(
|
||||
ObjectManager $manager,
|
||||
string $companyName,
|
||||
?string $firstName,
|
||||
?string $lastName,
|
||||
string $phonePrimary,
|
||||
string $email,
|
||||
array $categoryNames,
|
||||
?string $phoneSecondary = null,
|
||||
bool $isArchived = false,
|
||||
): array {
|
||||
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
|
||||
@@ -374,11 +315,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
$client = new Client();
|
||||
$client->setCompanyName($normalizedName);
|
||||
$client->setFirstName($this->normalizer->normalizePersonName($firstName));
|
||||
$client->setLastName($this->normalizer->normalizePersonName($lastName));
|
||||
$client->setPhonePrimary((string) $this->normalizer->normalizePhone($phonePrimary));
|
||||
$client->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
|
||||
$client->setEmail((string) $this->normalizer->normalizeEmail($email));
|
||||
|
||||
foreach ($categoryNames as $categoryName) {
|
||||
$client->addCategory($this->category($manager, $categoryName));
|
||||
|
||||
@@ -34,27 +34,80 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
||||
public function createListQueryBuilder(
|
||||
bool $includeArchived = false,
|
||||
?string $search = null,
|
||||
?string $categoryCode = null,
|
||||
array $categoryCodes = [],
|
||||
array $siteIds = [],
|
||||
bool $archivedOnly = false,
|
||||
): QueryBuilder {
|
||||
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
|
||||
// L'hydratation des collections affichees (Catégories / Site(s)) est
|
||||
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
|
||||
// imposer un produit cartesien aux chemins non pagines (export,
|
||||
// ?pagination=false) — ERP-100.
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->andWhere('c.deletedAt IS NULL')
|
||||
->orderBy('c.companyName', 'ASC')
|
||||
;
|
||||
|
||||
if (!$includeArchived) {
|
||||
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived.
|
||||
if ($archivedOnly) {
|
||||
$qb->andWhere('c.isArchived = true');
|
||||
} elseif (!$includeArchived) {
|
||||
$qb->andWhere('c.isArchived = false');
|
||||
}
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
$this->applyCategoryCode($qb, $categoryCode);
|
||||
$this->applyCategoryCodes($qb, $categoryCodes);
|
||||
$this->applySiteIds($qb, $siteIds);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function hydrateListCollections(array $clients): void
|
||||
{
|
||||
if ([] === $clients) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ids des clients deja charges (entites managees). On rehydrate leurs
|
||||
// collections via l'identity map : les requetes ci-dessous renvoient les
|
||||
// MEMES instances Client, dont les collections sont alors remplies.
|
||||
$ids = [];
|
||||
foreach ($clients as $client) {
|
||||
$id = $client->getId();
|
||||
if (null !== $id) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
if ([] === $ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1re passe : categories (colonne « Catégories »). Produit c x cat seul.
|
||||
$this->createQueryBuilder('c')
|
||||
->leftJoin('c.categories', 'cat')->addSelect('cat')
|
||||
->where('c.id IN (:ids)')->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
// 2e passe : adresses + sites (colonne « Site(s) », sites portes par les
|
||||
// adresses — RG-1.10). Le join addr -> site reste imbrique mais n'est
|
||||
// plus multiplie par les categories : le cartesien global est casse.
|
||||
$this->createQueryBuilder('c')
|
||||
->leftJoin('c.addresses', 'addr')->addSelect('addr')
|
||||
->leftJoin('addr.sites', 'site')->addSelect('site')
|
||||
->where('c.id IN (:ids)')->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||
* litteraux.
|
||||
* Recherche fuzzy insensible a la casse sur companyName (D1, refonte contact).
|
||||
* Depuis la suppression du contact inline du Client, la recherche ne porte
|
||||
* plus que sur le nom d'entreprise (les anciens criteres lastName / email
|
||||
* vivaient sur les colonnes inline disparues). Les metacaracteres LIKE
|
||||
* (%, _, \) saisis sont echappes pour rester litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
@@ -65,23 +118,24 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere(
|
||||
'LOWER(c.companyName) LIKE :search '
|
||||
.'OR LOWER(c.lastName) LIKE :search '
|
||||
.'OR LOWER(c.email) LIKE :search',
|
||||
)->setParameter('search', $pattern);
|
||||
$qb->andWhere('LOWER(c.companyName) LIKE :search')
|
||||
->setParameter('search', $pattern)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux clients possedant au moins une categorie du code donne
|
||||
* (ERP-78 : filtrage par code de Category, plus par type). Alimente notamment
|
||||
* les selects « distributeur » (categoryCode=DISTRIBUTEUR) et « courtier »
|
||||
* (COURTIER) cote front (RG-1.03). Sous-requete IN (plutot qu'un JOIN sur la
|
||||
* collection M2M) pour ne pas perturber le DISTINCT / ORDER BY principal.
|
||||
* Restreint aux clients possedant au moins une categorie dont le code figure
|
||||
* dans la liste (OR — ERP-78). Alimente le filtre « Catégories » du drawer
|
||||
* (multi) ainsi que les selects « distributeur »/« courtier » (un seul code,
|
||||
* RG-1.03). Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne
|
||||
* pas perturber le DISTINCT / ORDER BY principal.
|
||||
*
|
||||
* @param list<string> $categoryCodes
|
||||
*/
|
||||
private function applyCategoryCode(QueryBuilder $qb, ?string $categoryCode): void
|
||||
private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void
|
||||
{
|
||||
if (null === $categoryCode || '' === trim($categoryCode)) {
|
||||
$codes = $this->normalizeStringList($categoryCodes);
|
||||
if ([] === $codes) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,11 +143,84 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
||||
->select('c2.id')
|
||||
->from(Client::class, 'c2')
|
||||
->join('c2.categories', 'cat2')
|
||||
->where('cat2.code = :categoryCode')
|
||||
->where('cat2.code IN (:categoryCodes)')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||
->setParameter('categoryCode', trim($categoryCode))
|
||||
->setParameter('categoryCodes', $codes)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux clients ayant au moins une adresse rattachee a l'un des
|
||||
* sites donnes (OR — RG-1.10 : les sites vivent sur les adresses, pas sur le
|
||||
* client). Sous-requete IN pour ne pas perturber le tri/pagination principal.
|
||||
*
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
private function applySiteIds(QueryBuilder $qb, array $siteIds): void
|
||||
{
|
||||
$ids = $this->normalizeIntList($siteIds);
|
||||
if ([] === $ids) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('c3.id')
|
||||
->from(Client::class, 'c3')
|
||||
->join('c3.addresses', 'addr3')
|
||||
->join('addr3.sites', 'site3')
|
||||
->where('site3.id IN (:siteIds)')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||
->setParameter('siteIds', $ids)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie une liste de chaines : trim, retrait des vides, reindexation.
|
||||
* Defensive : tolere des elements scalaires non-string (cast) et ignore le
|
||||
* reste sans lever de TypeError, le contrat etant justement de normaliser une
|
||||
* entree potentiellement brute (query params).
|
||||
*
|
||||
* @param array<mixed> $values
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeStringList(array $values): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) || is_int($value) || is_float($value)) {
|
||||
$trimmed = trim((string) $value);
|
||||
if ('' !== $trimmed) {
|
||||
$out[] = $trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation.
|
||||
* Defensive (cf. normalizeStringList) : accepte des entiers ou des chaines
|
||||
* numeriques ('1', '2') sans TypeError, ignore le reste.
|
||||
*
|
||||
* @param array<mixed> $values
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function normalizeIntList(array $values): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_numeric($value) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ final class RbacSeeder
|
||||
'permissions' => [
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.manage',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
],
|
||||
],
|
||||
self::ROLE_COMPTA => [
|
||||
@@ -70,6 +73,9 @@ final class RbacSeeder
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.accounting.view',
|
||||
'commercial.clients.accounting.manage',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
],
|
||||
],
|
||||
self::ROLE_COMMERCIALE => [
|
||||
@@ -77,6 +83,9 @@ final class RbacSeeder
|
||||
'permissions' => [
|
||||
'commercial.clients.view',
|
||||
'commercial.clients.manage',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
'catalog.categories.read_ref',
|
||||
'sites.read_ref',
|
||||
],
|
||||
],
|
||||
self::ROLE_USINE => [
|
||||
|
||||
@@ -40,13 +40,18 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
// Lecture (liste + item) : permission d'administration `sites.view` OU
|
||||
// permission de lecture-referentiel transverse `sites.read_ref` (ERP-102).
|
||||
// Le referentiel sites alimente les selects d'adresse des modules Tiers :
|
||||
// tout role qui gere des tiers doit pouvoir le lire sans porter l'acces
|
||||
// admin des Sites.
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
security: "is_granted('sites.view')",
|
||||
security: "is_granted('sites.view') or is_granted('sites.read_ref')",
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
security: "is_granted('sites.view')",
|
||||
security: "is_granted('sites.view') or is_granted('sites.read_ref')",
|
||||
),
|
||||
new Post(
|
||||
normalizationContext: ['groups' => ['site:read']],
|
||||
|
||||
+12
@@ -30,6 +30,8 @@ use function sprintf;
|
||||
* - resource != Site::class → no-op (les autres resources sont
|
||||
* gerees par SiteScopedQueryExtension) ;
|
||||
* - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ;
|
||||
* - is_granted('sites.read_ref') → pas de filtre (lecture-referentiel
|
||||
* transverse complet, ERP-102) ;
|
||||
* - user non authentifie → no-op (API Platform renvoie 401 avant) ;
|
||||
* - user sans aucun site → WHERE 1 = 0 (aucun acces) ;
|
||||
* - cas normal → WHERE site.id IN (:allowedSites).
|
||||
@@ -84,6 +86,16 @@ final class SiteCollectionScopedExtension implements QueryCollectionExtensionInt
|
||||
return;
|
||||
}
|
||||
|
||||
// 2bis) Lecture-referentiel transverse (ERP-102) : `sites.read_ref` donne
|
||||
// acces a la LISTE COMPLETE des sites (selects d'adresse des modules Tiers).
|
||||
// Sans ce bypass, le cloisonnement par site rattache reduirait le select
|
||||
// aux seuls sites de l'utilisateur (voire a rien s'il n'en a aucun) et le
|
||||
// referentiel ne serait plus "transverse". `read_ref` est une lecture seule :
|
||||
// il ouvre la visibilite sans permettre la moindre ecriture.
|
||||
if ($this->security->isGranted('sites.read_ref')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
|
||||
@@ -33,6 +33,11 @@ final class SitesModule
|
||||
['code' => 'sites.view', 'label' => 'Voir les sites'],
|
||||
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
|
||||
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
||||
// Lecture-referentiel transverse (ERP-102) : permet de LISTER les sites
|
||||
// pour alimenter les selects des modules Tiers (adresses client...), sans
|
||||
// donner l'acces d'administration `.view` (qui ouvre la page Sites dans la
|
||||
// sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
|
||||
['code' => 'sites.read_ref', 'label' => 'Lire le referentiel sites (transverse, lecture seule)'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +169,9 @@ final class ColumnCommentsCatalog
|
||||
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).',
|
||||
'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
|
||||
'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
|
||||
'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.',
|
||||
'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).',
|
||||
'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).',
|
||||
// Contact principal inline supprime (refonte contact) : first_name,
|
||||
// last_name, phone_primary, phone_secondary, email vivent desormais
|
||||
// uniquement sur client_contact.
|
||||
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
|
||||
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
|
||||
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
|
||||
|
||||
@@ -6,12 +6,12 @@ namespace App\Shared\Infrastructure\Doctrine;
|
||||
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
@@ -30,12 +30,19 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
#[AsDoctrineListener(event: Events::preUpdate)]
|
||||
final class TimestampableBlamableSubscriber
|
||||
{
|
||||
public function __construct(private readonly Security $security) {}
|
||||
// L'horloge est injectee (et non un `new DateTimeImmutable()` direct) pour
|
||||
// que les tests puissent figer/avancer le temps de facon deterministe via
|
||||
// ClockSensitiveTrait (cf. ERP-98). En prod, le service `clock` delegue a
|
||||
// l'horloge systeme reelle.
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
public function prePersist(PrePersistEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
$now = new DateTimeImmutable();
|
||||
$now = $this->clock->now();
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if ($entity instanceof TimestampableInterface) {
|
||||
@@ -55,7 +62,7 @@ final class TimestampableBlamableSubscriber
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if ($entity instanceof TimestampableInterface) {
|
||||
$entity->setUpdatedAt(new DateTimeImmutable());
|
||||
$entity->setUpdatedAt($this->clock->now());
|
||||
}
|
||||
|
||||
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
/**
|
||||
* Garde-fou architecture : toute entite `#[Auditable]` doit avoir son libelle
|
||||
* i18n dans le bloc `audit.entity` du `fr.json` du shell.
|
||||
*
|
||||
* Pourquoi : le filtre « Type d'entite » de l'audit-log est dynamique
|
||||
* (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents
|
||||
* en base). Des qu'un module audite une entite, un nouveau type apparait. Le
|
||||
* rendu front (`formatEntityType`, audit-log.vue) construit la cle
|
||||
* `audit.entity.<module>_<entity>` et, faute de traduction, retombe
|
||||
* SILENCIEUSEMENT sur le type technique brut (ex: `commercial.Client`). Le
|
||||
* manque passe donc inapercu jusqu'a observation dans l'UI.
|
||||
*
|
||||
* Ce test rend le manque BLOQUANT (meme esprit que ColumnsHaveSqlCommentTest) :
|
||||
* il scanne les entites `#[Auditable]` sous `src/Module/<m>/Domain/Entity/`,
|
||||
* derive la cle attendue comme le fait le front, et echoue si elle est absente
|
||||
* du `fr.json`.
|
||||
*
|
||||
* Derivation de la cle (miroir exact de AuditListener::formatEntityType + de
|
||||
* formatEntityType cote front) :
|
||||
* FQCN `App\Module\Commercial\Domain\Entity\ClientAddress`
|
||||
* -> entity_type `commercial.ClientAddress` (module en minuscules, Entity intacte)
|
||||
* -> cle i18n `commercial_clientaddress` (tout en minuscules, `.` -> `_`)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class AuditableEntitiesHaveI18nLabelTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Chemin du fichier de traductions FR du shell. Source unique des libelles
|
||||
* d'entite audit (decision ERP-99 : emplacement centralise, schema flat).
|
||||
*/
|
||||
private const LOCALE_FILE = __DIR__.'/../../frontend/i18n/locales/fr.json';
|
||||
|
||||
public function testEveryAuditableEntityHasAnI18nLabel(): void
|
||||
{
|
||||
$labels = $this->loadAuditEntityLabels();
|
||||
|
||||
$finder = new Finder()
|
||||
->files()
|
||||
->in(__DIR__.'/../../src/Module')
|
||||
->path('Domain/Entity')
|
||||
->name('*.php')
|
||||
;
|
||||
|
||||
// Garde : si le scan ne trouve rien, le chemin est casse — le test
|
||||
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
|
||||
self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?');
|
||||
|
||||
$checked = 0;
|
||||
foreach ($finder as $file) {
|
||||
$fqcn = $this->extractFqcn($file->getRealPath());
|
||||
if (null === $fqcn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($fqcn);
|
||||
// On ne s'interesse qu'aux entites reellement auditees.
|
||||
if ($reflection->isAbstract() || [] === $reflection->getAttributes(Auditable::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->deriveI18nKey($fqcn);
|
||||
self::assertNotNull(
|
||||
$key,
|
||||
sprintf('Entite %s hors structure modulaire attendue (App\Module\<M>\Domain\Entity\<E>).', $fqcn),
|
||||
);
|
||||
|
||||
self::assertArrayHasKey(
|
||||
$key,
|
||||
$labels,
|
||||
sprintf(
|
||||
'L\'entite auditable %s n\'a pas de libelle i18n. Ajouter "%s" dans le bloc '
|
||||
.'`audit.entity` de frontend/i18n/locales/fr.json (sinon le filtre audit-log '
|
||||
.'affiche le type technique brut). Cf. ERP-99 + .claude/rules/backend.md § Audit.',
|
||||
$fqcn,
|
||||
$key,
|
||||
),
|
||||
);
|
||||
self::assertNotSame('', trim($labels[$key]), sprintf('Le libelle audit "%s" est vide.', $key));
|
||||
|
||||
++$checked;
|
||||
}
|
||||
|
||||
// Garde : au moins une entite auditable doit avoir ete verifiee, sinon
|
||||
// la detection de l'attribut est cassee (faux positif vert).
|
||||
self::assertGreaterThan(0, $checked, 'Aucune entite #[Auditable] detectee : detection d\'attribut cassee ?');
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le bloc `audit.entity` du fr.json sous forme de map cle -> libelle.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function loadAuditEntityLabels(): array
|
||||
{
|
||||
$raw = file_get_contents(self::LOCALE_FILE);
|
||||
self::assertIsString($raw, sprintf('Fichier de locale introuvable : %s', self::LOCALE_FILE));
|
||||
|
||||
/** @var array<string, mixed> $json */
|
||||
$json = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$entity = $json['audit']['entity'] ?? null;
|
||||
self::assertIsArray($entity, 'Bloc `audit.entity` absent ou invalide dans fr.json.');
|
||||
|
||||
$labels = [];
|
||||
foreach ($entity as $key => $value) {
|
||||
if (is_string($key) && is_string($value)) {
|
||||
$labels[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive la cle i18n `<module>_<entity>` depuis le FQCN, en miroir de
|
||||
* AuditListener::formatEntityType (module en minuscules) suivi de
|
||||
* l'aplatissement front (tout en minuscules, `.` -> `_`).
|
||||
*
|
||||
* Retourne null si le FQCN ne respecte pas la structure modulaire.
|
||||
*/
|
||||
private function deriveI18nKey(string $fqcn): ?string
|
||||
{
|
||||
if (1 !== preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $fqcn, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower($m['module']).'_'.strtolower($m['entity']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
|
||||
* source, sans charger le fichier.
|
||||
*/
|
||||
private function extractFqcn(string $path): ?string
|
||||
{
|
||||
$source = file_get_contents($path);
|
||||
if (false === $source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|
||||
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($nsMatch[1]).'\\'.$classMatch[1];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace App\Tests\Module\Catalog\Api;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Clock\Test\ClockSensitiveTrait;
|
||||
|
||||
/**
|
||||
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
|
||||
@@ -20,12 +22,39 @@ use DateTimeImmutable;
|
||||
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
|
||||
* Doctrine declenche le subscriber)
|
||||
*
|
||||
* ERP-98 : ces tests pilotent une horloge mockee (ClockSensitiveTrait) plutot
|
||||
* que de dependre d'un `sleep(1)` reel. Le subscriber lit le service `clock`,
|
||||
* que `self::mockTime()` remplace par un MockClock fige au niveau du process —
|
||||
* ce qui survit aux reboots de kernel entre requetes (POST admin / PATCH bob)
|
||||
* et reste insensible a la derive d'horloge WSL2 a l'origine des flakes.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
use ClockSensitiveTrait;
|
||||
|
||||
/**
|
||||
* Fige l'horloge globale sur l'instant courant DANS LE FUSEAU PHP par
|
||||
* defaut, et la retourne pour la piloter (`sleep()`).
|
||||
*
|
||||
* Subtilite : `self::mockTime()` cree par defaut un MockClock en UTC, or
|
||||
* les colonnes `TIMESTAMP WITHOUT TIME ZONE` round-trippent via le fuseau
|
||||
* PHP (Europe/Paris). Un MockClock UTC decalerait createdAt de l'offset
|
||||
* (2h) au rechargement. On seede donc avec `new DateTimeImmutable()`
|
||||
* (fuseau par defaut), exactement comme le NativeClock en prod.
|
||||
*/
|
||||
private function freezeClock(): ClockInterface
|
||||
{
|
||||
return self::mockTime(new DateTimeImmutable());
|
||||
}
|
||||
|
||||
public function testCreatedByAdminOnPost(): void
|
||||
{
|
||||
// Horloge figee : le subscriber posera createdAt/updatedAt sur cet
|
||||
// instant exact, insensible a tout decalage d'horloge reel.
|
||||
$clock = $this->freezeClock();
|
||||
|
||||
$type = $this->createCategoryType();
|
||||
|
||||
/** @var User $admin */
|
||||
@@ -33,9 +62,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
self::assertNotNull($admin);
|
||||
$adminId = $admin->getId();
|
||||
|
||||
$before = new DateTimeImmutable();
|
||||
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
|
||||
sleep(1);
|
||||
$before = $clock->now();
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('POST', '/api/categories', [
|
||||
@@ -103,6 +130,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
|
||||
public function testPatchUpdatesUpdatedFieldsOnly(): void
|
||||
{
|
||||
$clock = $this->freezeClock();
|
||||
|
||||
// Etape 1 : creation par admin pour figer createdBy=admin.
|
||||
$type = $this->createCategoryType();
|
||||
$adminClient = $this->createAdminClient();
|
||||
@@ -127,9 +156,9 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||
$initialCreatedById = $initial->getCreatedBy()->getId();
|
||||
|
||||
// Decalage temporel suffisant pour que la precision PG (seconde)
|
||||
// capte un updatedAt different.
|
||||
sleep(1);
|
||||
// Avance deterministe de l'horloge mockee : garantit un updatedAt
|
||||
// strictement superieur cote PG (precision seconde) sans sleep reel.
|
||||
$clock->sleep(1);
|
||||
|
||||
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
|
||||
$manage = $this->createManageClient();
|
||||
@@ -180,6 +209,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
|
||||
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
|
||||
{
|
||||
$clock = $this->freezeClock();
|
||||
|
||||
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
|
||||
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
|
||||
$type = $this->createCategoryType();
|
||||
@@ -202,7 +233,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||
$initial = $em->getRepository(Category::class)->find($createdId);
|
||||
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||
|
||||
sleep(1);
|
||||
// Avance deterministe de l'horloge mockee (cf. testPatch).
|
||||
$clock->sleep(1);
|
||||
|
||||
// Soft delete par un manager non-admin.
|
||||
$manage = $this->createManageClient();
|
||||
|
||||
@@ -126,9 +126,6 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
|
||||
// Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait
|
||||
// produit le ClientProcessor via l'API.
|
||||
$client->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
|
||||
$client->setLastName('Seed');
|
||||
$client->setPhonePrimary('0102030405');
|
||||
$client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test');
|
||||
$client->addCategory($this->createCategory($categoryCode));
|
||||
$client->setIsArchived($isArchived);
|
||||
if ($isArchived) {
|
||||
|
||||
@@ -169,6 +169,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Non Billing Empty Email');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -179,6 +180,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -286,6 +288,29 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
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).
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'API /api/clients (M1) — branche ERP-55.
|
||||
*
|
||||
@@ -20,7 +25,7 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
public function testPostNormalizesTextFields(): void
|
||||
public function testPostNormalizesCompanyName(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
@@ -29,22 +34,17 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'acme sas',
|
||||
'firstName' => 'JEAN',
|
||||
'lastName' => 'dupont',
|
||||
'phonePrimary' => '06.12.34.56.78',
|
||||
'email' => 'Jean.DUPONT@ACME.FR',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
// RG-1.18 / 1.19 / 1.20 / 1.21
|
||||
// RG-1.18 : companyName normalise en MAJUSCULES. Les champs de contact
|
||||
// inline ont disparu (refonte contact) -> plus de normalisation ici.
|
||||
self::assertSame('ACME SAS', $data['companyName']);
|
||||
self::assertSame('Jean', $data['firstName']);
|
||||
self::assertSame('Dupont', $data['lastName']);
|
||||
self::assertSame('0612345678', $data['phonePrimary']);
|
||||
self::assertSame('jean.dupont@acme.fr', $data['email']);
|
||||
self::assertArrayNotHasKey('firstName', $data);
|
||||
self::assertArrayNotHasKey('email', $data);
|
||||
self::assertFalse($data['isArchived']);
|
||||
}
|
||||
|
||||
@@ -56,9 +56,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
|
||||
$payload = [
|
||||
'companyName' => 'Doublon SARL',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'dup@test.fr',
|
||||
'categories' => [$iri],
|
||||
];
|
||||
|
||||
@@ -66,30 +63,10 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16).
|
||||
$payload['email'] = 'dup2@test.fr';
|
||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testPostWithoutFirstOrLastNameReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'No Contact Name',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'nc@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.01
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostWithoutCategoryReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
@@ -98,9 +75,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'No Category',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'nocat@test.fr',
|
||||
'categories' => [],
|
||||
],
|
||||
]);
|
||||
@@ -119,9 +93,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Mutex Client',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'mutex@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
'distributor' => '/api/clients/'.$distributor->getId(),
|
||||
'broker' => '/api/clients/'.$distributor->getId(),
|
||||
@@ -142,9 +113,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Bad Distrib Ref',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'baddistrib@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
'distributor' => '/api/clients/'.$notDistro->getId(),
|
||||
],
|
||||
@@ -164,9 +132,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Client Avec Distrib',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'okdistrib@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
'distributor' => '/api/clients/'.$distributor->getId(),
|
||||
],
|
||||
@@ -185,9 +150,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Bad Broker Ref',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'badbroker@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
'broker' => '/api/clients/'.$notBroker->getId(),
|
||||
],
|
||||
@@ -207,9 +169,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Client Avec Courtier',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'okbroker@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
'broker' => '/api/clients/'.$broker->getId(),
|
||||
],
|
||||
@@ -325,4 +284,146 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
self::assertArrayHasKey('addresses', $data);
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* ERP-62 : la LISTE doit alimenter les colonnes « Catégories » (codes) et
|
||||
* « Site(s) » (badges name + color) du Repertoire. On verifie donc que la
|
||||
* collection embarque le `code` de chaque categorie et les sites agreges des
|
||||
* adresses (accessoire Client::getSites()).
|
||||
*/
|
||||
public function testListEmbedsCategoryCodesAndAggregatedSites(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
// Client seede + une adresse rattachee a un site (fixtures Sites).
|
||||
$seed = $this->seedClient('Embed List Co', false, 'DISTRIBUTEUR');
|
||||
$em = $this->getEm();
|
||||
$site = $em->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).');
|
||||
|
||||
$address = new ClientAddress();
|
||||
$address->setClient($seed);
|
||||
$address->setPostalCode('86100');
|
||||
$address->setCity('Châtellerault');
|
||||
$address->setStreet('1 rue du Test');
|
||||
$address->addSite($site);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
$member = $client->request('GET', '/api/clients?pagination=false', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray()['member'];
|
||||
|
||||
$row = null;
|
||||
foreach ($member as $candidate) {
|
||||
if ('EMBED LIST CO' === $candidate['companyName']) {
|
||||
$row = $candidate;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
self::assertNotNull($row, 'Le client seede doit figurer dans la liste.');
|
||||
|
||||
// Colonne « Catégories » : chaque categorie embarquee porte son code.
|
||||
self::assertNotEmpty($row['categories']);
|
||||
self::assertArrayHasKey('code', $row['categories'][0]);
|
||||
self::assertSame('DISTRIBUTEUR', $row['categories'][0]['code']);
|
||||
|
||||
// Colonne « Site(s) » : sites agreges des adresses, avec name + color.
|
||||
self::assertArrayHasKey('sites', $row);
|
||||
self::assertNotEmpty($row['sites']);
|
||||
self::assertArrayHasKey('name', $row['sites'][0]);
|
||||
self::assertArrayHasKey('color', $row['sites'][0]);
|
||||
self::assertSame($site->getName(), $row['sites'][0]['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* ERP-62 (drawer) : filtre Catégories multi (?categoryCode[]=A&categoryCode[]=B)
|
||||
* — union des clients possedant l'un OU l'autre code.
|
||||
*/
|
||||
public function testListFilterByMultipleCategoryCodes(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Filtre Distrib Co', false, 'DISTRIBUTEUR');
|
||||
$this->seedClient('Filtre Courtier Co', false, 'COURTIER');
|
||||
$this->seedClient('Filtre Secteur Co', false, 'SECTEUR');
|
||||
|
||||
$names = $this->companyNames($client, '/api/clients?pagination=false&categoryCode[]=DISTRIBUTEUR&categoryCode[]=COURTIER');
|
||||
|
||||
self::assertContains('FILTRE DISTRIB CO', $names);
|
||||
self::assertContains('FILTRE COURTIER CO', $names);
|
||||
self::assertNotContains('FILTRE SECTEUR CO', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* ERP-62 (drawer) : filtre Sites (?siteId[]=X) — clients ayant >= 1 adresse
|
||||
* rattachee au site donne.
|
||||
*/
|
||||
public function testListFilterBySite(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$em = $this->getEm();
|
||||
|
||||
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
|
||||
self::assertCount(2, $sites, 'Deux sites seedes requis pour ce test.');
|
||||
[$siteA, $siteB] = $sites;
|
||||
|
||||
$onSiteA = $this->seedClient('Client Sur Site A');
|
||||
$this->attachAddressWithSite($onSiteA, $siteA);
|
||||
|
||||
$onSiteB = $this->seedClient('Client Sur Site B');
|
||||
$this->attachAddressWithSite($onSiteB, $siteB);
|
||||
|
||||
$names = $this->companyNames($client, '/api/clients?pagination=false&siteId[]='.$siteA->getId());
|
||||
|
||||
self::assertContains('CLIENT SUR SITE A', $names);
|
||||
self::assertNotContains('CLIENT SUR SITE B', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* ERP-62 (drawer) : statut « Archivés » (?archivedOnly=true) — uniquement les
|
||||
* archives, contrairement a includeArchived qui ajoute les archives aux actifs.
|
||||
*/
|
||||
public function testListArchivedOnlyReturnsOnlyArchived(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Actif Visible Co');
|
||||
$this->seedClient('Archive Visible Co', true);
|
||||
|
||||
$names = $this->companyNames($client, '/api/clients?pagination=false&archivedOnly=true');
|
||||
|
||||
self::assertContains('ARCHIVE VISIBLE CO', $names);
|
||||
self::assertNotContains('ACTIF VISIBLE CO', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattache une adresse minimale portant un site au client (les sites vivent
|
||||
* sur les adresses, RG-1.10).
|
||||
*/
|
||||
private function attachAddressWithSite(ClientEntity $client, Site $site): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$address = new ClientAddress();
|
||||
$address->setClient($client);
|
||||
$address->setPostalCode('86100');
|
||||
$address->setCity('Châtellerault');
|
||||
$address->setStreet('1 rue du Test');
|
||||
$address->addSite($site);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper : recupere les companyName d'une collection /api/clients.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function companyNames(Client $client, string $url): array
|
||||
{
|
||||
$members = $client->request('GET', $url, [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray()['member'];
|
||||
|
||||
return array_map(static fn (array $c): string => $c['companyName'], $members);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,6 @@ final class ClientAuditTest extends AbstractCommercialApiTestCase
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Blamable Co',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'blamable@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
@@ -88,6 +90,39 @@ final class ClientExportControllerTest extends AbstractCommercialApiTestCase
|
||||
self::assertNotContains('SECTEUR CO', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* ERP-100 : depuis le decouplage hydratation/selection, le QueryBuilder de
|
||||
* liste ne fetch-join plus les collections — l'export les recharge en lot via
|
||||
* hydrateListCollections(). Ce test garde que les colonnes « Catégories » et
|
||||
* « Site(s) » restent peuplees (un oubli d'hydratation les rendrait vides
|
||||
* sans erreur).
|
||||
*/
|
||||
public function testExportPopulatesCategoryAndSiteColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Hydrate Co', false, 'DISTRIBUTEUR');
|
||||
|
||||
$em = $this->getEm();
|
||||
$site = $em->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).');
|
||||
|
||||
$address = new ClientAddress();
|
||||
$address->setClient($seed);
|
||||
$address->setPostalCode('86100');
|
||||
$address->setCity('Châtellerault');
|
||||
$address->setStreet('1 rue du Test');
|
||||
$address->addSite($site);
|
||||
$em->persist($address);
|
||||
$em->flush();
|
||||
|
||||
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
|
||||
|
||||
// Colonne « Catégories » : libelle de la categorie du client (getName()).
|
||||
self::assertStringContainsString('test_cli_cat_distributeur', $flat);
|
||||
// Colonne « Site(s) » : site agrege depuis l'adresse (RG-1.10).
|
||||
self::assertStringContainsString((string) $site->getName(), $flat);
|
||||
}
|
||||
|
||||
public function testSirenColumnPresentWithAccountingView(): void
|
||||
{
|
||||
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
||||
|
||||
@@ -7,12 +7,15 @@ namespace App\Tests\Module\Commercial\Api;
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels du formulaire principal — combler les trous (ERP-60).
|
||||
* Tests fonctionnels du formulaire principal apres la refonte contact.
|
||||
*
|
||||
* RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs
|
||||
* + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les
|
||||
* reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire),
|
||||
* non encore testee.
|
||||
* RG-1.01 (prenom OU nom) et RG-1.02 (telephone secondaire) ont ete SUPPRIMEES
|
||||
* du Client : le contact principal n'est plus porte inline, il vit uniquement
|
||||
* dans ClientContact (onglet Contact). Ce fichier verifie que :
|
||||
* - le formulaire principal se cree avec les seuls champs subsistants
|
||||
* (companyName + categories), sans aucun champ de contact ;
|
||||
* - les anciens champs de contact (firstName, lastName, phonePrimary,
|
||||
* phoneSecondary, email) ne sont plus exposes ni persistes.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -21,11 +24,10 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/**
|
||||
* RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes
|
||||
* distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur
|
||||
* la colonne secondaire.
|
||||
* Le formulaire principal n'exige plus que companyName + au moins une
|
||||
* categorie (RG-1.16 / RG sur categories). Aucun champ de contact requis.
|
||||
*/
|
||||
public function testPostPersistsSecondaryPhoneNormalized(): void
|
||||
public function testPostMainFormWithoutContactFields(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
@@ -33,26 +35,28 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
|
||||
$data = $client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Two Phones SARL',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '06.12.34.56.78',
|
||||
'phoneSecondary' => '05 49 00 11 22',
|
||||
'email' => 'twophones@test.fr',
|
||||
'companyName' => 'Main Form SARL',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('0612345678', $data['phonePrimary']);
|
||||
self::assertSame('0549001122', $data['phoneSecondary']);
|
||||
self::assertSame('MAIN FORM SARL', $data['companyName']);
|
||||
|
||||
// Les champs de contact inline ont disparu de la representation.
|
||||
self::assertArrayNotHasKey('firstName', $data);
|
||||
self::assertArrayNotHasKey('lastName', $data);
|
||||
self::assertArrayNotHasKey('phonePrimary', $data);
|
||||
self::assertArrayNotHasKey('phoneSecondary', $data);
|
||||
self::assertArrayNotHasKey('email', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.02 : maximum 2 telephones — le modele n'expose que phonePrimary et
|
||||
* phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est
|
||||
* ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero.
|
||||
* Les anciens champs de contact envoyes par un appel API direct (payload
|
||||
* historique) sont ignores par le denormaliseur : ils n'apparaissent pas
|
||||
* dans la representation et ne creent aucune colonne sur le client.
|
||||
*/
|
||||
public function testThirdPhoneFieldIsIgnored(): void
|
||||
public function testLegacyContactFieldsAreIgnored(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
@@ -60,25 +64,25 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
|
||||
$data = $client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Third Phone SARL',
|
||||
'firstName' => 'A',
|
||||
'companyName' => 'Legacy Fields SARL',
|
||||
'firstName' => 'Ignored',
|
||||
'lastName' => 'Ignored',
|
||||
'phonePrimary' => '0612345678',
|
||||
'phoneSecondary' => '0549001122',
|
||||
'phoneTertiary' => '0700000000',
|
||||
'email' => 'thirdphone@test.fr',
|
||||
'email' => 'ignored@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
// Le champ inconnu est ignore par le denormaliseur : il n'apparait pas
|
||||
// dans la representation et n'a pas ete persiste.
|
||||
self::assertArrayNotHasKey('phoneTertiary', $data);
|
||||
self::assertArrayNotHasKey('firstName', $data);
|
||||
self::assertArrayNotHasKey('phonePrimary', $data);
|
||||
self::assertArrayNotHasKey('email', $data);
|
||||
|
||||
// Confirmation cote base : seules les 2 colonnes telephone existent.
|
||||
// Confirmation cote base : le client cree ne porte aucun contact inline
|
||||
// (les colonnes n'existent plus, l'entite n'a plus les proprietes).
|
||||
$persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']);
|
||||
self::assertNotNull($persisted);
|
||||
self::assertSame('0612345678', $persisted->getPhonePrimary());
|
||||
self::assertSame('0549001122', $persisted->getPhoneSecondary());
|
||||
self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,41 @@ namespace App\Tests\Module\Commercial\Api;
|
||||
* - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active
|
||||
* (RG-1.17) ont ete supprimes / ne sont jamais crees.
|
||||
*
|
||||
* Verifie aussi la refonte contact (Version20260603120000) : les 5 colonnes de
|
||||
* contact principal inline ont ete supprimees de la table `client`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientMigrationTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
/**
|
||||
* Refonte contact : first_name / last_name / phone_primary / phone_secondary
|
||||
* / email ne doivent plus exister sur la table `client` (deplaces vers
|
||||
* client_contact). NB : le backfill de la migration ne s'exerce que sur une
|
||||
* base portant des donnees pre-refonte ; sur le schema de test (table client
|
||||
* vierge au moment de la migration) il est un no-op, donc non assertable ici
|
||||
* au runtime — seul l'etat de schema final est verifie.
|
||||
*/
|
||||
public function testInlineContactColumnsAreDropped(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var list<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
|
||||
{
|
||||
$rows = $this->clientIndexes();
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
@@ -272,15 +273,60 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
public function testBusinessRolesCanReadCategoriesAndSitesReferentials(): void
|
||||
{
|
||||
// ERP-102 : /categories et /sites sont des referentiels TRANSVERSES.
|
||||
// Tout role qui gere des clients (bureau / compta / commerciale) doit
|
||||
// pouvoir les LISTER pour alimenter les selects de creation/filtre client,
|
||||
// via la permission de lecture-referentiel dediee (catalog.categories.read_ref
|
||||
// / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter
|
||||
// la permission d'administration `.view`. Usine, sans aucune permission,
|
||||
// reste interdit.
|
||||
// Le referentiel /sites est TRANSVERSE et COMPLET : le cloisonnement par
|
||||
// site rattache (SiteCollectionScopedExtension) est neutralise par
|
||||
// `sites.read_ref` (ERP-102). Les comptes demo ne sont rattaches qu'a un
|
||||
// seul site (Chatellerault) alors que la base en compte plusieurs : on
|
||||
// verifie donc que le role voit la TOTALITE du referentiel, pas son seul
|
||||
// site rattache. Sans le bypass de scope, totalItems vaudrait 1.
|
||||
$totalSites = $this->getEm()->getRepository(Site::class)->count([]);
|
||||
self::assertGreaterThan(
|
||||
1,
|
||||
$totalSites,
|
||||
'Pre-requis du test : la base doit contenir plusieurs sites pour distinguer scope et bypass.',
|
||||
);
|
||||
|
||||
foreach (['bureau', 'compta', 'commerciale'] as $role) {
|
||||
$client = $this->authAs($role);
|
||||
|
||||
$client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role));
|
||||
|
||||
$response = $client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role));
|
||||
self::assertSame(
|
||||
$totalSites,
|
||||
$response->toArray()['totalItems'] ?? null,
|
||||
sprintf('Le role %s doit voir tout le referentiel sites (%d), pas seulement son site rattache', $role, $totalSites),
|
||||
);
|
||||
}
|
||||
|
||||
// Usine : aucune permission -> reste a 403 sur les referentiels.
|
||||
$usine = $this->authAs('usine');
|
||||
$usine->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /categories');
|
||||
$usine->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /sites');
|
||||
}
|
||||
|
||||
private function authAs(string $role): Client
|
||||
{
|
||||
return $this->authenticatedClient($role, self::PWD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ;
|
||||
* une categorie SECTEUR). Si $categoryId est null, une categorie est creee a
|
||||
* la volee.
|
||||
* Payload minimal valide de l'onglet principal (companyName + une categorie
|
||||
* SECTEUR ; le contact inline a ete supprime). Si $categoryId est null, une
|
||||
* categorie est creee a la volee.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -290,9 +336,6 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
|
||||
|
||||
return [
|
||||
'companyName' => $companyName,
|
||||
'firstName' => 'Jean',
|
||||
'phonePrimary' => '0612345678',
|
||||
'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test',
|
||||
'categories' => ['/api/categories/'.$categoryId],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire clients (M1).
|
||||
*
|
||||
* Captures reelles du 02/06/2026 (cf. docs/specs/M2-suppliers/spec-back.md
|
||||
* § 4.0.ter) ayant revele 4 bugs silencieux du contrat (aucune erreur levee) :
|
||||
* - #81 : booleens d'adresse (isProspect/isDelivery/isBilling) absents du JSON
|
||||
* (Groups sur la propriete `isX`, getter `isX()` derivant l'attribut `x`).
|
||||
* - #80 : fuite RIB (IBAN/BIC) vers un user sans accounting.view.
|
||||
* - #82 : code/libelle de Category et Site non embarques (stub IRI nu).
|
||||
* - enveloppe AP4 : member/totalItems/view sans prefixe `hydra:`, archives exclus.
|
||||
*
|
||||
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
|
||||
* annotations. Toute regression de groupe de serialisation casse ici.
|
||||
*
|
||||
* Limite connue (dependance module Sites) : l'entite Site ne porte PAS de champ
|
||||
* `code` (ni SiteInterface) — son libelle est `name`. Les « codes 86/17/82 » de
|
||||
* la spec M2 correspondent en realite au prefixe du code postal des 3 sites
|
||||
* fixtures (86100/17400/82400). On asserte donc le libelle `name` du site
|
||||
* embarque ; l'ajout d'un `Site.code` reste un ticket cote module Sites.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientSerializationContractTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
private const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
private const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
|
||||
// === #81 — Booleens d'adresse presents dans le JSON ===
|
||||
|
||||
public function testAddressBooleansArePresentInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$seed = $this->seedCompleteClient('Bool Addr Co');
|
||||
$id = $seed->getId();
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('addresses', $data);
|
||||
self::assertNotEmpty($data['addresses']);
|
||||
$address = $data['addresses'][0];
|
||||
|
||||
// Le bug droppait TOTALEMENT ces cles. Apres correctif (Groups +
|
||||
// SerializedName sur le getter), elles sont presentes ET typees bool.
|
||||
self::assertArrayHasKey('isProspect', $address);
|
||||
self::assertArrayHasKey('isDelivery', $address);
|
||||
self::assertArrayHasKey('isBilling', $address);
|
||||
|
||||
// L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
|
||||
// Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
|
||||
self::assertFalse($address['isProspect']);
|
||||
self::assertTrue($address['isDelivery']);
|
||||
self::assertTrue($address['isBilling']);
|
||||
}
|
||||
|
||||
// === #80 — Gating des RIB par accounting.view ===
|
||||
|
||||
public function testRibsPresentForAdminWithAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$seed = $this->seedCompleteClient('Rib Admin Co');
|
||||
$id = $seed->getId();
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
self::assertSame('Compte principal', $data['ribs'][0]['label']);
|
||||
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
|
||||
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
|
||||
}
|
||||
|
||||
public function testRibsAbsentForUserWithoutAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$seed = $this->seedCompleteClient('Rib Commerciale Co');
|
||||
$id = $seed->getId();
|
||||
|
||||
// Commerciale : commercial.clients.view SANS accounting.view.
|
||||
$creds = $this->createUserWithPermission('commercial.clients.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// La cle `ribs` est ABSENTE (pas null) : le groupe client:read:accounting
|
||||
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
|
||||
// fuite IBAN/BIC.
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
// === #80.bis — Gating par OMISSION des scalaires comptables ===
|
||||
|
||||
public function testAccountingScalarsGatedByOmission(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$seed = $this->seedCompleteClient('Compta Gating Co');
|
||||
$id = $seed->getId();
|
||||
|
||||
// Admin : scalaires comptables presents.
|
||||
$admin = $this->createAdminClient();
|
||||
$adminData = $admin->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('siren', $adminData);
|
||||
self::assertSame('123456789', $adminData['siren']);
|
||||
self::assertArrayHasKey('accountNumber', $adminData);
|
||||
|
||||
// Commerciale : scalaires comptables ABSENTS (omission, pas null).
|
||||
$creds = $this->createUserWithPermission('commercial.clients.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayNotHasKey('siren', $data);
|
||||
self::assertArrayNotHasKey('accountNumber', $data);
|
||||
self::assertArrayNotHasKey('nTva', $data);
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
// === #82 — Embed code/libelle des Category et Site ===
|
||||
|
||||
public function testCategoriesEmbedCodeAndLabel(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$seed = $this->seedCompleteClient('Embed Cat Co');
|
||||
$id = $seed->getId();
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertNotEmpty($data['categories']);
|
||||
$category = $data['categories'][0];
|
||||
// Avant correctif : seuls @id/@type/createdAt/updatedAt (category:read
|
||||
// absent du contexte). Apres : code + name (libelle) embarques.
|
||||
self::assertArrayHasKey('code', $category);
|
||||
self::assertArrayHasKey('name', $category);
|
||||
self::assertNotSame('', $category['code']);
|
||||
}
|
||||
|
||||
public function testAddressSitesEmbedLabel(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$seed = $this->seedCompleteClient('Embed Site Co');
|
||||
$id = $seed->getId();
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$address = $data['addresses'][0];
|
||||
self::assertArrayHasKey('sites', $address);
|
||||
self::assertNotEmpty($address['sites']);
|
||||
// Site embarque : libelle `name` present (avant : stub @id/@type nu).
|
||||
// NB : Site n'a pas de champ `code` (cf. note de classe) -> on asserte name.
|
||||
self::assertArrayHasKey('name', $address['sites'][0]);
|
||||
self::assertNotSame('', $address['sites'][0]['name']);
|
||||
|
||||
// L'adresse seedee est multi-sites : preuve que l'embed parcourt la collection.
|
||||
self::assertGreaterThanOrEqual(2, count($address['sites']));
|
||||
|
||||
// Categories d'adresse : code embarque (category:read dans le contexte).
|
||||
self::assertArrayHasKey('categories', $address);
|
||||
self::assertNotEmpty($address['categories']);
|
||||
self::assertArrayHasKey('code', $address['categories'][0]);
|
||||
}
|
||||
|
||||
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
|
||||
|
||||
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$prefix = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$this->seedClient($prefix.' Active');
|
||||
$this->seedClient($prefix.' Archived', true);
|
||||
|
||||
// Liste par defaut filtree sur le prefixe : enveloppe member/totalItems
|
||||
// sans prefixe hydra:, archive EXCLU du totalItems (RG-1.24).
|
||||
$default = $http->request('GET', '/api/clients?search='.$prefix, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $default);
|
||||
self::assertArrayHasKey('totalItems', $default);
|
||||
self::assertArrayNotHasKey('hydra:member', $default);
|
||||
self::assertArrayNotHasKey('hydra:totalItems', $default);
|
||||
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
|
||||
|
||||
// includeArchived : l'archive reintegre le total.
|
||||
$all = $http->request('GET', '/api/clients?search='.$prefix.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertSame(2, $all['totalItems']);
|
||||
|
||||
// `view` (PartialCollectionView) sans prefixe hydra: : force le multi-page
|
||||
// via itemsPerPage=1 sur les 2 resultats archives inclus.
|
||||
$paged = $http->request('GET', '/api/clients?search='.$prefix.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('view', $paged);
|
||||
self::assertArrayNotHasKey('hydra:view', $paged);
|
||||
}
|
||||
|
||||
// === Helper ===
|
||||
|
||||
/**
|
||||
* Seede un client COMPLET (sans passer par l'API, validations applicatives
|
||||
* non rejouees mais CHECK BDD respectes) : bloc comptable non nul, >= 1 RIB,
|
||||
* >= 1 adresse multi-sites avec categories, >= 1 contact, >= 1 categorie.
|
||||
*
|
||||
* L'adresse est livraison + facturation (prospect exclusif, RG-1.06 ; email
|
||||
* de facturation present, RG-1.11) afin de poser des booleens `true`
|
||||
* serialisables tout en respectant les CHECK Postgres.
|
||||
*/
|
||||
private function seedCompleteClient(string $companyName): ClientEntity
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Nom unique parmi les actifs (index partiel uq_client_company_name_active).
|
||||
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$client = new ClientEntity();
|
||||
$client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
|
||||
$client->addCategory($this->createCategory('SECTEUR'));
|
||||
// Bloc comptable non nul (gating par omission cote Commerciale).
|
||||
$client->setSiren('123456789');
|
||||
$client->setAccountNumber('C0001');
|
||||
$client->setNTva('FR00123456789');
|
||||
$em->persist($client);
|
||||
|
||||
// >= 2 sites fixtures pour une adresse multi-sites (RG-1.10).
|
||||
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
|
||||
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
|
||||
|
||||
$address = new ClientAddress();
|
||||
$address->setClient($client);
|
||||
$address->setIsProspect(false);
|
||||
$address->setIsDelivery(true);
|
||||
$address->setIsBilling(true);
|
||||
$address->setBillingEmail('billing'.$suffix.'@seed.test');
|
||||
$address->setPostalCode('86000');
|
||||
$address->setCity('Poitiers');
|
||||
$address->setStreet('12 rue des Acacias');
|
||||
foreach ($sites as $site) {
|
||||
$address->addSite($site);
|
||||
}
|
||||
$address->addCategory($this->createCategory('SECTEUR'));
|
||||
$em->persist($address);
|
||||
|
||||
$rib = new ClientRib();
|
||||
$rib->setClient($client);
|
||||
$rib->setLabel('Compte principal');
|
||||
$rib->setBic(self::VALID_BIC);
|
||||
$rib->setIban(self::VALID_IBAN);
|
||||
$em->persist($rib);
|
||||
|
||||
$contact = new ClientContact();
|
||||
$contact->setClient($client);
|
||||
$contact->setFirstName('Marie');
|
||||
$contact->setLastName('Martin');
|
||||
$em->persist($contact);
|
||||
|
||||
$em->flush();
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Host');
|
||||
$siteIri = $this->firstSiteIri();
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
|
||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -123,6 +124,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$siteIri],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->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
|
||||
* ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier
|
||||
* verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee)
|
||||
* et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques.
|
||||
* n'est PLUS contraint unique. (L'email — RG-1.17 — a disparu du Client avec la
|
||||
* refonte contact, il vit desormais sur ClientContact.)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientUniquenessTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/**
|
||||
* RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme
|
||||
* email principal — aucune contrainte d'unicite (un email peut servir
|
||||
* plusieurs clients).
|
||||
*/
|
||||
public function testDuplicateEmailIsAllowed(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$iri = '/api/categories/'.$cat->getId();
|
||||
|
||||
$payload = static fn (string $name): array => [
|
||||
'companyName' => $name,
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'partage@test.fr',
|
||||
'categories' => [$iri],
|
||||
];
|
||||
|
||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// Meme email, nom different -> doit passer (pas d'index unique email).
|
||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements
|
||||
* multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on
|
||||
|
||||
@@ -134,14 +134,11 @@ final class ClientProcessorTest extends TestCase
|
||||
'isArchived' => false,
|
||||
],
|
||||
managed: true,
|
||||
// Etat persiste complet (valeurs normalisees) : sans les champs
|
||||
// metier, guardManage (ERP-74) les croirait modifies (companyName,
|
||||
// lastName... compares a null) et leverait un 403 parasite.
|
||||
// Etat persiste (valeurs normalisees) : sans companyName, guardManage
|
||||
// (ERP-74) le croirait modifie (compare a null) et leverait un 403
|
||||
// parasite.
|
||||
originalData: [
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
@@ -164,13 +161,10 @@ final class ClientProcessorTest extends TestCase
|
||||
managed: true,
|
||||
// getOriginalEntityData renvoie tous les champs mappes d'une entite
|
||||
// geree : isArchived (non-null) y figure toujours, ainsi que les
|
||||
// champs metier (sinon guardManage les croirait modifies).
|
||||
// champs metier (companyName) sinon guardManage les croirait modifies.
|
||||
originalData: [
|
||||
'siren' => '123456789',
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
@@ -193,9 +187,6 @@ final class ClientProcessorTest extends TestCase
|
||||
managed: true,
|
||||
originalData: [
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
@@ -220,9 +211,6 @@ final class ClientProcessorTest extends TestCase
|
||||
originalData: [
|
||||
'siren' => '111111111',
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
@@ -324,9 +312,6 @@ final class ClientProcessorTest extends TestCase
|
||||
managed: true,
|
||||
originalData: [
|
||||
'companyName' => 'TEST CO',
|
||||
'lastName' => 'Dupont',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 't@test.fr',
|
||||
'triageService' => false,
|
||||
'isArchived' => false,
|
||||
],
|
||||
@@ -401,16 +386,14 @@ final class ClientProcessorTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) — suffisant
|
||||
* pour atteindre les validations testees.
|
||||
* Client minimal — companyName seul depuis la suppression du contact inline.
|
||||
* Suffisant pour atteindre les validations testees (le contact vit desormais
|
||||
* dans ClientContact, hors scope du ClientProcessor).
|
||||
*/
|
||||
private function minimalClient(): Client
|
||||
{
|
||||
$client = new Client();
|
||||
$client->setCompanyName('Test Co');
|
||||
$client->setLastName('Dupont');
|
||||
$client->setPhonePrimary('0102030405');
|
||||
$client->setEmail('t@test.fr');
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
@@ -16,17 +16,18 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
*/
|
||||
final class SitesModuleTest extends KernelTestCase
|
||||
{
|
||||
public function testPermissionsSetContainsExactlyThreeCodes(): void
|
||||
public function testPermissionsSetContainsExactlyFourCodes(): void
|
||||
{
|
||||
// Garde-fou : si quelqu'un ajoute une permission sans ajuster les
|
||||
// tests ou la doc, ce test casse explicitement. Si au contraire une
|
||||
// permission disparait (ex: bypass_scope retire par erreur), meme
|
||||
// effet. Le set de 3 permissions est fige par ce test.
|
||||
// effet. Le set de permissions est fige par ce test.
|
||||
// `sites.read_ref` ajoutee en ERP-102 (lecture-referentiel transverse).
|
||||
$codes = array_column(SitesModule::permissions(), 'code');
|
||||
sort($codes);
|
||||
|
||||
self::assertSame(
|
||||
['sites.bypass_scope', 'sites.manage', 'sites.view'],
|
||||
['sites.bypass_scope', 'sites.manage', 'sites.read_ref', 'sites.view'],
|
||||
$codes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
||||
public function testPrePersistWithUser(): void
|
||||
{
|
||||
$user = $this->createStub(UserInterface::class);
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
|
||||
$entity = new FullAuditableFixture();
|
||||
|
||||
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||
@@ -45,7 +46,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
||||
|
||||
public function testPrePersistWithoutUser(): void
|
||||
{
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null));
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null), new MockClock());
|
||||
$entity = new FullAuditableFixture();
|
||||
|
||||
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||
@@ -60,7 +61,12 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
||||
public function testPreUpdate(): void
|
||||
{
|
||||
$user = $this->createStub(UserInterface::class);
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||
// Horloge figee 1s apres le createdAt simule : updatedAt doit avancer
|
||||
// de facon deterministe, sans dependre de l'heure reelle.
|
||||
$subscriber = new TimestampableBlamableSubscriber(
|
||||
$this->securityReturning($user),
|
||||
new MockClock(new DateTimeImmutable('2020-01-01 10:00:01')),
|
||||
);
|
||||
|
||||
// On simule une entite deja persistee : createdAt fige dans le passe,
|
||||
// createdBy positionne par une creation anterieure.
|
||||
@@ -80,7 +86,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
|
||||
public function testPartialEntityTimestampableOnly(): void
|
||||
{
|
||||
$user = $this->createStub(UserInterface::class);
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
|
||||
$entity = new TimestampableOnlyFixture();
|
||||
|
||||
// Entite Timestampable mais NON Blamable : seules les dates sont posees,
|
||||
|
||||
Reference in New Issue
Block a user