Compare commits

..

8 Commits

Author SHA1 Message Date
Matthieu 94955905cd feat(commercial) : expose accounting referentials read-only API
Expose TvaMode, PaymentDelay, PaymentType et Bank en lecture seule
(GetCollection + Get), security commercial.clients.view au niveau
operations + ressource. Aucune ecriture declaree -> POST/PATCH/DELETE
renvoient 405.

Tri par defaut position ASC puis label ASC (spec M1 § 4.7). Pagination
serveur conservee (ERP-72) avec paginationClientEnabled pour activer
l'echappatoire ?pagination=false (alimenter un select sans pagination).

Endpoints : GET /api/tva_modes, /api/payment_delays, /api/payment_types,
/api/banks. Tests fonctionnels : 200 + seed, tri position/label,
405 ecritures, 403 sans permission, 401 anonyme, pagination toggle.
2026-06-01 12:17:21 +02:00
Matthieu f5040a20fa feat(commercial) : declare commercial.clients permissions + sync RBAC mirrors
Ajoute CommercialModule::permissions() (5 codes commercial.clients.* :
view, manage, accounting.view, accounting.manage, archive) — alignes sur
les is_granted deja references par ERP-55 (Client ApiResource, ClientProcessor,
ClientReadGroupContextBuilder).

Synchronise les 3 sources RBAC (regle ABSOLUE n8) : item sidebar
"Repertoire clients" (commercial.clients.view), persona user-full dans
personas.ts et SeedE2ECommand.php, cle i18n sidebar.commercial.clients.

Les roles metier Bureau/Compta/Commerciale/Usine sont seedes par ERP-74 :
les 5 permissions sont mappees ici sur le seul persona technique user-full
en attendant, sans creer de nouveau persona (regle n7).
2026-06-01 12:17:20 +02:00
Matthieu 13eb0722dc fix(commercial) : robust gating + strict category denormalizer + provider via EM (review ERP-55) 2026-06-01 12:15:33 +02:00
Matthieu d9a6d0fa5a feat(commercial) : add Client API Platform provider + processor + business rules
Branche l'API REST du repertoire clients (M1) sur l'entite Client preparee en
ERP-54. Operations GetCollection / Get / Post / Patch (pas de Delete au M1 :
l'archivage passe par PATCH isArchived).

ClientProvider :
- liste paginee (Paginator ORM, aligne sur la convention ERP-72) + echappatoire
  ?pagination=false
- exclut archives + soft-deletes par defaut (RG-1.24), ?includeArchived=true
  reintegre les archives (RG-1.25)
- tri companyName ASC (RG-1.26), filtres ?search (fuzzy companyName/lastName/
  email) et ?categoryType=<code>
- detail : 404 sur soft-delete, embarque contacts/adresses/ribs

ClientProcessor :
- normalisation serveur via ClientFieldNormalizer (RG-1.18 a 1.21)
- 409 sur doublon de nom de societe (RG-1.16) ; 409 dedie sur conflit de
  restauration (RG-1.23)
- gating par onglet : champ comptable -> accounting.manage, isArchived ->
  archive, mode strict 403 sur tout le payload (RG-1.28) ; archivage exclusif
  (RG-1.22) + pose/retrait archivedAt
- regles metier RG-1.01 (prenom/nom), RG-1.03 (distributor/broker exclusifs +
  controle du type de categorie), RG-1.12 (Virement -> banque), RG-1.13 (LCR ->
  >= 1 RIB), RG-1.04 (completude Information pour le role Commerciale)

Lecture comptable conditionnelle : ClientReadGroupContextBuilder ajoute le
groupe client:read:accounting selon commercial.clients.accounting.view.

Resolution des references categorie : CategoryReferenceDenormalizer resout les
IRI vers Category quand la propriete est type-hintee par le contrat
CategoryInterface (denormalisation impossible sur une interface sinon).

Contrats Shared :
- CategoryInterface::getCategoryTypeCode() (implemente par Category) pour la
  verification de type sans import inter-modules
- BusinessRoleAwareInterface (implemente par User) + BusinessRoles::COMMERCIALE
  pour detecter le role metier ; le code de role sera seede par ERP-74 et
  reutilise par ERP-59/60. RG-1.04 reste dormante tant qu'aucun user ne porte
  ce role.

Coordination stack :
- chaines de permission commercial.clients.* referencees ici, declarees en
  ERP-59 (tests RBAC complets en ERP-60)
- config globale de pagination (itemsPerPage client, max 50) portee par ERP-72
- referentiels comptables (PaymentType/Bank/...) exposes en ERP-56

Tests : 31 tests Commercial (integration admin sur les regles metier + unitaires
sur le gating, RG-1.04/1.12/1.13 et le context builder). Suite complete verte
(339 tests).
2026-06-01 12:10:05 +02:00
Matthieu 78cb5bfa23 feat(commercial) : add M1 client entities + accounting referentials + repositories
Entites metier (Client, ClientContact, ClientAddress, ClientRib) avec
#[Auditable] + Timestampable/Blamable, et 4 referentiels comptables statiques
(TvaMode, PaymentDelay, PaymentType, Bank). 8 repositories interfaces + impl
Doctrine. Aucun ApiResource (Provider/Processor = ERP-55).

- Client : 2 FK auto-referentes distributor/broker (mutuellement exclusives,
  CHECK en base), M2M categories, FK referentiels comptables, groupes de
  serialisation par onglet. Pas de #[ORM\UniqueConstraint] : unicite du nom de
  societe portee par l'index partiel Postgres (decision Q4).
- ClientRib : tous les champs audites, aucun #[AuditIgnore] sur iban/bic
  (decision 29/05, audit admin-only).
- M2M Category via le contrat Shared CategoryInterface + resolve_target_entities
  (regle n°1, pas d'import inter-modules) ; sites via SiteInterface.
- CommercialReferentialFixtures : re-seed idempotent des 4 referentiels (sinon
  vides apres db-reset car desormais tables mappees, purgees par les fixtures).
- Referentiels whitelistes dans EntitiesAreTimestampableBlamableTest::EXCLUDED.
- doctrine.yaml : mapping ORM du module Commercial + resolve CategoryInterface.
- ColumnCommentsCatalog : ajout des colonnes M1 (chemin schema:update/test) ;
  migration retrofit Version20260528120000 filtree sur les tables existantes
  pour ne pas casser sur les tables des modules crees plus tard.
- makefile test-db-setup : recreation de l'index partiel uq_client_company_name_active.

Refs ERP-54.
2026-06-01 12:09:57 +02:00
Matthieu 3f21248e73 fix(commercial) : down() orphan-only + index FK referentiels (review ERP-53)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m15s
2026-06-01 12:08:17 +02:00
Matthieu 9f96d1c40d feat(commercial) : migrate M1 client tables + accounting referentials + extend category_type seed
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m20s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m36s
2026-05-29 14:48:40 +02:00
Matthieu 836f177ff9 docs(commercial) : migration racine + seed fixture CategoryType (blocages ERP-53 vérifiés) 2026-05-29 14:41:43 +02:00
147 changed files with 1035 additions and 16927 deletions
-76
View File
@@ -13,64 +13,6 @@
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`) - Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
- **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}` - **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
## Pagination (obligatoire)
**Regle** : toute collection API DOIT etre paginee. Aucun retour de collection complete cote serveur.
### Standard global
Pose dans `config/packages/api_platform.yaml` (section `defaults:`) et heritee par toutes les ressources :
| Cle | Valeur | Effet |
|---|---|---|
| `pagination_enabled` | `true` | Pagination Hydra active par defaut. |
| `pagination_items_per_page` | `10` | Taille de page par defaut, aligne sur l'UI `MalioDataTable`. |
| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramene a 50. Anti deep-fetch. |
| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornee par le max). |
| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT recuperer (echappatoire selects). |
### Override par ressource (rare)
Si une ressource a besoin d'un autre defaut (ex: payload lourd), utiliser les attributs sur l'operation. **JAMAIS `paginationEnabled: false`** sans whitelist explicite dans `tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`.
```php
new GetCollection(
paginationItemsPerPage: 5, // override taille par defaut
paginationMaximumItemsPerPage: 20, // override borne max
)
```
### Selects et autocompletions
Pour alimenter un `<select>` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe :
```ts
useApi().get('/api/roles?pagination=false')
```
Le serveur retourne toute la collection, sans `view`. C'est l'echappatoire prevue par `pagination_client_enabled: true`. Sur les ressources a forte volumetrie, preferer une saisie assistee (recherche serveur via `?q=`) — a planifier dans un ticket dedie.
Les tests fonctionnels qui exercent ce comportement doivent egalement passer `?pagination=false` (cf. `CategoryListTest`, `PermissionApiTest`).
### Providers customs et pagination
Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface` **court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportes :
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
- **DBAL** : implementer un paginator local conforme a `PaginatorInterface`. Exemple : `DbalPaginator` (Core) + `AuditLogProvider`.
Gerer l'echappatoire `?pagination=false` :
```php
if (!$this->pagination->isEnabled($operation, $context)) {
return $qb->getQuery()->getResult(); // tout retourner
}
```
### Garde-fou architecture
`tests/Architecture/CollectionsArePaginatedTest` scanne reflexivement toutes les classes `#[ApiResource]` sous `src/` et echoue si une `GetCollection` pose `paginationEnabled: false` hors whitelist `EXCLUDED`. Ajouter une entree a la whitelist requiert une justification courte + un ticket Lesstime ouvert.
## Repositories ## Repositories
- Interface : `*RepositoryInterface` dans `Domain/Repository/` - Interface : `*RepositoryInterface` dans `Domain/Repository/`
@@ -98,24 +40,6 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire - Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
- Spec complete : @doc/audit-log.md - 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) ## 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 : 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 :
-47
View File
@@ -53,53 +53,6 @@ Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut. **Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
## Listes paginees (standard) — usePaginatedList obligatoire
**Toute liste qui consomme une `GetCollection` API doit passer par `usePaginatedList`** (`frontend/shared/composables/usePaginatedList.ts`). Le composable est le pendant front de la regle ABSOLUE n°13 (« toute collection est paginee cote back ») : il consomme l'envelope Hydra (`member` / `totalItems` / `view`) et expose un etat reactif a brancher directement sur `MalioDataTable`.
Pattern de reference :
```ts
const {
items,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadList,
goToPage,
setItemsPerPage,
} = usePaginatedList<MyEntity>({ url: '/my-resources' })
onMounted(loadList)
```
```vue
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:empty-message="t('foo.empty')"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
/>
```
Garanties offertes par le composable :
- Force `Accept: application/ld+json` → API Platform 4 renvoie bien `member` / `totalItems` (sans Accept, retour tableau plat sans pagination).
- Defaut 10 items/page, choix client 10 / 25 / 50, aligne sur le defaut serveur.
- Mutation `setFilters` / `setSort` / `setItemsPerPage` → retombe systematiquement en page 1.
- Cas limite « page hors borne apres filtre » : retombe automatiquement sur la derniere page valide (`tests/usePaginatedList.test.ts`).
- Etat 100 % local (refs internes a l'instance) — **jamais reflete dans l'URL**, conformement a la regle « Etat des tableaux — pas de persistance URL » ci-dessous.
A NE PAS faire :
- Charger une collection complete via `?itemsPerPage=999` pour bypasser la pagination. Le seul cas legitime de retour complet est l'alimentation d'un `<select>` sur un referentiel ≤ quelques dizaines d'entrees, et il passe par `?pagination=false` (echappatoire prevue par `pagination_client_enabled: true`).
- Reimplementer la pagination prev/next a la main au-dessus de `MalioDataTable` — le composant porte deja le selecteur items/page et les boutons Prev/Next.
- Persister `page`/`tri`/`filtre` dans la query string — meme regle que pour `<MalioDataTable>` brut (cf. section suivante).
## Etat des tableaux — pas de persistance URL ## Etat des tableaux — pas de persistance URL
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage. **Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
+2 -7
View File
@@ -3,7 +3,7 @@
## Contexte ## Contexte
CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable. CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable.
Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la demande, non chargés en permanence). Doc humaine : @README.md — Spec audit : @doc/audit-log.md
## Stack ## Stack
- Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437) - Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437)
@@ -25,8 +25,6 @@ Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la dem
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement. 10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison. 11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine. 12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
13. **Toujours paginer toute collection exposee par l'API.** Aucun retour de collection complete (pas de provider qui retourne un array brut). Standard pose dans `config/packages/api_platform.yaml` : 10 items par defaut, max 50, le client peut basculer entre 10/25/50 et peut envoyer `?pagination=false` pour alimenter un select. Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist. Details et exemples : @.claude/rules/backend.md § Pagination.
14. **`symfony.lock` est versionne** (au meme titre que `composer.lock`) — ne JAMAIS le `.gitignore`. C'est le registre des recipes Flex appliquees : sans lui, chaque `composer require` rejoue toutes les recipes et repollue `.env`, `config/bundles.php`, `docker-compose.yml` et recree du scaffolding parasite (`src/Entity/`, `src/Controller/`...). Le regenerer si besoin via `composer recipes:install --force`.
## Conventions ## Conventions
@.claude/rules/architecture.md @.claude/rules/architecture.md
@@ -37,7 +35,7 @@ Doc humaine : `README.md` — Spec audit : `doc/audit-log.md` (à lire à la dem
@.claude/rules/git.md @.claude/rules/git.md
@.claude/rules/workflow.md @.claude/rules/workflow.md
## Commandes (liste complete dans `README.md`) ## Commandes (liste complete dans @README.md)
- Demarrer : `make start` - Demarrer : `make start`
- Dev front (hot reload) : `make dev-nuxt` (port 3004) - Dev front (hot reload) : `make dev-nuxt` (port 3004)
@@ -55,7 +53,6 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
## A NE PAS faire ## A NE PAS faire
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`). - Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
- Pas de provider API Platform qui retourne un array brut sur une `GetCollection` — court-circuite la pagination Hydra (`totalItems` / `view` absents). Utiliser `ApiPlatform\Doctrine\Orm\Paginator` (ORM) ou un paginator implementant `PaginatorInterface` (DBAL — cf. `DbalPaginator`).
- Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur). - Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur).
- Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`. - Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`.
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement. - Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
@@ -70,5 +67,3 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
## Credentials (dev) ## Credentials (dev)
`admin` / `admin` (ROLE_ADMIN) ; `alice` / `alice`, `bob` / `bob` (ROLE_USER). `admin` / `admin` (ROLE_ADMIN) ; `alice` / `alice`, `bob` / `bob` (ROLE_USER).
Comptes demo des roles metier (seedes par `RbacDemoFixtures` / `app:seed-rbac --with-demo-users`, mot de passe `demo`) : `bureau` / `demo`, `compta` / `demo`, `commerciale` / `demo`, `usine` / `demo`. Matrice RBAC § 2.7 (M1 Clients) attachee aux roles correspondants.
+119 -309
View File
@@ -1,362 +1,181 @@
# Starseed # Starseed
CRM/ERP en architecture **modular monolith DDD** — Symfony 8 (API Platform 4) + Nuxt 4. CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
Le backend est la **source de vérité unique** : il décide des modules actifs et de
l'organisation de la sidebar. Le frontend scanne `frontend/modules/*/` comme layers
Nuxt et consomme l'API pour la navigation.
---
## Sommaire
- [Stack](#stack)
- [Prérequis](#prérequis)
- [Démarrage rapide](#démarrage-rapide)
- [Dev local : avec ou sans données de seed](#dev-local--avec-ou-sans-données-de-seed)
- [Comptes (dev)](#comptes-dev)
- [Bases de données : dev et test](#bases-de-données--dev-et-test)
- [Tests](#tests)
- [Déploiement : seed RBAC en recette / prod](#déploiement--seed-rbac-en-recette--prod)
- [Commandes make](#commandes-make)
- [Architecture](#architecture)
- [Structure du dépôt](#structure-du-dépôt)
- [CI/CD](#cicd)
- [Conventions](#conventions)
---
## Stack ## Stack
- **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 - **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16
- **Frontend** : Nuxt 4 (SPA, SSR off), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, @nuxtjs/i18n - **Frontend** : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui
- **Auth** : JWT HTTP-only cookie (Lexik), login sur `/login_check` - **Auth** : JWT HTTP-only cookie (Lexik)
- **Infra** : Docker Compose (dev + prod multi-stage) - **Infra** : Docker Compose (dev + prod multi-stage)
- **CI/CD** : Gitea Actions (auto-tag + build Docker) - **CI/CD** : Gitea Actions (auto-tag + build Docker)
| Service | Port | ## Quick Start
|---------------|------|
| API (Nginx) | 8083 |
| Frontend dev | 3004 |
| PostgreSQL | 5437 |
---
## Prérequis
- Docker + Docker Compose
- `make`
- `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`)
Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ;
rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur
l'hôte (navigateur réel, voir [Tests](#tests)).
---
## Démarrage rapide
```bash ```bash
make start # Démarre les containers Docker make start # Demarrer les containers Docker
make install # Composer + clés JWT + migrations + permissions + BDD de test make install # Composer, migrations, fixtures, build Nuxt
make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004)
``` ```
`make install` prépare une base de dev **vierge** (schéma + RBAC structurel, sans Dev frontend (hot reload) :
données de démo) et la base de **test**. Pour obtenir des comptes et des données de
démo prêtes à l'emploi, lis la section suivante.
> Override local possible : `make` lit `infra/dev/.env.docker`, surchargé par
> `infra/dev/.env.docker.local` s'il existe (créé automatiquement par `make env-init`).
---
## Dev local : avec ou sans données de seed
Le projet distingue deux états de base de données de dev. Les **fixtures Doctrine sont
en `require-dev`** : elles n'existent qu'en dev, jamais dans le build de prod.
### Sans données de seed (base vierge)
C'est ce que produit `make install`. La base contient :
- le **schéma** complet (toutes les migrations jouées) ;
- les **rôles système** `admin` / `user` (seedés en SQL par la migration RBAC) ;
- le **catalogue de permissions** synchronisé (`app:sync-permissions`).
Mais **aucun compte utilisateur ni donnée métier**. Pour pouvoir te connecter,
crée toi-même un compte :
```bash ```bash
make shell make dev-nuxt # Port 3003
php bin/console app:create-user admin monMotDePasse --admin # compte ROLE_ADMIN
``` ```
Optionnel — provisionner les **rôles métier** (bureau / compta / commerciale / usine ## Ports
+ matrice RBAC § 2.7) sans comptes de démo :
```bash | Service | Port |
php bin/console app:seed-rbac |------------|------|
``` | API (Nginx)| 8083 |
| Frontend | 3004 |
| PostgreSQL | 5437 |
Cet état est utile pour repartir d'une base propre, reproduire un bug sur données ## Commandes
minimales, ou tester un parcours d'onboarding réel.
### Avec données de seed (base de démo) | Commande | Description |
|----------|-------------|
`make db-reset` (ou `make fixtures` après un `make install`) recharge la base de dev | `make start` | Demarrer les containers |
avec un jeu complet de données de démonstration, **idempotent** : | `make stop` | Arreter les containers |
| `make restart` | Redemarrer les containers |
```bash | `make install` | Install complet |
make db-reset # ATTENTION : drop + recrée la base de dev, puis charge tout le seed | `make reset` | Tout supprimer et reinstaller |
``` | `make dev-nuxt` | Serveur dev Nuxt (hot reload) |
| `make shell` | Shell dans le container PHP |
Ce que les fixtures posent : | `make cache-clear` | Vider le cache Symfony |
| `make migration-migrate` | Lancer les migrations |
- **3 utilisateurs système** : `admin` (ROLE_ADMIN), `alice`, `bob` (ROLE_USER), | `make fixtures` | Charger les fixtures |
rattachés à des sites distincts ; | `make db-reset` | Reset BDD + migrations + fixtures |
- **3 sites** : Chatellerault, Saint-Jean, Pommevic ; | `make test` | PHPUnit (tests back) |
- **les comptes de démo RBAC métier** (`bureau`, `compta`, `commerciale`, `usine`, | `make nuxt-test` | Vitest (tests unitaires front) |
mot de passe `demo`) avec la matrice § 2.7 attachée ; | `make test-e2e` | Playwright (tests E2E front) |
- les **référentiels et données métier** des modules (catégories, clients de démo, | `make test-e2e-ui` | Playwright UI interactive (debug) |
référentiels comptables…). | `make seed-e2e` | Seed les 6 personas E2E |
| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) |
Toutes les fixtures sont rejouables sans effet de bord (lookup par clé naturelle, | `make php-cs-fixer-allow-risky` | Fix code style PHP |
aucun doublon). | `make logs-dev` | Tail logs Symfony |
> Différence avec `make install` : `install` ne charge **pas** les fixtures sur la base
> de dev (il alimente uniquement la base de test). Utilise `make db-reset` ou
> `make fixtures` quand tu veux des données de démo en dev.
---
## Comptes (dev)
Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec seed ») :
| Username | Password | Rôle | RBAC métier |
|---------------|----------|------------|---------------------------------------------------------------|
| `admin` | `admin` | ROLE_ADMIN | bypass complet (`is_admin`) |
| `alice` | `alice` | ROLE_USER | — |
| `bob` | `bob` | ROLE_USER | — |
| `bureau` | `demo` | ROLE_USER | clients : view + manage |
| `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage |
| `commerciale` | `demo` | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| `usine` | `demo` | ROLE_USER | aucun accès clients |
---
## Bases de données : dev et test
Deux bases distinctes vivent dans le **même container PostgreSQL** (port 5437) :
| Base | Environnement | Construite par | Usage |
|------------|---------------|--------------------------------------|--------------------------------|
| `<db>` | `dev` | `make install` / `make db-reset` | développement manuel, dev-nuxt |
| `<db>_test` | `test` | `make test-db-setup` | PHPUnit (jamais touchée à la main) |
Le suffixe `_test` est appliqué **automatiquement** par Doctrine quand `APP_ENV=test`
(config `when@test` dans `config/packages/doctrine.yaml`). La base de test est donc
totalement **isolée** de la base de dev : lancer `make test` ne touche jamais tes
données de dev.
`make test-db-setup` fait davantage que jouer les migrations, car certaines structures
ne sont pas portées par des migrations « métier » :
1. `doctrine:migrations:migrate` — schéma métier réel ;
2. `doctrine:schema:update --force` — crée les tables mappées en `when@test`
uniquement (entités de test) ;
3. `app:apply-column-comments` — réapplique les `COMMENT ON COLUMN` que
`schema:update` efface sur les tables managées par l'ORM (garde-fou
`ColumnsHaveSqlCommentTest`) ;
4. `fixtures:load``sync-permissions``seed-rbac` — dans cet ordre précis
(le purger des fixtures vide la table `permission`, donc la sync passe après) ;
5. recréation des **index partiels uniques** (`LOWER(...) WHERE ...`) non exprimables
en attributs ORM, indispensables aux tests d'unicité (RG-1.07, RG-1.16, RG-1.03/1.29).
`make install` et `make db-reset` appellent déjà `test-db-setup` : tu n'as à le
relancer à la main que si la base de test diverge (nouvelle migration, nouvelle
permission) sans vouloir reseed la base de dev.
---
## Tests ## Tests
| Suite | Commande | Outil | Où | - **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`.
|-------------------|------------------|----------------------|-----------------------------------| - **Front unitaire** : `make nuxt-test` (Vitest, happy-dom). Composables, utils, stores — rapide, <30s.
| Back | `make test` | PHPUnit | container PHP, base `<db>_test` | - **Front E2E** : `make test-e2e` (Playwright). Couvre login + matrice RBAC sidebar. Suite volontairement minimaliste (11 tests) — voir la regle d'or dans `CLAUDE.md`.
| Front unitaire | `make nuxt-test` | Vitest (happy-dom) | container Node, < 30 s |
| Front E2E | `make test-e2e` | Playwright | **hôte** (navigateur réel requis) |
| Tout (back+front) | `make test-all` | PHPUnit + Vitest | — |
### Tests back (PHPUnit)
**Bootstrap E2E (une fois par poste)** :
```bash ```bash
make test # toute la suite make install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo)
make test FILES=tests/Module/Commercial # un dossier / fichier ciblé
``` ```
PHPUnit force `APP_ENV=test` (`phpunit.dist.xml`) : les tests tournent **toujours** **Workflow E2E** :
sur la base `<db>_test`, jamais sur la base de dev. Prérequis : que la base de test
existe — c'est le cas après `make install`. Si elle a divergé, rejoue
`make test-db-setup` (cf. [Bases de données](#bases-de-données--dev-et-test)).
### Tests front unitaires (Vitest)
```bash ```bash
make nuxt-test # composables, utils, stores — rapide et stable # Terminal 1 : containers + dev server
```
C'est la **place par défaut** pour étendre la couverture (cf. règle d'or ci-dessous).
### Tests E2E (Playwright)
Suite volontairement minimaliste (login + matrice RBAC sidebar). **Règle d'or : un
nouveau test E2E ne s'ajoute que si un bug critique est passé en prod** — sinon,
préférer un test Vitest ou étendre un persona existant.
Bootstrap (une fois par poste) :
```bash
make install-e2e-deps # télécharge Chromium + libs système (apt/dnf, sudo)
```
Workflow :
```bash
# Terminal 1 — containers, seed des personas, serveur dev
make start && make seed-e2e && make dev-nuxt make start && make seed-e2e && make dev-nuxt
# Terminal 2 tests # Terminal 2 : tests
make test-e2e # headless make test-e2e
make test-e2e-ui # UI interactive (debug)
``` ```
> Toute permission testable touche **3 miroirs** à garder alignés : `config/sidebar.php`,
> `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
---
## Déploiement : seed RBAC en recette / prod
Les fixtures Doctrine étant en `require-dev`, elles sont **absentes du build de prod**.
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente**, jouée dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions (commercial.clients.*, …)
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test. Le mot de
passe est fourni **explicitement** (jamais en dur, jamais committé) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'environnement RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de
compte). Pour créer un premier administrateur en prod :
```bash
php bin/console app:create-user <username> <password> --admin
```
---
## Commandes make
`make` (sans argument) ou `make help` affiche l'aide colorée. Les principales :
| Commande | Description |
|--------------------------------|----------------------------------------------------------|
| `make start` / `stop` / `restart` | Cycle de vie des containers |
| `make install` | Install complet (base dev vierge + base de test) |
| `make reset` | Tout supprimer et réinstaller (**drop la BDD**) |
| `make dev-nuxt` | Serveur Nuxt hot reload (port 3004) |
| `make shell` / `shell-root` | Shell bash dans le container PHP |
| `make migration-migrate` | Jouer les migrations Doctrine |
| `make fixtures` | Charger les fixtures (données de démo dev) |
| `make sync-permissions` | Synchroniser le catalogue RBAC |
| `make seed-rbac` | Seed RBAC métier (rôles + matrice § 2.7) |
| `make db-reset` | Reset base dev : drop + migrate + fixtures + RBAC |
| `make test-db-setup` | (Re)construire la base de test |
| `make test` | PHPUnit (back) |
| `make nuxt-test` | Vitest (front unitaire) |
| `make test-all` | PHPUnit + Vitest |
| `make test-e2e` / `test-e2e-ui`| Playwright (E2E, sur l'hôte) |
| `make seed-e2e` | Seed des 6 personas E2E |
| `make php-cs-fixer-allow-risky`| Fix du code style PHP |
| `make php-cs-fixer-check` | Dry-run du fixer (CI / avant push) |
| `make logs-dev` | Tail des logs Symfony |
---
## Architecture ## Architecture
**Modular Monolith DDD** : chaque module est un bounded context autonome, **Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar.
activable / désactivable par tenant. Le backend est la seule source de vérité pour
l'activation des modules et l'organisation de la sidebar.
- `config/modules.php` — liste des modules actifs - `config/modules.php` — liste des modules actifs
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner) - `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
- `GET /api/modules` — IDs des modules actifs (public) - `GET /api/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees
- `GET /api/sidebar` — sections filtrées par modules actifs + routes désactivées (public) - Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
**Désactiver un module** : commenter sa ligne dans `config/modules.php`, vider le cache. Pour desactiver un module : commenter sa ligne dans `config/modules.php`, clear cache. Ses items de sidebar disparaissent et ses routes sont bloquees par le middleware front.
Ses items de sidebar disparaissent et ses routes sont bloquées par le middleware front.
Le code reste dans le bundle (layer auto-détecté) → réactivation instantanée.
**Réorganiser la sidebar** : éditer `config/sidebar.php` uniquement le code des Pour reorganiser la sidebar (ex: deplacer un item d'une section a l'autre) : editer `config/sidebar.php` uniquement, le code des modules n'est pas touche.
modules n'est pas touché.
**Communication inter-modules** : jamais d'import direct d'un module à l'autre. Passer ## Structure
par `Shared/Domain/Contract/` (interfaces) ou des domain events.
---
## Structure du dépôt
``` ```
src/ # Backend Symfony src/ # Backend Symfony
Shared/ # Noyau technique partagé (Domain/, Application/Bus/, Infrastructure/ApiPlatform/) Kernel.php
Shared/ # Noyau technique partage
Domain/
ValueObject/ # Email, ...
Event/ # DomainEventInterface
Contract/ # Interfaces inter-modules
Application/
Bus/ # CommandBusInterface, QueryBusInterface
Infrastructure/
ApiPlatform/
Resource/ # AppVersion, ModulesResource, SidebarResource
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
Module/ Module/
Core/ # Module obligatoire (auth, users, RBAC) Core/ # Module obligatoire (auth, users)
CoreModule.php # Déclaration (ID, LABEL, REQUIRED, permissions()) CoreModule.php # Declaration (ID, LABEL, REQUIRED)
Domain/ Application/ Infrastructure/ Domain/
Commercial/ Catalog/ Sites/ # Modules métier Entity/ # User
Repository/ # UserRepositoryInterface
Event/ # UserCreated
Application/
DTO/ # UserOutput
Infrastructure/
Doctrine/ # DoctrineUserRepository, Migrations/
ApiPlatform/State/
Provider/ # MeProvider
Processor/ # UserPasswordHasherProcessor
Console/ # CreateUserCommand
DataFixtures/ # AppFixtures
Commercial/ # Autre module (exemple)
CommercialModule.php
config/ config/
modules.php # Source de vérité : activation modules.php # Source de verite activation
sidebar.php # Source de vérité : navigation sidebar.php # Source de verite navigation
packages/ # Config Symfony (doctrine, api_platform, security…) version.yaml
migrations/ # Migrations d'initialisation (namespace racine : setup, RBAC, seed de base) packages/ # Config Symfony
jwt/ # Cles JWT
migrations/ # Anciennes migrations
frontend/ # App Nuxt 4 (SPA) frontend/ # App Nuxt 4 (SPA)
app/ # Shell : layouts, middlewares (auth.global, modules.global) app/
shared/ # Code inter-modules (composables, stores, utils, types) layouts/ # default.vue, auth.vue
modules/ # Layers Nuxt auto-détectés (core/, commercial/…) middleware/ # auth.global.ts, modules.global.ts
i18n/locales/ # Traductions (sidebar.*, audit.entity.*, …) shared/ # Code partage (hors modules)
composables/ # useApi, useAppVersion, useSidebar
components/ui/ # AppTopNav, ...
stores/ # auth, ui
services/ # auth
types/ # SidebarSection, UserData
utils/ # api (Hydra)
modules/ # Modules auto-detectes comme layers Nuxt
core/
nuxt.config.ts # Marqueur layer
pages/ # index, login, logout
commercial/
nuxt.config.ts
pages/ # commercial.vue
app.vue
nuxt.config.ts # Scanne modules/*/ automatiquement
i18n/locales/ # Traductions (sidebar.*, etc.)
assets/ # CSS, images
public/ # Fichiers statiques
infra/ infra/
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug, .env.docker) dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
prod/ # Docker prod (multi-stage, nginx, php-prod.ini) prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
.gitea/workflows/ # CI Gitea (auto-tag, build Docker) .gitea/workflows/ # CI Gitea (auto-tag, build Docker)
.claude/
skills/create-module/ # Skill Claude Code pour scaffolder un module
``` ```
---
## CI/CD ## CI/CD
- **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z` - **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z`
- **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry - **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry
Secrets requis dans Gitea : Secrets requis dans Gitea :
- `RELEASE_TOKEN` — PAT avec droits `write:repository` - `RELEASE_TOKEN` — PAT avec droits `write:repository`
- `REGISTRY_TOKEN` — token pour le registry Docker - `REGISTRY_TOKEN` — token pour le registry Docker
--- ## Credentials (dev)
| Username | Password | Role |
|----------|----------|------|
| admin | admin | ROLE_ADMIN |
| alice | alice | ROLE_USER |
| bob | bob | ROLE_USER |
## Conventions ## Conventions
@@ -366,13 +185,4 @@ Secrets requis dans Gitea :
<type>(<scope optionnel>) : <message> <type>(<scope optionnel>) : <message>
``` ```
Espaces obligatoires autour du `:`. Types : `build`, `chore`, `ci`, `docs`, `feat`, Types : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`
`fix`, `perf`, `refactor`, `revert`, `style`, `test`.
### Langue
- UI et communication : **français**
- Code (classes, méthodes, variables) : **anglais**
- Commentaires (PHP, TS, Vue) : **français**
> Règles détaillées : `CLAUDE.md` et `.claude/rules/`.
-2
View File
@@ -16,7 +16,6 @@
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8", "nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6|^6.0", "phpdocumentor/reflection-docblock": "^5.6|^6.0",
"phpoffice/phpspreadsheet": "^5.7",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "8.0.*", "symfony/asset": "8.0.*",
"symfony/console": "8.0.*", "symfony/console": "8.0.*",
@@ -24,7 +23,6 @@
"symfony/expression-language": "8.0.*", "symfony/expression-language": "8.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/intl": "8.0.*",
"symfony/mime": "8.0.*", "symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0", "symfony/monolog-bundle": "^4.0",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
Generated
+80 -514
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "aada2e60fd7563f1498b5505b37e3f4b", "content-hash": "d65a546151abb6b977fbf7f1c86d14fe",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -1160,85 +1160,6 @@
}, },
"time": "2026-03-17T15:23:21+00:00" "time": "2026-03-17T15:23:21+00:00"
}, },
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{ {
"name": "composer/semver", "name": "composer/semver",
"version": "3.4.4", "version": "3.4.4",
@@ -2709,191 +2630,6 @@
], ],
"time": "2025-12-20T17:47:00+00:00" "time": "2025-12-20T17:47:00+00:00"
}, },
{
"name": "maennchen/zipstream-php",
"version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -3316,115 +3052,6 @@
}, },
"time": "2026-01-06T21:53:42+00:00" "time": "2026-01-06T21:53:42+00:00"
}, },
{
"name": "phpoffice/phpspreadsheet",
"version": "5.7.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
},
"time": "2026-04-20T02:42:17+00:00"
},
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.3.2", "version": "2.3.2",
@@ -3886,57 +3513,6 @@
}, },
"time": "2024-09-11T13:17:53+00:00" "time": "2024-09-11T13:17:53+00:00"
}, },
{
"name": "psr/simple-cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for simple caching",
"keywords": [
"cache",
"caching",
"psr",
"psr-16",
"simple-cache"
],
"support": {
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{ {
"name": "symfony/asset", "name": "symfony/asset",
"version": "v8.0.8", "version": "v8.0.8",
@@ -5596,95 +5172,6 @@
], ],
"time": "2026-03-31T21:14:05+00:00" "time": "2026-03-31T21:14:05+00:00"
}, },
{
"name": "symfony/intl",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/intl.git",
"reference": "604a1dbbd67471e885e93274379cadd80dc33535"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/intl/zipball/604a1dbbd67471e885e93274379cadd80dc33535",
"reference": "604a1dbbd67471e885e93274379cadd80dc33535",
"shasum": ""
},
"require": {
"php": ">=8.4"
},
"conflict": {
"symfony/string": "<7.4"
},
"require-dev": {
"symfony/filesystem": "^7.4|^8.0",
"symfony/var-exporter": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Intl\\": ""
},
"exclude-from-classmap": [
"/Tests/",
"/Resources/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Bernhard Schussek",
"email": "bschussek@gmail.com"
},
{
"name": "Eriksen Costa",
"email": "eriksen.costa@infranology.com.br"
},
{
"name": "Igor Wiedler",
"email": "igor@wiedler.ch"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides access to the localization data of the ICU library",
"homepage": "https://symfony.com",
"keywords": [
"i18n",
"icu",
"internationalization",
"intl",
"l10n",
"localization"
],
"support": {
"source": "https://github.com/symfony/intl/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{ {
"name": "symfony/mime", "name": "symfony/mime",
"version": "v8.0.8", "version": "v8.0.8",
@@ -8776,6 +8263,85 @@
], ],
"time": "2022-12-23T10:58:28+00:00" "time": "2022-12-23T10:58:28+00:00"
}, },
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{ {
"name": "composer/xdebug-handler", "name": "composer/xdebug-handler",
"version": "3.0.5", "version": "3.0.5",
-15
View File
@@ -21,18 +21,3 @@ api_platform:
stateless: true stateless: true
cache_headers: cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin'] vary: ['Content-Type', 'Authorization', 'Origin']
# === Pagination Hydra (regle projet : toute collection DOIT etre paginee) ===
# Standard datatable : 10 items par defaut, choix client 10 / 25 / 50.
# Borne dure cote serveur a 50 pour prevenir tout `?itemsPerPage=999999`
# (attaque memoire / deep-fetch). Le client peut neanmoins desactiver la
# pagination via `?pagination=false` pour alimenter un <select> ou autre
# vue "tout-en-un" — c'est l'echappatoire prevue pour les ressources
# servant a la fois de datatable et de source de select (Role,
# Permission, Site, CategoryType). Override par ressource possible via
# `paginationItemsPerPage` / `paginationMaximumItemsPerPage` /
# `paginationEnabled` sur l'attribut #[ApiResource] ou sur une operation.
pagination_enabled: true
pagination_items_per_page: 10
pagination_maximum_items_per_page: 50
pagination_client_items_per_page: true
pagination_client_enabled: true
@@ -3,14 +3,6 @@ lexik_jwt_authentication:
public_key: '%env(resolve:JWT_PUBLIC_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%' pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: '%env(int:JWT_TOKEN_TTL)%' 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 remove_token_from_body_when_cookies_used: true
token_extractors: token_extractors:
authorization_header: authorization_header:
+1 -1
View File
@@ -105,7 +105,7 @@ return [
'items' => [ 'items' => [
[ [
'label' => 'sidebar.commercial.clients', 'label' => 'sidebar.commercial.clients',
'to' => '/clients', 'to' => '/commercial/clients',
'icon' => 'mdi:account-group-outline', 'icon' => 'mdi:account-group-outline',
'module' => 'commercial', 'module' => 'commercial',
'permission' => 'commercial.clients.view', 'permission' => 'commercial.clients.view',
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.78' app.version: '0.1.54'
-2
View File
@@ -118,8 +118,6 @@ Aucun pattern soft delete existant dans Starseed (vérifié, aucune entité ne p
Index unique partiel sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL`. Permet de recréer une catégorie avec le même `(name, type)` après suppression logique. Postgres supporte nativement (`CREATE UNIQUE INDEX ... WHERE`). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job. Index unique partiel sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL`. Permet de recréer une catégorie avec le même `(name, type)` après suppression logique. Postgres supporte nativement (`CREATE UNIQUE INDEX ... WHERE`). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job.
> **🔗 Évolution ERP-78 (refonte taxonomie M1)** : `Category` porte désormais une colonne **`code`** (`VARCHAR(50)`, NOT NULL), slug MAJUSCULE auto-généré du nom (figé à la création, lecture seule via l'API), avec un **second index unique partiel** `uq_category_code (code) WHERE deleted_at IS NULL`. Ce code est la clé métier stable utilisée par le M1 Commercial (RG-1.03 / RG-1.29). Détail : `docs/specs/M1-clients/spec-back.md` § 3.3.
### 2.5 Audit & traces temporelles — deux niveaux complémentaires ### 2.5 Audit & traces temporelles — deux niveaux complémentaires
Deux mécanismes **indépendants** cohabitent : Deux mécanismes **indépendants** cohabitent :
@@ -1,80 +0,0 @@
# Cahier de test back — M1 Répertoire clients (ticket ERP-60 / #478)
Mapping **toutes les RG (§ 7) → test(s) PHPUnit**, à jour après ERP-60.
Légende source : `ERP-55` `ERP-56` `ERP-57` `ERP-58` = tests écrits par les wagons
précédents ; **`ERP-60`** = tests ajoutés par ce ticket (stratégie « combler les
trous, zéro duplication »).
## Stratégie
ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici
l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04
fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
merge de la stack.
## Mapping RG → test
| RG | Intitulé | Test(s) | Source |
|----|----------|---------|--------|
| ~~RG-1.01~~ | _(supprimée V1 — refonte-contact)_ contact inline retiré du Client ; complétude couverte par RG-1.05 / RG-1.14 (`ClientContact`) | — | refonte-contact |
| ~~RG-1.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 |
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | ERP-60 / **ERP-76** |
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
| RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 |
| RG-1.11 | billingEmail obligatoire ssi isBilling → 422 (Assert\Callback + CHECK filet) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | ERP-60 / **ERP-76** |
| RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 |
| RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 |
| RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 |
| RG-1.15 | ~~Unicité SIREN~~ supprimée (Q4) — SIREN partageable | `ClientUniquenessTest::testDuplicateSirenIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
| RG-1.16 | companyName unique (case-insensitive) parmi actifs → 409 | `ClientApiTest::testPostDuplicateCompanyNameReturns409` ; `ClientMigrationTest::testCompanyNameActivePartialIndexExistsExactlyOnce` | ERP-55 / **ERP-60** |
| RG-1.17 | ~~Unicité email~~ supprimée (Q4) — email partageable | `ClientUniquenessTest::testDuplicateEmailIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
| RG-1.18 | companyName upper-cased serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testCompanyNameIsUppercased` (unit) | ERP-55 |
| RG-1.19 | firstName/lastName capitalize serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPersonNameIsTitleCased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` | ERP-55 / ERP-57 |
| RG-1.20 | Téléphones chiffres-seuls serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPhoneKeepsOnlyDigits` (unit) ; `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` (secondary) | ERP-55 / **ERP-60** |
| RG-1.21 | email lowercase serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testEmailIsLowercased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` / `::testPostAddressNormalizesBillingEmail` | ERP-55 / ERP-57 |
| RG-1.22 | Archive : permission `archive` + archivedAt + aucun autre champ | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` ; `::testPatchArchiveWithOtherFieldReturns422` ; `ClientProcessorTest` (unit, gating archive) | ERP-55 |
| RG-1.23 | Restauration : archivedAt=null ; **409 si conflit d'unicité** | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` (cas nominal) ; **`ClientArchiveTest::testRestoreConflictReturns409`** (409 restauration, gap P1) | ERP-55 / **ERP-60** |
| RG-1.24 | Liste exclut les archivés par défaut | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
| RG-1.25 | `?includeArchived=true` inclut les archivés | `ClientApiTest::testListIncludeArchivedReturnsArchived` | ERP-55 |
| RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
| RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** |
| RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** |
| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE** : `ClientAddress::validateCategoryTypes` (Assert\Callback) rejette une catégorie DISTRIBUTEUR/COURTIER en 422 (violation `categories`). Tests : `ClientAddressTest::testAddressRejectsDistributorCategory` / `::testAddressRejectsBrokerCategory` / `::testAddressAcceptsSectorCategory` / `::testAddressAcceptsOtherCategory` | **ERP-76** |
## Couvertures transverses
| Sujet | Test(s) | Source |
|-------|---------|--------|
| Audit iban/bic présents dans le diff (pas d'`#[AuditIgnore]`) | `ClientAuditTest::testRibCreateAuditIncludesIbanAndBic` | **ERP-60** |
| Sécurité générique : 401 anonyme + 403 sans `commercial.clients.view` | `ClientSecurityTest` (collection + détail) ; `ClientExportControllerTest::testForbiddenWithoutClientsViewPermission` / `::testUnauthorizedWhenAnonymous` | **ERP-60** / ERP-58 |
| Migration : index partiel unique présent (1 seul), pas de siren/email unique | `ClientMigrationTest` | **ERP-60** |
| Référentiels comptables read-only (405 écriture, 401/403) | `ReferentialApiTest` | ERP-56 |
| Export XLSX (colonnes accounting selon permission, 401/403) | `ClientExportControllerTest` | ERP-58 |
## Délégué à ERP-74 (#493) — NE PAS faire dans ERP-60
- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale /
Usine) : 200/403 par verbe et par onglet selon le rôle.
- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec
champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec).
- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1.
## Gaps & suivi
- ~~**RG-1.29 (validation écriture)**~~ — **résolu en ERP-76**. La validation
d'écriture refuse désormais une catégorie de type `DISTRIBUTEUR`/`COURTIER` sur
une `ClientAddress` (→ 422, violation `categories`) via l'Assert\Callback
`ClientAddress::validateCategoryTypes`. Le filtrage de lecture reste
front-driven (SearchFilter). Couvert par `ClientAddressTest`.
- ~~**Violations CHECK → statut HTTP**~~ — **résolu en ERP-76**. Les règles
d'adresse RG-1.06/07/08/11 sont désormais rejetées en **422** par des
Assert\Callback applicatifs (`validateProspectExclusivity` /
`validateBillingEmailPresence`) qui s'exécutent AVANT la base. Les CHECK
Postgres (`chk_client_address_prospect_exclusive` /
`chk_client_address_billing_email`) restent en filet de sécurité. Les tests
`ClientAddressTest` assertent maintenant le 422 explicite (et non plus ≥ 400).
@@ -1,135 +0,0 @@
# M1 · Ticket 1/3 (Backend) — Supprimer le contact inline du `Client`
## 1. Objectif
Retirer de l'entité `Client` (et de la table `client`) les **5 champs du contact
principal inline** : `firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`.
La gestion des contacts passe désormais **exclusivement** par la sous-entité
`ClientContact` (onglet « Contacts »), déjà en place et déjà porteuse des mêmes champs.
Le code M1 est **déjà livré en prod** : ce ticket inclut donc une **migration de données**
(backfill) pour ne perdre aucune information de contact existante avant de supprimer les
colonnes.
Contexte et justification : voir `README.md` du dossier `refonte-contact`.
## 2. Périmètre
### IN
- Migration Doctrine : **backfill puis suppression** des 5 colonnes de `client`.
- `Client` (entité) : supprimer les 5 propriétés, getters/setters, annotations ORM /
`Assert` / `Groups`.
- `ClientProcessor` : retirer les 5 champs de `MAIN_FIELDS`, `changedBusinessFields()`,
`normalize()` ; supprimer `validateMainContact()` (RG-1.01 — n'a plus d'objet).
- `DoctrineClientRepository::applySearch()` : trancher D1 (recherche) et l'appliquer.
- `ClientExportController` : trancher D2 (colonnes export) et l'appliquer.
- `ClientFixtures` : retirer les 5 paramètres inline de `ensureClient()` ; garantir que
chaque client seedé possède au moins 1 `ClientContact` (déjà géré par `addContact()`).
- Tests PHPUnit : mettre à jour / retirer les cas qui exercent ces 5 champs sur `Client`.
### OUT
- Toute modification de `ClientContact` / `ClientContactProcessor` : **inchangés** (c'est la
cible, les champs y restent). `ClientFieldNormalizer` reste tel quel (toujours appelé par
`ClientContactProcessor`).
- Le front (formulaires, vues, types, i18n) → **ticket 2/3**.
- Les specs (`spec-back.md`, `spec-front.md`, cahier de test) → **ticket 3/3**.
## 3. Fichiers à modifier
| Fichier | Action |
|---|---|
| `src/Module/Commercial/Domain/Entity/Client.php` | Supprimer props `firstName` (~l.158), `lastName` (~l.163), `phonePrimary` (~l.168), `phoneSecondary` (~l.172), `email` (~l.178) + leurs getters/setters (~l.329-382) + groupes `client:read`/`client:write:main` + `Assert\*`. |
| `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php` | Retirer les 5 clés de `MAIN_FIELDS` (~l.63) ; de `changedBusinessFields()` (~l.277-281) ; les 6 lignes de `normalize()` qui touchent email/phone/first/last/secondary (~l.433-441) ; supprimer `validateMainContact()` (~l.447-456) et son appel. |
| `src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php` | `applySearch()` (~l.110-124) : appliquer **D1**. |
| `src/Module/Commercial/Infrastructure/Controller/ClientExportController.php` | `buildHeaders()` (~l.94-114) + `buildRows()` (~l.121-143) : appliquer **D2**. |
| `src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php` | `ensureClient()` (~l.357-395) : retirer firstName/lastName/phonePrimary/phoneSecondary/email ; conserver `addContact()`. |
| `migrations/Version<timestamp>.php` (NOUVELLE) | Backfill + `DROP COLUMN` (cf. § 4). |
| `tests/Module/Commercial/**` | Voir § 5. |
## 4. Migration Doctrine — backfill puis suppression
> Migration **modulaire** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : ce
> n'est PAS une migration d'initialisation, le schéma `client` / `client_contact` existe
> déjà (règle ABSOLUE n°11).
### `up()`
1. **Backfill — ne créer un contact que pour les clients qui n'en ont aucun**, afin de ne
pas dupliquer le contact déjà recopié à la création (`prefillFirstContact`) :
```sql
INSERT INTO client_contact
(client_id, first_name, last_name, phone_primary, phone_secondary, email, position, created_at, updated_at)
SELECT c.id, c.first_name, c.last_name, c.phone_primary, c.phone_secondary, c.email, 0, NOW(), NOW()
FROM client c
WHERE NOT EXISTS (SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id)
AND (c.first_name IS NOT NULL OR c.last_name IS NOT NULL);
```
> Le `WHERE ... first_name OU last_name IS NOT NULL` respecte le CHECK
> `chk_client_contact_name`. Les rares clients sans nom de contact ET sans contact
> existant ne reçoivent pas de ligne (cas théorique : `phone_primary`/`email` étaient
> `NOT NULL` mais les noms nullables).
2. **Supprimer les 5 colonnes** :
```sql
ALTER TABLE client
DROP COLUMN first_name,
DROP COLUMN last_name,
DROP COLUMN phone_primary,
DROP COLUMN phone_secondary,
DROP COLUMN email;
```
> Pas de `COMMENT ON COLUMN` à poser (on supprime). Vérifier qu'aucun index ne portait
> sur `email` (l'index unique `uq_client_email_active` a déjà été supprimé — décision Q4 /
> RG-1.17, cf. `ClientMigrationTest`).
### `down()` (best-effort)
1. Recréer les 5 colonnes (`phone_primary`/`email` en `NOT NULL` impose un défaut transitoire
ou un re-remplissage depuis le contact `position = 0`).
2. Re-remplir depuis `client_contact` (`position = 0`) si possible.
3. Reposer les `COMMENT ON COLUMN` d'origine (textes RG-1.19/1.20/1.21/1.01/1.17 — cf.
`migrations/Version20260601000000.php` l.251-255).
> `down()` ne peut pas restaurer parfaitement les données (ambiguïté si plusieurs contacts).
> Documenter cette limite dans le docblock de la migration.
## 5. Tests à mettre à jour
| Fichier | Action |
|---|---|
| `tests/Module/Commercial/Api/ClientApiTest.php` | Retirer firstName/lastName/phone/email des payloads POST/PATCH `client` et des assertions JSON. |
| `tests/.../ClientFormulaireMainTest.php` | Supprimer les tests RG-1.01 (firstName/lastName) et RG-1.02 (téléphones) **côté Client** — ils basculent côté `ClientContact` (couverts ailleurs). |
| `tests/.../ClientExportControllerTest.php` | Aligner les en-têtes/lignes attendus sur **D2**. |
| `tests/.../ClientMigrationTest.php` | Asserter que les 5 colonnes **n'existent plus** sur `client` ; vérifier le backfill (un client sans contact obtient bien 1 `client_contact`). |
| `tests/.../ClientFieldNormalizerTest.php` | Conserver les tests du normalizer (toujours utilisé par `ClientContact`) ; retirer les cas spécifiques aux champs `Client` s'il y en a. |
| RG-1.01/1.02 (matrice) | Ne plus tester sur `Client` ; vérifier qu'ils restent couverts sur `ClientContact` (RG-1.05). |
## 6. Décisions à trancher (cf. README § 3)
- **D1 — recherche** : recommandé = `LEFT JOIN client_contact` (fuzzy sur
`companyName` + contact `first_name`/`last_name`/`email`). Attention au `DISTINCT` /
risque de doublons de lignes si plusieurs contacts matchent (grouper par `client.id`).
- **D2 — export** : recommandé = alimenter les colonnes contact depuis le contact de plus
petit `position` (fetch-join `contacts` pour éviter le N+1).
## 7. Critères d'acceptation (DoD)
- [ ] Les colonnes `first_name`, `last_name`, `phone_primary`, `phone_secondary`, `email`
n'existent plus sur la table `client`.
- [ ] La migration est jouable sur une base seedée sans perte de contact (backfill vérifié)
et `down()` documenté comme best-effort.
- [ ] `Client`, `ClientProcessor`, `DoctrineClientRepository`, `ClientExportController`,
`ClientFixtures` ne référencent plus les 5 champs.
- [ ] D1 et D2 implémentées conformément à la décision validée.
- [ ] `ClientContact` / `ClientContactProcessor` / `ClientFieldNormalizer` inchangés.
- [ ] `make test` vert (notamment `tests/Architecture/ColumnsHaveSqlCommentTest` et
`EntitiesAreTimestampableBlamableTest`).
- [ ] `make php-cs-fixer-allow-risky` ne signale rien sur les fichiers touchés.
- [ ] Aucune régression du contrat de sérialisation : capturer le JSON réel de
`GET /api/clients/{id}` et vérifier l'absence des 5 champs (réflexe RETEX M1).
@@ -1,58 +0,0 @@
# Prompt d'implémentation — M1 · Ticket 1/3 (Backend)
Tu travailles sur le projet **Starseed** (Symfony 8 / API Platform 4 / Doctrine / PostgreSQL).
Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Commentaires en français,
code en anglais, `declare(strict_types=1);` partout.
## Mission
Supprimer le **contact principal inline** de l'entité `Client` : les 5 champs
`firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`. Les contacts sont gérés
uniquement via la sous-entité `ClientContact` (onglet Contacts), déjà en place. Le code est
déjà en prod → migration avec **backfill** avant `DROP`.
La spec détaillée du ticket est dans `docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.md`.
Lis-la en entier, ainsi que le `README.md` du même dossier (décision + RG impactées + D1/D2).
## Étapes
1. **Explorer** : `Client.php`, `ClientProcessor.php`, `DoctrineClientRepository.php`,
`ClientExportController.php`, `ClientFixtures.php`, et `ClientContact.php` (pour confirmer
que la cible porte bien les mêmes champs).
2. **Demander la validation des décisions D1 (recherche) et D2 (export)** avant de coder —
défauts recommandés : D1 = LEFT JOIN sur `client_contact`, D2 = colonnes export depuis le
contact `position` minimal. Ne pas inventer un autre comportement.
3. **Migration** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : backfill
`INSERT INTO client_contact ... WHERE NOT EXISTS(...)` puis `ALTER TABLE client DROP COLUMN ...`
(les 5). `down()` best-effort documenté. Voir le SQL exact dans la spec § 4.
4. **Entité** : retirer les 5 props + getters/setters + `#[ORM\Column]` + `#[Assert\*]` +
`#[Groups(['client:read','client:write:main'])]`.
5. **Processor** : retirer de `MAIN_FIELDS`, `changedBusinessFields()`, `normalize()` ;
supprimer `validateMainContact()` et son appel.
6. **Repository** : `applySearch()` selon D1.
7. **Export** : `buildHeaders()` / `buildRows()` selon D2.
8. **Fixtures** : alléger `ensureClient()` ; garder `addContact()`.
9. **Tests** : mettre à jour `ClientApiTest`, `ClientFormulaireMainTest`,
`ClientExportControllerTest`, `ClientMigrationTest`, `ClientFieldNormalizerTest`
(cf. spec § 5). Ajouter une assertion que le backfill crée bien un contact pour un client
qui n'en avait pas.
## Garde-fous
- Ne touche **pas** `ClientContact`, `ClientContactProcessor`, `ClientFieldNormalizer`.
- Respecte les règles ABSOLUES : pagination, `#[Auditable]`, COMMENT ON COLUMN (ici on
supprime → pas de commentaire à poser, mais ne pas casser le garde-fou).
- Les RG-1.01 et RG-1.02 disparaissent **du Client** : leur équivalent (RG-1.05 / RG-1.14)
vit déjà sur `ClientContact`, ne le duplique pas.
## Vérification finale (obligatoire avant de dire « fini »)
```bash
make db-reset && make migration-migrate # migration rejouable sur base fraîche
make test # PHPUnit vert
make php-cs-fixer-allow-risky # lint
```
Puis capture le JSON réel de `GET /api/clients/{id}` (avec un JWT) et confirme que les 5
champs ont disparu de la réponse et que `contacts[]` porte bien l'info (réflexe RETEX M1 :
on valide sur le contrat réel, pas sur les annotations).
@@ -1,74 +0,0 @@
# M1 · Ticket 2/3 (Frontend) — Retirer le bloc contact principal des écrans Client
## 1. Objectif
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
trois écrans Client — **création**, **consultation**, **modification** — ainsi que des
types, mappeurs, validations et clés i18n associés. La saisie des contacts se fait
désormais uniquement dans l'**onglet « Contacts »** (composant `ClientContactBlock`, déjà
en place et inchangé).
Dépend du **ticket 1/3 (back)** : l'API ne renvoie/n'accepte plus ces 5 champs sur `client`.
Contexte : voir `README.md` du dossier `refonte-contact`.
## 2. Périmètre
### IN — fichiers `frontend/modules/commercial/`
| Fichier | Action |
|---|---|
| `pages/clients/new.vue` | Supprimer le bloc principal Nom/Prénom/Téléphones/Email (~l.27-63), l'état `main.firstName/lastName/email`, `mainPhones` (~l.445-459), la fonction `prefillFirstContact()` (~l.658-665) et son appel, le mapping payload POST `phonePrimary/phoneSecondary` (~l.513-524). Adapter `isMainValid` (~l.479-493) : la validation principale ne porte plus que sur `companyName` (+ relation/catégories selon RG existantes). L'onglet **Contacts** devient le point de saisie des coordonnées ; garantir au moins un `ClientContactBlock` vide au départ. |
| `pages/clients/[id]/edit.vue` | Supprimer les 5 champs du bloc principal (~l.32-73). `mapMainDraft()` et `buildMainPayload()` ne portent plus ces champs. L'onglet Contacts reste éditable. |
| `pages/clients/[id]/index.vue` | Supprimer l'affichage lecture seule des 5 champs du bloc principal (~l.49-104, partie contact). Conserver l'onglet Contacts (lecture seule). |
| `types/clientForm.ts` | `MainFormDraft` : retirer `firstName`, `lastName`, `email`, `phonePrimary`, `phoneSecondary`, `hasSecondaryPhone`. Garder `ContactFormDraft` (inchangé). |
| `types/clientConsultation.ts` | `ClientDetail` : retirer `firstName/lastName/phonePrimary/phoneSecondary/email` (les commentaires « Contact principal »). Garder `ContactRead`. |
| `utils/clientEdit.ts` | `mapMainDraft()` et `buildMainPayload()` : retirer les 5 champs. Garder `buildContactPayload()`. |
| `utils/clientConsultation.ts` | Retirer toute lecture des 5 champs inline du client (garder `mapContactToDraft`, `contactOptionsOf`). |
| `i18n/locales/fr.json` | Retirer `commercial.clients.form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone`. **Conserver** tout le bloc `commercial.clients.form.contact.*`. Vérifier qu'aucune autre vue ne référence les clés retirées. |
| `**/__tests__/*.spec.ts` | Mettre à jour `clientFormRules.spec.ts`, `clientEdit.spec.ts`, `clientConsultation.spec.ts` (cf. § 4). |
### OUT
- `ClientContactBlock.vue`, l'onglet Contacts, `useClient`, la liste/répertoire
(`pages/clients/index.vue` — ses colonnes n'affichent déjà pas le contact inline) :
**inchangés**.
- Le back → ticket 1/3. Les specs → ticket 3/3.
## 3. Comportement attendu après modification
- **Création** : le formulaire principal demande l'entreprise (et relation/catégories selon
l'existant), plus de Nom/Prénom/Téléphone/Email inline. L'utilisateur renseigne les
coordonnées dans l'onglet **Contacts**. La création reste valide tant qu'il y a
`companyName` **et** ≥ 1 bloc Contact valide (Nom OU Prénom) — RG-1.05/RG-1.14 inchangées.
- **Consultation** : plus de bloc contact principal ; l'onglet Contacts affiche les
contacts.
- **Modification** : idem ; le PATCH du groupe `client:write:main` n'envoie plus les 5
champs.
## 4. Tests Vitest à mettre à jour
- `clientFormRules.spec.ts` : la validité du « principal » ne dépend plus de
firstName/email/phone ; conserver `isContactNamed()` (RG-1.05) sur les blocs Contacts.
- `clientEdit.spec.ts` : `buildMainPayload()` ne contient plus les 5 champs ; `mapMainDraft()`
non plus.
- `clientConsultation.spec.ts` : retirer les assertions sur les 5 champs inline.
## 5. Tips & rappels projet
- `useApi()` obligatoire (jamais `$fetch`/`ofetch`). Composants `Malio*` obligatoires.
- État de tableau jamais dans l'URL (règle inchangée).
- Les valeurs sont **normalisées côté serveur** (Capitalize / chiffres / lowercase) : le
front envoie la saisie et réaffiche la valeur renvoyée — ne pas réintroduire de
normalisation front.
- Ne pas créer de clé i18n orpheline ni laisser de clé `form.main.*` morte.
## 6. Critères d'acceptation (DoD)
- [ ] Les 3 écrans n'affichent plus Nom/Prénom/Téléphone/Téléphone 2/Email en bloc principal.
- [ ] Le parcours de création fonctionne avec `companyName` + onglet Contacts (≥ 1 contact).
- [ ] `MainFormDraft` / `ClientDetail` ne déclarent plus les 5 champs ; `mapMainDraft` /
`buildMainPayload` non plus.
- [ ] Aucune clé i18n `form.main.firstName/lastName/email/phone*` restante ni référencée.
- [ ] `make nuxt-test` vert.
- [ ] Vérification visuelle du golden path (`make dev-nuxt`, port 3004) : création →
consultation → modification d'un client sans bloc contact inline.
@@ -1,47 +0,0 @@
# Prompt d'implémentation — M1 · Ticket 2/3 (Frontend)
Projet **Starseed** (Nuxt 4 / Vue 3 Composition API / TypeScript / @malio/layer-ui).
Lis `CLAUDE.md` et `.claude/rules/frontend.md` avant de coder. Commentaires en français,
code en anglais, 4 espaces d'indentation.
## Mission
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
écrans Client (création / consultation / modification) et de tout le code associé (types,
mappeurs, validations, i18n). Les contacts restent gérés par l'onglet **Contacts**
(`ClientContactBlock`, inchangé).
Spec détaillée : `docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.md` (lis-la en
entier + le `README.md` du dossier). Ce ticket dépend du ticket back (l'API ne porte plus
les 5 champs sur `client`).
## Étapes
1. Explorer `frontend/modules/commercial/` : `pages/clients/new.vue`, `[id]/edit.vue`,
`[id]/index.vue`, `types/clientForm.ts`, `types/clientConsultation.ts`,
`utils/clientEdit.ts`, `utils/clientConsultation.ts`, `i18n/locales/fr.json`.
2. Supprimer le bloc principal des 3 écrans + l'état réactif `main.firstName/lastName/email`,
`mainPhones`, `prefillFirstContact()`.
3. Adapter `isMainValid` : ne dépend plus que de `companyName` (+ relation/catégories selon
l'existant). La garantie « ≥ 1 contact valide » reste portée par l'onglet Contacts.
4. Nettoyer les types (`MainFormDraft`, `ClientDetail`) et les mappeurs (`mapMainDraft`,
`buildMainPayload`, `clientConsultation`).
5. Retirer les clés i18n `form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone` ;
vérifier par recherche qu'aucune vue ne les utilise plus. **Garder** `form.contact.*`.
6. Mettre à jour les specs Vitest (`clientFormRules`, `clientEdit`, `clientConsultation`).
## Garde-fous
- `useApi()` uniquement ; composants `Malio*` uniquement ; pas d'état tableau dans l'URL.
- Ne touche pas `ClientContactBlock.vue`, l'onglet Contacts, ni la liste/répertoire.
- Pas de normalisation front (le serveur normalise).
## Vérification finale
```bash
make nuxt-test # Vitest vert
make dev-nuxt # port 3004 — golden path manuel
```
Golden path à vérifier dans le navigateur : créer un client (entreprise + 1 contact dans
l'onglet Contacts), le consulter, le modifier — sans aucun bloc contact inline.
@@ -1,51 +0,0 @@
# M1 · Ticket 3/3 (Specs) — Acter la suppression du contact inline dans les specs M1
## 1. Objectif
Mettre à jour la **documentation fonctionnelle/technique M1** pour refléter la décision :
le contact principal inline est supprimé du `Client`, les contacts vivent uniquement dans
`ClientContact`. Les specs sont la **source de vérité** du projet (cf. `workflow.md`) : elles
doivent décrire le modèle cible, pas l'ancien.
> Idéalement réalisé **avant** les tickets 1 et 2 (la spec guide le code), mais peut être
> fait en parallèle. À minima, ne pas merger le code sans aligner la spec.
## 2. Fichiers à modifier
| Fichier | Sections concernées |
|---|---|
| `docs/specs/M1-clients/spec-back.md` | § 3.1 diagramme E-R (retirer les 5 colonnes du bloc `client`) ; § 3.2 migration SQL `CREATE TABLE client` (retirer `first_name`/`last_name`/`phone_primary`/`phone_secondary`/`email` + leurs COMMENT) ; § 3.4 squelette entité `Client` (retirer les 5 props) ; § 4.3 exemple payload `POST /api/clients` (retirer les 5 champs) ; § 4.1 filtre `?search=` (refléter D1) ; § 4.6 export (refléter D2) ; § 7 RG (voir § 3 ci-dessous) ; § 8 cahier de tests (déplacer RG-1.01/1.02 vers ClientContact). |
| `docs/specs/M1-clients/spec-front.md` | « Formulaire principal » (l.85-103) : retirer les lignes Nom/Prénom/Téléphone/Téléphone 2/Email ; écrans Consultation / Modification ; règles de formatage. Préciser que les coordonnées se saisissent dans l'onglet Contact. |
| `docs/specs/M1-clients/cahier-test-back-M1.md` | Retirer / requalifier les lignes RG-1.01 et RG-1.02 (désormais couvertes par RG-1.05 sur `ClientContact`). |
## 3. Traitement des règles de gestion
- **RG-1.01** (firstName OU lastName obligatoire sur Client) → marquer **supprimée** :
« Remplacée par RG-1.05 (≥ 1 contact valide) + RG-1.14 (≥ 1 bloc Contact). Le contact
principal inline n'existe plus. »
- **RG-1.02** (max 2 téléphones sur Client) → marquer **supprimée du Client** (reste
applicable aux blocs `ClientContact`).
- **RG-1.19 / RG-1.20 / RG-1.21** (normalisation) → préciser que le **scope `Client`
disparaît** ; la normalisation reste sur `ClientContact` (et `ClientAddress.billingEmail`
pour RG-1.21).
- Ne **pas renuméroter** les RG existantes (éviter le drift avec le code/tests) : marquer
« supprimée / requalifiée » en place, avec date et renvoi à la décision.
## 4. Forme
- Bumper la version des deux specs (`version: V0``V1`) dans le frontmatter, avec une
entrée d'historique : date `2026-06-03`, motif « Suppression du contact inline du Client
(refonte-contact) », auteur.
- Ajouter un encadré « Décision » en tête de la section modèle de données, renvoyant au
`README.md` du dossier `refonte-contact`.
- Conserver le style des specs (sections numérotées, tableaux RG, exemples JSON).
## 5. Critères d'acceptation (DoD)
- [ ] `spec-back.md` : aucune mention des 5 colonnes inline dans le modèle `client`
(E-R + SQL + entité + payload) ; RG-1.01/1.02 marquées supprimées ; D1/D2 décrites.
- [ ] `spec-front.md` : le formulaire principal ne liste plus les champs de contact ;
l'onglet Contact est présenté comme seul lieu de saisie des coordonnées.
- [ ] `cahier-test-back-M1.md` : RG-1.01/1.02 retirées/requalifiées.
- [ ] Versions bumpées (V1) + historique daté dans les deux specs.
- [ ] Cohérence vérifiée avec les tickets 1 et 2 (mêmes décisions D1/D2).
@@ -1,38 +0,0 @@
# Prompt d'implémentation — M1 · Ticket 3/3 (Specs)
Projet **Starseed**. Tâche **documentaire** : mettre à jour les specs M1 Clients pour acter
la suppression du contact principal inline du `Client`. Les specs sont la source de vérité ;
elles doivent décrire le modèle cible.
## Mission
Modifier `docs/specs/M1-clients/spec-back.md`, `spec-front.md` et `cahier-test-back-M1.md`
pour retirer le contact inline du `Client` (5 champs `firstName/lastName/phonePrimary/
phoneSecondary/email`) — les contacts vivent uniquement dans `ClientContact`.
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.md` (lis-la + le
`README.md` du dossier, qui contient la décision, les RG impactées et les décisions D1/D2).
## Étapes
1. Lire les 3 fichiers de specs M1 visés, repérer toutes les occurrences des 5 champs
(diagramme E-R, CREATE TABLE client, squelette entité, payload POST, filtre search,
export, RG, cahier de test).
2. Retirer les 5 colonnes du modèle `client` (E-R + SQL + entité + exemple JSON).
3. Marquer **supprimées** RG-1.01 et RG-1.02 (renvoi à RG-1.05/RG-1.14 sur `ClientContact`),
restreindre le scope de RG-1.19/1.20/1.21 à `ClientContact`. **Ne pas renuméroter** les RG.
4. Refléter les décisions D1 (recherche) et D2 (export) une fois tranchées.
5. Côté `spec-front.md` : retirer les champs de contact du formulaire principal ; présenter
l'onglet Contact comme seul lieu de saisie.
6. Bumper `version: V0 → V1` + ajouter une entrée d'historique datée (2026-06-03).
## Garde-fous
- Ne touche pas au code, uniquement aux `.md` de specs.
- Garde le style existant (sections numérotées, tableaux RG, exemples JSON).
- Cohérence stricte avec les tickets 1 (back) et 2 (front) : mêmes décisions D1/D2.
## Vérification
Relire les 3 fichiers : plus aucune mention des 5 champs inline dans le modèle `client` ;
RG-1.01/1.02 marquées supprimées ; versions à V1 avec historique.
@@ -1,57 +0,0 @@
# Amendement des tickets M2 existants — suppression du contact inline du `Supplier`
Les 14 tickets M2 (n° 8497, groupe Lesstime « M2 — Répertoire fournisseurs ») ont été
rédigés sur le modèle initial **avec** contact inline. La décision `refonte-contact` les
amende : `Supplier` ne porte **plus** les 5 champs `firstName/lastName/phonePrimary/
phoneSecondary/email` ; les contacts vivent uniquement dans `SupplierContact` (onglet
Contacts). Comme M2 n'est pas codé, il suffit de **ne jamais créer** ces colonnes/champs.
## Bandeau injecté en tête des tickets impactés
> ⚠️ **AMENDEMENT 2026-06-03 — refonte-contact.** Le contact principal inline est
> **supprimé** du `Supplier` : ne pas créer/saisir les colonnes ni les champs `firstName`,
> `lastName`, `phonePrimary`, `phoneSecondary`, `email` sur l'entité/le formulaire
> `Supplier`. Les contacts sont gérés **uniquement** via `SupplierContact` (onglet
> Contacts). RG-2.01 et RG-2.02 sont supprimées (équivalent assuré par RG-2.04 / RG-2.13).
> RG-2.12 ne s'applique qu'à `companyName` + `SupplierContact`. Décisions transverses
> recherche (D1) et export (D2) : cf. `docs/specs/M1-clients/refonte-contact/README.md`.
## Tickets à amender
### Back
| Ticket | n° | Impact |
|---|---|---|
| migration BDD M2 (supplier + sous-collections) | #85 | Retirer `first_name/last_name/phone_primary/phone_secondary/email` du `CREATE TABLE supplier` et leurs `COMMENT ON COLUMN`. `supplier_contact` inchangé. |
| entités + repositories M2 | #86 | `Supplier` : retirer les 5 props + `Assert\Callback` RG-2.01. `SupplierContact` inchangé. |
| SupplierProvider + SupplierProcessor | #87 | Retirer la validation RG-2.01, la normalisation des champs inline, leur présence dans `MAIN_FIELDS` / changedFields. Recherche selon D1. |
| export XLSX fournisseurs | #91 | Colonnes contact selon D2 (depuis le contact principal, ou supprimées). |
| tests PHPUnit M2 | #92 | RG-2.01/2.02 testées sur `SupplierContact` (pas `Supplier`) ; contrat de sérialisation sans les 5 champs inline sur le supplier. |
### Front
| Ticket | n° | Impact |
|---|---|---|
| page Ajouter un fournisseur (`/suppliers/new`) + `useSupplierForm` | #94 | Retirer le bloc contact principal du formulaire + le pré-remplissage du 1er contact. Saisie des coordonnées dans l'onglet Contacts. |
| page Consultation fournisseur (`/suppliers/{id}`) | #95 | Retirer l'affichage du bloc contact principal. |
| page Modification fournisseur (`/suppliers/{id}/edit`) | #96 | Retirer les 5 champs du bloc principal ; payload `supplier:write:main` sans ces champs. |
### Léger
| Ticket | n° | Impact |
|---|---|---|
| page Répertoire fournisseurs + datatable | #93 | Recherche « nom / contact / email » selon D1. Datatable : colonnes inchangées (pas de contact inline en colonne). |
| i18n + sidebar fournisseurs | #97 | Ne pas créer les clés i18n `form.main.firstName/lastName/email/phone*` (garder `form.contact.*`). |
## Tickets NON impactés
- #84 (taxonomie FOURNISSEUR), #88 (sous-ressources contacts/adresses/ribs —
`SupplierContact` est la cible, inchangé), #89 (validators Information Commerciale /
catégorie / RG-2.07-2.08), #90 (RBAC fournisseurs).
## Méthode d'amendement
Pour chaque ticket impacté : **préfixer** la description existante du bandeau ci-dessus
(sans rien supprimer du contenu d'origine), via `mcp__lesstime__update-task`
(`description` = bandeau + description actuelle). La méthode préserve l'historique et reste
réversible (retirer le bandeau).
@@ -1,55 +0,0 @@
# M2 · Ticket Specs — Retirer le contact inline du `Supplier` dans les specs M2
## 1. Objectif
Mettre à jour les specs **M2 Fournisseurs** déjà rédigées pour **ne plus inclure** le contact
principal inline sur le `Supplier`. M2 est le jumeau strict de M1 (`Supplier` /
`SupplierContact` / `SupplierAddress` / `SupplierRib`) et n'est **pas encore codé** : il faut
donc corriger la conception **en amont**, pour que les 14 tickets M2 « prêts à dev » soient
implémentés directement sans les 5 colonnes inline.
> Pendant de M1 ticket 3/3, mais côté M2 : **aucune migration de suppression ni backfill** —
> on retire simplement le contact inline du modèle cible. Contexte : `README.md` du dossier
> `refonte-contact`.
## 2. Fichiers à modifier
| Fichier | Sections concernées |
|---|---|
| `docs/specs/M2-suppliers/spec-back.md` | § 3.1 diagramme E-R (l.175-179 : retirer les 5 colonnes du bloc `supplier`) ; § 3.2 `CREATE TABLE supplier` (l.227-231) ; § 3.4 squelette entité `Supplier` (l.496-517 : props + `Assert\Callback` RG-2.01) ; § 4 exemples payload POST/GET (l.782-805, 867-871) ; recherche `?search=` (l.847 : refléter D1) ; export (refléter D2) ; § contrat de sérialisation (l.725, 729) ; § 7 RG (voir § 3). |
| `docs/specs/M2-suppliers/spec-front.md` | « Formulaire principal » (l.105-117 : retirer Nom/Prénom/Téléphone/Téléphone 2/Email) ; onglet « Contact » (l.140-157 : retirer la phrase de pré-remplissage depuis le formulaire principal, l.142) ; écrans Consultation/Modification ; règles de formatage (l.283-285) ; recherche (l.76 : refléter D1). |
## 3. Traitement des règles de gestion M2
- **RG-2.01** (firstName OU lastName obligatoire sur Supplier) → **supprimée** : remplacée
par RG-2.04 (≥ 1 contact valide) + RG-2.13 (≥ 1 bloc Contact). Le contact inline n'existe
plus sur `Supplier`.
- **RG-2.02** (max 2 téléphones sur Supplier) → **supprimée du Supplier** (reste sur
`SupplierContact`).
- **RG-2.12** (normalisation Capitalize / chiffres / lowercase) → restreindre le scope :
s'applique à `companyName` (UPPERCASE) et aux champs de `SupplierContact` ; **plus** aux
champs inline du `Supplier` (qui disparaissent).
- Ne pas renuméroter les RG : marquer « supprimée / requalifiée » en place, avec date.
## 4. Forme
- Bumper la version des deux specs M2 + entrée d'historique datée (2026-06-03, motif
« Suppression du contact inline du Supplier — alignement refonte-contact M1 »).
- Encadré « Décision » renvoyant au `README.md` du dossier `refonte-contact`.
- Garder le style des specs M2.
## 5. Lien avec les tickets M2 existants
La mise à jour des specs doit être cohérente avec l'**amendement des tickets M2** (voir
`M2-amendement-tickets.md`) : tickets back #85/#86/#87/#91/#92 et front #94/#95/#96 (+ #93/#97
légers). Specs et tickets décrivent le **même** modèle cible (sans contact inline).
## 6. Critères d'acceptation (DoD)
- [ ] `spec-back.md` M2 : aucune mention des 5 colonnes inline dans le modèle `supplier`
(E-R + SQL + entité + payloads + sérialisation) ; RG-2.01/2.02 marquées supprimées ;
D1/D2 décrites.
- [ ] `spec-front.md` M2 : formulaire principal sans champs de contact ; onglet Contact
présenté comme seul lieu de saisie (sans pré-remplissage depuis le principal).
- [ ] Versions bumpées + historique daté.
- [ ] Cohérence avec l'amendement des tickets M2.
@@ -1,36 +0,0 @@
# Prompt d'implémentation — M2 · Ticket Specs
Projet **Starseed**. Tâche **documentaire**. Mettre à jour les specs M2 Fournisseurs
(`docs/specs/M2-suppliers/spec-back.md` + `spec-front.md`) pour retirer le contact principal
inline du `Supplier` (5 champs `firstName/lastName/phonePrimary/phoneSecondary/email`).
M2 n'est **pas encore codé** : on corrige la conception en amont, **sans** migration ni
backfill (contrairement à M1). Les contacts vivent uniquement dans `SupplierContact`.
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M2-ticket-specs.md` (lis-la + le
`README.md` du dossier).
## Étapes
1. Lire `spec-back.md` et `spec-front.md` M2 ; repérer toutes les occurrences des 5 champs
(E-R l.175-179, CREATE TABLE supplier l.227-231, entité l.496-517, payloads l.782-805 /
867-871, sérialisation l.725-729, RG-2.01/2.02/2.12, recherche, export, formulaire
principal front l.105-117, pré-remplissage onglet Contact l.142).
2. Retirer les 5 colonnes du modèle `supplier`.
3. Marquer **supprimées** RG-2.01 et RG-2.02 (renvoi RG-2.04/RG-2.13) ; restreindre RG-2.12
à `companyName` + `SupplierContact`. Ne pas renuméroter.
4. Refléter D1 (recherche : LEFT JOIN supplier_contact recommandé) et D2 (export depuis le
contact principal recommandé).
5. Front : retirer les champs de contact du formulaire principal ; retirer la phrase de
pré-remplissage du 1er bloc Contact ; présenter l'onglet Contact comme seul lieu de saisie.
6. Bumper la version + historique daté (2026-06-03).
## Garde-fous
- Uniquement les `.md` de specs M2. Style existant conservé.
- Cohérence stricte avec l'amendement des tickets M2 et avec la décision M1 (jumeau).
## Vérification
Relire les 2 specs : plus aucune mention des 5 champs inline dans le modèle `supplier` ;
RG-2.01/2.02 supprimées ; versions bumpées.
@@ -1,84 +0,0 @@
# Refonte « contact » — suppression du contact inline des tiers (Client M1 + Supplier M2)
> Dossier de tickets transverse. Source de vérité de la décision et de son découpage.
> Rédigé le 2026-06-03. Owner : Matthieu.
## 1. Décision
Le **contact « principal » inline** (les 5 colonnes plates `first_name`, `last_name`,
`phone_primary`, `phone_secondary`, `email`) est **supprimé de l'entité tier** (`Client`,
puis `Supplier`). La gestion des contacts passe **exclusivement** par la sous-entité
dédiée (`ClientContact` / `SupplierContact`), c.-à-d. l'**onglet « Contacts »**.
### Pourquoi
- **Modèle unique, zéro duplication.** Aujourd'hui le contact est saisi deux fois : une
fois dans le bloc principal (inline sur le tier) et une fois dans l'onglet Contacts
(sous-entité). À la création, le front recopie même l'un dans l'autre
(`prefillFirstContact`). Deux sources pour la même information = risque de divergence.
- **Cohérence métier.** Un tier peut avoir plusieurs contacts ; il n'y a pas de raison
qu'un seul soit « privilégié » au niveau de la table tier. La notion de contact
appartient à la collection de contacts.
- **Garantie préservée.** L'invariant « il y a toujours au moins un contact » est déjà
assuré par la sous-entité : RG-1.05/RG-1.14 (M1) et RG-2.04/RG-2.13 (M2) imposent
**≥ 1 bloc Contact valide** (Nom OU Prénom). Supprimer le contact inline ne crée donc
aucun trou : le contact reste obligatoire, mais au bon endroit.
### Règles de gestion impactées
| RG | Avant | Après |
|---|---|---|
| RG-1.01 / RG-2.01 (firstName OU lastName obligatoire **sur le tier**) | sur `Client` / `Supplier` | **supprimée** du tier — équivalent assuré par RG-1.05 / RG-2.04 sur la sous-entité |
| RG-1.02 / RG-2.02 (max 2 téléphones **sur le tier**) | sur le tier | **supprimée** du tier — reste sur la sous-entité |
| RG-1.19/1.20/1.21 — RG-2.12 (normalisation Capitalize / chiffres / lowercase) | appliquée aux champs **du tier ET** de la sous-entité | ne s'applique plus aux champs du tier (qui n'existent plus) — **inchangée** sur la sous-entité |
## 2. Périmètre & découpage
### M1 — Clients (code DÉJÀ livré → suppression + migration de données)
| # | Ticket | Tag | Effort |
|---|--------|-----|--------|
| 1 | `M1-ticket-01-back` — supprimer le contact inline du `Client` (migration + backfill + entité + processor + provider + export + fixtures + tests) | Backend | M |
| 2 | `M1-ticket-02-front` — retirer le bloc contact principal des écrans création / consultation / modification | Frontend | M |
| 3 | `M1-ticket-03-specs` — acter la décision dans les specs M1 (back + front + cahier de test) | Maintenance | S |
### M2 — Fournisseurs (NON codé → on retire le contact inline dès la conception)
| # | Action | Tag | Effort |
|---|--------|-----|--------|
| 4 | `M2-ticket-specs` — mettre à jour les specs M2 déjà écrites (back + front) pour retirer le contact inline du `Supplier` | Maintenance | S |
| — | `M2-amendement-tickets` — amender les tickets M2 existants (n° 8497) impactés (migration, entités, processor, export, front, tests, i18n) | — | — |
> M2 ne nécessite **pas** de migration de suppression ni de backfill : il suffit de **ne
> jamais créer** les 5 colonnes inline sur `supplier`. Le travail M2 est donc un
> ajustement de specs + un amendement des tickets « prêts à dev ».
## 3. Décisions transverses à trancher (mêmes pour M1 et M2)
Deux comportements s'appuyaient sur les colonnes inline du tier. À la suppression, il faut
choisir leur nouvelle source. Recommandation par défaut entre parenthèses.
- **D1 — Recherche serveur** (`?search=`). Aujourd'hui : fuzzy sur `companyName` +
`lastName` + `email` **du tier**. Après suppression, deux options :
- (a) restreindre la recherche à `companyName` seul (simple, mais perte de la recherche
par contact) ;
- (b) **[recommandé]** étendre la recherche en `LEFT JOIN` sur la sous-entité contact
(`first_name` / `last_name` / `email` du contact), pour préserver l'UX « recherche par
nom / contact / email » annoncée dans la barre de recherche.
- **D2 — Colonnes de l'export XLSX** (Nom contact / Prénom / Téléphone / Téléphone 2 /
Email). Après suppression :
- (a) supprimer ces colonnes ;
- (b) **[recommandé]** les alimenter depuis le **contact principal** (le contact de plus
petit `position`), pour garder un export utile.
Ces deux décisions sont à valider par le métier (Matthieu) avant implémentation et sont
rappelées dans chaque ticket concerné.
## 4. Fichiers de ce dossier
- `README.md` (ce fichier) — décision + découpage.
- `M1-ticket-01-back.md` / `.prompt.md` — description + prompt d'implémentation.
- `M1-ticket-02-front.md` / `.prompt.md`.
- `M1-ticket-03-specs.md` / `.prompt.md`.
- `M2-ticket-specs.md` / `.prompt.md`.
- `M2-amendement-tickets.md` — bandeau d'amendement + liste des tickets M2 à mettre à jour.
+71 -51
View File
@@ -5,11 +5,8 @@ nom: "Répertoire clients"
ecran: repertoire-clients ecran: repertoire-clients
owner_spec: Matthieu owner_spec: Matthieu
backup_spec: Tristan backup_spec: Tristan
version: V1 version: V0
date_redaction: 2026-05-28 date_redaction: 2026-05-28
# Historique : V1 (2026-06-03) — Refonte contact : suppression du contact principal inline
# du Client (firstName/lastName/phonePrimary/phoneSecondary/email retirés de la table client).
# Les contacts vivent uniquement dans ClientContact. Cf. docs/specs/M1-clients/refonte-contact/README.md
# === LIENS === # === LIENS ===
spec_front: ./spec-front.md spec_front: ./spec-front.md
@@ -206,11 +203,11 @@ Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter V
| | +-----------------------+ | (Catalog) | | | +-----------------------+ | (Catalog) |
| id (PK) | +--------------+ | id (PK) | +--------------+
| company_name | | company_name |
| (contact inline | +-----------------------+ +--------------+ | first_name | +-----------------------+ +--------------+
| retiré V1 — |--1:n-->| client_contact | | site | | last_name |--1:n-->| client_contact | | site |
| firstName, | +-----------------------+ | (Sites) | | phone_primary | +-----------------------+ | (Sites) |
| lastName, phones,| +--------------+ | phone_secondary | +--------------+
| email) | +-----------------------+ ^ | email | +-----------------------+ ^
| distributor_id |--1:n-->| client_address |--n:m---------+ | distributor_id |--1:n-->| client_address |--n:m---------+
| broker_id | +-----------------------+ | broker_id | +-----------------------+
| triage_service | | | triage_service | |
@@ -305,8 +302,11 @@ CREATE TABLE client (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
-- Formulaire principal -- Formulaire principal
company_name VARCHAR(180) NOT NULL, company_name VARCHAR(180) NOT NULL,
-- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary / first_name VARCHAR(120),
-- phone_secondary / email vivent désormais uniquement dans client_contact (onglet Contacts). last_name VARCHAR(120),
phone_primary VARCHAR(20) NOT NULL,
phone_secondary VARCHAR(20),
email VARCHAR(180) NOT NULL,
distributor_id INT REFERENCES client(id) ON DELETE SET NULL, distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
broker_id INT REFERENCES client(id) ON DELETE SET NULL, broker_id INT REFERENCES client(id) ON DELETE SET NULL,
triage_service BOOLEAN NOT NULL DEFAULT FALSE, triage_service BOOLEAN NOT NULL DEFAULT FALSE,
@@ -465,32 +465,26 @@ CREATE TABLE client_rib (
CREATE INDEX idx_client_rib_client ON client_rib(client_id); CREATE INDEX idx_client_rib_client ON client_rib(client_id);
``` ```
### 3.3 Seed taxonomie — type unique `CLIENT` + `Category.code` (refonte ERP-78) ### 3.3 Seed `CategoryType` (extension du M0)
> **⚠ Refonte ERP-78 (décision produit 01/06) — le modèle ci-dessous remplace l'ancien.** Au M0, la table `category_type` a été créée mais reste vide (HP-1 du M0). Le M1 lève cette restriction avec un seed initial des **types métier** dont le module Tiers a besoin :
> Historique : à l'origine (#38), `DISTRIBUTEUR` / `COURTIER` / `SECTEUR` / `AUTRE` étaient des **`category_type`**. Le modèle a été **inversé** :
>
> - **UN SEUL `category_type` : `CLIENT`** (code `CLIENT`, label « Client »).
> - `Distributeur` / `Courtier` / `Secteur` / `Autre` (+ catégories métier fines) sont désormais des **`Category`** rattachées au type `CLIENT`.
> - Le filtrage métier ne se fait plus sur le **type** mais sur un **`code` stable porté par la `Category`** (NOT NULL, unique parmi les actifs — index partiel `uq_category_code`). Le code est un **slug MAJUSCULE auto-généré du nom** (`CategoryCodeGenerator`), figé à la création, et exposé en **lecture seule** (groupe `category:read`). Les codes `DISTRIBUTEUR` / `COURTIER` (anciennement portés par le type) sont reportés sur les `Category` correspondantes.
Seed cible (migration corrective `Version20260602100000`, namespace racine) :
```sql ```sql
-- Type unique INSERT INTO category_type (code, label, position) VALUES
INSERT INTO category_type (code, label) VALUES ('CLIENT', 'Client') ON CONFLICT (code) DO NOTHING; ('DISTRIBUTEUR', 'Distributeur', 10),
-- Catégories système sous CLIENT (codes stables pilotant les RG) ('COURTIER', 'Courtier', 20),
-- Distributeur -> DISTRIBUTEUR, Courtier -> COURTIER, Secteur -> SECTEUR, Autre -> AUTRE ('SECTEUR', 'Secteur', 30),
('AUTRE', 'Autre', 99);
``` ```
> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). Le `code` de `Category` n'est PAS saisissable via l'API (auto-généré côté serveur). > **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0).
> >
> **Seed en DEUX endroits (décision 29/05, vérifiée empiriquement)** : le `make db-reset` lance les fixtures, dont le purger Doctrine **vide `category` / `category_type`** (entités M0 mappées) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc : > **Seed en DEUX endroits (décision 29/05, vérifiée empiriquement)** : le `make db-reset` lance les fixtures, dont le purger Doctrine **vide `category_type`** (entité M0 mappée) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc :
> 1. **Migration** (`ON CONFLICT` / guards `NOT EXISTS`) → sert en **prod** (pas de fixtures). > 1. **Migration** (`ON CONFLICT (code) DO NOTHING`) → sert en **prod** (pas de fixtures).
> 2. **Fixtures idempotentes** (`CategoryTypeFixtures` → type CLIENT ; `CategoryFixtures` → catégories codées sous CLIENT) → survivent au `db-reset`. > 2. **Fixture Commercial idempotente** (ex. `CommercialReferentialFixtures`) re-seedant les 4 types → survit au `db-reset`, satisfait le critère « 4 types présents après db-reset ».
> >
> ⚠ **À venir en ERP-54** : `tva_mode` / `payment_delay` / `payment_type` / `bank` ne sont pas encore des entités mappées au M1.0 → le purger ne les touche pas, leur seed migration survit. **Dès qu'ERP-54 crée leurs entités, ils seront purgés au db-reset** → il faudra les ajouter à la même fixture référentielle. > ⚠ **À venir en ERP-54** : `tva_mode` / `payment_delay` / `payment_type` / `bank` ne sont pas encore des entités mappées au M1.0 → le purger ne les touche pas, leur seed migration survit. **Dès qu'ERP-54 crée leurs entités, ils seront purgés au db-reset** → il faudra les ajouter à la même fixture référentielle.
> 🔗 **Coordination ERP-68** : ERP-78 (cette refonte) atterrit avant ERP-68. `CategoryFixtures` / `ClientFixtures` ont été adaptées au type unique CLIENT + codes (les tiers distributeur/courtier portent les `Category` de code DISTRIBUTEUR/COURTIER). > 🔗 **Coordination ERP-68** : ERP-53 pose la fixture référentielle minimale (4 category_types). ERP-68 l'**étend** (clients de démo, ~12-15) sans la dupliquer.
### 3.4 Entité `Client` — squelette ### 3.4 Entité `Client` — squelette
@@ -580,9 +574,32 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
// Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary / // RG-1.01 — first_name OU last_name obligatoire (validation Assert\Callback
// phoneSecondary / email ne sont plus portés par Client — ils vivent dans ClientContact // au niveau de l'entite, levee dans le Processor).
// (onglet Contacts). La garantie « ≥ 1 contact nommé » est portée par RG-1.05 + RG-1.14. #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $lastName = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups(['client:read', 'client:write:main'])]
private ?string $email = null;
// RG-1.03 — distributor / broker auto-references (mutuellement exclusives, // RG-1.03 — distributor / broker auto-references (mutuellement exclusives,
// contrainte CHECK en base). // contrainte CHECK en base).
@@ -725,8 +742,8 @@ class Client implements TimestampableInterface, BlamableInterface
- **Security** : `is_granted('commercial.clients.view')` - **Security** : `is_granted('commercial.clients.view')`
- **Query params** : - **Query params** :
- `includeArchived=true|false` (default `false`) - `includeArchived=true|false` (default `false`)
- `categoryCode=<code>` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`) - `categoryType=<code>` (filtre par type de catégorie via `SearchFilter`)
- `search=<text>` (recherche fuzzy sur companyName + contacts liés `client_contact` (firstName / lastName / email) via LEFT JOIN groupé par `client.id` — décision D1, refonte-contact) - `search=<text>` (recherche fuzzy sur companyName + lastName + email)
- **Tri par défaut** : `companyName ASC` - **Tri par défaut** : `companyName ASC`
- **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible). Pas de pagination serveur au M1. - **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible). Pas de pagination serveur au M1.
- **Réponse 200** (JSON-LD Hydra) : items avec champs `client:read` UNIQUEMENT (pas les champs `client:read:accounting` sauf si l'user a la permission `accounting.view`). - **Réponse 200** (JSON-LD Hydra) : items avec champs `client:read` UNIQUEMENT (pas les champs `client:read:accounting` sauf si l'user a la permission `accounting.view`).
@@ -745,6 +762,10 @@ class Client implements TimestampableInterface, BlamableInterface
```json ```json
{ {
"companyName": "ACME SAS", "companyName": "ACME SAS",
"firstName": "Jean",
"lastName": "Dupont",
"phonePrimary": "0612345678",
"email": "jean.dupont@acme.fr",
"categories": ["/api/categories/3", "/api/categories/7"], "categories": ["/api/categories/3", "/api/categories/7"],
"distributor": null, "distributor": null,
"broker": null, "broker": null,
@@ -756,7 +777,7 @@ class Client implements TimestampableInterface, BlamableInterface
- `201` / `400` / `401` / `403` - `201` / `400` / `401` / `403`
- `409 Conflict` si doublon de nom de société (`companyName` — RG-1.16). SIREN et email ne sont pas uniques (cf. Q4, § 2.4). - `409 Conflict` si doublon de nom de société (`companyName` — RG-1.16). SIREN et email ne sont pas uniques (cf. Q4, § 2.4).
- `422 Unprocessable Entity` : - `422 Unprocessable Entity` :
- (RG-1.01 supprimée V1 — la complétude du contact est portée par l'onglet Contacts : RG-1.05 / RG-1.14) - RG-1.01 : ni firstName ni lastName
- RG-1.03 : distributor + broker remplis simultanément - RG-1.03 : distributor + broker remplis simultanément
- Catégories vides (Assert\Count min=1) - Catégories vides (Assert\Count min=1)
@@ -858,14 +879,13 @@ Cf. § 2.6. Pattern Shared standard.
### Formulaire principal ### Formulaire principal
- ~~**RG-1.01**~~ _(SUPPRIMÉE — V1, 2026-06-03, refonte-contact)_ : le contact principal inline est retiré du `Client`. La garantie « au moins un contact nommé » est désormais portée par **RG-1.05** (bloc Contact valide) + **RG-1.14** (≥ 1 bloc Contact) sur `ClientContact`. - **RG-1.01** : Au moins l'un des champs `firstName` (Prénom du contact principal) ou `lastName` (Nom du contact principal) doit être renseigné. Sinon → 422.
- ~~**RG-1.02**~~ _(SUPPRIMÉE du Client — V1, refonte-contact)_ : plus de téléphones inline sur le `Client`. Le « maximum 2 téléphones » reste applicable aux blocs `ClientContact` (normalisation RG-1.20). - **RG-1.02** : Le champ `phoneSecondary` est optionnel et apparaît au clic sur un bouton `+` côté front. Maximum 2 téléphones (primary + secondary). Comportement purement front au niveau UI ; côté serveur, les 2 colonnes existent et sont distinctes.
- **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. Un `distributor` référencé doit porter une **`Category` de code `DISTRIBUTEUR`** ; un `broker` une **`Category` de code `COURTIER`** — sinon 422. _(Refonte ERP-78 : le filtrage se fait sur le `code` de la `Category`, plus sur le type — `ClientProcessor::hasCategoryCode`.)_ La liste front de `distributor` = clients ayant une catégorie de code `DISTRIBUTEUR`, via `GET /api/clients?categoryCode=DISTRIBUTEUR` ; idem `broker` avec `COURTIER`. - **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. La liste front de `distributor` = clients ayant au moins une catégorie de type `DISTRIBUTEUR` ; idem pour `broker` avec `COURTIER`.
### Onglet Information ### Onglet Information
- **RG-1.04** _(durcie — ERP-74)_ : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) sont obligatoires sur **POST et sur tout PATCH**, **indépendamment des champs réellement envoyés** (la condition d'intersection avec `client:write:information` a été retirée). Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué systématiquement par le `ClientProcessor` quand le user porte le rôle Commerciale. - **RG-1.04** : Pour un utilisateur portant le rôle métier **Commerciale**, **tous** les champs de l'onglet Information (`description`, `competitors`, `foundedAt`, `employeesCount`, `revenueAmount`, `directorName`, `profitAmount`) deviennent obligatoires lors d'un PATCH sur le groupe `client:write:information`. Pour les autres rôles, ces champs restent optionnels. Implémenté via un validator custom `ClientInformationCompletenessValidator` invoqué par le `ClientProcessor` quand le user porte le rôle Commerciale.
- **Conséquence** : le POST n'exposant que le groupe `client:write:main`, l'onglet Information n'y est pas renseignable → une Commerciale obtient **422** sur tout POST (cf. § 8.1). La complétude se fait donc via les PATCH `client:write:information` ultérieurs. Un Admin (non gaté) crée normalement (201).
### Onglet Contact ### Onglet Contact
@@ -902,9 +922,9 @@ Cf. § 2.6. Pattern Shared standard.
### Normalisation serveur (formatage) ### Normalisation serveur (formatage)
- **RG-1.18** : `companyName` est **upper-cased** intégralement côté serveur avant validation et persistance (`mb_strtoupper(trim($v), 'UTF-8')`). Le client n'a pas besoin de saisir en majuscules ; la BDD stocke en majuscules. - **RG-1.18** : `companyName` est **upper-cased** intégralement côté serveur avant validation et persistance (`mb_strtoupper(trim($v), 'UTF-8')`). Le client n'a pas besoin de saisir en majuscules ; la BDD stocke en majuscules.
- **RG-1.19** : `firstName`, `lastName` (sur `ClientContact` ; scope `Client` retiré en V1) sont **capitalize**-és serveur (`mb_convert_case(trim($v), MB_CASE_TITLE, 'UTF-8')`). Exemple : `JEAN dupont``Jean Dupont`. - **RG-1.19** : `firstName`, `lastName` (sur `Client` et `ClientContact`) sont **capitalize**-és serveur (`mb_convert_case(trim($v), MB_CASE_TITLE, 'UTF-8')`). Exemple : `JEAN dupont``Jean Dupont`.
- **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `ClientContact` ; scope `Client` retiré en V1) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front). - **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `Client`, et idem sur `ClientContact`) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front).
- **RG-1.21** : `email` (`ClientAddress.billingEmail`, `ClientContact.email` ; `Client.email` retiré en V1) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`). - **RG-1.21** : `email` (`Client.email`, `ClientAddress.billingEmail`, `ClientContact.email`) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`).
### Archivage ### Archivage
@@ -925,18 +945,18 @@ Cf. § 2.6. Pattern Shared standard.
- **RG-1.28** : Si un PATCH contient des champs de **plusieurs groupes** de sérialisation et que l'utilisateur **n'a pas toutes les permissions** correspondantes, le `ClientProcessor` renvoie **403 Forbidden sur l'ensemble du payload** (mode strict — pas de filtrage silencieux). Le front est responsable de ne JAMAIS envoyer de champs hors-permission (les onglets masqués via `usePermissions()` ne génèrent pas de payload). Cette règle protège contre les appels API directs malveillants. Exemple : un Bureau qui envoie `{ "companyName": "...", "siren": "..." }` → 403, le message d'erreur précise « Champ `siren` requiert la permission `commercial.clients.accounting.manage` ». - **RG-1.28** : Si un PATCH contient des champs de **plusieurs groupes** de sérialisation et que l'utilisateur **n'a pas toutes les permissions** correspondantes, le `ClientProcessor` renvoie **403 Forbidden sur l'ensemble du payload** (mode strict — pas de filtrage silencieux). Le front est responsable de ne JAMAIS envoyer de champs hors-permission (les onglets masqués via `usePermissions()` ne génèrent pas de payload). Cette règle protège contre les appels API directs malveillants. Exemple : un Bureau qui envoie `{ "companyName": "...", "siren": "..." }` → 403, le message d'erreur précise « Champ `siren` requiert la permission `commercial.clients.accounting.manage` ».
### Catégorie sur ClientAddress (filtrage par code) ### Catégorie sur ClientAddress (filtrage par type)
- **RG-1.29** _(refonte ERP-78)_ : sur une adresse, les `Category` de **code `DISTRIBUTEUR` ou `COURTIER`** sont **interdites** — elles qualifient une **relation entre clients** (cf. RG-1.03) et n'ont pas de sens sur une adresse physique. **Toute autre** catégorie (type unique CLIENT) est autorisée. Validation du POST/PATCH : poster une catégorie de code DISTRIBUTEUR/COURTIER sur une adresse → **422** avec violation `categories: "Type de catégorie non autorisé sur une adresse."` (`ClientAddress::validateCategoryCodes`). Côté front, le `<MalioSelectCheckbox>` Catégorie de l'onglet Adresse exclut les `Category` de code `DISTRIBUTEUR` / `COURTIER` (le `code` est exposé en lecture sur `/api/categories`). - **RG-1.29** : Le `<MalioSelectCheckbox>` Catégorie de l'onglet Adresse n'expose **que** les `Category` dont `categoryType.code IN ('SECTEUR', 'AUTRE')`. Les types `DISTRIBUTEUR` et `COURTIER` qualifient une **relation entre clients** (cf. RG-1.03) et n'ont pas de sens sur une adresse physique. Implémentation : `ClientAddressProvider` filtre côté serveur via paramètre de requête à l'endpoint `GET /api/categories?categoryType.code[]=SECTEUR&categoryType.code[]=AUTRE` (SearchFilter API Platform). Côté validation du POST/PATCH : si l'utilisateur tente de poster une catégorie de type DISTRIBUTEUR ou COURTIER sur une adresse → **422** avec violation `categories: "Type de catégorie non autorisé sur une adresse."`.
## 8. Tests à automatiser ## 8. Tests à automatiser
### 8.1 Cas à couvrir (back — PHPUnit) ### 8.1 Cas à couvrir (back — PHPUnit)
- [ ] ~~RG-1.01~~ _(supprimée V1)_ : la complétude du contact est couverte par RG-1.05 / RG-1.14 sur `ClientContact` - [ ] **RG-1.01** : POST sans firstName ni lastName → 422
- [ ] ~~RG-1.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`) - [ ] **RG-1.02** : POST avec phoneSecondary rempli → persistance OK ; PATCH ajoutant un 3e téléphone → côté API, 2 colonnes uniquement (test que le payload ne peut pas créer un 3e)
- [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201 - [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201
- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`) - [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de type DISTRIBUTEUR → 422 (validation custom)
- [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200 - [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200
- [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception) - [ ] **RG-1.05** : POST contact sans firstName ni lastName → 422 (BDD CHECK lève une exception)
- [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK - [ ] **RG-1.06/07/08** : POST adresse avec isProspect=true ET isDelivery=true → 422 / CHECK
@@ -948,9 +968,9 @@ Cf. § 2.6. Pattern Shared standard.
- [ ] **RG-1.14** : front-driven uniquement, pas de test back - [ ] **RG-1.14** : front-driven uniquement, pas de test back
- [ ] **RG-1.16** : POST avec `companyName` déjà pris → 409 ; POST avec même `companyName` après archivage de l'ancien → 201. SIREN et email dupliqués → 201 (plus d'unicité — RG-1.15/1.17 supprimées, Q4). - [ ] **RG-1.16** : POST avec `companyName` déjà pris → 409 ; POST avec même `companyName` après archivage de l'ancien → 201. SIREN et email dupliqués → 201 (plus d'unicité — RG-1.15/1.17 supprimées, Q4).
- [ ] **RG-1.18** : POST `companyName="acme sas"` → BDD persiste `"ACME SAS"` - [ ] **RG-1.18** : POST `companyName="acme sas"` → BDD persiste `"ACME SAS"`
- [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` (via un bloc `ClientContact`) → persiste `"Jean"`, `"Dupont"` - [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` → persiste `"Jean"`, `"Dupont"`
- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` (via un bloc `ClientContact`) → persiste `"0612345678"` - [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` → persiste `"0612345678"`
- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` (via `ClientContact` ou `ClientAddress.billingEmail`) → persiste `"jean.dupont@acme.fr"` - [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` → persiste `"jean.dupont@acme.fr"`
- [ ] **RG-1.22/23** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; PATCH isArchived=false sur un client archivé dont le SIREN a été repris → 409 - [ ] **RG-1.22/23** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; PATCH isArchived=false sur un client archivé dont le SIREN a été repris → 409
- [ ] **RG-1.24/25** : GET liste sans flag → exclut archivés ; avec `?includeArchived=true` → inclut - [ ] **RG-1.24/25** : GET liste sans flag → exclut archivés ; avec `?includeArchived=true` → inclut
- [ ] **RG-1.26** : GET liste → tri companyName ASC - [ ] **RG-1.26** : GET liste → tri companyName ASC
+17 -15
View File
@@ -5,10 +5,7 @@ nom: "Répertoire clients"
ecran: repertoire-clients ecran: repertoire-clients
owner_spec: Matthieu owner_spec: Matthieu
backup_spec: Tristan backup_spec: Tristan
version: V1 version: V0
# Historique : V1 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
# (Nom/Prénom/Téléphone/Téléphone 2/Email retirés du formulaire principal et des écrans).
# Saisie via l'onglet Contacts uniquement. Cf. docs/specs/M1-clients/refonte-contact/README.md
date_redaction: 2026-05-28 date_redaction: 2026-05-28
# === LIENS === # === LIENS ===
@@ -71,6 +68,9 @@ Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maqu
| Colonne | Source | Tri | | Colonne | Source | Tri |
|---|---|---| |---|---|---|
| **Nom entreprise** | `client.companyName` | ASC par défaut | | **Nom entreprise** | `client.companyName` | ASC par défaut |
| **Contact principal** | `firstName + lastName` | Oui |
| **Téléphone principal** | `phonePrimary` (formaté `XX XX XX XX XX`) | Non |
| **Email principal** | `email` | Oui |
| **Catégories** | liste des codes catégories séparés par `,` | Non | | **Catégories** | liste des codes catégories séparés par `,` | Non |
| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non | | **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
@@ -86,15 +86,18 @@ Création par **onglets successifs avec validation incrémentale** : pour pouvoi
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles. C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
> **V1 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent désormais dans l'onglet **Contacts** (RG-1.05 / RG-1.14). Le formulaire principal ne contient plus que Entreprise + Catégorie + relation Distributeur/Courtier.
| Champ | Type composant | Obligatoire | Règle | | Champ | Type composant | Obligatoire | Règle |
|---|---|---|---| |---|---|---|---|
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) | | **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
| **Nom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
| **Prénom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category | | **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
| **Téléphone principal** | `<MalioInputText>` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) |
| **Téléphone secondaire** | `<MalioInputText>` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. |
| **Email** | `<MalioInputText>` type email | Oui | RG-1.21 (lowercase) |
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. | | **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de **code** `DISTRIBUTEUR` (ERP-78), via `GET /api/clients?categoryCode=DISTRIBUTEUR`. RG-1.03. | | **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de type `DISTRIBUTEUR`. RG-1.03. |
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de **code** `COURTIER` (ERP-78), via `GET /api/clients?categoryCode=COURTIER`. RG-1.03. | | **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de type `COURTIER`. RG-1.03. |
| **Prestation de triage** | `<MalioCheckbox>` | Non | — | | **Prestation de triage** | `<MalioCheckbox>` | Non | — |
**Action** : « Valider » (`<MalioButton>`) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ». **Action** : « Valider » (`<MalioButton>`) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ».
@@ -117,7 +120,7 @@ Saisir les informations de l'entreprise.
### Onglet « Contact » ### Onglet « Contact »
Saisir un ou plusieurs contacts associés au client. **(V1 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-1.14). Saisir un ou plusieurs contacts associés au client. Le 1er bloc est **pré-rempli** depuis les champs du formulaire principal (Nom, Prénom, Téléphone, Email — édition autorisée).
**Bloc Contact** : **Bloc Contact** :
@@ -147,7 +150,7 @@ Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites
| **Prospect** | `<MalioCheckbox>` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché | | **Prospect** | `<MalioCheckbox>` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché |
| **Adresse de livraison** | `<MalioCheckbox>` | Non | RG-1.07 — masque Prospect si coché | | **Adresse de livraison** | `<MalioCheckbox>` | Non | RG-1.07 — masque Prospect si coché |
| **Facturation** | `<MalioCheckbox>` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) | | **Facturation** | `<MalioCheckbox>` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` **hors codes `DISTRIBUTEUR` / `COURTIER`** (ERP-78 — ces codes qualifient une relation entre clients, pas un lieu). Le front exclut ces 2 codes du select (le `code` est exposé en lecture sur `/api/categories`). | | **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de **type SECTEUR + AUTRE** uniquement (cf. décision Q5 — DISTRIBUTEUR et COURTIER qualifient une relation entre clients, pas un lieu) |
| **Pays** | `<MalioSelect>` | Oui | Préremplie « France » | | **Pays** | `<MalioSelect>` | Oui | Préremplie « France » |
| **Code postal** | `<MalioInputText>` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN | | **Code postal** | `<MalioInputText>` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN |
| **Ville** | `<MalioSelect>` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP | | **Ville** | `<MalioSelect>` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP |
@@ -247,7 +250,7 @@ Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.
|---|---|---| |---|---|---|
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE | | Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique | | Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
| Téléphone (téléphones des blocs `ClientContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) | | Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
| Email | lowercase intégral | identique | | Email | lowercase intégral | identique |
> **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`. > **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`.
@@ -258,22 +261,21 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.
- Composable dédié `useAddressAutocomplete()` (à créer en M1). - Composable dédié `useAddressAutocomplete()` (à créer en M1).
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back. - Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back.
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}` (sans filtre `type`) → suggestions adresse. - Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse.
-**Ne pas forcer `type=housenumber`** sur la recherche d'adresse (corrigé en ERP-66) : la BAN ne renvoie un résultat de ce type qu'une fois un numéro saisi, donc une recherche par nom de rue (« boulevard du port ») renverrait **0 résultat** pendant toute la frappe. Sans filtre `type`, la BAN classe rues + numéros par pertinence — comportement d'autocomplétion attendu.
- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre. - Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre.
## Points laissés ouverts par la V0 (résolus côté back) ## Points laissés ouverts par la V0 (résolus côté back)
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) | | # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|---|---|---| |---|---|---|
| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. Refonte ERP-78 : type unique `CLIENT` ; `Distributeur`/`Courtier`/`Secteur`/`Autre` (+ catégories métier) sont des `Category` portant un `code` stable (HP-3 du M0 levé). | | 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. CategoryType seedé avec `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` (HP-3 du M0 levé). |
| 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. | | 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. |
| 3 | Onglet « Comptabilité » : qui édite ? | **Admin et Compta** peuvent éditer l'onglet Comptabilité (`commercial.clients.accounting.manage`). Bureau / Commerciale ne voient pas l'onglet. Compta ne peut pas créer un client (pas de `manage` global), mais peut éditer la partie comptable d'un client existant. | | 3 | Onglet « Comptabilité » : qui édite ? | **Admin et Compta** peuvent éditer l'onglet Comptabilité (`commercial.clients.accounting.manage`). Bureau / Commerciale ne voient pas l'onglet. Compta ne peut pas créer un client (pas de `manage` global), mais peut éditer la partie comptable d'un client existant. |
| 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». | | 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». |
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. | | 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. | | 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
| 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. | | 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. |
| 8 | Téléphones (max 2) | Sur les blocs `ClientContact` (`phone_primary` + `phone_secondary`). _(V1 : retirés du Client — refonte-contact.)_ | | 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. |
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. | | 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. |
| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). | | 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). |
| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. | | 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
File diff suppressed because it is too large Load Diff
-331
View File
@@ -1,331 +0,0 @@
---
# === IDENTITÉ ===
module: M2
nom: "Répertoire fournisseurs"
ecran: repertoire-fournisseurs
owner_spec: Matthieu
backup_spec: Tristan
version: V0.2
date_redaction: 2026-06-02
# Historique : V0.2 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
# du formulaire Supplier (Nom/Prénom/Téléphone/Téléphone 2/Email). Saisie via l'onglet Contacts.
# Aligné sur M1. Cf. docs/specs/M1-clients/refonte-contact/README.md
# === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev"
regles_metier: [RG-2.01, RG-2.02, RG-2.03, RG-2.04, RG-2.05, RG-2.06, RG-2.07, RG-2.08, RG-2.09, RG-2.10, RG-2.11, RG-2.12, RG-2.13, RG-2.14, RG-2.15, RG-2.16, RG-2.17]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT ===
client_validation_1:
statut: validee
date: 2026-05-22
version: V0
valide_par: "Matthieu (CP MALIO)"
client_validation_2:
statut: validee
date: 2026-06-01
version: V0.1
valide_par: "Matthieu (CP MALIO)"
resume: "Module 2 — Répertoire fournisseurs. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders 'À venir')."
trace_archivee: "uploads/M2-reportoire-fournisseurs.docx (V0.1) + M2-reportoire-fournisseurs-V01.pdf"
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 26
lesstime_project_id: 6
statut_global: a_dev
---
# Module 2 — Répertoire fournisseurs (V0.1 front)
> **Origine** : spec front livrée le 22/05/2026 (V0), amendée le 01/06/2026 (V0.1) — `M2-reportoire-fournisseurs.docx`. Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié ; toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M2 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md).
## But
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des fournisseurs** de l'organisation : consultation, création, modification, archivage. C'est la **deuxième porte d'entrée du module Commercial** (aux côtés des Clients).
## Accès
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire fournisseurs » (route `/suppliers`).
- **Rôles autorisés** :
| Rôle | Consultation | Création / Modification | Archivage |
|---|---|---|---|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
| **Usine** | ❌ (pas d'accès) | ❌ | ❌ |
> **Note** : RBAC identique au M1, transposée sur `commercial.suppliers.*`. Compta édite uniquement l'onglet Comptabilité (SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) d'un fournisseur existant ; Compta ne peut pas **créer** un fournisseur. **L'archivage est réservé à Admin** (cf. tableau du docx).
## Navigation
Page d'entrée du module **Commercial** (route `/suppliers`). Titre : « **Répertoire fournisseurs** ».
- Affichage principal : un **datatable** listant tous les fournisseurs **actifs** (les archivés sont masqués par défaut — toggle UI dédié).
- **Clic sur une ligne** → écran **Consultation fournisseur** (page dédiée).
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un fournisseur**.
- **Bouton « Filtrer »** (haut droite, **à côté de « + Ajouter »**) → ouvre le **panneau de filtres** (cf. ci-dessous). Un badge/compteur indique le nombre de filtres actifs ; un bouton « Réinitialiser » les vide.
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des fournisseurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
### Panneau de filtres (bouton « Filtrer »)
Ouvre un drawer/popover (composant à confirmer côté équipe front — réutiliser le pattern M1 s'il existe). Filtres proposés, branchés sur les query params de `GET /api/suppliers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
| Filtre | Composant | Query param back |
|---|---|---|
| **Recherche** (nom entreprise / contact / email — recherche contact via `supplier_contact`, décision D1) | `<MalioInputText>` | `?search=` |
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type FOURNISSEUR) | `?categoryCode=` |
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/suppliers` avec les params.
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6). Le bouton « Filtrer » + son panneau remplacent/regroupent l'ancien toggle « archivés » isolé.
## Datatable du Répertoire
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Supplier>({ url: '/suppliers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes **conformes à la maquette Figma** (4 colonnes) :
| Colonne | Source | Tri |
|---|---|---|
| **Nom** | `supplier.companyName` | ASC par défaut |
| **Catégories** | `supplier.categories[].name` (embarquées en liste — cohérence M1/ERP-62 ; libellé = `name`, pas `label`) | Non |
| **Site** | `supplier.sites[].name` (agrégat des adresses via `getSites()` ; `Site` n'a pas de `code`) | Non |
| **Dernière activité** | `supplier.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `supplier:read` | Oui |
> **Clic sur une ligne** (texte en bleu lien) → écran Consultation.
> **Filtres** : regroupés dans le panneau du bouton « Filtrer » (cf. section précédente), dont l'inclusion des archivés (désactivée par défaut). **État local** (jamais dans l'URL — règle ABSOLUE n°6).
> **Pagination** : `<MalioDataTable>` + `usePaginatedList`, options **standard Starseed 10 / 25 / 50 (défaut 10)** — on **n'applique pas** le « Ligne : 20 » de la maquette (décision Matthieu : on reste sur le standard). Tri serveur `companyName ASC` par défaut.
## Écran « Ajouter un fournisseur »
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
**Barre d'onglets en création (5 onglets, conforme maquette)** : `Information` · `Contacts` · `Adresses` · `Transport` · `Comptabilité`. L'onglet `Information` est actif par défaut juste après validation du formulaire principal. Les onglets `Statistiques`, `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification.
### Formulaire principal (pré-onglets)
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/suppliers`, puis bascule sur l'onglet Information ; les champs passent en readonly.
> **V0.2 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent dans l'onglet **Contacts** (RG-2.04 / RG-2.13). Le formulaire principal ne contient plus que Entreprise + Catégorie.
| Champ | Type composant | Obligatoire | Règle |
|---|---|---|---|
| **Nom du fournisseur (Entreprise)** | `<MalioInputText>` | Oui | RG-2.12 (UPPERCASE serveur) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type FOURNISSEUR** via `GET /api/categories?typeCode=FOURNISSEUR` (RG-2.10). Libellé affiché = `category.name`. ⚠️ Le type + le filtre `?typeCode=` sont **à créer** côté back (n'existent pas en prod — cf. spec-back § 2.4). |
**Action** : « Valider » (`<MalioButton>`) → POST `/api/suppliers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Information ».
### Onglet « Information »
Saisir les informations du fournisseur.
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-2.03 (obligatoire rôle Commerciale) |
| **Concurrent** | `<MalioInputText>` | Conditionnel | RG-2.03 |
| **Date création** (entreprise) | `<input type="date">` (exception Malio — `// TODO migrer`) | Conditionnel | RG-2.03 |
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-2.03 |
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-2.03 |
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
| **Volume Prévisionnel** | `<MalioInputNumber>` | Conditionnel | RG-2.03 (champ spécifique fournisseur) |
> **Disposition maquette** : 3 colonnes — ligne 1 (Description / Concurrent / Date création), ligne 2 (Nombre de salariés / CA / Dirigeant), ligne 3 (Résultat / Volume Prévisionnel).
**Action** : « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:information`).
### Onglet « Contact »
Saisir un ou plusieurs contacts. **(V0.2 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-2.13).
**Bloc Contact** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Nom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
| **Fonction** | `<MalioInputText>` | Non | — |
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-2.12 (format) |
| **Email** | `<MalioInputText>` type email | Non | RG-2.12 (lowercase) |
**RG-2.04 / RG-2.13** : au moins 1 bloc Contact valide (Nom OU Prénom rempli) pour valider l'onglet — l'onglet Contact ne peut pas être finalisé vide.
**Actions** :
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas Prénom OU Nom** (RG-2.04).
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
- « Valider » → PATCH `/api/suppliers/{id}/contacts`.
### Onglet « Adresse »
Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
**Bloc Adresse** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Type d'adresse** | `<MalioRadioButton>``Prospect` / `Départ` / `Rendu` | Oui | RG-2.09 (exclusif, enum `PROSPECT`/`DEPART`/`RENDU`) |
| **Pays** | `<MalioSelect>` (saisie assistée — préremplie « France ») | Oui | — |
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — déclenche autocomplete ville (BAN) |
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-2.05 — alimentée par api-adresse.data.gouv.fr suivant le CP |
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — autocomplete BAN |
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-2.06 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100/17400/82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M). |
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type FOURNISSEUR (RG-2.10), liées aux catégories du fournisseur |
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
| **Benne(s)** | `<MalioInputNumber>` (stepper /+ , défaut 0) | Non | Champ spécifique fournisseur |
| **Prestation de triage** | `<MalioCheckbox>` | Non | Champ spécifique fournisseur (porté par l'adresse — colonne back `triage_provider`) |
> **Disposition maquette par bloc** : ligne 1 = radio (Prospect / Départ / Rendu) + Pays + Code postal ; ligne 2 = Ville + Adresse + Adresse complémentaire ; ligne 3 = sites (86 / 17 / 82) + Catégories + Contact ; ligne 4 = Benne(s) + Prestation de triage. Icône corbeille en haut à droite de chaque bloc pour le supprimer.
**Actions** :
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
- « Supprimer » : modal de confirmation puis suppression.
- « Valider » → PATCH `/api/suppliers/{id}/addresses`.
### Onglet « Transport »
🚧 **Onglet placeholder minimal au M2.** Conforme à la maquette : la frame est **vide** (aucun champ, aucun bouton de validation, aucune API back). L'onglet reste navigable. Un libellé discret « À venir » est toléré mais non requis (la maquette ne l'affiche pas). Cet onglet **fait partie de la barre de création** (entre Adresses et Comptabilité).
### Onglet « Comptabilité »
**Accessible aux rôles avec `commercial.suppliers.accounting.view`** (Admin + Compta au M2). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un fournisseur (pas de `manage` global).
**Champs comptables** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. § 2.6) |
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel M1) |
| **N° de TVA** | `<MalioInputText>` | Oui | — |
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
| **Banque** | `<MalioSelect>` | Conditionnel | RG-2.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-2.08) :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
**Actions** :
- « + RIB » : ajoute un bloc.
- « Supprimer » (icône) : modal de confirmation.
- « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:accounting`) + sous-ressource RIBs.
### Onglets « Statistiques » / « Rapports » / « Échanges »
🚧 **Placeholders minimaux au M2 — uniquement en Consultation / Modification** (ils n'apparaissent **pas** dans le flux de création, cf. maquette). Frames vides, pas de validation, pas d'API.
## Écran « Consultation fournisseur »
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
- **Flèche retour** (gauche) → revient au Répertoire.
- **Bouton « Modifier »** (droite, visible si `commercial.suppliers.manage`) → écran Modification.
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `commercial.suppliers.archive`) → modal de confirmation, puis PATCH `/api/suppliers/{id}` `{ "isArchived": true }`.
> Un fournisseur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
### Onglets affichés en consultation
Information / Contacts / Adresses / Transport / Statistiques / Rapports / Échanges / Comptabilité (les 4 derniers métiers en placeholder « À venir », Comptabilité selon permission). L'utilisateur navigue **librement** entre les onglets (pas de séquence forcée en consultation).
## Écran « Modification fournisseur »
Comportement identique à l'écran Ajouter sauf :
- **Pas de formulaire principal** réaffiché (champs principaux édités via les onglets correspondants).
- Les champs sont **pré-remplis** avec les valeurs actuelles.
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
- Les onglets placeholders « À venir » restent non éditables.
## Composants UI à utiliser (`@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
- **Input texte** : `<MalioInputText>`
- **Input numérique** : `<MalioInputNumber>` (Nombre de salariés, Volume prévisionnel, Bennes)
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
- **TextArea** : `<MalioInputTextArea>` (Description)
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
- **Radio** : `<MalioRadioButton>` (Type d'adresse Prospect / Départ / Rendu — RG-2.09)
- **Checkbox** : `<MalioCheckbox>` (Prestataire de triage)
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
- **Toasts** : standards via `useApi()`
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
- `<input type="date">` pour « Date Création » (`MalioDate` non couvert).
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1 si présent).
## Composables & appels API
- `usePaginatedList<Supplier>({ url: '/suppliers' })` — liste paginée (obligatoire, règle frontend). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cohérence M1/ERP-62, cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)). Côté back, fetch-joins anti-N+1.
- `useSupplier(id)` — charge le détail via `GET /api/suppliers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Les écrans Consultation et Modification se peuplent depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1 d'appels). **DoD avant intégration** : vérifier que le JSON réel contient bien ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
- `useSupplierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useClientForm()`.
- `useAddressAutocomplete()`**réutilisé du M1** (BAN), pas de réécriture.
- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
- Filter `formatPhoneFR()`**réutilisé du M1** pour l'affichage `XX XX XX XX XX`.
## Règles de formatage et normalisation
Le serveur normalise systématiquement (RG-2.12 — cf. [`spec-back.md`](./spec-back.md)) :
| Champ | Normalisation serveur | Affichage front |
|---|---|---|
| Nom fournisseur (`companyName`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize | identique |
| Téléphones (blocs `SupplierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
| Email | lowercase intégral | identique |
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche. Cohérent avec `useApi()`.
## API adresse postale
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1** (réutilisé tel quel) :
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville.
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
## Différences notables avec le M1 (clients)
| Zone | M1 clients | M2 fournisseurs |
|---|---|---|
| Distributeur / Courtier | Auto-référence Client (RG-1.03) | **Absent** |
| Prestation de triage | Booléen sur le client (formulaire principal) | **Booléen sur l'adresse** (`triage_provider`) |
| Type d'adresse | 3 checkboxes Prospect / Livraison / Facturation | **Radio exclusif** Prospect / Départ / Rendu (RG-2.09) |
| Email facturation sur adresse | Oui (conditionnel) | **Absent** |
| Champ adresse « Bennes » | — | **Présent** (nombre) |
| Onglet Information | 7 champs | **8 champs** (ajout « Volume prévisionnel ») |
| Catégories | type unique `CLIENT` (codes ERP-78) | **nouveau type `FOURNISSEUR`** |
| Archivage | Admin | **Admin uniquement** (idem) |
| Onglets « À venir » | frames blanches | **placeholder « À venir »** (minimal) |
## Points résolus côté back
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Catégorie multi-select | M2M `supplier_category`, `Category` de type **FOURNISSEUR** (RG-2.10) |
| 2 | Type d'adresse Prospect/Départ/Rendu | Enum exclusif `address_type` (RG-2.09) |
| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Transport / Stats / Rapports / Échanges) |
| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
| 7 | Unicité métier | Nom de fournisseur uniquement (à valider — § 2.6). SIREN/email non uniques |
| 8 | Référentiels comptables | Réutilisés du M1 (zéro duplication) |
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1 |
| 10 | Format export | XLSX uniquement (CSV = HP) |
---
## 📦 Tickets Lesstime
**TaskGroup Lesstime** : à créer — `M2 — Répertoire fournisseurs` (projet `ERP / Starseed`, projectId=6).
> Détail complet et action manuelle → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
+2 -2
View File
@@ -24,10 +24,10 @@
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0"> <div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<SiteSelector v-if="showSiteSelector"/> <SiteSelector v-if="showSiteSelector"/>
<main <main
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11"> class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-[170px]">
<div <div
aria-hidden="true" aria-hidden="true"
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/> class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
<slot/> <slot/>
</main> </main>
</div> </div>
+6 -172
View File
@@ -10,11 +10,7 @@
"confirm": "Confirmer", "confirm": "Confirmer",
"yes": "Oui", "yes": "Oui",
"no": "Non", "no": "Non",
"actions": "Actions", "actions": "Actions"
"comingSoon": {
"title": "En cours de dev",
"subtitle": "Cette fonctionnalité arrive bientôt."
}
}, },
"sidebar": { "sidebar": {
"administration": { "administration": {
@@ -48,164 +44,7 @@
}, },
"commercial": { "commercial": {
"title": "Commercial", "title": "Commercial",
"welcome": "Module Commercial", "welcome": "Module Commercial"
"clients": {
"title": "Répertoire clients",
"add": "Ajouter",
"export": "Exporter",
"empty": "Aucun client pour l'instant.",
"column": {
"companyName": "Nom",
"categories": "Catégories",
"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",
"contact": "Contact",
"address": "Adresse",
"transport": "Transport",
"accounting": "Comptabilité",
"statistics": "Statistiques",
"reports": "Rapports",
"exchanges": "Échanges"
},
"action": {
"edit": "Modifier",
"archive": "Archiver",
"restore": "Restaurer"
},
"toast": {
"createSuccess": "Client créé avec succès",
"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.",
"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.",
"contactRequired": "Au moins un contact (nom ou prénom) est obligatoire.",
"siteRequired": "Au moins un site Starseed doit être rattaché à l'adresse.",
"billingEmailRequired": "L'email de facturation est obligatoire pour une adresse de facturation.",
"bankRequiredForTransfer": "La banque est obligatoire pour un règlement par virement.",
"ribRequiredForLcr": "Au moins un RIB complet est obligatoire pour un règlement par LCR.",
"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"
}
}
}
}, },
"auth": { "auth": {
"login": "Connexion", "login": "Connexion",
@@ -243,15 +82,10 @@
"delete": "Suppression" "delete": "Suppression"
}, },
"entity": { "entity": {
"core_user": "Utilisateur", "core_user": "Utilisateur",
"core_role": "Rôle", "core_role": "Rôle",
"core_permission": "Permission", "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", "empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres", "no_results": "Aucun résultat pour ces filtres",
@@ -12,7 +12,7 @@
</h2> </h2>
</template> </template>
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave"> <form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim). <!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. --> Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
<MalioInputText <MalioInputText
@@ -52,21 +52,21 @@
variant="danger" variant="danger"
icon-name="mdi:delete-outline" icon-name="mdi:delete-outline"
icon-position="left" icon-position="left"
button-class="w-m-btn-action" button-class="w-[150px]"
@click="emit('delete')" @click="emit('delete')"
/> />
<MalioButton <MalioButton
v-else v-else
:label="t('common.cancel')" :label="t('common.cancel')"
variant="tertiary" variant="tertiary"
button-class="w-m-btn-action" button-class="w-[150px]"
@click="emit('update:modelValue', false)" @click="emit('update:modelValue', false)"
/> />
<MalioButton <MalioButton
v-if="canShowSave" v-if="canShowSave"
:label="t('common.save')" :label="t('common.save')"
variant="primary" variant="primary"
button-class="w-m-btn-action" button-class="w-[150px]"
:disabled="form.submitting.value || loadingTypes" :disabled="form.submitting.value || loadingTypes"
@click="handleSave" @click="handleSave"
/> />
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { CategoryType } from '~/modules/catalog/types/category' import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api' import type { HydraCollection } from '~/shared/utils/api'
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via // Mock du store auth : useCategoriesAdmin s'auto-enregistre via
@@ -28,6 +28,27 @@ const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' } const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' } const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT_A: Category = {
id: 10,
name: 'Vis',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
const CAT_B: Category = {
id: 11,
name: 'Boulons',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-02T10:00:00+00:00',
updatedAt: '2026-01-02T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
function makeHydra<T>(items: T[]): HydraCollection<T> { function makeHydra<T>(items: T[]): HydraCollection<T> {
return { return {
totalItems: items.length, totalItems: items.length,
@@ -35,32 +56,113 @@ function makeHydra<T>(items: T[]): HydraCollection<T> {
} }
} }
/**
* Apres ERP-73, `useCategoriesAdmin` ne porte plus la liste paginee des
* categories (elle est geree par `usePaginatedList<Category>` cote page).
* Le composable se concentre sur le referentiel CategoryType (lecture
* seule, ≤ 5 entrees connues) charge en une fois via `?pagination=false`.
*/
describe('useCategoriesAdmin', () => { describe('useCategoriesAdmin', () => {
beforeEach(() => { beforeEach(() => {
mockGet.mockReset() mockGet.mockReset()
// Reset systematique du state singleton entre tests : sans ca, // Reset systematique du state singleton entre tests : sans ca,
// les types charges dans un test fuiteraient dans le suivant. // les categories chargees dans un test fuiteraient dans le suivant.
const { resetCategoriesAdmin } = useCategoriesAdmin() const { resetCategoriesAdmin } = useCategoriesAdmin()
resetCategoriesAdmin() resetCategoriesAdmin()
}) })
describe('fetchAll', () => {
it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999 },
{ toast: false },
)
})
it('peuple categories.value depuis le champ Hydra member', async () => {
mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([CAT_A, CAT_B])
})
it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
const queryArg = mockGet.mock.calls[0]?.[1] as Record<string, unknown>
expect(queryArg).not.toHaveProperty('includeDeleted')
})
it('ajoute includeDeleted=true quand demande explicitement', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll(true)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999, includeDeleted: 'true' },
{ toast: false },
)
})
it('passe loading a true pendant la requete et false apres', async () => {
let resolveRequest: (v: HydraCollection<Category>) => void = () => {}
mockGet.mockImplementationOnce(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const { fetchAll, loading } = useCategoriesAdmin()
const pending = fetchAll()
expect(loading.value).toBe(true)
resolveRequest(makeHydra<Category>([]))
await pending
expect(loading.value).toBe(false)
})
it('peuple error.value et vide categories en cas d echec', async () => {
mockGet.mockRejectedValueOnce(new Error('Network down'))
const { fetchAll, categories, error, loading } = useCategoriesAdmin()
// Pre-charge volontairement quelque chose pour verifier la purge.
categories.value = [CAT_A]
await fetchAll()
expect(categories.value).toEqual([])
expect(error.value).toBe('Network down')
expect(loading.value).toBe(false)
})
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
mockGet.mockResolvedValueOnce({
totalItems: 0,
} as unknown as HydraCollection<Category>)
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([])
})
})
describe('fetchTypes', () => { describe('fetchTypes', () => {
it('appelle GET /category_types avec ?pagination=false (echappatoire selects)', async () => { it('appelle GET /category_types avec itemsPerPage=999', async () => {
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([])) mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
const { fetchTypes } = useCategoriesAdmin() const { fetchTypes } = useCategoriesAdmin()
await fetchTypes() await fetchTypes()
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith( expect(mockGet).toHaveBeenCalledWith(
'/category_types', '/category_types',
{ pagination: 'false' }, { itemsPerPage: 999 },
{ toast: false }, { toast: false },
) )
}) })
@@ -101,55 +203,48 @@ describe('useCategoriesAdmin', () => {
expect(loadingTypes.value).toBe(false) expect(loadingTypes.value).toBe(false)
}) })
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
mockGet.mockResolvedValueOnce({
totalItems: 0,
} as unknown as HydraCollection<CategoryType>)
const { fetchTypes, types } = useCategoriesAdmin()
await fetchTypes()
expect(types.value).toEqual([])
})
}) })
describe('resetCategoriesAdmin', () => { describe('resetCategoriesAdmin', () => {
it('vide types, loadingTypes et error', () => { it('vide categories, types, loading, loadingTypes et error', () => {
const { resetCategoriesAdmin, types, loadingTypes, error } const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
= useCategoriesAdmin() = useCategoriesAdmin()
// Pre-peuple le state pour verifier la purge effective. // Pre-peuple le state pour verifier la purge effective.
categories.value = [CAT_A]
types.value = [TYPE_VENTE] types.value = [TYPE_VENTE]
loading.value = true
loadingTypes.value = true loadingTypes.value = true
error.value = 'oops' error.value = 'oops'
resetCategoriesAdmin() resetCategoriesAdmin()
expect(categories.value).toEqual([])
expect(types.value).toEqual([]) expect(types.value).toEqual([])
expect(loading.value).toBe(false)
expect(loadingTypes.value).toBe(false) expect(loadingTypes.value).toBe(false)
expect(error.value).toBeNull() expect(error.value).toBeNull()
}) })
}) })
describe('singleton', () => { describe('singleton', () => {
it('deux appels a useCategoriesAdmin() partagent la meme ref types', () => { it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
const a = useCategoriesAdmin() const a = useCategoriesAdmin()
const b = useCategoriesAdmin() const b = useCategoriesAdmin()
// Les fonctions sont reinstanciees a chaque appel mais les refs // Les fonctions sont reinstanciees a chaque appel mais les refs
// doivent etre rigoureusement les memes (state au niveau module). // doivent etre rigoureusement les memes (state au niveau module).
expect(a.categories).toBe(b.categories)
expect(a.types).toBe(b.types) expect(a.types).toBe(b.types)
expect(a.loadingTypes).toBe(b.loadingTypes) expect(a.loading).toBe(b.loading)
expect(a.error).toBe(b.error)
}) })
it('une mutation via une instance est visible depuis une autre instance', () => { it('une mutation via une instance est visible depuis une autre instance', () => {
const a = useCategoriesAdmin() const a = useCategoriesAdmin()
const b = useCategoriesAdmin() const b = useCategoriesAdmin()
a.types.value = [TYPE_VENTE] a.categories.value = [CAT_A]
expect(b.types.value).toEqual([TYPE_VENTE]) expect(b.categories.value).toEqual([CAT_A])
}) })
}) })
}) })
@@ -1,56 +1,96 @@
/** /**
* Composable de chargement du referentiel CategoryType (M0 — Gestion des * Composable d'administration des categories (M0 — Gestion des categories).
* categories).
* *
* Apres ERP-73 (composable de liste paginee), la liste des categories * Centralise le chargement et le state des deux ressources lues par la page
* elle-meme passe par `usePaginatedList<Category>` directement dans * `/admin/categories` : la liste des categories et le referentiel
* `admin/categories.vue` — c'est un etat propre a la page (pagination, * CategoryType (utilise dans le select du drawer).
* filtres, tri locaux). Ce composable se concentre donc sur le
* referentiel CategoryType : petite collection lue une fois et reutilisee
* dans le drawer (select de type) → singleton volontaire pour eviter de
* la recharger a chaque ouverture du drawer.
* *
* State singleton au niveau module : reset automatique au logout via * State singleton au niveau module (meme convention que `useSidebar` /
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset * `useModules` / `useAuditLog`) : reset automatique au logout via
* explicite via `resetCategoriesAdmin()` appele depuis logout.vue. * `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
* avec state singleton doivent etre reinitialises au logout »), et reset
* explicite expose via `resetCategoriesAdmin()` appele depuis
* `modules/core/pages/logout.vue`.
*/ */
import { ref } from 'vue' import { ref } from 'vue'
import type { CategoryType } from '~/modules/catalog/types/category' import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api' import type { HydraCollection } from '~/shared/utils/api'
import { onAuthSessionCleared } from '~/shared/stores/auth' import { onAuthSessionCleared } from '~/shared/stores/auth'
/** /**
* CategoryType est un referentiel lecture-seule (RG-1.06) avec une * Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
* cardinalite minuscule (5 entrees connues). On force `pagination=false` * cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
* pour recuperer toutes les entrees en un appel et alimenter le select du * toute la liste en un coup. A basculer en pagination serveur quand la
* drawer sans pagination — echappatoire prevue par * volumetrie reelle depassera ce plafondmeme pattern que sites.vue.
* `pagination_client_enabled: true` cote API Platform.
*/ */
const NO_PAGINATION_QUERY = { pagination: 'false' } as const const HYDRA_NO_PAGINATION = 999
// State singleton — partage entre tous les composants qui appellent le
// composable dans la meme session. Les refs sont declarees au niveau module
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
// instance soit creee a chaque appel.
const categories = ref<Category[]>([])
const types = ref<CategoryType[]>([]) const types = ref<CategoryType[]>([])
const loading = ref(false)
const loadingTypes = ref(false) const loadingTypes = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
function resetCategoriesAdminState(): void { function resetCategoriesAdminState(): void {
categories.value = []
types.value = [] types.value = []
loading.value = false
loadingTypes.value = false loadingTypes.value = false
error.value = null error.value = null
} }
// Auto-enregistrement singleton : purge le state sur 401/clearSession // Auto-enregistrement singleton : purge le state sur 401/clearSession pour
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le // eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue) // l'ancien. Le logout volontaire (page logout.vue) appelle directement
// appelle directement `resetCategoriesAdmin()` ci-dessous. // `resetCategoriesAdmin()` ci-dessous.
onAuthSessionCleared(resetCategoriesAdminState) onAuthSessionCleared(resetCategoriesAdminState)
export function useCategoriesAdmin() { export function useCategoriesAdmin() {
const api = useApi() const api = useApi()
/** /**
* Charge le referentiel CategoryType. Appele a l'ouverture de la page * Charge la liste des categories. Le serveur exclut les soft-deleted par
* admin pour que le select du drawer ait deja les options pretes au * defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
* moment de la creation/edition. * serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
*
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
* de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
* option mais on l'expose pour la suite (corbeille future).
*
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
* toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
* la page seulement par URL directe et on affiche un tableau vide propre.
*/
async function fetchAll(includeDeleted = false): Promise<void> {
loading.value = true
error.value = null
try {
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
if (includeDeleted) {
query.includeDeleted = 'true'
}
const data = await api.get<HydraCollection<Category>>(
'/categories',
query,
{ toast: false },
)
categories.value = data.member ?? []
} catch (e) {
categories.value = []
error.value = (e as Error)?.message ?? 'Erreur de chargement'
} finally {
loading.value = false
}
}
/**
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
* l'ouverture de la page admin pour que le select du drawer ait deja les
* options pretes au moment de la creation/edition.
* *
* Toast desactive : on stocke l'erreur dans `error` plutot que de * Toast desactive : on stocke l'erreur dans `error` plutot que de
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu. * spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
@@ -60,7 +100,7 @@ export function useCategoriesAdmin() {
try { try {
const data = await api.get<HydraCollection<CategoryType>>( const data = await api.get<HydraCollection<CategoryType>>(
'/category_types', '/category_types',
NO_PAGINATION_QUERY, { itemsPerPage: HYDRA_NO_PAGINATION },
{ toast: false }, { toast: false },
) )
types.value = data.member ?? [] types.value = data.member ?? []
@@ -73,18 +113,21 @@ export function useCategoriesAdmin() {
} }
/** /**
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()` * Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
* pour garantir que la prochaine session reparte sur un state propre * garantir que la prochaine session reparte sur un state propre meme si
* meme si `clearSession()` n'a pas ete declenche (cas logout volontaire). * `clearSession()` n'a pas ete declenche (cas logout volontaire).
*/ */
function resetCategoriesAdmin(): void { function resetCategoriesAdmin(): void {
resetCategoriesAdminState() resetCategoriesAdminState()
} }
return { return {
categories,
types, types,
loading,
loadingTypes, loadingTypes,
error, error,
fetchAll,
fetchTypes, fetchTypes,
resetCategoriesAdmin, resetCategoriesAdmin,
} }
@@ -13,23 +13,18 @@
</template> </template>
</PageHeader> </PageHeader>
<!-- Table des categories. Tri serveur (name ASC, RG-1.10) + <!-- Table des categories. Affichage exhaustif (volumetrie cible
pagination serveur via usePaginatedList (#73). Le composable <= 300, cf. spec § 4.1) tri 100% serveur via CategoryProvider
remplace l'ancien chargement « tout en un coup » a volumetrie (name ASC, RG-1.10). La barre de pagination du MalioDataTable
cible ≤ 300 — la pagination est desormais alignee sur la regle reste cosmetique tant qu'aucun slice client n'est cable : a
projet (toute collection paginee, regle ABSOLUE n°13). --> traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
<MalioDataTable <MalioDataTable
:columns="columns" :columns="columns"
:items="categoryItems" :items="categoryItems"
:total-items="totalItems" :total-items="categories.length"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:row-clickable="true" :row-clickable="true"
:empty-message="t('admin.categories.noCategories')" :empty-message="t('admin.categories.noCategories')"
@row-click="onRowClick" @row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
/> />
<!-- Drawer creation / consultation / edition. --> <!-- Drawer creation / consultation / edition. -->
@@ -55,27 +50,13 @@ import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n() const { t } = useI18n()
const { can } = usePermissions() const { can } = usePermissions()
const { fetchTypes } = useCategoriesAdmin() const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm() const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') }) useHead({ title: t('admin.categories.title') })
const canManage = computed(() => can('catalog.categories.manage')) const canManage = computed(() => can('catalog.categories.manage'))
// Pagination serveur via le composable partage (#73). Le CategoryProvider
// applique deja name ASC (RG-1.10) — pas besoin de defaultSort cote front
// tant qu'aucun OrderFilter n'est expose.
const {
items: categories,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: fetchCategories,
goToPage,
setItemsPerPage,
} = usePaginatedList<Category>({ url: '/categories' })
const drawerOpen = ref(false) const drawerOpen = ref(false)
const selectedCategory = ref<Category | null>(null) const selectedCategory = ref<Category | null>(null)
const deleteModalOpen = ref(false) const deleteModalOpen = ref(false)
@@ -137,7 +118,7 @@ async function handleDelete(): Promise<void> {
deleteModalOpen.value = false deleteModalOpen.value = false
categoryToDelete.value = null categoryToDelete.value = null
drawerOpen.value = false drawerOpen.value = false
await fetchCategories() await fetchAll()
} }
} finally { } finally {
deleting.value = false deleting.value = false
@@ -145,14 +126,14 @@ async function handleDelete(): Promise<void> {
} }
function onCategorySaved() { function onCategorySaved() {
fetchCategories() fetchAll()
} }
// Chargement initial des deux ressources (liste + referentiel des types). // Chargement initial des deux ressources (liste + referentiel des types).
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le // Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ». // select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
onMounted(() => { onMounted(() => {
fetchCategories() fetchAll()
fetchTypes() fetchTypes()
}) })
</script> </script>
@@ -1,328 +0,0 @@
<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>
@@ -1,97 +0,0 @@
<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>
@@ -1,76 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, computed } from 'vue'
import { emptyAddress } from '~/modules/commercial/types/clientForm'
import ClientAddressBlock from '../ClientAddressBlock.vue'
// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee.
// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions
// vide » (remontage apres validation / edition d'une adresse existante).
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
searchCity: vi.fn(),
searchAddress: vi.fn(),
}),
}))
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
// Stub de MalioInputAutocomplete : expose les `value` des options recues, pour
// verifier que la rue courante figure bien dans la liste (sinon le composant
// Malio ne peut pas resoudre/afficher la valeur liee -> champ vide).
const MalioInputAutocompleteStub = defineComponent({
name: 'MalioInputAutocomplete',
props: {
modelValue: { type: [String, Number, null], default: undefined },
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
loading: { type: Boolean, default: false },
minSearchLength: { type: Number, default: 0 },
label: { type: String, default: '' },
readonly: { type: Boolean, default: false },
},
emits: ['update:modelValue', 'search', 'select'],
setup(props) {
return () => h('div', {
'data-testid': 'addr-autocomplete',
'data-options': JSON.stringify(props.options.map(o => o.value)),
})
},
})
function mountBlock(street: string | null) {
return mount(ClientAddressBlock, {
props: {
modelValue: { ...emptyAddress(), street },
title: 'Adresse',
categoryOptions: [],
siteOptions: [],
contactOptions: [],
countryOptions: [],
},
global: {
stubs: {
MalioButtonIcon: true,
MalioCheckbox: true,
MalioSelect: true,
MalioSelectCheckbox: true,
MalioInputText: true,
MalioInputAutocomplete: MalioInputAutocompleteStub,
},
},
})
}
describe('ClientAddressBlock — affichage de l\'adresse persistee', () => {
it('inclut la rue courante dans les options de l\'autocomplete meme sans recherche BAN', () => {
const wrapper = mountBlock('8 Boulevard du Port')
const el = wrapper.find('[data-testid="addr-autocomplete"]')
const values = JSON.parse(el.attributes('data-options') ?? '[]')
expect(values).toContain('8 Boulevard du Port')
})
})
@@ -1,95 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
const mockGet = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
const { useClient } = await import('../useClient')
const SAMPLE = { '@id': '/api/clients/42', id: 42, companyName: 'ACME', isArchived: false }
describe('useClient', () => {
beforeEach(() => {
mockGet.mockReset()
mockPatch.mockReset()
mockGet.mockResolvedValue(SAMPLE)
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
})
it('charge le detail via GET /clients/{id} en Hydra, sans toast', async () => {
const { client, load } = useClient(42)
await load()
expect(mockGet).toHaveBeenCalledWith(
'/clients/42',
{},
expect.objectContaining({
headers: { Accept: 'application/ld+json' },
toast: false,
}),
)
expect(client.value).toEqual(SAMPLE)
})
it('bascule loading pendant le chargement et le retombe a false', async () => {
const { loading, load } = useClient(42)
const promise = load()
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
})
it('marque error et laisse client null si le GET echoue (404...)', async () => {
mockGet.mockRejectedValueOnce(new Error('not found'))
const { client, error, load } = useClient(99)
await load()
expect(error.value).toBe(true)
expect(client.value).toBeNull()
})
it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
// 1er GET = chargement initial, 2e GET = rechargement post-archivage.
mockGet.mockResolvedValueOnce(SAMPLE)
mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
const { client, load, archive } = useClient(42)
await load()
await archive()
expect(mockPatch).toHaveBeenCalledWith(
'/clients/42',
{ isArchived: true },
expect.objectContaining({ toast: false }),
)
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
expect(mockGet).toHaveBeenCalledTimes(2)
expect(client.value?.isArchived).toBe(true)
})
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
const { load, restore } = useClient(42)
await load()
await restore()
expect(mockPatch).toHaveBeenCalledWith(
'/clients/42',
{ isArchived: false },
expect.objectContaining({ toast: false }),
)
})
it('propage l\'erreur (ex: 409 conflit homonyme RG-1.23) au lieu de l\'avaler', async () => {
const conflict = { response: { status: 409 } }
mockPatch.mockRejectedValueOnce(conflict)
const { load, restore } = useClient(42)
await load()
await expect(restore()).rejects.toBe(conflict)
})
})
@@ -1,74 +0,0 @@
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' }])
})
})
@@ -1,85 +0,0 @@
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 }),
)
})
})
@@ -1,70 +0,0 @@
import { ref } from 'vue'
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
/**
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
* client », 1.11). Lit le detail embarque via `GET /api/clients/{id}` (contacts /
* adresses / ribs sous `client:item:read` / `client:read:accounting`) et expose
* les bascules d'archivage (PATCH `isArchived` SEUL — tout autre champ => 422).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
* Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs
* d'archivage/restauration (notamment le 409 RG-1.23 : homonyme actif a la
* restauration) sont PROPAGEES a l'appelant, qui decide du toast a afficher.
*/
export function useClient(id: number | string) {
const api = useApi()
const client = ref<ClientDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
function fetchDetail(): Promise<ClientDetail> {
return api.get<ClientDetail>(
`/clients/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le detail du client. En cas d'echec : `error = true`, `client = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
client.value = await fetchDetail()
}
catch {
error.value = true
client.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422),
* puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
* `client:read` (ni l'embed contacts/adresses/ribs ni les libelles des
* referentiels comptables), un simple merge laisserait l'affichage incoherent.
* Toute erreur (notamment le 409 d'homonyme actif a la restauration, RG-1.23)
* est propagee a l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/clients/${id}`, { isArchived }, { toast: false })
client.value = await fetchDetail()
}
return {
client,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -1,151 +0,0 @@
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,
}
}
@@ -1,53 +0,0 @@
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' })
}
@@ -1,879 +0,0 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du client. -->
<div class="flex items-center gap-3">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
@click="goBack"
/>
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.edit.notFound') }}</p>
<template v-else-if="client">
<!-- Bloc principal (pre-rempli, editable si `manage`)
Decision Tristan : on conserve le bloc principal en modification
(« pour ne pas tout casser »), edite via son propre PATCH scope
sur le groupe client:write:main. Readonly pour les roles sans
`manage` (ex. Compta). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
/>
<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>
@@ -1,471 +0,0 @@
<template>
<div>
<!-- En-tete : retour repertoire + nom du client + actions (Modifier / Archiver|Restaurer). -->
<div class="flex items-center gap-3">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[32px] font-bold text-m-primary">{{ headerTitle }}</h1>
<!-- gap-12 = 48px : meme espacement que Ajouter / Filtres du repertoire. -->
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canEdit"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('commercial.clients.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('commercial.clients.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('commercial.clients.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('commercial.clients.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('commercial.clients.consultation.notFound') }}</p>
<template v-else-if="client">
<!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:model-value="client.companyName"
:label="t('commercial.clients.form.main.companyName')"
readonly
/>
<MalioSelectCheckbox
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
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>
@@ -1,421 +0,0 @@
<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>
@@ -1,892 +0,0 @@
<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>
@@ -1,98 +0,0 @@
/**
* 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,
}
}
@@ -1,235 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
canEditClient,
categoryOptionsOf,
contactOptionsOf,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
referentialOptionOf,
relationOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
type ClientDetail,
} from '../clientConsultation'
describe('iriOf', () => {
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
expect(iriOf({ '@id': '/api/payment_types/10', code: 'LCR' })).toBe('/api/payment_types/10')
})
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
})
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
})
describe('relationOf', () => {
it('detecte une relation distributeur et expose son nom', () => {
const client = { distributor: { '@id': '/api/clients/15', companyName: 'DISTRIB GRAND SUD-OUEST' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'distributeur', name: 'DISTRIB GRAND SUD-OUEST' })
})
it('detecte une relation courtier et expose son nom', () => {
const client = { broker: { '@id': '/api/clients/16', companyName: 'CABINET LEONARD' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'courtier', name: 'CABINET LEONARD' })
})
it('retourne type null quand aucune relation n\'est posee (cles omises)', () => {
expect(relationOf({} as ClientDetail)).toEqual({ type: null, name: null })
})
})
describe('mapContactToDraft', () => {
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/18',
id: 18,
firstName: 'Sophie',
lastName: 'Léonard',
jobTitle: 'Gérante',
phonePrimary: '0549112233',
email: 'sophie@x.fr',
})
expect(draft.id).toBe(18)
expect(draft.iri).toBe('/api/client_contacts/18')
expect(draft.phonePrimary).toBe('05 49 11 22 33')
expect(draft.hasSecondaryPhone).toBe(false)
})
it('revele le 2e telephone quand phoneSecondary est present', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/19',
id: 19,
phonePrimary: '0600000000',
phoneSecondary: '0611111111',
})
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
})
})
describe('mapAddressToDraft', () => {
it('extrait les iris de sites / categories / contacts (objets ou chaines)', () => {
const draft = mapAddressToDraft({
'@id': '/api/client_addresses/18',
id: 18,
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '5 rue des Courtiers',
billingEmail: 'factures@x.fr',
isProspect: false,
isDelivery: false,
isBilling: true,
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#056CF2' }],
categories: [{ '@id': '/api/categories/3', code: 'SECTEUR' }],
contacts: [{ '@id': '/api/client_contacts/18' }, '/api/client_contacts/20'],
})
expect(draft.siteIris).toEqual(['/api/sites/4'])
expect(draft.categoryIris).toEqual(['/api/categories/3'])
expect(draft.contactIris).toEqual(['/api/client_contacts/18', '/api/client_contacts/20'])
expect(draft.isBilling).toBe(true)
expect(draft.city).toBe('Châtellerault')
expect(draft.country).toBe('France')
})
it('tolere les sous-collections absentes (defaut tableau vide, pays France)', () => {
const draft = mapAddressToDraft({ '@id': '/api/client_addresses/9', id: 9 })
expect(draft.siteIris).toEqual([])
expect(draft.categoryIris).toEqual([])
expect(draft.contactIris).toEqual([])
expect(draft.country).toBe('France')
expect(draft.isBilling).toBe(false)
})
})
describe('mapRibToDraft', () => {
it('mappe label / bic / iban et l\'id serveur', () => {
const draft = mapRibToDraft({ '@id': '/api/client_ribs/3', id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
expect(draft).toEqual({ id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
})
})
describe('mapAccountingDraft', () => {
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
const acc = mapAccountingDraft({
'@id': '/api/clients/1',
id: 1,
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/1' },
paymentDelay: { '@id': '/api/payment_delays/2' },
paymentType: { '@id': '/api/payment_types/10', code: 'LCR' },
bank: { '@id': '/api/banks/3' },
} as ClientDetail)
expect(acc).toEqual({
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/2',
paymentTypeIri: '/api/payment_types/10',
bankIri: '/api/banks/3',
})
})
it('renvoie des null quand les champs comptables sont absents (sans accounting.view)', () => {
const acc = mapAccountingDraft({} as ClientDetail)
expect(acc).toEqual({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
})
})
})
describe('options construites depuis l\'embed (role-independantes)', () => {
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }])).toEqual([
{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' },
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/4', label: 'Chatellerault' },
])
})
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
expect(contactOptionsOf([
{ '@id': '/api/client_contacts/1', id: 1, firstName: 'Jean', lastName: 'Dupont' },
{ '@id': '/api/client_contacts/2', id: 2, email: 'a@b.fr' },
])).toEqual([
{ value: '/api/client_contacts/1', label: 'Jean Dupont' },
{ value: '/api/client_contacts/2', label: 'a@b.fr' },
])
})
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
expect(referentialOptionOf({ '@id': '/api/payment_types/10', label: 'LCR' })).toEqual([
{ value: '/api/payment_types/10', label: 'LCR' },
])
expect(referentialOptionOf('/api/banks/3')).toEqual([])
expect(referentialOptionOf(null)).toEqual([])
})
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
const view = mapAddressView({
'@id': '/api/client_addresses/18',
id: 18,
city: 'Châtellerault',
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault' }],
categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }],
})
expect(view.draft.id).toBe(18)
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }])
})
})
describe('canEditClient', () => {
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('visible pour manage', () => {
expect(canEditClient(can(['commercial.clients.manage']))).toBe(true)
})
it('visible pour accounting.manage (role Compta)', () => {
expect(canEditClient(can(['commercial.clients.accounting.manage']))).toBe(true)
})
it('masque sans aucune des deux permissions (role Usine)', () => {
expect(canEditClient(can(['commercial.clients.view']))).toBe(false)
})
})
describe('showArchiveAction / showRestoreAction', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
it('Archiver : visible avec la permission archive ET client non archive', () => {
expect(showArchiveAction(can(['commercial.clients.archive']), false)).toBe(true)
expect(showArchiveAction(can(['commercial.clients.archive']), true)).toBe(false)
expect(showArchiveAction(can([]), false)).toBe(false)
})
it('Restaurer : visible avec la permission archive ET client archive', () => {
expect(showRestoreAction(can(['commercial.clients.archive']), true)).toBe(true)
expect(showRestoreAction(can(['commercial.clients.archive']), false)).toBe(false)
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
@@ -1,241 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildAccountingPayload,
buildAddressPayload,
buildContactPayload,
buildInformationPayload,
buildMainPayload,
buildRibPayload,
mapAccountingFormDraft,
mapInformationDraft,
mapMainDraft,
resolveTabEditability,
type AccountingFormDraft,
type InformationFormDraft,
type MainFormDraft,
} from '../clientEdit'
import type { ClientDetail } from '../clientConsultation'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
// ── Fabriques de brouillons (valeurs distinctes pour reperer les fuites) ─────
function mainDraft(overrides: Partial<MainFormDraft> = {}): MainFormDraft {
return {
companyName: 'ACME',
categoryIris: ['/api/categories/1'],
relationType: null,
distributorIri: null,
brokerIri: null,
triageService: false,
...overrides,
}
}
function informationDraft(overrides: Partial<InformationFormDraft> = {}): InformationFormDraft {
return {
description: 'desc',
competitors: 'concurrents',
foundedAt: '2010-05-01',
employeesCount: '42',
revenueAmount: '1000000',
profitAmount: '50000',
directorName: 'PDG',
...overrides,
}
}
function accountingDraft(overrides: Partial<AccountingFormDraft> = {}): AccountingFormDraft {
return {
siren: '123456789',
accountNumber: 'C-001',
nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/1',
paymentTypeIri: '/api/payment_types/1',
bankIri: '/api/banks/1',
...overrides,
}
}
// Champs de chaque groupe de serialisation (miroir back ClientProcessor).
// Le contact inline (nom/prenom/telephones/email) ne fait plus partie du groupe
// main : les coordonnees vivent desormais sur la sous-ressource ClientContact.
const MAIN_KEYS = [
'companyName', 'categories', 'distributor', 'broker', 'triageService',
]
const INFORMATION_KEYS = [
'description', 'competitors', 'foundedAt', 'employeesCount',
'revenueAmount', 'profitAmount', 'directorName',
]
const ACCOUNTING_KEYS = ['siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', 'bank']
describe('buildMainPayload — scoping strict groupe client:write:main', () => {
it('n\'expose QUE les champs du groupe main (aucune fuite information/accounting)', () => {
expect(Object.keys(buildMainPayload(mainDraft())).sort()).toEqual([...MAIN_KEYS].sort())
})
it('relation distributeur : renseigne distributor, force broker a null (RG-1.03)', () => {
const payload = buildMainPayload(mainDraft({
relationType: 'distributeur',
distributorIri: '/api/clients/9',
brokerIri: '/api/clients/7',
}))
expect(payload.distributor).toBe('/api/clients/9')
expect(payload.broker).toBeNull()
})
it('relation courtier : renseigne broker, force distributor a null (RG-1.03)', () => {
const payload = buildMainPayload(mainDraft({
relationType: 'courtier',
distributorIri: '/api/clients/9',
brokerIri: '/api/clients/7',
}))
expect(payload.broker).toBe('/api/clients/7')
expect(payload.distributor).toBeNull()
})
it('sans relation : distributor et broker a null', () => {
const payload = buildMainPayload(mainDraft({ relationType: null }))
expect(payload.distributor).toBeNull()
expect(payload.broker).toBeNull()
})
})
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
it('n\'expose QUE les champs du groupe information (aucune fuite main/accounting)', () => {
expect(Object.keys(buildInformationPayload(informationDraft())).sort()).toEqual([...INFORMATION_KEYS].sort())
})
it('convertit employeesCount en nombre et vide -> null', () => {
expect(buildInformationPayload(informationDraft({ employeesCount: '42' })).employeesCount).toBe(42)
expect(buildInformationPayload(informationDraft({ employeesCount: null })).employeesCount).toBeNull()
expect(buildInformationPayload(informationDraft({ employeesCount: '' })).employeesCount).toBeNull()
})
it('chaines vides normalisees en null', () => {
const payload = buildInformationPayload(informationDraft({ description: '', directorName: '' }))
expect(payload.description).toBeNull()
expect(payload.directorName).toBeNull()
})
})
describe('buildAccountingPayload — scoping strict groupe client:write:accounting', () => {
it('n\'expose QUE les champs du groupe accounting (aucune fuite main/information)', () => {
expect(Object.keys(buildAccountingPayload(accountingDraft(), true)).sort()).toEqual([...ACCOUNTING_KEYS].sort())
})
it('banque conservee si requise (Virement), forcee a null sinon (RG-1.12)', () => {
expect(buildAccountingPayload(accountingDraft(), true).bank).toBe('/api/banks/1')
expect(buildAccountingPayload(accountingDraft(), false).bank).toBeNull()
})
})
describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
it('contact : telephone secondaire ignore si non revele', () => {
const contact: ContactFormDraft = {
id: 5, iri: '/api/client_contacts/5', firstName: 'A', lastName: 'B',
jobTitle: null, phonePrimary: '0549112233', phoneSecondary: '0600000000',
email: null, hasSecondaryPhone: false,
}
expect(buildContactPayload(contact).phoneSecondary).toBeNull()
})
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
const address: AddressFormDraft = {
id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France',
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: 'facturation@acme.fr',
}
expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr')
expect(buildAddressPayload(address, false).billingEmail).toBeNull()
})
it('rib : label / bic / iban transmis tels quels', () => {
const rib: RibFormDraft = { id: 1, label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' }
expect(buildRibPayload(rib)).toEqual({ label: 'Compte principal', bic: 'BNPAFRPP', iban: 'FR76...' })
})
})
describe('mapMainDraft — pre-remplissage bloc principal', () => {
it('resout la relation et extrait les IRI (sans contact inline)', () => {
const client = {
'@id': '/api/clients/1', id: 1,
companyName: 'ACME', triageService: true,
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
} as ClientDetail
const draft = mapMainDraft(client)
expect(draft.companyName).toBe('ACME')
expect(draft.categoryIris).toEqual(['/api/categories/1'])
expect(draft.relationType).toBe('distributeur')
expect(draft.distributorIri).toBe('/api/clients/9')
expect(draft.brokerIri).toBeNull()
expect(draft.triageService).toBe(true)
})
it('gere les cles omises (skip_null_values) sans planter', () => {
const draft = mapMainDraft({ '@id': '/api/clients/2', id: 2 } as ClientDetail)
expect(draft.companyName).toBeNull()
expect(draft.categoryIris).toEqual([])
expect(draft.relationType).toBeNull()
expect(draft.triageService).toBe(false)
})
})
describe('mapInformationDraft — pre-remplissage onglet Information', () => {
it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => {
const draft = mapInformationDraft({
'@id': '/api/clients/1', id: 1,
foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000',
} as ClientDetail)
expect(draft.foundedAt).toBe('2010-05-01')
expect(draft.employeesCount).toBe('42')
expect(draft.revenueAmount).toBe('1000000')
})
it('cles omises -> null', () => {
const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail)
expect(draft.foundedAt).toBeNull()
expect(draft.employeesCount).toBeNull()
expect(draft.description).toBeNull()
})
})
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
it('extrait les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingFormDraft({
'@id': '/api/clients/1', id: 1,
siren: '123456789', accountNumber: 'C-001', nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' },
paymentType: '/api/payment_types/3',
} as ClientDetail)
expect(draft.siren).toBe('123456789')
expect(draft.tvaModeIri).toBe('/api/tva_modes/2')
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
expect(draft.bankIri).toBeNull()
})
})
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
it('Admin : tout editable', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
})
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
})
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
})
it('Sans permission d\'edition : rien d\'editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
})
})
@@ -1,152 +0,0 @@
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)
})
})
@@ -1,316 +0,0 @@
/**
* Helpers purs de l'ecran « Consultation client » (M1 Commercial, lecture seule).
*
* Mappent le payload `GET /api/clients/{id}` (relations embarquees, cf. groupe
* `client:item:read` + `client:read:accounting`) vers les brouillons « plats »
* partages avec les blocs reutilisables `ClientContactBlock` / `ClientAddressBlock`
* et l'onglet Comptabilite. Ne touchent ni a l'API ni a l'etat reactif : testables
* unitairement (cf. clientConsultation.spec.ts).
*
* Rappels de contrat back (verifies sur l'API reelle) :
* - les relations ManyToOne (distributor/broker/tvaMode/paymentType/...) sont
* serialisees en OBJETS embarques (avec @id + companyName/code/label), pas en IRI nu ;
* - les champs nuls sont OMIS du JSON (skip_null_values) → toujours lire avec `?? null` ;
* - les champs comptables et `ribs` sont TOTALEMENT ABSENTS sans permission
* accounting.view (gate serveur via ClientReadGroupContextBuilder).
*/
import { formatPhoneFR } from '~/shared/utils/phone'
import type {
AddressFormDraft,
ContactFormDraft,
RibFormDraft,
} from '~/modules/commercial/types/clientForm'
/** Reference Hydra embarquee minimale (@id toujours present). */
export interface HydraRef {
'@id': string
[key: string]: unknown
}
/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */
export type Relation = HydraRef | string | null | undefined
/** Site embarque dans une adresse (groupe site:read). */
export interface SiteRead extends HydraRef {
name?: string
color?: string
}
/** Categorie embarquee (groupe category:read). */
export interface CategoryRead extends HydraRef {
code?: string
name?: string
}
/** Contact embarque (groupe client_contact:read). */
export interface ContactRead extends HydraRef {
id: number
firstName?: string | null
lastName?: string | null
jobTitle?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
}
/** Adresse embarquee (groupe client_address:read). */
export interface AddressRead extends HydraRef {
id: number
country?: string | null
postalCode?: string | null
city?: string | null
street?: string | null
streetComplement?: string | null
billingEmail?: string | null
isProspect?: boolean
isDelivery?: boolean
isBilling?: boolean
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
contacts?: Array<HydraRef | string>
}
/** RIB embarque (groupe client:read:accounting, present ssi accounting.view). */
export interface RibRead extends HydraRef {
id: number
label?: string | null
bic?: string | null
iban?: string | null
}
/** Client relie (distributeur / courtier) embarque (groupe client:read). */
export interface RelatedClientRead extends HydraRef {
companyName?: string | null
}
/**
* Detail d'un client tel que renvoye par `GET /api/clients/{id}`. Tous les
* champs sont optionnels : skip_null_values cote serveur et gating accounting
* peuvent omettre n'importe quelle cle.
*/
export interface ClientDetail extends HydraRef {
id: number
companyName?: string | null
triageService?: boolean
isArchived?: boolean
categories?: CategoryRead[]
distributor?: RelatedClientRead | string | null
broker?: RelatedClientRead | string | null
contacts?: ContactRead[]
addresses?: AddressRead[]
ribs?: RibRead[]
// Onglet Information
description?: string | null
competitors?: string | null
foundedAt?: string | null
employeesCount?: number | null
revenueAmount?: string | null
profitAmount?: string | null
directorName?: string | null
// Onglet Comptabilite (present ssi accounting.view)
siren?: string | null
accountNumber?: string | null
nTva?: string | null
tvaMode?: Relation
paymentDelay?: Relation
paymentType?: Relation
bank?: Relation
}
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire 1.10). */
export interface AccountingDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
bankIri: string | null
}
/** Relation Distributeur/Courtier resolue pour l'affichage en lecture seule. */
export interface ClientRelation {
type: 'distributeur' | 'courtier' | null
name: string | null
}
/** Option de select ({ value, label }) construite a partir de l'embed. */
export interface SelectOption {
value: string
label: string
}
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
export interface CategorySelectOption extends SelectOption {
code: string
}
/**
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
*/
export interface AddressView {
draft: AddressFormDraft
siteOptions: SelectOption[]
categoryOptions: CategorySelectOption[]
}
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
export function iriOf(relation: Relation): string | null {
if (relation === null || relation === undefined) {
return null
}
if (typeof relation === 'string') {
return relation
}
return relation['@id'] ?? null
}
/**
* Resout la relation Distributeur/Courtier (RG-1.03 : mutuellement exclusives).
* Le nom est lu sur l'objet embarque (`companyName`) ; null si la relation est
* un IRI nu ou absente.
*/
export function relationOf(client: ClientDetail): ClientRelation {
const nameOf = (rel: RelatedClientRead | string | null | undefined): string | null =>
rel && typeof rel === 'object' ? (rel.companyName ?? null) : null
if (client.distributor) {
return { type: 'distributeur', name: nameOf(client.distributor) }
}
if (client.broker) {
return { type: 'courtier', name: nameOf(client.broker) }
}
return { type: null, name: null }
}
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
export function mapContactToDraft(contact: ContactRead): ContactFormDraft {
const phoneSecondary = contact.phoneSecondary ?? null
return {
id: contact.id,
iri: contact['@id'] ?? null,
firstName: contact.firstName ?? null,
lastName: contact.lastName ?? null,
jobTitle: contact.jobTitle ?? null,
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
email: contact.email ?? null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
}
}
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
return {
id: address.id,
isProspect: address.isProspect ?? false,
isDelivery: address.isDelivery ?? false,
isBilling: address.isBilling ?? false,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
street: address.street ?? null,
streetComplement: address.streetComplement ?? null,
categoryIris: (address.categories ?? []).map(c => c['@id']),
siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
billingEmail: address.billingEmail ?? null,
}
}
/** Mappe un RIB embarque vers un brouillon. */
export function mapRibToDraft(rib: RibRead): RibFormDraft {
return {
id: rib.id,
label: rib.label ?? null,
bic: rib.bic ?? null,
iban: rib.iban ?? null,
}
}
/** Mappe les champs comptables du client (scalaires + IRI des referentiels). */
export function mapAccountingDraft(client: ClientDetail): AccountingDraft {
return {
siren: client.siren ?? null,
accountNumber: client.accountNumber ?? null,
nTva: client.nTva ?? null,
tvaModeIri: iriOf(client.tvaMode),
paymentDelayIri: iriOf(client.paymentDelay),
paymentTypeIri: iriOf(client.paymentType),
bankIri: iriOf(client.bank),
}
}
/**
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
* roles metier non-admin), qui laisserait les libelles vides.
*/
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
return (categories ?? []).map(c => ({
value: c['@id'],
label: c.name ?? c.code ?? c['@id'],
code: c.code ?? '',
}))
}
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
return (contacts ?? []).map(c => ({
value: c['@id'],
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
}))
}
/**
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
* `GET` de referentiel — l'affichage reste correct quel que soit le role.
*/
export function referentialOptionOf(relation: Relation): SelectOption[] {
if (!relation || typeof relation === 'string') {
return []
}
const label = (relation.label as string | undefined)
?? (relation.name as string | undefined)
?? relation['@id']
return [{ value: relation['@id'], label }]
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView {
return {
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
* par onglet est gere sur l'ecran d'edition (1.12).
*/
export function canEditClient(canAny: (codes: string[]) => boolean): boolean {
return canAny(['commercial.clients.manage', 'commercial.clients.accounting.manage'])
}
/** Bouton « Archiver » : permission archive ET client encore actif. */
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.clients.archive') && !isArchived
}
/** Bouton « Restaurer » : permission archive ET client deja archive. */
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.clients.archive') && isArchived
}
@@ -1,247 +0,0 @@
/**
* Helpers purs de l'ecran « Modification client » (M1 Commercial, 1.12).
*
* Deux responsabilites, toutes deux testables unitairement (cf. clientEdit.spec.ts) :
* 1. Pre-remplissage : mapper le payload `GET /api/clients/{id}` (embed
* contacts/adresses/ribs + scalaires) vers les brouillons « plats » edites
* par la page et les blocs reutilisables (mappers contacts/adresses/ribs/
* comptabilite reutilises depuis clientConsultation).
* 2. Scoping STRICT des payloads PATCH (mode strict RG-1.28 / ERP-74) : chaque
* onglet n'envoie QUE les champs de SON groupe de serialisation, jamais un
* payload mixte — un champ hors-permission = 403 sur l'integralite cote back.
*
* Ces helpers ne touchent ni a l'API ni a l'etat reactif.
*
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement NON
* miroitee cote front (cf. clientFormRules.ts) — /api/me n'expose pas le code de
* role et Bureau partage les permissions de Commerciale. Le back l'applique de
* maniere fiable (422) ; on laisse remonter ce 422 en toast.
*/
import {
iriOf,
relationOf,
type ClientDetail,
} from '~/modules/commercial/utils/clientConsultation'
import type { AddressFormDraft, ContactFormDraft, RibFormDraft } from '~/modules/commercial/types/clientForm'
/**
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
* categories, relation, triage), pas sur une sous-ressource ClientContact. Les
* coordonnees de contact (nom, prenom, telephones, email) ne sont plus portees
* par le Client : elles vivent exclusivement dans l'onglet Contacts.
*/
export interface MainFormDraft {
companyName: string | null
/** IRI des categories rattachees (M2M). */
categoryIris: string[]
relationType: 'distributeur' | 'courtier' | null
distributorIri: string | null
brokerIri: string | null
triageService: boolean
}
/** Etat « plat » de l'onglet Information (groupe client:write:information). */
export interface InformationFormDraft {
description: string | null
competitors: string | null
/** Date de creation de l'entreprise au format YYYY-MM-DD (MalioDate). */
foundedAt: string | null
/** Nombre de salaries en chaine (saisie masquee), converti en number au PATCH. */
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
}
/** Etat « plat » de l'onglet Comptabilite (groupe client:write:accounting). */
export interface AccountingFormDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
bankIri: string | null
}
/** Permissions de l'utilisateur courant pertinentes pour l'edition d'un client. */
export interface ClientEditAbilities {
/** `commercial.clients.manage` : bloc principal + onglets metier. */
canManage: boolean
/** `commercial.clients.accounting.view` : visibilite de l'onglet Comptabilite. */
canAccountingView: boolean
/** `commercial.clients.accounting.manage` : edition de l'onglet Comptabilite. */
canAccountingManage: boolean
}
/** Editabilite resolue par zone d'onglet (deduite des permissions). */
export interface TabEditability {
/** Bloc principal + onglets Information / Contact / Adresse editables. */
businessEditable: boolean
/** Onglet Comptabilite present (affiche). */
accountingVisible: boolean
/** Onglet Comptabilite editable. */
accountingEditable: boolean
}
// ── Pre-remplissage (GET detail -> brouillons) ──────────────────────────────
/**
* Mappe le detail client vers le brouillon du bloc principal. La relation
* Distributeur/Courtier est resolue par exclusivite (RG-1.03) et son IRI extrait
* de l'embed.
*/
export function mapMainDraft(client: ClientDetail): MainFormDraft {
const relation = relationOf(client)
return {
companyName: client.companyName ?? null,
categoryIris: (client.categories ?? []).map(c => c['@id']),
relationType: relation.type,
distributorIri: iriOf(client.distributor),
brokerIri: iriOf(client.broker),
triageService: client.triageService === true,
}
}
/** Mappe le detail client vers le brouillon de l'onglet Information. */
export function mapInformationDraft(client: ClientDetail): InformationFormDraft {
return {
description: client.description ?? null,
competitors: client.competitors ?? null,
// MalioDate attend strictement YYYY-MM-DD : on tronque l'ISO datetime.
foundedAt: client.foundedAt ? client.foundedAt.slice(0, 10) : null,
employeesCount: client.employeesCount != null ? String(client.employeesCount) : null,
revenueAmount: client.revenueAmount ?? null,
profitAmount: client.profitAmount ?? null,
directorName: client.directorName ?? null,
}
}
/** Mappe les champs comptables du detail vers le brouillon de l'onglet (scalaires + IRI). */
export function mapAccountingFormDraft(client: ClientDetail): AccountingFormDraft {
return {
siren: client.siren ?? null,
accountNumber: client.accountNumber ?? null,
nTva: client.nTva ?? null,
tvaModeIri: iriOf(client.tvaMode),
paymentDelayIri: iriOf(client.paymentDelay),
paymentTypeIri: iriOf(client.paymentType),
bankIri: iriOf(client.bank),
}
}
// ── Scoping strict des payloads PATCH ────────────────────────────────────────
/**
* Payload du bloc principal — groupe client:write:main UNIQUEMENT. La relation
* Distributeur/Courtier est mutuellement exclusive (RG-1.03) : on ne renseigne
* que la FK correspondant au type choisi, l'autre est forcee a null.
*/
export function buildMainPayload(main: MainFormDraft): Record<string, unknown> {
return {
companyName: main.companyName,
categories: main.categoryIris,
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
broker: main.relationType === 'courtier' ? main.brokerIri : null,
triageService: main.triageService,
}
}
/** Payload de l'onglet Information — groupe client:write:information UNIQUEMENT. */
export function buildInformationPayload(information: InformationFormDraft): Record<string, unknown> {
return {
description: information.description || null,
competitors: information.competitors || null,
foundedAt: information.foundedAt || null,
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
revenueAmount: information.revenueAmount || null,
profitAmount: information.profitAmount || null,
directorName: information.directorName || null,
}
}
/**
* Payload des scalaires de l'onglet Comptabilite — groupe client:write:accounting
* UNIQUEMENT (les RIB passent par la sous-ressource /clients/{id}/ribs). La banque
* n'a de sens que pour un Virement (RG-1.12) : forcee a null sinon.
*/
export function buildAccountingPayload(
accounting: AccountingFormDraft,
isBankRequired: boolean,
): Record<string, unknown> {
return {
siren: accounting.siren || null,
accountNumber: accounting.accountNumber || null,
tvaMode: accounting.tvaModeIri,
nTva: accounting.nTva || null,
paymentDelay: accounting.paymentDelayIri,
paymentType: accounting.paymentTypeIri,
bank: isBankRequired ? accounting.bankIri : null,
}
}
/** Payload d'un contact (sous-ressource client_contact). */
export function buildContactPayload(contact: ContactFormDraft): Record<string, unknown> {
return {
firstName: contact.firstName || null,
lastName: contact.lastName || null,
jobTitle: contact.jobTitle || null,
phonePrimary: contact.phonePrimary || null,
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
email: contact.email || null,
}
}
/** Payload d'une adresse (sous-ressource client_address). */
export function buildAddressPayload(
address: AddressFormDraft,
isBillingEmailRequired: boolean,
): Record<string, unknown> {
return {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
street: address.street || null,
streetComplement: address.streetComplement || null,
categories: address.categoryIris,
sites: address.siteIris,
contacts: address.contactIris,
billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null,
}
}
/** Payload d'un RIB (sous-ressource client_rib). */
export function buildRibPayload(rib: RibFormDraft): Record<string, unknown> {
return {
label: rib.label,
bic: rib.bic,
iban: rib.iban,
}
}
// ── Gating par permission ────────────────────────────────────────────────────
/**
* Resout l'editabilite par zone a partir des permissions (option 1 ERP-74,
* miroir UI du re-gating champ-par-champ du ClientProcessor) :
* - bloc principal + Information/Contact/Adresse : editables ssi `manage` ;
* - Comptabilite : visible ssi `accounting.view`, editable ssi `accounting.manage`.
*
* Produit le comportement attendu :
* - Admin : tout editable.
* - Bureau / Commerciale (manage, sans accounting) : metier editable, Compta masquee.
* - Compta (accounting seul, sans manage) : metier readonly, Compta editable.
*/
export function resolveTabEditability(abilities: ClientEditAbilities): TabEditability {
return {
businessEditable: abilities.canManage,
accountingVisible: abilities.canAccountingView,
accountingEditable: abilities.canAccountingManage,
}
}
@@ -1,158 +0,0 @@
/**
* 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
}
@@ -11,7 +11,7 @@
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`" :title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
header-class="capitalize" header-class="capitalize"
> >
<div class="flex flex-col"> <div class="flex flex-col gap-3">
<!-- Tout selectionner pour ce module --> <!-- Tout selectionner pour ce module -->
<MalioCheckbox <MalioCheckbox
:id="`${idPrefix}-group-${group.module}`" :id="`${idPrefix}-group-${group.module}`"
@@ -20,7 +20,7 @@
label-class="font-semibold text-sm text-neutral-700" label-class="font-semibold text-sm text-neutral-700"
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)" @update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
/> />
<div class="flex flex-col"> <div class="flex flex-col gap-2">
<MalioCheckbox <MalioCheckbox
v-for="perm in group.permissions" v-for="perm in group.permissions"
:id="`${idPrefix}-perm-${perm.id}`" :id="`${idPrefix}-perm-${perm.id}`"
@@ -11,7 +11,7 @@
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }} {{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
</h2> </h2>
</template> </template>
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave"> <form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<!-- Champs du role --> <!-- Champs du role -->
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"
@@ -71,7 +71,7 @@
variant="danger" variant="danger"
icon-name="mdi:delete-outline" icon-name="mdi:delete-outline"
icon-position="left" icon-position="left"
button-class="w-m-btn-action" button-class="w-[150px]"
:disabled="role?.isSystem" :disabled="role?.isSystem"
@click="emit('delete')" @click="emit('delete')"
/> />
@@ -79,13 +79,13 @@
v-else v-else
:label="t('common.cancel')" :label="t('common.cancel')"
variant="tertiary" variant="tertiary"
button-class="w-m-btn-action" button-class="w-[150px]"
@click="emit('update:modelValue', false)" @click="emit('update:modelValue', false)"
/> />
<MalioButton <MalioButton
:label="t('common.save')" :label="t('common.save')"
variant="primary" variant="primary"
button-class="w-m-btn-action" button-class="w-[150px]"
:disabled="saving || permissionsLoadFailed" :disabled="saving || permissionsLoadFailed"
@click="handleSave" @click="handleSave"
/> />
@@ -11,7 +11,7 @@
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }} {{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
</h2> </h2>
</template> </template>
<div class="flex flex-col space-y-4 py-4"> <div class="flex flex-col gap-4 py-4">
<!-- Etat d'erreur de chargement des referentiels : bloque la <!-- Etat d'erreur de chargement des referentiels : bloque la
sauvegarde pour empecher un ecrasement silencieux des droits. --> sauvegarde pour empecher un ecrasement silencieux des droits. -->
<div <div
@@ -41,13 +41,11 @@
/> />
<!-- Section Roles --> <!-- Section Roles -->
<!-- !mt-0 : la MalioCheckbox au-dessus expose son slot message (16px), <div>
qui couvre deja l'ecart attendu pas besoin du space-y-4 ici. -->
<div class="!mt-0">
<h4 class="mb-3 text-sm font-semibold text-neutral-700"> <h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.rolesSection') }} {{ t('admin.users.drawer.rolesSection') }}
</h4> </h4>
<div class="flex flex-col"> <div class="flex flex-col gap-2">
<MalioCheckbox <MalioCheckbox
v-for="role in allRoles" v-for="role in allRoles"
:key="role.id" :key="role.id"
@@ -86,7 +84,7 @@
<div v-if="allSites.length === 0" class="text-sm text-neutral-400"> <div v-if="allSites.length === 0" class="text-sm text-neutral-400">
{{ t('admin.sites.noSites') }} {{ t('admin.sites.noSites') }}
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col gap-2">
<MalioCheckbox <MalioCheckbox
v-for="site in allSites" v-for="site in allSites"
:id="`site-${site.id}`" :id="`site-${site.id}`"
@@ -115,13 +113,13 @@
<MalioButton <MalioButton
:label="t('common.cancel')" :label="t('common.cancel')"
variant="tertiary" variant="tertiary"
button-class="w-m-btn-action" button-class="w-[150px]"
@click="emit('update:modelValue', false)" @click="emit('update:modelValue', false)"
/> />
<MalioButton <MalioButton
:label="t('common.save')" :label="t('common.save')"
variant="primary" variant="primary"
button-class="w-m-btn-action" button-class="w-[150px]"
:disabled="saving || loadFailed" :disabled="saving || loadFailed"
@click="handleSave" @click="handleSave"
/> />
@@ -66,18 +66,15 @@
<MalioAccordion> <MalioAccordion>
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) --> <!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates"> <MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
<!-- pb-4 sur les labels Du/Au : simule le slot message <div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
du MalioDateTime voisin pour qu'items-center recentre <span>{{ t('audit.filters.date_from') }}</span>
le label sur le centre visible du champ. -->
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3">
<span class="pb-4">{{ t('audit.filters.date_from') }}</span>
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage <!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
inversee a la saisie (le backend renverrait silencieusement 0 ligne). --> inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
<MalioDateTime <MalioDateTime
v-model="draftDateFrom" v-model="draftDateFrom"
:max="draftDateTo ?? undefined" :max="draftDateTo ?? undefined"
/> />
<span class="pb-4">{{ t('audit.filters.date_to') }}</span> <span>{{ t('audit.filters.date_to') }}</span>
<MalioDateTime <MalioDateTime
v-model="draftDateTo" v-model="draftDateTo"
:min="draftDateFrom ?? undefined" :min="draftDateFrom ?? undefined"
@@ -87,7 +84,7 @@
<!-- Type d'entite : cases a cocher (multi-selection) --> <!-- Type d'entite : cases a cocher (multi-selection) -->
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity"> <MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
<div class="flex flex-col"> <div class="flex flex-col gap-4">
<MalioCheckbox <MalioCheckbox
v-for="opt in entityTypeOptions" v-for="opt in entityTypeOptions"
:id="`filter-entity-${opt.value}`" :id="`filter-entity-${opt.value}`"
@@ -108,7 +105,6 @@
name="audit-action" name="audit-action"
:value="opt.value" :value="opt.value"
:label="opt.label" :label="opt.label"
group-class="mt-0"
/> />
</MalioAccordionItem> </MalioAccordionItem>
@@ -125,7 +121,7 @@
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
:label="t('audit.filters.reset')" :label="t('audit.filters.reset')"
button-class="w-m-btn-action" button-class="w-[150px]"
@click="resetFilters" @click="resetFilters"
/> />
<MalioButton <MalioButton
+23 -18
View File
@@ -13,19 +13,14 @@
</template> </template>
</PageHeader> </PageHeader>
<!-- Table des roles pagination serveur via usePaginatedList (#73). --> <!-- Table des roles -->
<MalioDataTable <MalioDataTable
:columns="columns" :columns="columns"
:items="roleItems" :items="roleItems"
:total-items="totalItems" :total-items="roles.length"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:row-clickable="canManage" :row-clickable="canManage"
:empty-message="t('admin.roles.noRoles')" :empty-message="t('admin.roles.noRoles')"
@row-click="onRowClick" @row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
> >
<template #cell-code="{ item }"> <template #cell-code="{ item }">
<span class="font-mono text-xs">{{ item.code }}</span> <span class="font-mono text-xs">{{ item.code }}</span>
@@ -71,17 +66,8 @@ const canManage = computed(() => can('core.roles.manage'))
useHead({ title: t('admin.roles.title') }) useHead({ title: t('admin.roles.title') })
// Pagination serveur via le composable partage (#73). const roles = ref<Role[]>([])
const { const loading = ref(false)
items: roles,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadRoles,
goToPage,
setItemsPerPage,
} = usePaginatedList<Role>({ url: '/roles' })
const columns = [ const columns = [
{ key: 'label', label: t('admin.roles.table.label') }, { key: 'label', label: t('admin.roles.table.label') },
@@ -116,6 +102,25 @@ const deleteModalOpen = ref(false)
const roleToDelete = ref<Role | null>(null) const roleToDelete = ref<Role | null>(null)
const deleting = ref(false) const deleting = ref(false)
// Charger la liste des roles
async function loadRoles() {
loading.value = true
try {
const data = await api.get<{ member: Role[] }>(
'/roles',
{},
{ toast: false },
)
roles.value = data.member
} catch {
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
// requete reussie avant une perte reseau ou 403).
roles.value = []
} finally {
loading.value = false
}
}
function openCreateDrawer() { function openCreateDrawer() {
selectedRole.value = null selectedRole.value = null
drawerOpen.value = true drawerOpen.value = true
+20 -21
View File
@@ -2,19 +2,14 @@
<div> <div>
<PageHeader>{{ t('admin.users.title') }}</PageHeader> <PageHeader>{{ t('admin.users.title') }}</PageHeader>
<!-- Table des utilisateurs pagination serveur via usePaginatedList (#73). --> <!-- Table des utilisateurs -->
<MalioDataTable <MalioDataTable
:columns="columns" :columns="columns"
:items="userItems" :items="userItems"
:total-items="totalItems" :total-items="users.length"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:row-clickable="canManage" :row-clickable="canManage"
:empty-message="t('admin.users.noUsers')" :empty-message="t('admin.users.noUsers')"
@row-click="onRowClick" @row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
> >
<template #cell-admin="{ item }"> <template #cell-admin="{ item }">
<span <span
@@ -39,26 +34,15 @@
import type { UserListItem } from '~/shared/types/rbac' import type { UserListItem } from '~/shared/types/rbac'
const { t } = useI18n() const { t } = useI18n()
const api = useApi()
const { can } = usePermissions() const { can } = usePermissions()
useHead({ title: t('admin.users.title') }) useHead({ title: t('admin.users.title') })
const canManage = computed(() => can('core.users.manage')) const canManage = computed(() => can('core.users.manage'))
// Pagination serveur via le composable partage (#73). Le payload `users` const users = ref<UserListItem[]>([])
// reste leger (pas de detail RBAC dans la liste cf. commentaire colonne const loading = ref(false)
// "Sites" plus bas) ce qui rend la pagination 10/25/50 par page confortable.
const {
items: users,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadUsers,
goToPage,
setItemsPerPage,
} = usePaginatedList<UserListItem>({ url: '/users' })
const drawerOpen = ref(false) const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null) const selectedUser = ref<UserListItem | null>(null)
@@ -83,6 +67,21 @@ const userItems = computed(() =>
})), })),
) )
async function loadUsers() {
loading.value = true
try {
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
users.value = usersData.member
} catch {
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
// requete reussie avant une perte reseau ou 403). Pas de toast par
// design ici : on laisse la liste vide parler d'elle-meme.
users.value = []
} finally {
loading.value = false
}
}
function getUserById(id: number): UserListItem | undefined { function getUserById(id: number): UserListItem | undefined {
return users.value.find(u => u.id === id) return users.value.find(u => u.id === id)
} }
+2 -2
View File
@@ -6,7 +6,7 @@
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/> <img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
</span> </span>
<form <form
class="mt-8 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm" class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit" @submit.prevent="handleSubmit"
> >
<MalioInputText <MalioInputText
@@ -30,7 +30,7 @@
type="submit" type="submit"
:disabled="isSubmitting" :disabled="isSubmitting"
/> />
<p class="mt-6 font-bold">v{{ version }}</p> <p class="font-bold">v{{ version }}</p>
</form> </form>
</div> </div>
</template> </template>
@@ -11,7 +11,7 @@
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }} {{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
</h2> </h2>
</template> </template>
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave"> <form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
:label="t('admin.sites.form.name')" :label="t('admin.sites.form.name')"
@@ -65,16 +65,11 @@
input-class="w-full font-mono" input-class="w-full font-mono"
required required
/> />
<!-- pb-4 sur le wrapper : simule le slot message du <span
MalioInputText voisin pour qu'items-center recentre :style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
la puce sur le centre visible de l'input. --> class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
<div class="shrink-0 pb-4"> :class="{ 'border-dashed': !isValidHex }"
<span />
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
class="inline-block size-10 rounded-lg border border-neutral-200"
:class="{ 'border-dashed': !isValidHex }"
/>
</div>
</div> </div>
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600"> <p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
{{ t('admin.sites.form.colorInvalid') }} {{ t('admin.sites.form.colorInvalid') }}
@@ -92,20 +87,20 @@
variant="danger" variant="danger"
icon-name="mdi:delete-outline" icon-name="mdi:delete-outline"
icon-position="left" icon-position="left"
button-class="w-m-btn-action" button-class="w-[150px]"
@click="emit('delete')" @click="emit('delete')"
/> />
<MalioButton <MalioButton
v-else v-else
:label="t('common.cancel')" :label="t('common.cancel')"
variant="tertiary" variant="tertiary"
button-class="w-m-btn-action" button-class="w-[150px]"
@click="emit('update:modelValue', false)" @click="emit('update:modelValue', false)"
/> />
<MalioButton <MalioButton
:label="t('common.save')" :label="t('common.save')"
variant="primary" variant="primary"
button-class="w-m-btn-action" button-class="w-[150px]"
:disabled="saving || !isValidHex" :disabled="saving || !isValidHex"
@click="handleSave" @click="handleSave"
/> />
+22 -21
View File
@@ -13,19 +13,14 @@
</template> </template>
</PageHeader> </PageHeader>
<!-- Table des sites pagination serveur via usePaginatedList (#73). --> <!-- Table des sites -->
<MalioDataTable <MalioDataTable
:columns="columns" :columns="columns"
:items="siteItems" :items="siteItems"
:total-items="totalItems" :total-items="sites.length"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
:row-clickable="canManage" :row-clickable="canManage"
:empty-message="t('admin.sites.noSites')" :empty-message="t('admin.sites.noSites')"
@row-click="onRowClick" @row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
> >
<template #cell-color="{ item }"> <template #cell-color="{ item }">
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
@@ -72,20 +67,8 @@ const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') }) useHead({ title: t('admin.sites.title') })
// Pagination serveur via le composable partage (#73). Aucun OrderFilter const sites = ref<Site[]>([])
// declare cote API Platform sur Site, donc on s'appuie sur le tri par const loading = ref(false)
// defaut du repository (id ASC). Le composable est neanmoins pret a
// recevoir un `defaultSort` ou des filtres le jour ou l'API les expose.
const {
items: sites,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadSites,
goToPage,
setItemsPerPage,
} = usePaginatedList<Site>({ url: '/sites' })
const columns = [ const columns = [
{ key: 'name', label: t('admin.sites.table.name') }, { key: 'name', label: t('admin.sites.table.name') },
@@ -124,6 +107,24 @@ const deleteModalOpen = ref(false)
const siteToDelete = ref<Site | null>(null) const siteToDelete = ref<Site | null>(null)
const deleting = ref(false) const deleting = ref(false)
async function loadSites() {
loading.value = true
try {
const data = await api.get<{ member: Site[] }>(
'/sites',
{ itemsPerPage: 999 },
{ toast: false },
)
sites.value = data.member
} catch {
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
// requete reussie avant une perte reseau ou 403).
sites.value = []
} finally {
loading.value = false
}
}
function openCreateDrawer() { function openCreateDrawer() {
selectedSite.value = null selectedSite.value = null
drawerOpen.value = true drawerOpen.value = true
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.3", "@malio/layer-ui": "^1.7.1",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.7.3", "version": "1.7.1",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
"integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==", "integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.3", "@malio/layer-ui": "^1.7.1",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

@@ -1,51 +0,0 @@
<template>
<!--
Placeholder generique « En cours de dev » pour les ecrans / onglets non
encore implementes. Composant PARTAGE (shared/components) : auto-importe
sans prefixe (`<ComingSoonPlaceholder>`) et reutilisable depuis n'importe
quel module. Affiche un gif (asset local par defaut) + un message i18n.
-->
<div class="flex min-h-[240px] flex-col items-center justify-center gap-4 rounded-md bg-white py-10">
<img
v-if="!imageFailed"
:src="src"
:alt="resolvedTitle"
class="max-h-[220px] w-auto rounded-md"
@error="imageFailed = true"
>
<!-- Repli si le gif ne charge pas (offline, CSP, asset absent) :
illustration emoji, le message reste affiche. -->
<div v-else class="text-5xl" aria-hidden="true">🚧 👨💻 🚧</div>
<div class="text-center">
<p class="text-xl font-bold text-black">{{ resolvedTitle }}</p>
<p class="mt-1 text-black/60">{{ resolvedSubtitle }}</p>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
/** Source de l'image/gif affichee. Defaut : asset local `/coming-soon.gif`. */
src?: string
/** Titre. Defaut : i18n `common.comingSoon.title`. */
title?: string
/** Sous-titre. Defaut : i18n `common.comingSoon.subtitle`. */
subtitle?: string
}>(),
{
src: '/coming-soon.gif',
title: '',
subtitle: '',
},
)
const { t } = useI18n()
const imageFailed = ref(false)
// Les props priment sur les libelles i18n par defaut (permet a un module
// d'override le texte sans toucher au composant).
const resolvedTitle = computed(() => props.title || t('common.comingSoon.title'))
const resolvedSubtitle = computed(() => props.subtitle || t('common.comingSoon.subtitle'))
</script>
@@ -1,132 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
useAddressAutocomplete,
AddressAutocompleteUnavailableError,
} from '../useAddressAutocomplete'
// On mocke le helper d'appel externe : aucun vrai appel reseau a la BAN.
// vi.mock est hoiste par Vitest au-dessus des imports.
const mockHttp = vi.hoisted(() => vi.fn())
vi.mock('~/shared/utils/httpExternal', () => ({ httpExternal: mockHttp }))
const BAN_URL = 'https://api-adresse.data.gouv.fr/search/'
describe('useAddressAutocomplete', () => {
beforeEach(() => {
mockHttp.mockReset()
})
describe('searchCity', () => {
it('interroge la BAN en type=municipality et mappe { city, postalCode }', async () => {
mockHttp.mockResolvedValueOnce({
type: 'FeatureCollection',
features: [
{ properties: { city: 'Amiens', postcode: '80000', name: 'Amiens', type: 'municipality' } },
{ properties: { city: 'Amiens', postcode: '80080', name: 'Amiens', type: 'municipality' } },
],
})
const { searchCity } = useAddressAutocomplete()
const res = await searchCity('80000')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({ query: { q: '80000', type: 'municipality' } }),
)
expect(res).toEqual([
{ city: 'Amiens', postalCode: '80000' },
{ city: 'Amiens', postalCode: '80080' },
])
})
it('throw une AddressAutocompleteUnavailableError sur erreur reseau / 5xx', async () => {
mockHttp.mockRejectedValueOnce(new Error('500 Server Error'))
const { searchCity } = useAddressAutocomplete()
await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError)
})
it('throw une AddressAutocompleteUnavailableError sur timeout', async () => {
mockHttp.mockRejectedValueOnce(new Error('The operation was aborted due to timeout'))
const { searchCity } = useAddressAutocomplete()
await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError)
})
})
describe('searchAddress', () => {
it('interroge la BAN avec postcode et mappe la suggestion', async () => {
mockHttp.mockResolvedValueOnce({
type: 'FeatureCollection',
features: [
{
properties: {
label: '8 Boulevard du Port 80000 Amiens',
name: '8 Boulevard du Port',
street: 'Boulevard du Port',
postcode: '80000',
city: 'Amiens',
type: 'housenumber',
},
},
],
})
const { searchAddress } = useAddressAutocomplete()
const res = await searchAddress('8 boulevard du port', '80000')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({
query: { q: '8 boulevard du port', postcode: '80000' },
}),
)
expect(res).toEqual([
{
label: '8 Boulevard du Port 80000 Amiens',
street: '8 Boulevard du Port',
postalCode: '80000',
city: 'Amiens',
},
])
})
it('omet le parametre postcode quand aucun code postal n\'est fourni', async () => {
mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] })
const { searchAddress } = useAddressAutocomplete()
await searchAddress('8 boulevard du port')
expect(mockHttp).toHaveBeenCalledWith(
BAN_URL,
expect.objectContaining({
query: { q: '8 boulevard du port' },
}),
)
})
it('ne restreint PAS la recherche a type=housenumber (sinon la BAN ne renvoie rien tant qu\'aucun numero n\'est saisi)', async () => {
// Regression : avec `type=housenumber`, une saisie de nom de rue sans
// numero (ex: « boulevard du port ») renvoie 0 resultat cote BAN.
mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] })
const { searchAddress } = useAddressAutocomplete()
await searchAddress('boulevard du port', '80000')
const sentQuery = mockHttp.mock.calls[0]?.[1]?.query as Record<string, string>
expect(sentQuery.type).toBeUndefined()
})
it('throw une AddressAutocompleteUnavailableError sur erreur reseau', async () => {
mockHttp.mockRejectedValueOnce(new Error('network down'))
const { searchAddress } = useAddressAutocomplete()
await expect(searchAddress('8 boulevard du port', '80000')).rejects.toBeInstanceOf(
AddressAutocompleteUnavailableError,
)
})
})
})
@@ -1,412 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { usePaginatedList } from '../usePaginatedList'
const mockApiGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
/**
* Tests du composable `usePaginatedList`.
*
* Couvre les invariants critiques :
* - parse Hydra (member / totalItems)
* - navigation page (goToPage / next / prev / bornes)
* - changement items/page retour page 1
* - mutation filtres / tri retour page 1
* - cas limite : page courante hors borne apres filtre derniere page valide
* - liste vide / page unique
* - reset defaults
* - swallow d'erreur reseau (la promesse `fetch` ne reject jamais)
* - header `Accept: application/ld+json` toujours envoye (besoin du
* paginator Hydra cote API Platform 4).
*/
describe('usePaginatedList', () => {
beforeEach(() => {
mockApiGet.mockReset()
})
function mockResponse(member: unknown[], totalItems: number): void {
mockApiGet.mockResolvedValueOnce({ member, totalItems })
}
it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => {
mockResponse([{ id: 1 }, { id: 2 }], 42)
const list = usePaginatedList<{ id: number }>({ url: '/sites' })
await list.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/sites')
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
expect(opts).toMatchObject({
toast: false,
headers: { Accept: 'application/ld+json' },
})
expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }])
expect(list.totalItems.value).toBe(42)
expect(list.totalPages.value).toBe(5)
expect(list.isEmpty.value).toBe(false)
expect(list.isSinglePage.value).toBe(false)
})
it('itemsPerPage personnalise est respecte au premier fetch', async () => {
mockResponse([], 0)
const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 })
await list.fetch()
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 })
expect(list.itemsPerPage.value).toBe(25)
})
it('goToPage(N) declenche un nouvel appel avec page=N', async () => {
mockResponse([{ id: 1 }], 30) // page 1
const list = usePaginatedList<{ id: number }>({ url: '/users' })
await list.fetch()
mockResponse([{ id: 2 }], 30) // page 2
await list.goToPage(2)
expect(mockApiGet).toHaveBeenCalledTimes(2)
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 })
expect(list.currentPage.value).toBe(2)
})
it('goToPage hors borne sup. est clampe a totalPages', async () => {
mockResponse([], 30) // totalPages = 3
const list = usePaginatedList({ url: '/roles' })
await list.fetch()
mockResponse([], 30)
await list.goToPage(999)
expect(list.currentPage.value).toBe(3)
})
it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => {
mockResponse([], 30)
const list = usePaginatedList({ url: '/roles' })
await list.fetch()
mockApiGet.mockClear()
await list.goToPage(-5)
// Deja en page 1 -> aucun nouvel appel.
expect(mockApiGet).toHaveBeenCalledTimes(0)
expect(list.currentPage.value).toBe(1)
})
it('nextPage / prevPage avancent et reculent dans les bornes', async () => {
mockResponse([], 30) // page 1, totalPages 3
const list = usePaginatedList({ url: '/roles' })
await list.fetch()
mockResponse([], 30)
await list.nextPage()
expect(list.currentPage.value).toBe(2)
mockResponse([], 30)
await list.nextPage()
expect(list.currentPage.value).toBe(3)
// En derniere page -> no-op
mockApiGet.mockClear()
await list.nextPage()
expect(mockApiGet).toHaveBeenCalledTimes(0)
expect(list.currentPage.value).toBe(3)
mockResponse([], 30)
await list.prevPage()
expect(list.currentPage.value).toBe(2)
})
it('setItemsPerPage revient en page 1 et refetch', async () => {
mockResponse([], 100)
const list = usePaginatedList({ url: '/users' })
await list.fetch()
// place-toi page 3
mockResponse([], 100)
await list.goToPage(3)
expect(list.currentPage.value).toBe(3)
mockResponse([], 100)
await list.setItemsPerPage(25)
expect(list.currentPage.value).toBe(1)
expect(list.itemsPerPage.value).toBe(25)
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 })
})
it('setItemsPerPage no-op si meme valeur', async () => {
mockResponse([], 10)
const list = usePaginatedList({ url: '/users' })
await list.fetch()
mockApiGet.mockClear()
await list.setItemsPerPage(10)
expect(mockApiGet).toHaveBeenCalledTimes(0)
})
it('setFilters fusionne et retombe en page 1', async () => {
mockResponse([], 100)
const list = usePaginatedList<unknown, { name?: string; active?: boolean }>({
url: '/users',
defaultFilters: { active: true },
})
await list.fetch()
mockResponse([], 100)
await list.goToPage(2)
mockResponse([], 100)
await list.setFilters({ name: 'alice' })
expect(list.currentPage.value).toBe(1)
expect(list.filters.value).toEqual({ active: true, name: 'alice' })
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({
page: 1,
active: true,
name: 'alice',
})
})
it('setFilters({ key: undefined }) supprime la cle', async () => {
mockResponse([], 100)
const list = usePaginatedList<unknown, { name?: string }>({
url: '/users',
defaultFilters: { name: 'alice' },
})
await list.fetch()
mockResponse([], 100)
await list.setFilters({ name: undefined })
expect(list.filters.value).toEqual({})
// Le query envoye ne contient plus `name` (compactQuery elimine
// aussi les valeurs vides).
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(q.name).toBeUndefined()
})
it('setFilters({ replace: true }) remplace integralement', async () => {
mockResponse([], 100)
const list = usePaginatedList<unknown, { a?: string; b?: string }>({
url: '/users',
defaultFilters: { a: 'x' },
})
await list.fetch()
mockResponse([], 100)
await list.setFilters({ b: 'y' }, { replace: true })
expect(list.filters.value).toEqual({ b: 'y' })
})
it('setSort envoie order[field]=direction et reset page', async () => {
mockResponse([], 100)
const list = usePaginatedList({ url: '/users' })
await list.fetch()
mockResponse([], 100)
await list.goToPage(2)
mockResponse([], 100)
await list.setSort({ field: 'username', direction: 'desc' })
expect(list.currentPage.value).toBe(1)
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(q['order[username]']).toBe('desc')
})
it('setSort(null) retire le tri', async () => {
mockResponse([], 100)
const list = usePaginatedList({
url: '/users',
defaultSort: { field: 'name', direction: 'asc' },
})
await list.fetch()
// Le tri initial est applique
let q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(q['order[name]']).toBe('asc')
mockResponse([], 100)
await list.setSort(null)
q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
expect(q['order[name]']).toBeUndefined()
})
it('setFilters retombe en page 1 (cas standard, pas le hors-borne)', async () => {
// setFilters remet toujours page=1 avant de refetcher : ce n'est
// donc PAS le chemin de retry hors-borne (couvert par le test
// suivant via un refetch a page constante). On verifie juste le
// reset de page ici.
mockResponse([], 50) // 5 pages
const list = usePaginatedList({ url: '/users' })
await list.fetch()
mockResponse([], 50)
await list.goToPage(5)
expect(list.currentPage.value).toBe(5)
mockResponse([{ id: 1 }, { id: 2 }], 12)
await list.setFilters({ active: true } as never)
expect(list.currentPage.value).toBe(1)
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1 })
})
it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
// Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux
// filtres mais entre deux fetchs la donnee a change cote serveur,
// la page courante peut devenir hors borne. On force le scenario
// en montant manuellement currentPage via goToPage borne, puis en
// simulant une reponse plus petite.
mockResponse([], 50) // 5 pages
const list = usePaginatedList({ url: '/users' })
await list.fetch()
mockResponse([], 50)
await list.goToPage(5)
expect(list.currentPage.value).toBe(5)
// Maintenant simule : refetch -> totalItems chute a 12 (2 pages),
// le composable doit refetcher sur page=2.
mockApiGet.mockReset()
mockApiGet
.mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide
.mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2
await list.fetch()
expect(mockApiGet).toHaveBeenCalledTimes(2)
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 })
expect(list.currentPage.value).toBe(2)
expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }])
})
it('liste vide : isEmpty true, isSinglePage true', async () => {
mockResponse([], 0)
const list = usePaginatedList({ url: '/users' })
await list.fetch()
expect(list.totalItems.value).toBe(0)
expect(list.isEmpty.value).toBe(true)
expect(list.isSinglePage.value).toBe(true)
expect(list.totalPages.value).toBe(1)
})
it('isEmpty est faux avant le premier fetch (etat indetermine)', () => {
const list = usePaginatedList({ url: '/users' })
expect(list.isEmpty.value).toBe(false)
})
it('reset revient aux defaults', async () => {
mockResponse([], 100)
const list = usePaginatedList<unknown, { a?: string }>({
url: '/users',
defaultItemsPerPage: 10,
defaultFilters: { a: 'x' },
defaultSort: { field: 'name', direction: 'asc' },
})
await list.fetch()
mockResponse([], 100)
await list.setItemsPerPage(50)
mockResponse([], 100)
await list.setFilters({ a: 'y' })
mockResponse([], 100)
await list.setSort({ field: 'id', direction: 'desc' })
mockResponse([], 100)
await list.goToPage(2)
expect(list.currentPage.value).toBe(2)
mockResponse([], 100)
await list.reset()
expect(list.itemsPerPage.value).toBe(10)
expect(list.filters.value).toEqual({ a: 'x' })
expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' })
expect(list.currentPage.value).toBe(1)
})
it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
const boom = new Error('boom')
mockApiGet.mockRejectedValueOnce(boom)
const list = usePaginatedList({ url: '/users' })
await expect(list.fetch()).resolves.toBeUndefined()
expect(list.items.value).toEqual([])
expect(list.totalItems.value).toBe(0)
expect(list.loading.value).toBe(false)
// L'erreur est consideree comme un fetch consume -> isEmpty=true.
expect(list.isEmpty.value).toBe(true)
// ... mais `error` est expose pour distinguer « vide » d'« echec ».
expect(list.error.value).toBe(boom)
})
it('error est remis a null des qu\'un fetch ulterieur reussit', async () => {
mockApiGet.mockRejectedValueOnce(new Error('boom'))
const list = usePaginatedList({ url: '/users' })
await list.fetch()
expect(list.error.value).toBeInstanceOf(Error)
mockResponse([{ id: 1 }], 1)
await list.fetch()
expect(list.error.value).toBeNull()
expect(list.items.value).toEqual([{ id: 1 }])
})
it('ignore une reponse periemee : la derniere requete *demandee* gagne', async () => {
// Deux fetch concurrents : le 1er resout APRES le 2eme. Sans garde
// de sequence, la reponse arrivee en dernier (token 1) ecraserait
// les donnees plus fraiches du token 2. Avec la garde, token 2 fait
// foi quel que soit l'ordre d'arrivee reseau.
const list = usePaginatedList<{ id: number }>({ url: '/users' })
let resolveSlow!: (v: unknown) => void
const slow = new Promise((r) => { resolveSlow = r })
// 1er appel : reponse lente (en vol).
mockApiGet.mockReturnValueOnce(slow)
// 2eme appel : reponse immediate avec des donnees plus fraiches.
mockApiGet.mockResolvedValueOnce({ member: [{ id: 2 }], totalItems: 30 })
const p1 = list.fetch() // token 1, en vol
const p2 = list.fetch() // token 2, resout tout de suite
await p2
expect(list.items.value).toEqual([{ id: 2 }])
// La reponse lente du token 1 arrive enfin : elle doit etre ignoree.
resolveSlow({ member: [{ id: 1 }], totalItems: 30 })
await p1
expect(list.items.value).toEqual([{ id: 2 }])
// Le spinner reste eteint (la requete recente l'avait deja coupe).
expect(list.loading.value).toBe(false)
})
it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
mockResponse([], 0)
const list = usePaginatedList({
url: '/categories',
extraQuery: { includeDeleted: 'true' },
})
await list.fetch()
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' })
})
it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => {
mockResponse([], 0)
const list = usePaginatedList<unknown, { name?: string; q?: string }>({
url: '/users',
defaultFilters: { name: '', q: undefined } as never,
})
await list.fetch()
const q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
expect(q.name).toBeUndefined()
expect(q.q).toBeUndefined()
})
it('refresh() est un alias de fetch()', async () => {
mockResponse([{ id: 1 }], 1)
const list = usePaginatedList<{ id: number }>({ url: '/users' })
await list.refresh()
expect(list.items.value).toEqual([{ id: 1 }])
})
})
@@ -1,117 +0,0 @@
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 ?? '',
}
})
},
}
}
@@ -1,364 +0,0 @@
import { computed, ref, type Ref } from 'vue'
import type { HydraCollection } from '~/shared/utils/api'
/**
* Composable generique de liste paginee serveur.
*
* Responsabilites :
* - centraliser l'etat tableau (page courante, items/page, tri, filtres,
* totalItems, items, loading, error) cote local JAMAIS dans l'URL,
* conformement a la regle ABSOLUE n°6 du CLAUDE.md (« Jamais persister
* l'etat de tableau dans l'URL »).
* - dialoguer avec une ressource API Platform 4 (Hydra) en passant
* `page`, `itemsPerPage` et le tri/filtres en query params.
* - exposer une API simple a brancher sur `MalioDataTable`
* (props page/perPage/totalItems + events update:page / update:per-page).
*
* Volontairement **par-instance** (state local a chaque appel) : a la
* difference de `useAuditLog` / `useCategoriesAdmin` qui sont des
* singletons module-level partages, une liste paginee est propre a son
* ecran et ne doit pas etre partagee entre pages (sinon un retour
* arriere reprendrait la pagination d'une autre liste).
*
* Pas de gestion URL : si une page veut un deep link (ex : ouvrir un
* detail), elle le fait via sa propre route, pas via la query string
* de pagination. Derogation possible uniquement si l'utilisateur le
* demande explicitement, cf. CLAUDE.md.
*/
/**
* Direction de tri serveur. API Platform 4 attend `asc` ou `desc` via la
* syntaxe `?order[field]=asc`.
*/
export type SortDirection = 'asc' | 'desc'
/**
* Specification de tri : un seul champ trie a la fois cote front (la
* majorite des tableaux Malio n'expose pas le multi-tri). Si null, aucun
* `order[...]` n'est envoye et l'API applique son tri par defaut.
*/
export interface SortSpec {
field: string
direction: SortDirection
}
/**
* Type des filtres : un dictionnaire de valeurs serialisables en query
* params. Le caller decide du mapping (ex : `{ active: true }`,
* `{ 'name[ilike]': 'a' }`). Valeurs `null` / `undefined` / chaines vides
* sont automatiquement omises au moment de la requete.
*/
export type PaginatedListFilters = Record<string, string | number | boolean | string[] | null | undefined>
export interface UsePaginatedListOptions<F extends PaginatedListFilters = PaginatedListFilters> {
/** URL relative au prefix `/api` (ex : `/sites`, `/categories`). */
url: string
/** Items par page initial. Defaut 10 (aligne avec le defaut serveur). */
defaultItemsPerPage?: number
/** Options proposees dans le selecteur items/page. Defaut [10, 25, 50]. */
itemsPerPageOptions?: number[]
/** Filtres initiaux. */
defaultFilters?: F
/** Tri initial. */
defaultSort?: SortSpec | null
/**
* Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
* `groups[]=foo`) injectes a chaque requete. **Snapshot statique** : l'objet
* est lu tel quel a chaque `fetch()`, ses valeurs ne sont pas deballees. Ne
* pas y passer de `ref` / `computed` (elles seraient serialisees comme objet,
* pas comme valeur) pour un extra reactif, muter les filtres via
* `setFilters` ou ouvrir un ticket pour un support `MaybeRefOrGetter`.
*/
extraQuery?: Record<string, unknown>
}
export interface UsePaginatedListReturn<T, F extends PaginatedListFilters = PaginatedListFilters> {
/** Items de la page courante. */
items: Ref<T[]>
/** Total d'items (toutes pages) renvoye par Hydra. */
totalItems: Ref<number>
/** Page courante (1-based). */
currentPage: Ref<number>
/** Taille de page courante. */
itemsPerPage: Ref<number>
/** Options exposees au selecteur items/page. */
itemsPerPageOptions: Ref<number[]>
/** Nombre total de pages (≥ 1). */
totalPages: Ref<number>
/** Indicateur de chargement (vrai pendant `fetch()`). */
loading: Ref<boolean>
/**
* Derniere erreur de `fetch()` (null si le dernier appel a abouti).
* Permet a la page de distinguer « liste reellement vide » d'un echec
* reseau / 403 : sans ca, `isEmpty` confond les deux cas (la liste
* tombe a 0 item dans les deux situations). La page decide de l'UX
* (bandeau, bouton reessayer) le composable ne toaste pas.
*/
error: Ref<unknown | null>
/** Vrai apres au moins un fetch reussi avec 0 item. */
isEmpty: Ref<boolean>
/** Vrai si la collection tient en une seule page (totalPages <= 1). */
isSinglePage: Ref<boolean>
/** Filtres courants (mutation via `setFilters`). */
filters: Ref<F>
/** Tri courant (mutation via `setSort`). */
sort: Ref<SortSpec | null>
/** Lance un fetch contre l'API et met a jour items/totalItems. */
fetch: () => Promise<void>
/** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */
goToPage: (page: number) => Promise<void>
/** Page suivante (no-op si deja en derniere page). */
nextPage: () => Promise<void>
/** Page precedente (no-op si deja en premiere page). */
prevPage: () => Promise<void>
/** Change la taille de page et revient en page 1. */
setItemsPerPage: (value: number) => Promise<void>
/** Applique de nouveaux filtres et revient en page 1. */
setFilters: (next: Partial<F>, options?: { replace?: boolean }) => Promise<void>
/** Change le tri et revient en page 1. */
setSort: (next: SortSpec | null) => Promise<void>
/** Reinitialise filtres + tri + page sur les valeurs par defaut. */
reset: () => Promise<void>
/** Alias de `fetch()` (intention plus claire dans certains contextes). */
refresh: () => Promise<void>
}
/**
* Force `application/ld+json` : sous `application/json`, API Platform 4
* renvoie un tableau plat sans envelope de pagination on ne pourrait pas
* lire `totalItems` ni `view`. Voir aussi `useAuditLog.ts`.
*/
const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
/**
* Filtre les entrees nulles/undefined/vides d'un objet de query : evite
* d'envoyer `?foo=&bar=null` a l'API qui declencherait parfois des erreurs
* de filtre cote Symfony (`FilterInterface::apply` strict).
*/
function compactQuery(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(raw)) {
if (value === null || value === undefined) continue
if (typeof value === 'string' && value === '') continue
if (Array.isArray(value) && value.length === 0) continue
out[key] = value
}
return out
}
export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedListFilters>(
options: UsePaginatedListOptions<F>,
): UsePaginatedListReturn<T, F> {
const api = useApi()
const defaultItemsPerPage = options.defaultItemsPerPage ?? 10
const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F
const initialSort = options.defaultSort ?? null
const items = ref<T[]>([]) as Ref<T[]>
const totalItems = ref(0)
const currentPage = ref(1)
const itemsPerPage = ref(defaultItemsPerPage)
const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
const loading = ref(false)
const error = ref<unknown | null>(null)
// Jeton de sequence : incremente a chaque `fetch()`. Une reponse dont
// le jeton n'est plus le dernier est ignoree (protection contre les
// reponses periemes quand l'utilisateur enchaine page / tri / filtres
// plus vite que le reseau ne repond — sinon la derniere reponse
// *arrivee* gagnerait au lieu de la derniere *demandee*).
let fetchToken = 0
// `hasFetched` evite que `isEmpty` retourne `true` avant le premier
// chargement (etat initial = 0 items mais on ne sait pas encore si la
// ressource est vide ou en cours de chargement). Un appel reseau au
// moins doit avoir abouti pour qu'on annonce une liste « vide ».
const hasFetched = ref(false)
const filters = ref({ ...initialFilters }) as Ref<F>
const sort = ref<SortSpec | null>(initialSort ? { ...initialSort } : null)
const totalPages = computed(() => {
if (totalItems.value <= 0 || itemsPerPage.value <= 0) return 1
return Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
})
const isEmpty = computed(() => hasFetched.value && totalItems.value === 0)
const isSinglePage = computed(() => totalPages.value <= 1)
/**
* Construit l'objet query envoye a l'API : extras + filtres, puis
* pagination + tri. Les cles reservees (`page`, `itemsPerPage`,
* `order[...]`) sont assignees **en dernier** pour qu'un filtre ou un
* extra portant le meme nom ne puisse pas ecraser silencieusement la
* pagination. Les filtres `null`/`undefined`/'' sont elimines pour ne
* pas polluer l'URL.
*/
function buildQuery(): Record<string, unknown> {
const query: Record<string, unknown> = {}
if (options.extraQuery) {
Object.assign(query, options.extraQuery)
}
Object.assign(query, filters.value)
// Cles reservees en dernier : priorite a la pagination/au tri.
query.page = currentPage.value
query.itemsPerPage = itemsPerPage.value
if (sort.value) {
// Format API Platform : ?order[field]=asc
query[`order[${sort.value.field}]`] = sort.value.direction
}
return compactQuery(query)
}
/**
* Lance un fetch et applique la borne haute si necessaire. Si la page
* courante depasse `totalPages` apres l'application des filtres (cas
* « j'etais en page 5, je filtre, il ne reste qu'une page »), on
* rappelle l'API sur la derniere page valide. Un seul niveau de retry
* pour eviter une boucle si l'API renvoie des resultats incoherents.
*/
async function fetch(): Promise<void> {
const token = ++fetchToken
loading.value = true
error.value = null
try {
const data = await api.get<HydraCollection<T>>(
options.url,
buildQuery(),
{ toast: false, headers: JSONLD_HEADERS },
)
// Une requete plus recente a ete lancee entre-temps : on jette
// cette reponse pour ne pas ecraser des donnees plus fraiches.
if (token !== fetchToken) return
items.value = data.member ?? []
totalItems.value = data.totalItems ?? 0
const tp = totalItems.value > 0
? Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
: 1
// Si on est hors borne ET qu'il y a au moins une page valide
// a viser, on retombe sur la derniere page (cf. cas limite
// « page hors borne apres filtre » de la spec #73). On ne
// refetch que si la nouvelle page est differente, sinon
// boucle infinie potentielle.
if (currentPage.value > tp && tp >= 1 && totalItems.value > 0) {
currentPage.value = tp
const data2 = await api.get<HydraCollection<T>>(
options.url,
buildQuery(),
{ toast: false, headers: JSONLD_HEADERS },
)
// Meme garde apres le refetch hors-borne.
if (token !== fetchToken) return
items.value = data2.member ?? []
totalItems.value = data2.totalItems ?? 0
}
hasFetched.value = true
} catch (e) {
// Reponse periemee : ne pas toucher au state, une requete plus
// recente est en cours et fera foi.
if (token !== fetchToken) return
// Swallow volontaire : on remet la liste a vide pour ne pas
// afficher de donnees stale, et on expose l'erreur pour que la
// page distingue « vide » d'« echec ». Le composant parent
// decide de l'UX (toast / message d'erreur) — pas d'a-priori ici.
error.value = e
items.value = []
totalItems.value = 0
hasFetched.value = true
} finally {
// Seule la requete la plus recente eteint le spinner : une
// reponse periemee ne doit pas le couper alors qu'un fetch plus
// recent est encore en vol.
if (token === fetchToken) loading.value = false
}
}
async function goToPage(page: number): Promise<void> {
const tp = totalPages.value
const next = Math.max(1, Math.min(page, tp))
if (next === currentPage.value) return
currentPage.value = next
await fetch()
}
async function nextPage(): Promise<void> {
if (currentPage.value >= totalPages.value) return
await goToPage(currentPage.value + 1)
}
async function prevPage(): Promise<void> {
if (currentPage.value <= 1) return
await goToPage(currentPage.value - 1)
}
async function setItemsPerPage(value: number): Promise<void> {
if (!Number.isFinite(value) || value <= 0) return
const rounded = Math.floor(value)
if (rounded === itemsPerPage.value) return
itemsPerPage.value = rounded
currentPage.value = 1
await fetch()
}
/**
* `replace: false` (defaut) fusionne avec les filtres courants. Une
* valeur explicitement `undefined` retire la cle (utile pour effacer
* un filtre depuis un champ controle). `replace: true` remplace
* integralement l'objet par `next`.
*/
async function setFilters(next: Partial<F>, opts?: { replace?: boolean }): Promise<void> {
if (opts?.replace) {
filters.value = { ...(next as F) }
} else {
const merged = { ...filters.value, ...next } as F
// Supprime les cles explicitement passees a undefined : sans ce
// nettoyage, l'objet `filters` accumulerait des cles fantomes.
for (const key of Object.keys(next)) {
if (next[key as keyof F] === undefined) {
delete (merged as Record<string, unknown>)[key]
}
}
filters.value = merged
}
currentPage.value = 1
await fetch()
}
async function setSort(next: SortSpec | null): Promise<void> {
sort.value = next ? { ...next } : null
currentPage.value = 1
await fetch()
}
async function reset(): Promise<void> {
filters.value = { ...initialFilters }
sort.value = initialSort ? { ...initialSort } : null
itemsPerPage.value = defaultItemsPerPage
currentPage.value = 1
await fetch()
}
return {
items,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
totalPages,
loading,
error,
isEmpty,
isSinglePage,
filters,
sort,
fetch,
goToPage,
nextPage,
prevPage,
setItemsPerPage,
setFilters,
setSort,
reset,
refresh: fetch,
}
}
@@ -1,56 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { httpExternal } from '../httpExternal'
// On mocke ofetch : httpExternal s'appuie sur $fetch sans jamais toucher le
// reseau pendant les tests. vi.mock est hoiste par Vitest au-dessus des imports.
const mockFetch = vi.hoisted(() => vi.fn())
vi.mock('ofetch', () => ({ $fetch: mockFetch }))
describe('httpExternal', () => {
beforeEach(() => {
mockFetch.mockReset()
})
it('retourne le JSON parse renvoye par $fetch', async () => {
mockFetch.mockResolvedValueOnce({ ok: true })
const res = await httpExternal<{ ok: boolean }>('https://example.test/api')
expect(res).toEqual({ ok: true })
})
it('transmet la query, coupe le cookie (credentials omit) et pose un timeout par defaut', async () => {
mockFetch.mockResolvedValueOnce([])
await httpExternal('https://example.test/search', {
query: { q: '80000', type: 'municipality' },
})
expect(mockFetch).toHaveBeenCalledWith(
'https://example.test/search',
expect.objectContaining({
query: { q: '80000', type: 'municipality' },
credentials: 'omit',
retry: 0,
timeout: 5000,
}),
)
})
it('permet de surcharger le timeout', async () => {
mockFetch.mockResolvedValueOnce(null)
await httpExternal('https://example.test', { timeoutMs: 1000 })
expect(mockFetch).toHaveBeenCalledWith(
'https://example.test',
expect.objectContaining({ timeout: 1000 }),
)
})
it('propage l\'erreur reseau / timeout (throw)', async () => {
mockFetch.mockRejectedValueOnce(new Error('network down'))
await expect(httpExternal('https://example.test')).rejects.toThrow('network down')
})
})
@@ -1,46 +0,0 @@
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')
})
})
-40
View File
@@ -1,40 +0,0 @@
import { $fetch } from 'ofetch'
/**
* Options d'un appel HTTP externe.
*/
export interface HttpExternalOptions {
/** Parametres de query string (encodes par ofetch). */
query?: Record<string, string | number | undefined>
/** Timeout en millisecondes avant abandon (defaut 5000). */
timeoutMs?: number
}
/**
* Petit client HTTP pour les APIs PUBLIQUES EXTERNES (domaine tiers, hors `/api`).
*
* Pourquoi un helper dedie plutot que `useApi()` : `useApi()` est le client de
* l'API interne Starseed (baseURL `/api`, cookie JWT `credentials: 'include'`,
* parsing/erreurs Hydra, redirection `/login` sur 401, toasts i18n). Tout cela
* est inadapte voire indesirable pour un endpoint public externe comme la
* Base Adresse Nationale (`api-adresse.data.gouv.fr`).
*
* Ce helper est donc le SEUL point d'entree autorise pour un `$fetch` brut vers
* l'externe (cf. regle frontend n°4 : pas de `$fetch` eparpille dans les
* composants). Il :
* - cible une URL absolue (pas de baseURL `/api`) ;
* - n'envoie PAS le cookie de session (`credentials: 'omit'`) ;
* - ne retente pas (`retry: 0`) et applique un timeout ;
* - laisse remonter l'erreur (throw) au consommateur de gerer le mode degrade.
*/
export async function httpExternal<T>(
url: string,
opts: HttpExternalOptions = {},
): Promise<T> {
return $fetch<T>(url, {
query: opts.query,
credentials: 'omit',
retry: 0,
timeout: opts.timeoutMs ?? 5000,
})
}
-23
View File
@@ -1,23 +0,0 @@
/**
* 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
}
+2 -19
View File
@@ -198,18 +198,13 @@ migration-migrate:
# doctrine:fixtures:load essaie de DELETE toutes les tables connues # doctrine:fixtures:load essaie de DELETE toutes les tables connues
# via les mappings — si fake_site_aware_entity est mappe mais absent # via les mappings — si fake_site_aware_entity est mappe mais absent
# en DB, le purger crash. # en DB, le purger crash.
# 3. fixtures -> sync-permissions -> seed-rbac : fixtures:load purge la table # 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
# permission, donc sync doit passer apres. seed-rbac (matrice RBAC § 2.7) # donc sync doit passer apres.
# passe ensuite, car attachMatrix() exige les permissions en base. Les
# comptes demo sont crees par RbacDemoFixtures au load (sans la matrice,
# attachee ici). Cf. ERP-74.
# 4. recreation des index partiels uniques : schema:update drop les index # 4. recreation des index partiels uniques : schema:update drop les index
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas # orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc # exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
# ils disparaissent apres schema:update. On les recree par dbal:run-sql : # ils disparaissent apres schema:update. On les recree par dbal:run-sql :
# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07. # - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi
# les actifs (slug du nom), pilote RG-1.03/1.29.
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe # - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
# parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55. # parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55.
# Sans ces restores, les POST doublons remontent 201 au lieu de 409. # Sans ces restores, les POST doublons remontent 201 au lieu de 409.
@@ -225,9 +220,7 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments $(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions $(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
fixtures: fixtures:
@@ -238,15 +231,6 @@ fixtures:
sync-permissions: sync-permissions:
$(SYMFONY_CONSOLE) --no-interaction app:sync-permissions $(SYMFONY_CONSOLE) --no-interaction app:sync-permissions
# Seed RBAC metier : roles (bureau/compta/commerciale/usine) + matrice § 2.7
# (+ comptes demo en dev). Idempotent et NON destructif. A lancer APRES
# sync-permissions (attachMatrix exige les permissions en base). Les comptes
# demo dev sont deja crees par RbacDemoFixtures (make fixtures) ; ici on attache
# la matrice (les permissions etaient purgees au moment du load fixtures).
# En recette/prod, c'est cette commande (avec/sans --with-demo-users) qui seede.
seed-rbac:
$(SYMFONY_CONSOLE) --no-interaction app:seed-rbac
# Attention, supprime votre bdd local # Attention, supprime votre bdd local
db-reset: db-reset:
$(DOCKER_COMPOSE) down -v $(DOCKER_COMPOSE) down -v
@@ -256,7 +240,6 @@ db-reset:
$(MAKE) migration-migrate $(MAKE) migration-migrate
$(MAKE) fixtures $(MAKE) fixtures
$(MAKE) sync-permissions $(MAKE) sync-permissions
$(MAKE) seed-rbac
$(MAKE) test-db-setup $(MAKE) test-db-setup
# Restart la bdd # Restart la bdd
+13 -33
View File
@@ -39,40 +39,20 @@ final class Version20260528120000 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// Ne commente que les tables ET colonnes deja presentes a ce stade de la // Ne commente que les tables deja presentes a ce stade de la chaine de
// chaine de migrations. Les tables des modules crees plus tard (M1 // migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01)
// Commercial, 06-01) ET les colonnes ajoutees ensuite sur une table // figurent desormais dans le catalogue partage mais leurs tables
// existante (ex: category.code, ERP-78 06-02) figurent desormais dans le // n'existent pas encore ici : elles posent leurs propres COMMENT dans
// catalogue partage mais n'existent pas encore ici : elles posent leur // leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable,
// propre COMMENT dans leur migration dediee (regle ABSOLUE n°12). Garde-fou // sinon l'ajout d'un module au catalogue casse ce retrofit avec un
// indispensable (table + colonne), sinon enrichir le catalogue casse ce // "relation X does not exist".
// retrofit avec un "relation/column X does not exist". $existingTables = array_values(array_filter(
foreach (ColumnCommentsCatalog::comments() as $table => $entries) { array_keys(ColumnCommentsCatalog::comments()),
if (!$schema->hasTable($table)) { static fn (string $table): bool => $schema->hasTable($table),
continue; ));
}
$dbTable = $schema->getTable($table); foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) {
$quotedTable = '"'.str_replace('"', '""', $table).'"'; $this->addSql($sql);
foreach ($entries as $column => $description) {
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
continue;
}
if (!$dbTable->hasColumn($column)) {
continue;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
} }
} }
-189
View File
@@ -1,189 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\CategoryCodeSql;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-78 Refonte de la taxonomie Categories (M0/M1).
*
* Modele AVANT (merge via Version20260527164000 + Version20260601000000) :
* DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE sont des `category_type`.
*
* Modele APRES (decision produit 01/06) :
* - UN SEUL `category_type` : CLIENT (code CLIENT, label « Client ») ;
* - Distributeur / Courtier / Secteur / Autre (+ categories metier fines)
* deviennent des `Category` rattachees au type CLIENT ;
* - filtrage metier sur un `code` stable porte par la `Category` (et non plus
* par le type) : on reporte les codes DISTRIBUTEUR / COURTIER sur la categorie
* correspondante. RG-1.03 (distributor/broker) et RG-1.29 (categorie interdite
* sur adresse) s'appuient desormais sur `category.code`.
*
* Migration CORRECTIVE et NOUVELLE : la migration mergee Version20260601000000
* (qui a pu tourner en CI / chez d'autres devs) n'est PAS editee.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
* Catalog : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
* FQCN alphabetique (AlphabeticalComparator). Introduire la 1re migration
* modulaire `App\Module\Catalog\...` la ferait trier AVANT toutes les
* `DoctrineMigrations\...` sur base vide -> elle s'executerait avant la creation
* des tables et le seed dont elle depend. Le namespace racine garantit l'ordre
* par timestamp.
*
* Idempotence : `ADD COLUMN IF NOT EXISTS`, `INSERT ... ON CONFLICT` / guards
* `NOT EXISTS`, `CREATE UNIQUE INDEX IF NOT EXISTS`. En prod la table `category`
* est vide (aucune fixture metier) : l'ajout de `code NOT NULL` est sur. En
* dev/test, le purger Doctrine vide `category`/`category_type` avant les
* fixtures, qui reproduisent le meme etat final (cf. CategoryTypeFixtures /
* CategoryFixtures).
*/
final class Version20260602100000 extends AbstractMigration
{
/**
* Categories systeme reportees depuis les anciens types : nom => code.
* Le code est la cle metier stable (RG-1.03 / RG-1.29).
*/
private const array SYSTEM_CATEGORIES = [
'Distributeur' => 'DISTRIBUTEUR',
'Courtier' => 'COURTIER',
'Secteur' => 'SECTEUR',
'Autre' => 'AUTRE',
];
/** Anciens codes de `category_type` devenus inutiles. */
private const array LEGACY_TYPE_CODES = ['DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE'];
public function getDescription(): string
{
return 'ERP-78 : Category.code + type unique CLIENT (categories Distributeur/Courtier/Secteur/Autre codees, anciens types supprimes).';
}
public function up(Schema $schema): void
{
// 1. Colonne `code` (nullable d'abord pour pouvoir backfiller, NOT NULL ensuite).
$this->addSql('ALTER TABLE category ADD COLUMN IF NOT EXISTS code VARCHAR(50) DEFAULT NULL');
// 2. Type unique CLIENT (idempotent via l'index unique uq_category_type_code).
$this->addSql(<<<'SQL'
INSERT INTO category_type (code, label) VALUES ('CLIENT', 'Client')
ON CONFLICT (code) DO NOTHING
SQL);
// 3. Re-pointer toute categorie pre-existante (rattachee a un ancien type)
// vers le type CLIENT, en lui donnant un code derive du nom si absent.
// En prod la table est vide -> no-op ; defensif pour les envs qui
// auraient deja seede des categories sous les anciens types. Le slug
// SQL est le miroir EXACT de CategoryCodeGenerator::slugify (cf.
// CategoryCodeSql + CategoryCodeSqlSlugTest) : un nom accentue produit
// le meme code que la generation applicative (« Independant » ->
// INDEPENDANT, et non IND_PENDANT).
$this->addSql(
'UPDATE category c '
."SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'), "
.'code = COALESCE(c.code, '.CategoryCodeSql::slugExpression('c.name').') '
.'WHERE c.category_type_id IN (SELECT id FROM category_type WHERE code IN (:legacyCodes))',
['legacyCodes' => self::LEGACY_TYPE_CODES],
['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING],
);
// 4. Creer les 4 categories systeme sous CLIENT (si leur code est libre
// parmi les actifs). created_at/updated_at NOT NULL -> now() ; le blame
// reste null (seed hors contexte HTTP, libelle « Systeme » cote front).
foreach (self::SYSTEM_CATEGORIES as $name => $code) {
$this->addSql(<<<'SQL'
INSERT INTO category (name, code, category_type_id, created_at, updated_at)
SELECT :name, :code, ct.id, NOW(), NOW()
FROM category_type ct
WHERE ct.code = 'CLIENT'
AND NOT EXISTS (
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
)
SQL, ['name' => $name, 'code' => $code]);
}
// 5. Backfill defensif : toute categorie encore sans code recoit un slug
// de son nom (garantit que le SET NOT NULL passe). Meme expression de
// slug fidele au generateur applicatif (CategoryCodeSql).
$this->addSql(
'UPDATE category SET code = '.CategoryCodeSql::slugExpression('name').' WHERE code IS NULL',
);
// 6. Index unique partiel sur le code parmi les actifs (non exprimable en
// ORM : recree aussi dans `test-db-setup` apres schema:update).
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL');
// 7. Code desormais obligatoire.
$this->addSql('ALTER TABLE category ALTER COLUMN code SET NOT NULL');
// 8. Documentation SQL (regle ABSOLUE n°12). Dollar-quoting Postgres.
$this->addSql(<<<'SQL'
COMMENT ON COLUMN category.code IS $_$Code technique stable (slug MAJUSCULE du nom, <= 50) unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.$_$
SQL);
// 9. Supprimer les anciens types devenus orphelins (aucune categorie ne
// les reference plus apres le re-pointage de l'etape 3). Le guard
// NOT EXISTS evite de casser sur la FK RESTRICT category.category_type_id.
$this->addSql(<<<'SQL'
DELETE FROM category_type
WHERE code IN (:legacyCodes)
AND NOT EXISTS (
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
)
SQL, ['legacyCodes' => self::LEGACY_TYPE_CODES], ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING]);
// 10. Realigner la doc SQL de client_address_category (migration mergee
// Version20260601000000, non editable) sur le nouveau modele RG-1.29.
$this->addSql(<<<'SQL'
COMMENT ON TABLE client_address_category IS $_$Jointure M2M client_address <-> category codes DISTRIBUTEUR/COURTIER interdits sur une adresse (RG-1.29).$_$
SQL);
$this->addSql(<<<'SQL'
COMMENT ON COLUMN client_address_category.category_id IS $_$FK -> category.id, ON DELETE RESTRICT categorie d adresse (tout code sauf DISTRIBUTEUR/COURTIER, RG-1.29).$_$
SQL);
}
public function down(Schema $schema): void
{
// Best-effort : rollback du modele CLIENT vers les 4 anciens types.
// 1. Retirer l'index unique sur le code.
$this->addSql('DROP INDEX IF EXISTS uq_category_code');
// 2. Recreer les 4 anciens types.
$this->addSql(<<<'SQL'
INSERT INTO category_type (code, label) VALUES
('DISTRIBUTEUR', 'Distributeur'),
('COURTIER', 'Courtier'),
('SECTEUR', 'Secteur'),
('AUTRE', 'Autre')
ON CONFLICT (code) DO NOTHING
SQL);
// 3. Re-pointer les categories systeme (par code) vers leur type d'origine.
// Codes inlines : constantes controlees (self::SYSTEM_CATEGORIES), pas
// d'entree utilisateur — evite le binding d'un parametre nomme repete.
foreach (self::SYSTEM_CATEGORIES as $name => $code) {
$this->addSql(sprintf(
"UPDATE category SET category_type_id = (SELECT id FROM category_type WHERE code = '%s') WHERE code = '%s'",
$code,
$code,
));
}
// 4. Supprimer le type CLIENT s'il ne reference plus aucune categorie.
$this->addSql(<<<'SQL'
DELETE FROM category_type
WHERE code = 'CLIENT'
AND NOT EXISTS (
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
)
SQL);
// 5. Retirer la colonne code (les categories libres sans type d'origine
// restent sous CLIENT si encore presentes — rollback uniquement
// pertinent en prod ou seules les 4 categories systeme existent).
$this->addSql('ALTER TABLE category DROP COLUMN IF EXISTS code');
}
}
-131
View File
@@ -1,131 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M1 Suppression du contact principal inline du `Client` (refonte contact).
*
* Modele AVANT : le `Client` portait 5 colonnes de contact principal
* (first_name, last_name, phone_primary, phone_secondary, email) en doublon
* conceptuel de la sous-entite `ClientContact` (onglet Contact).
*
* Modele APRES (decision produit, README refonte-contact) : les contacts vivent
* UNIQUEMENT dans `client_contact`. Les 5 colonnes inline disparaissent du
* `client`. RG-1.01 (firstName OU lastName sur Client) et RG-1.02 (max 2
* telephones sur Client) sont supprimees : leur equivalent vit deja sur
* `client_contact` (RG-1.05 / RG-1.14).
*
* Le code etant deja en prod, la suppression est precedee d'un BACKFILL : pour
* tout client n'ayant encore AUCUN contact, on materialise son contact principal
* inline en une ligne `client_contact` (position 0) avant le DROP, afin de ne
* perdre aucune donnee.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
* Commercial : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
* FQCN alphabetique (AlphabeticalComparator). Une migration
* `App\Module\Commercial\...` trierait AVANT toutes les `DoctrineMigrations\...`
* sur base vide -> ce DROP s'executerait avant le CREATE TABLE client
* (Version20260601000000). Le namespace racine garantit l'ordre par timestamp.
*/
final class Version20260603120000 extends AbstractMigration
{
/** Colonnes de contact inline supprimees du `client`. */
private const array INLINE_CONTACT_COLUMNS = [
'first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email',
];
public function getDescription(): string
{
return 'M1 : suppression du contact inline du Client (backfill vers client_contact puis DROP des 5 colonnes).';
}
public function up(Schema $schema): void
{
// 1. Backfill : tout client SANS contact recoit une ligne client_contact
// (position 0) reprenant ses champs inline. phone_primary / email du
// client sont NOT NULL -> toujours une donnee a reporter. Le CHECK
// chk_client_contact_name (first_name OU last_name) est garanti par le
// fallback company_name si jamais les deux noms etaient null (cas qui ne
// devrait pas exister, RG-1.01 ayant ete appliquee a l'ecriture).
// created_at/updated_at NOT NULL -> NOW() ; created_by/updated_by null
// (backfill hors contexte HTTP, libelle « Systeme » cote front).
$this->addSql(<<<'SQL'
INSERT INTO client_contact (
client_id, first_name, last_name, phone_primary, phone_secondary,
email, position, created_at, updated_at
)
SELECT
c.id,
c.first_name,
CASE
WHEN c.first_name IS NULL AND c.last_name IS NULL THEN c.company_name
ELSE c.last_name
END,
c.phone_primary,
c.phone_secondary,
c.email,
0,
NOW(),
NOW()
FROM client c
WHERE NOT EXISTS (
SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id
)
SQL);
// 2. DROP des 5 colonnes inline (rien a documenter : suppression).
$this->addSql(<<<'SQL'
ALTER TABLE client
DROP COLUMN first_name,
DROP COLUMN last_name,
DROP COLUMN phone_primary,
DROP COLUMN phone_secondary,
DROP COLUMN email
SQL);
}
public function down(Schema $schema): void
{
// Best-effort : on RECREE les 5 colonnes (en NULLABLE — l'etat NOT NULL
// d'origine de phone_primary/email ne peut etre restaure sur une table
// peuplee sans risque) et on retro-alimente depuis le contact principal
// (position minimale) de chaque client. La donnee n'est pas garantie
// identique a l'origine : ce down() sert au rollback technique, pas a une
// restauration fidele.
$this->addSql('ALTER TABLE client ADD COLUMN first_name VARCHAR(120) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN last_name VARCHAR(120) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN phone_primary VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN phone_secondary VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN email VARCHAR(180) DEFAULT NULL');
// Retro-alimentation depuis le contact de position la plus basse.
$this->addSql(<<<'SQL'
UPDATE client c SET
first_name = cc.first_name,
last_name = cc.last_name,
phone_primary = cc.phone_primary,
phone_secondary = cc.phone_secondary,
email = cc.email
FROM (
SELECT DISTINCT ON (client_id)
client_id, first_name, last_name, phone_primary, phone_secondary, email
FROM client_contact
ORDER BY client_id, position ASC, id ASC
) cc
WHERE cc.client_id = c.id
SQL);
// Re-pose des commentaires d'origine (regle ABSOLUE n°12) — dollar-quoting
// Postgres pour eviter tout echappement d apostrophe.
$this->addSql('COMMENT ON COLUMN client.first_name IS $_$Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
$this->addSql('COMMENT ON COLUMN client.last_name IS $_$Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
$this->addSql('COMMENT ON COLUMN client.phone_primary IS $_$Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.$_$');
$this->addSql('COMMENT ON COLUMN client.phone_secondary IS $_$Telephone secondaire optionnel — chiffres uniquement (RG-1.20).$_$');
$this->addSql('COMMENT ON COLUMN client.email IS $_$Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).$_$');
}
}
@@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Application\Service;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Symfony\Component\String\Slugger\AsciiSlugger;
/**
* Genere le code technique stable d'une Category a partir de son nom (ERP-78).
*
* Regle (decision produit 02/06) : `code` est obligatoire et auto-genere un
* slug MAJUSCULE du nom, sans accent, separateurs non alphanumeriques reduits a
* `_`, borne a 50 caracteres (longueur colonne). Exemples :
* - « Distributeur » -> DISTRIBUTEUR
* - « Agro-alimentaire » -> AGRO_ALIMENTAIRE
* - « Transport/Logistique » -> TRANSPORT_LOGISTIQUE
*
* Le code est FIGE a la creation (jamais recalcule sur renommage) afin de rester
* une cle deterministe stable entre environnements (RG-1.03 / RG-1.29 cote M1).
*
* Unicite : l'index partiel `uq_category_code` (WHERE deleted_at IS NULL) impose
* l'unicite parmi les categories actives. Deux noms distincts peuvent produire
* le meme slug (« Agro alimentaire » / « Agro-alimentaire ») : on suffixe alors
* le code par `_2`, `_3`... jusqu'a obtenir un code libre.
*/
final class CategoryCodeGenerator
{
/** Longueur maximale de la colonne `category.code`. */
private const int MAX_LENGTH = 50;
private readonly AsciiSlugger $slugger;
public function __construct(
private readonly CategoryRepositoryInterface $categoryRepository,
) {
$this->slugger = new AsciiSlugger();
}
/**
* Slug brut (sans garantie d'unicite) utile pour les seeds deterministes.
*/
public function slugify(string $name): string
{
$slug = $this->slugger->slug($name, '_')->upper()->toString();
// Borne a la longueur colonne, puis retire un eventuel `_` terminal
// introduit par la troncature.
$slug = substr($slug, 0, self::MAX_LENGTH);
$slug = trim($slug, '_');
// Garde-fou : un nom uniquement compose de caracteres non alphanumeriques
// (theorique, le nom est NotBlank + Length>=2) donnerait un slug vide.
return '' === $slug ? 'CATEGORY' : $slug;
}
/**
* Code unique parmi les categories actives : slug du nom, suffixe `_N` en
* cas de collision. `$excludeId` ignore la categorie courante (PATCH).
*/
public function generateUnique(string $name, ?int $excludeId = null): string
{
$base = $this->slugify($name);
$candidate = $base;
$suffix = 2;
while ($this->categoryRepository->existsActiveByCode($candidate, $excludeId)) {
$suffixStr = '_'.$suffix;
// Retronque la base pour que `base + suffixe` tienne dans 50 caracteres.
$candidate = substr($base, 0, self::MAX_LENGTH - strlen($suffixStr)).$suffixStr;
++$suffix;
}
return $candidate;
}
}
-5
View File
@@ -38,11 +38,6 @@ final class CatalogModule
return [ return [
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'], ['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'], ['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)'],
]; ];
} }
} }
+6 -38
View File
@@ -42,19 +42,13 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
#[ApiResource( #[ApiResource(
operations: [ 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( new GetCollection(
security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')", security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read', 'default:read']], normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class, provider: CategoryProvider::class,
), ),
new Get( new Get(
security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')", security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read', 'default:read']], normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class, provider: CategoryProvider::class,
), ),
@@ -80,11 +74,10 @@ use Symfony\Component\Validator\Constraints as Assert;
)] )]
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)] #[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')] #[ORM\Table(name: 'category')]
// Index nommes pour matcher la migration (cf. Role/Permission/Site). Les index // Index nommes pour matcher la migration (cf. Role/Permission/Site). L'index
// uniques partiels `uq_category_name_type_active` (LOWER(name), category_type_id // unique partiel `uq_category_name_type_active` (LOWER(name), category_type_id
// WHERE deleted_at IS NULL) et `uq_category_code` (code WHERE deleted_at IS NULL) // WHERE deleted_at IS NULL) reste possede par la seule migration : Doctrine ORM
// restent possedes par la seule migration : Doctrine ORM ne sait pas exprimer un // ne sait pas exprimer un index fonctionnel + partiel via attribut.
// index partiel via attribut.
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])] #[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])] #[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
@@ -116,16 +109,6 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
#[Groups(['category:read', 'category:write'])] #[Groups(['category:read', 'category:write'])]
private ?string $name = null; private ?string $name = null;
// Code technique stable (slug MAJUSCULE du nom) — NOT NULL + unique parmi les
// actifs (index partiel `uq_category_code` possede par la migration). Genere
// par le CategoryProcessor a la creation puis fige (jamais recalcule sur
// renommage) : sert de cle metier deterministe (RG-1.03 / RG-1.29). Lecture
// seule cote API (hors groupe category:write) : le front filtre dessus mais
// ne le saisit pas.
#[ORM\Column(length: 50)]
#[Groups(['category:read'])]
private ?string $code = null;
#[ORM\ManyToOne(targetEntity: CategoryType::class)] #[ORM\ManyToOne(targetEntity: CategoryType::class)]
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] #[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')] #[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
@@ -158,21 +141,6 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt
return $this; return $this;
} }
/**
* Implemente CategoryInterface : code technique stable de la categorie.
*/
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getCategoryType(): ?CategoryType public function getCategoryType(): ?CategoryType
{ {
return $this->categoryType; return $this->categoryType;
@@ -13,13 +13,6 @@ interface CategoryRepositoryInterface
public function save(Category $category): void; public function save(Category $category): void;
/**
* Vrai si une categorie active (deleted_at IS NULL) porte deja ce code.
* `$excludeId` exclut une categorie precise du test (cas PATCH). Sert a
* garantir l'unicite du code generee par le CategoryCodeGenerator (ERP-78).
*/
public function existsActiveByCode(string $code, ?int $excludeId = null): bool;
/** /**
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut. * Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08) * - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
@@ -7,7 +7,6 @@ namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Module\Catalog\Application\Service\CategoryCodeGenerator;
use App\Module\Catalog\Domain\Entity\Category; use App\Module\Catalog\Domain\Entity\Category;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
@@ -17,13 +16,10 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
/** /**
* Processor Category : applique les regles de gestion en ecriture. * Processor Category : applique les regles de gestion en ecriture.
* *
* - POST / PATCH : trim du nom (RG-1.03) ; a la CREATION, generation du `code` * - POST / PATCH : trim du nom (RG-1.03) puis delegation au persist_processor
* technique stable (slug MAJUSCULE du nom, unique parmi les actifs ERP-78) * Doctrine ORM. Toute UniqueConstraintViolationException remontee par Postgres
* via CategoryCodeGenerator ; puis delegation au persist_processor Doctrine * (collision sur l'index partiel uq_category_name_type_active) est traduite
* ORM. Le code est FIGE a la creation (jamais recalcule sur PATCH). Toute * en HTTP 409 avec le message attendu par la spec (RG-1.07).
* UniqueConstraintViolationException remontee par Postgres (collision sur
* l'index partiel uq_category_name_type_active) est traduite en HTTP 409 avec
* le message attendu par la spec (RG-1.07).
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ; * - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
* on pose deletedAt = now() puis on delegue au persist_processor pour que * on pose deletedAt = now() puis on delegue au persist_processor pour que
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette * le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
@@ -36,7 +32,6 @@ final class CategoryProcessor implements ProcessorInterface
public function __construct( public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor, private readonly ProcessorInterface $persistProcessor,
private readonly CategoryCodeGenerator $codeGenerator,
) {} ) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -67,14 +62,6 @@ final class CategoryProcessor implements ProcessorInterface
$data->setName(trim($data->getName())); $data->setName(trim($data->getName()));
} }
// ERP-78 : le code est genere a la CREATION puis fige. On le (re)genere
// uniquement s'il est absent (POST, ou entite seedee sans code) — un PATCH
// sur une categorie existante conserve son code. Genere depuis le nom
// (NotBlank, deja trimme), unique parmi les actifs.
if (null === $data->getCode() && null !== $data->getName()) {
$data->setCode($this->codeGenerator->generateUnique($data->getName(), $data->getId()));
}
try { try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) { } catch (UniqueConstraintViolationException $e) {
@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider; namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Domain\Entity\Category; use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface; use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
/** /**
@@ -32,32 +29,18 @@ final class CategoryProvider implements ProviderInterface
public function __construct( public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')] #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
private readonly CategoryRepositoryInterface $repository, private readonly CategoryRepositoryInterface $repository,
private readonly Pagination $pagination,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|Paginator|null public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
{ {
$includeDeleted = $this->readIncludeDeleted($context); $includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) { if ($operation instanceof CollectionOperationInterface) {
$qb = $this->repository->createListQueryBuilder($includeDeleted); return $this->repository
->createListQueryBuilder($includeDeleted)
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator. ->getQuery()
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>. ->getResult()
if (!$this->pagination->isEnabled($operation, $context)) { ;
return $qb->getQuery()->getResult();
}
// Branche paginee standard : on applique offset/limit via Pagination,
// puis on enveloppe dans le Paginator ORM (fetchJoinCollection: true
// pour que Doctrine compte correctement avec les JOINs futurs).
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
} }
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete. // Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
@@ -1,132 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\DataFixtures;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/test du module Catalog : ~11 categories de demonstration, toutes
* rattachees au type unique CLIENT (refonte taxonomie ERP-78). Chaque categorie
* porte un `code` stable. Alimente le repertoire clients (ClientFixtures, module
* Commercial) avec des donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR /
* COURTIER) et RG-1.29 (codes interdits sur adresse).
*
* Depend de CategoryTypeFixtures : le type CLIENT doit etre seede avant de
* pouvoir y rattacher des Category.
*
* Idempotence : lookup par `code` parmi les categories non supprimees (deletedAt
* null), coherent avec l'index unique partiel uq_category_code (code WHERE
* deleted_at IS NULL). Rejouable sans doublon meme si le purger Doctrine est
* desactive.
*
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu.
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
* la fixture ne charge rien : les tests seedent et nettoient leurs propres
* categories (prefixe dedie) et comptent sur une table `category` vierge y
* injecter des categories de demo casserait comptages et cleanups FK
* (client_category). Cf. ClientFixtures (meme garde-fou).
*/
class CategoryFixtures extends Fixture implements DependentFixtureInterface
{
/** Code du type unique (cf. CategoryTypeFixtures, migration ERP-78). */
private const string CLIENT_TYPE_CODE = 'CLIENT';
/**
* Source unique des categories de demonstration : nom => code stable. Les 4
* premieres (Distributeur / Courtier / Secteur / Autre) sont les categories
* « systeme » reportees des anciens types ; leurs codes pilotent les RG.
*
* @var array<string, string>
*/
private const CATEGORIES = [
'Distributeur' => 'DISTRIBUTEUR',
'Courtier' => 'COURTIER',
'Secteur' => 'SECTEUR',
'Autre' => 'AUTRE',
'BTP' => 'BTP',
'Industrie' => 'INDUSTRIE',
'Agro-alimentaire' => 'AGRO_ALIMENTAIRE',
'Transport/Logistique' => 'TRANSPORT_LOGISTIQUE',
'Services' => 'SERVICES',
'Association' => 'ASSOCIATION',
'Indépendant' => 'INDEPENDANT',
];
public function __construct(
private readonly CategoryTypeRepositoryInterface $categoryTypeRepository,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [CategoryTypeFixtures::class];
}
public function load(ObjectManager $manager): void
{
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
$clientType = null;
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
if (self::CLIENT_TYPE_CODE === $type->getCode()) {
$clientType = $type;
break;
}
}
if (!$clientType instanceof CategoryType) {
// Misconfiguration : CategoryTypeFixtures n'a pas tourne avant.
throw new RuntimeException(
'CategoryTypeFixtures doit avoir seede le type "CLIENT" avant CategoryFixtures.',
);
}
foreach (self::CATEGORIES as $name => $code) {
$this->ensureCategory($manager, $name, $code, $clientType);
}
$manager->flush();
}
/**
* Cree la categorie (name, code) sous le type CLIENT si son code n'existe pas
* encore parmi les categories actives, sinon la laisse en place. Lookup
* aligne sur l'index unique partiel uq_category_code.
*/
private function ensureCategory(ObjectManager $manager, string $name, string $code, CategoryType $type): void
{
$existing = $manager->getRepository(Category::class)->findOneBy([
'code' => $code,
'deletedAt' => null,
]);
if (null !== $existing) {
return;
}
$category = new Category();
$category->setName($name);
$category->setCode($code);
$category->setCategoryType($type);
$manager->persist($category);
}
}
@@ -10,19 +10,17 @@ use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
/** /**
* Fixtures du module Catalog : seed du type de categorie (M1). * Fixtures du module Catalog : seed des types de categorie metier (M1).
* *
* Refonte taxonomie ERP-78 : le modele n'a plus qu'UN SEUL `category_type`, * La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4
* CLIENT (code CLIENT, label « Client »). Distributeur / Courtier / Secteur / * types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3).
* Autre (et les categories metier fines) sont desormais des `Category` codees
* rattachees a ce type (cf. CategoryFixtures + migration Version20260602100000).
* *
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une * Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) :
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque * `category_type` est une entite managee par l ORM, donc le purger Doctrine la
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la * vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types
* migration disparaitrait apres `make db-reset` / setup de test. Le seed * seedes par la migration disparaitraient apres `make db-reset` / setup de test.
* migration couvre la prod (ou les fixtures ne tournent pas) ; cette fixture * Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette
* re-aligne dev et test. Les deux chemins produisent un etat identique. * fixture re-aligne dev et test. Les deux chemins produisent un etat identique.
* *
* Idempotence : lookup par `code` parmi les types existants avant insertion, * Idempotence : lookup par `code` parmi les types existants avant insertion,
* sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme * sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme
@@ -31,11 +29,14 @@ use Doctrine\Persistence\ObjectManager;
class CategoryTypeFixtures extends Fixture class CategoryTypeFixtures extends Fixture
{ {
/** /**
* Source unique du type : code technique => libelle FR. Doit rester aligne * Source unique des 4 types metier : code technique => libelle FR.
* sur le seed de la migration Version20260602100000 (type unique CLIENT). * Doit rester aligne sur le seed de la migration Version20260601000000.
*/ */
private const TYPES = [ private const TYPES = [
'CLIENT' => 'Client', 'DISTRIBUTEUR' => 'Distributeur',
'COURTIER' => 'Courtier',
'SECTEUR' => 'Secteur',
'AUTRE' => 'Autre',
]; ];
public function __construct( public function __construct(
@@ -31,23 +31,6 @@ class DoctrineCategoryRepository extends ServiceEntityRepository implements Cate
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
} }
public function existsActiveByCode(string $code, ?int $excludeId = null): bool
{
$qb = $this->createQueryBuilder('c')
->select('1')
->andWhere('c.code = :code')
->andWhere('c.deletedAt IS NULL')
->setParameter('code', $code)
->setMaxResults(1)
;
if (null !== $excludeId) {
$qb->andWhere('c.id != :excludeId')->setParameter('excludeId', $excludeId);
}
return [] !== $qb->getQuery()->getResult();
}
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
{ {
$qb = $this->createQueryBuilder('c') $qb = $this->createQueryBuilder('c')
@@ -10,15 +10,17 @@ use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\ConstraintViolationList;
/** /**
* Validator metier RG-1.04 (durcie ERP-74) : pour un utilisateur portant le * Validator metier RG-1.04 : pour un utilisateur portant le role metier
* role metier Commerciale, TOUS les champs de l'onglet Information sont * Commerciale, TOUS les champs de l'onglet Information deviennent obligatoires
* obligatoires sur POST comme sur tout PATCH, independamment des champs * lors d'un PATCH touchant le groupe `client:write:information`.
* reellement envoyes.
* *
* Invoque par le ClientProcessor des que l'utilisateur courant porte le role * Invoque par le ClientProcessor UNIQUEMENT quand les deux conditions sont
* Commerciale (plus de condition d'intersection avec l'onglet Information). * reunies (role Commerciale + payload touchant l'onglet Information). Pour les
* Pour les autres roles, ces champs restent optionnels le validator n'est * autres roles, ces champs restent optionnels le validator n'est pas appele.
* pas appele. *
* Tant qu'aucun user ne porte le role `commerciale` (seede par ERP-74,
* cf. App\Shared\Domain\Security\BusinessRoles::COMMERCIALE), cette regle reste
* DORMANTE : aucun appelant ne la declenche.
* *
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par * Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
* coherence avec les violations Symfony rendues par API Platform. * coherence avec les violations Symfony rendues par API Platform.
+95 -64
View File
@@ -15,7 +15,6 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable; use DateTimeImmutable;
@@ -59,57 +58,37 @@ use Symfony\Component\Validator\Constraints as Assert;
operations: [ operations: [
new GetCollection( new GetCollection(
security: "is_granted('commercial.clients.view')", security: "is_granted('commercial.clients.view')",
// La liste embarque les categories (avec leur code, groupe normalizationContext: ['groups' => ['client:read', 'default:read']],
// 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, provider: ClientProvider::class,
), ),
new Get( new Get(
security: "is_granted('commercial.clients.view')", security: "is_granted('commercial.clients.view')",
// Detail : client + sous-collections embarquees. // Detail : client + sous-collections embarquees. Le groupe
// - client:read:accounting est ajoute par le context builder selon la // client:read:accounting est ajoute par le context builder selon la
// permission (gate les scalaires comptables ET les RIB embarques), // permission, donc absent ici volontairement.
// 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' => [ normalizationContext: ['groups' => [
'client:read', 'client:read',
'client:item:read', 'client:item:read',
'client_contact:read', 'client_contact:read',
'client_address:read', 'client_address:read',
'category:read', 'client_rib:read',
'site:read',
'default:read', 'default:read',
]], ]],
provider: ClientProvider::class, provider: ClientProvider::class,
), ),
new Post( new Post(
security: "is_granted('commercial.clients.manage')", security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], normalizationContext: ['groups' => ['client:read', 'default:read']],
denormalizationContext: ['groups' => ['client:write:main']], denormalizationContext: ['groups' => ['client:write:main']],
processor: ClientProcessor::class, processor: ClientProcessor::class,
), ),
new Patch( new Patch(
// Security elargie (ERP-74) : `manage` OU `accounting.manage`. Le security: "is_granted('commercial.clients.manage')",
// role Compta n'a pas `manage` mais doit pouvoir editer l'onglet
// Comptabilite d'un client existant (§ 2.7). Le ClientProcessor
// re-gate ensuite onglet par onglet :
// - champs accounting -> accounting.manage (guardAccounting, RG-1.28) ;
// - champs main/information -> manage (guardManage : empeche Compta
// d'editer les autres onglets) ;
// - isArchived -> archive (guardArchive, RG-1.22).
security: "is_granted('commercial.clients.manage') or is_granted('commercial.clients.accounting.manage')",
// Le ClientProcessor inspecte les champs reellement envoyes pour // Le ClientProcessor inspecte les champs reellement envoyes pour
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les // autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
// champs accounting exigent accounting.manage, isArchived exige // champs accounting exigent accounting.manage, isArchived exige
// archive, le reste (main/information) exige manage. // archive.
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']], normalizationContext: ['groups' => ['client:read', 'default:read']],
denormalizationContext: ['groups' => [ denormalizationContext: ['groups' => [
'client:write:main', 'client:write:main',
'client:write:information', 'client:write:information',
@@ -151,9 +130,31 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
// Le contact principal n'est plus porte inline par le Client : les contacts // RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor).
// vivent uniquement dans ClientContact (onglet Contact). RG-1.01 / RG-1.02 #[ORM\Column(length: 120, nullable: true)]
// supprimees du Client (equivalent RG-1.05 / RG-1.14 sur ClientContact). #[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $lastName = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups(['client:read', 'client:write:main'])]
private ?string $email = null;
// RG-1.03 : distributor / broker auto-references mutuellement exclusives // RG-1.03 : distributor / broker auto-references mutuellement exclusives
// (CHECK chk_client_distrib_or_broker en base). // (CHECK chk_client_distrib_or_broker en base).
@@ -304,6 +305,66 @@ class Client implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getPhonePrimary(): ?string
{
return $this->phonePrimary;
}
public function setPhonePrimary(string $phonePrimary): static
{
$this->phonePrimary = $phonePrimary;
return $this;
}
public function getPhoneSecondary(): ?string
{
return $this->phoneSecondary;
}
public function setPhoneSecondary(?string $phoneSecondary): static
{
$this->phoneSecondary = $phoneSecondary;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getDistributor(): ?Client public function getDistributor(): ?Client
{ {
return $this->distributor; return $this->distributor;
@@ -582,38 +643,8 @@ class Client implements TimestampableInterface, BlamableInterface
return $this; 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> */ /** @return Collection<int, ClientRib> */
#[Groups(['client:read:accounting'])] #[Groups(['client:item:read'])]
public function getRibs(): Collection public function getRibs(): Collection
{ {
return $this->ribs; return $this->ribs;
@@ -4,13 +4,6 @@ declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity; namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientAddressProcessor;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
@@ -22,64 +15,24 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/** /**
* Adresse d'un client (1:n) onglet Adresse. Une adresse de prospection * Adresse d'un client (1:n) onglet Adresse. Une adresse de prospection
* (isProspect) est exclusive d'une adresse de livraison/facturation * (isProspect) est exclusive d'une adresse de livraison/facturation
* (RG-1.06/07/08). Un email de facturation est obligatoire ssi isBilling * (RG-1.06/07/08, CHECK BDD). Un email de facturation est obligatoire ssi
* (RG-1.11). Au moins un site doit etre rattache (RG-1.10, Assert\Count). Ces * isBilling (RG-1.11, CHECK BDD). Au moins un site doit etre rattache
* regles sont portees par des Assert\Callback (cf. validateProspectExclusivity / * (RG-1.10, Assert\Count).
* validateBillingEmailPresence, ERP-76) qui remontent une 422 avant la base ;
* les CHECK Postgres (chk_client_address_prospect_exclusive /
* chk_client_address_billing_email) restent en filet de securite.
* *
* Relations M2M : * Relations M2M :
* - sites : SiteInterface (module Sites) via resolve_target_entities * - sites : SiteInterface (module Sites) via resolve_target_entities
* - contacts : ClientContact (meme module) * - contacts : ClientContact (meme module)
* - categories : CategoryInterface (module Catalog) via resolve_target_entities * - categories : CategoryInterface (module Catalog) via resolve_target_entities
* codes DISTRIBUTEUR/COURTIER interdits (RG-1.29, validateCategoryCodes, ERP-78) * limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, futur Processor)
* *
* Audite (#[Auditable]) + Timestampable/Blamable. * Audite (#[Auditable]) + Timestampable/Blamable. Aucun ApiResource au M1.1
* * (sous-ressources branchees a un ticket dedie).
* Sous-ressource API (ERP-57, spec § 4.5) :
* - POST /api/clients/{clientId}/addresses : creation rattachee au client parent
* (Link toProperty 'client'), security commercial.clients.manage.
* - PATCH / DELETE /api/client_addresses/{id} : security commercial.clients.manage.
* - GET /api/client_addresses/{id} : lecture unitaire (security view) la
* lecture courante reste via le parent. Pas de GET collection autonome.
* Tout passe par le ClientAddressProcessor (normalisation RG-1.21 billingEmail).
*/ */
#[ApiResource(
operations: [
new Get(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['client_address:read']],
),
new Post(
uriTemplate: '/clients/{clientId}/addresses',
uriVariables: [
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
],
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client_address:read']],
denormalizationContext: ['groups' => ['client_address:write']],
processor: ClientAddressProcessor::class,
),
new Patch(
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client_address:read']],
denormalizationContext: ['groups' => ['client_address:write']],
processor: ClientAddressProcessor::class,
),
new Delete(
security: "is_granted('commercial.clients.manage')",
processor: ClientAddressProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)] #[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)]
#[ORM\Table(name: 'client_address')] #[ORM\Table(name: 'client_address')]
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])] #[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
@@ -88,13 +41,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
{ {
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
/**
* RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre
* clients (distributeur / courtier) et n'ont pas de sens sur une adresse.
* Toute autre categorie du type CLIENT est autorisee.
*/
private const array FORBIDDEN_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER'];
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -105,23 +51,16 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Client $client = null; 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])] #[ORM\Column(name: 'is_prospect', options: ['default' => false])]
#[Groups(['client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private bool $isProspect = false; private bool $isProspect = false;
#[ORM\Column(name: 'is_delivery', options: ['default' => false])] #[ORM\Column(name: 'is_delivery', options: ['default' => false])]
#[Groups(['client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private bool $isDelivery = false; private bool $isDelivery = false;
#[ORM\Column(name: 'is_billing', options: ['default' => false])] #[ORM\Column(name: 'is_billing', options: ['default' => false])]
#[Groups(['client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private bool $isBilling = false; private bool $isBilling = false;
#[ORM\Column(length: 80, options: ['default' => 'France'])] #[ORM\Column(length: 80, options: ['default' => 'France'])]
@@ -149,7 +88,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
// RG-1.11 : obligatoire ssi isBilling (validateBillingEmailPresence + CHECK BDD). // RG-1.11 : obligatoire ssi isBilling (CHECK BDD + futur Processor).
#[ORM\Column(length: 180, nullable: true)] #[ORM\Column(length: 180, nullable: true)]
#[Assert\Email] #[Assert\Email]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
@@ -177,14 +116,12 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private Collection $contacts; private Collection $contacts;
// Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse). // RG-1.29 : categories de type SECTEUR/AUTRE uniquement (filtre au Processor).
// RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_address_category')] #[ORM\JoinTable(name: 'client_address_category')]
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private Collection $categories; private Collection $categories;
@@ -195,80 +132,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
$this->categories = new ArrayCollection(); $this->categories = new ArrayCollection();
} }
/**
* RG-1.06 / RG-1.07 / RG-1.08 : une adresse de prospection est exclusive
* d'une adresse de livraison ou de facturation. Mirror applicatif (422) du
* CHECK chk_client_address_prospect_exclusive, joue avant la base afin de
* remonter une violation Hydra plutot qu'une 500 DBAL.
*/
#[Assert\Callback]
public function validateProspectExclusivity(ExecutionContextInterface $context): void
{
if ($this->isProspect && ($this->isDelivery || $this->isBilling)) {
$context->buildViolation('Une adresse de prospection ne peut pas être une adresse de livraison ni de facturation.')
->atPath('isProspect')
->addViolation()
;
}
}
/**
* RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK
* chk_client_address_billing_email.
*
* On raisonne sur la PRESENCE effective de l'email : null ET chaine vide
* sont traites comme « absent », car le ClientAddressProcessor normalise une
* chaine vide en null APRES la validation (RG-1.21). Sans ce traitement,
* billingEmail="" passerait les callbacks (null === "" est faux) puis serait
* persiste en null avec is_billing=true -> violation du CHECK -> 500 au lieu
* du 422 attendu (et symetriquement, "" sur une adresse non facturable
* serait rejete a tort).
*/
#[Assert\Callback]
public function validateBillingEmailPresence(ExecutionContextInterface $context): void
{
$hasBillingEmail = null !== $this->billingEmail && '' !== trim($this->billingEmail);
if ($this->isBilling && !$hasBillingEmail) {
$context->buildViolation('L\'email de facturation est obligatoire pour une adresse de facturation.')
->atPath('billingEmail')
->addViolation()
;
}
if (!$this->isBilling && $hasBillingEmail) {
$context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.')
->atPath('billingEmail')
->addViolation()
;
}
}
/**
* RG-1.29 (ERP-78) : une adresse interdit les categories de code
* DISTRIBUTEUR / COURTIER elles decrivent une relation entre clients
* (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec
* violation sur le champ `categories`. Toute autre categorie (type unique
* CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas
* d'import du module Catalog regle ABSOLUE n°1).
*/
#[Assert\Callback]
public function validateCategoryCodes(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) {
$context->buildViolation('Type de catégorie non autorisé sur une adresse.')
->atPath('categories')
->addViolation()
;
return;
}
}
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -286,12 +149,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this; 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 public function isProspect(): bool
{ {
return $this->isProspect; return $this->isProspect;
@@ -304,8 +161,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
#[Groups(['client_address:read'])]
#[SerializedName('isDelivery')]
public function isDelivery(): bool public function isDelivery(): bool
{ {
return $this->isDelivery; return $this->isDelivery;
@@ -318,8 +173,6 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
#[Groups(['client_address:read'])]
#[SerializedName('isBilling')]
public function isBilling(): bool public function isBilling(): bool
{ {
return $this->isBilling; return $this->isBilling;
@@ -4,13 +4,6 @@ declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity; namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientContactProcessor;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
@@ -23,50 +16,13 @@ use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Contact d'un client (1:n) onglet Contact. Au moins firstName OU lastName * Contact d'un client (1:n) onglet Contact. Au moins firstName OU lastName
* doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD * doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD
* (chk_client_contact_name) et validee dans le ClientContactProcessor ; * (chk_client_contact_name) et validee dans le futur ClientContactProcessor ;
* l'entite reste permissive (les deux champs sont nullable). * l'entite reste permissive (les deux champs sont nullable).
* *
* Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard). * Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard).
* * Les operations CRUD (sous-ressources POST/PATCH/DELETE) sont branchees au
* Sous-ressource API (ERP-57, spec § 4.5) : * ticket dedie des sous-ressources aucun ApiResource au M1.1 (ERP-54).
* - POST /api/clients/{clientId}/contacts : creation rattachee au client parent
* (Link toProperty 'client'), security commercial.clients.manage.
* - PATCH / DELETE /api/client_contacts/{id} : security commercial.clients.manage.
* Le DELETE est physique (sous-collection, pas le client) ; le processor
* refuse la suppression du dernier contact (RG-1.14, 409).
* - GET /api/client_contacts/{id} : lecture unitaire (security view) la
* lecture courante reste via le parent (client embarque ses contacts). Pas de
* GET collection autonome : non concernee par la pagination ERP-72.
* Tout passe par le ClientContactProcessor (normalisation RG-1.19/1.20/1.21).
*/ */
#[ApiResource(
operations: [
new Get(
security: "is_granted('commercial.clients.view')",
normalizationContext: ['groups' => ['client_contact:read']],
),
new Post(
uriTemplate: '/clients/{clientId}/contacts',
uriVariables: [
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
],
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client_contact:read']],
denormalizationContext: ['groups' => ['client_contact:write']],
processor: ClientContactProcessor::class,
),
new Patch(
security: "is_granted('commercial.clients.manage')",
normalizationContext: ['groups' => ['client_contact:read']],
denormalizationContext: ['groups' => ['client_contact:write']],
processor: ClientContactProcessor::class,
),
new Delete(
security: "is_granted('commercial.clients.manage')",
processor: ClientContactProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)] #[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)]
#[ORM\Table(name: 'client_contact')] #[ORM\Table(name: 'client_contact')]
#[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])] #[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])]
@@ -4,13 +4,6 @@ declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity; namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientRibProcessor;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
@@ -23,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Coordonnees bancaires d'un client (1:n) onglet Comptabilite. Au moins un * Coordonnees bancaires d'un client (1:n) onglet Comptabilite. Au moins un
* RIB est obligatoire si le type de reglement du client est LCR (RG-1.13, * RIB est obligatoire si le type de reglement du client est LCR (RG-1.13,
* verifie au ClientRibProcessor : refus du DELETE du dernier RIB sous LCR). * verifie au futur Processor).
* *
* Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et * Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et
* `bic` AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 : * `bic` AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 :
@@ -32,45 +25,8 @@ use Symfony\Component\Validator\Constraints as Assert;
* *
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1 * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable * (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
* standard. * standard. Aucun ApiResource au M1.1 (sous-ressource branchee ulterieurement).
*
* Sous-ressource API (ERP-57, spec § 4.5) gating comptable renforce :
* - POST /api/clients/{clientId}/ribs : creation rattachee au client parent
* (Link toProperty 'client'), security commercial.clients.accounting.manage.
* - PATCH / DELETE /api/client_ribs/{id} : security commercial.clients.accounting.manage.
* - GET /api/client_ribs/{id} : lecture unitaire, security
* commercial.clients.accounting.view (donnees bancaires sensibles). Pas de
* GET collection autonome.
* Tout passe par le ClientRibProcessor (RG-1.13 sur DELETE).
*/ */
#[ApiResource(
operations: [
new Get(
security: "is_granted('commercial.clients.accounting.view')",
normalizationContext: ['groups' => ['client_rib:read']],
),
new Post(
uriTemplate: '/clients/{clientId}/ribs',
uriVariables: [
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
],
security: "is_granted('commercial.clients.accounting.manage')",
normalizationContext: ['groups' => ['client_rib:read']],
denormalizationContext: ['groups' => ['client_rib:write']],
processor: ClientRibProcessor::class,
),
new Patch(
security: "is_granted('commercial.clients.accounting.manage')",
normalizationContext: ['groups' => ['client_rib:read']],
denormalizationContext: ['groups' => ['client_rib:write']],
processor: ClientRibProcessor::class,
),
new Delete(
security: "is_granted('commercial.clients.accounting.manage')",
processor: ClientRibProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)] #[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)]
#[ORM\Table(name: 'client_rib')] #[ORM\Table(name: 'client_rib')]
#[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])] #[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])]
@@ -79,17 +35,10 @@ class ClientRib implements TimestampableInterface, BlamableInterface
{ {
use TimestampableBlamableTrait; 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\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['client_rib:read', 'client:read:accounting'])] #[Groups(['client_rib:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')] #[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')]
@@ -99,23 +48,23 @@ class ClientRib implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Assert\Length(max: 120, normalizer: 'trim')] #[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] #[Groups(['client_rib:read', 'client_rib:write'])]
private ?string $label = null; private ?string $label = null;
#[ORM\Column(length: 20)] #[ORM\Column(length: 20)]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Assert\Bic] #[Assert\Bic]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] #[Groups(['client_rib:read', 'client_rib:write'])]
private ?string $bic = null; private ?string $bic = null;
#[ORM\Column(length: 34)] #[ORM\Column(length: 34)]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Assert\Iban] #[Assert\Iban]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] #[Groups(['client_rib:read', 'client_rib:write'])]
private ?string $iban = null; private ?string $iban = null;
#[ORM\Column(options: ['default' => 0])] #[ORM\Column(options: ['default' => 0])]
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] #[Groups(['client_rib:read', 'client_rib:write'])]
private int $position = 0; private int $position = 0;
public function getId(): ?int public function getId(): ?int
@@ -16,52 +16,8 @@ interface ClientRepositoryInterface
/** /**
* Construit un QueryBuilder de liste pour le repertoire clients. * Construit un QueryBuilder de liste pour le repertoire clients.
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24). * - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
* - Archivage (RG-1.25) : * - Exclut les archives sauf si $includeArchived = true (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). * - 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.
* - $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( public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
bool $includeArchived = false,
?string $search = 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;
} }
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\Metadata\IriConverterInterface;
use App\Shared\Domain\Contract\SiteInterface;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* Denormalise un IRI (`/api/sites/{id}`) vers le Site concret quand la propriete
* cible est type-hintee par le contrat SiteInterface (ClientAddress::$sites).
*
* Meme mecanisme que CategoryReferenceDenormalizer : API Platform deduit le type
* d'element de collection depuis le phpdoc `@var Collection<int, SiteInterface>`,
* donc l'INTERFACE. Le serializer ne sait pas denormaliser un IRI vers une
* interface (« Could not denormalize object of type SiteInterface[] ») ; on
* resout l'IRI via l'IriConverter (qui retourne le Site mappe a la route) sans
* importer la classe Site du module Sites la regle ABSOLUE n°1 (pas d'import
* cross-module) reste respectee : dependance au seul contrat Shared + API Platform.
*
* En lecture (normalisation), aucun probleme : l'objet reel EST un Site,
* ressource a part entiere, serialise en IRI par le normalizer standard.
*/
final class SiteReferenceDenormalizer implements DenormalizerInterface
{
public function __construct(
private readonly IriConverterInterface $iriConverter,
) {}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?SiteInterface
{
if (!is_string($data) || '' === $data) {
return null;
}
// getResourceFromIri leve une exception sur IRI invalide -> 400, ce qui
// est le comportement attendu pour une reference cassee.
$resource = $this->iriConverter->getResourceFromIri($data);
// IRI syntaxiquement valide mais pointant sur une autre ressource : on
// refuse explicitement plutot que de retourner null silencieusement.
if (!$resource instanceof SiteInterface) {
throw new UnexpectedValueException(sprintf(
'L\'IRI "%s" ne référence pas un site.',
$data,
));
}
return $resource;
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
// Support base sur le seul type cible : l'ArrayDenormalizer (collection
// `SiteInterface[]`) interroge le support en passant le TABLEAU complet
// comme $data avant de deleguer element par element. Tester
// is_string($data) ici casserait la chaine pour les collections.
return SiteInterface::class === $type;
}
/**
* @return array<class-string|string, bool>
*/
public function getSupportedTypes(?string $format): array
{
return [SiteInterface::class => true];
}
}
@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Processor d'ecriture de la sous-ressource Adresse d'un client (M1, § 4.5).
*
* Sequence :
* - POST / PATCH : normalisation serveur du billingEmail en lowercase (RG-1.21)
* via le ClientFieldNormalizer partage. Les autres regles de l'onglet Adresse
* sont deja garanties en amont : RG-1.09 (code postal) et RG-1.10 (>= 1 site)
* par des contraintes Assert sur l'entite, RG-1.06/07/08/11 par des CHECK BDD.
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
* La security de l'operation (commercial.clients.manage) est deja appliquee par
* API Platform, de meme que la validation Symfony des contraintes d'attribut.
*
* @implements ProcessorInterface<ClientAddress, null|ClientAddress>
*/
final class ClientAddressProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly ClientFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ClientAddress) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->normalize($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache l'adresse au client parent de la sous-ressource POST
* (/clients/{clientId}/addresses) : la relation n'est pas peuplee
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ClientAddress $address, array $uriVariables): void
{
if (null !== $address->getClient()) {
return;
}
$clientId = $uriVariables['clientId'] ?? null;
if (null === $clientId) {
return;
}
$client = $clientId instanceof Client
? $clientId
: $this->em->getRepository(Client::class)->find($clientId);
if ($client instanceof Client) {
$address->setClient($client);
}
}
/**
* Normalisation serveur (RG-1.21) : email de facturation en minuscules. La
* methode est null-safe une adresse non facturable (billingEmail null)
* reste null.
*/
private function normalize(ClientAddress $address): void
{
$address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail()));
}
}
@@ -1,151 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientContact;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture de la sous-ressource Contact d'un client (M1, § 4.5).
*
* Sequence :
* - POST / PATCH : normalisation serveur (RG-1.19 prenom/nom capitalize,
* RG-1.20 telephones reduits aux chiffres, RG-1.21 email lowercase) via le
* ClientFieldNormalizer partage (reutilise d'ERP-55), puis validation RG-1.05
* (au moins prenom OU nom) avant persistance.
* - DELETE : RG-1.14 la suppression du DERNIER contact d'un client est
* refusee (409). Au M1, la completude de l'onglet Contact est purement front
* (pas de state machine back) : on garantit seulement qu'un client deja dote
* d'un contact n'en soit jamais vide via l'API.
*
* La security de l'operation (commercial.clients.manage) est deja appliquee par
* API Platform en amont. La validation Symfony des contraintes d'attribut
* (Assert\Email, Assert\Length...) est jouee avant ce processor.
*
* @implements ProcessorInterface<ClientContact, null|ClientContact>
*/
final class ClientContactProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly ClientFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ClientContact) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
$this->guardLastContactDeletion($data);
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->validateName($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le contact au client parent de la sous-ressource POST
* (/clients/{clientId}/contacts). La relation n'est pas peuplee
* automatiquement par le Link sur une operation d'ecriture : on resout donc
* le parent depuis l'uri variable. Sur PATCH (entite existante), le client
* est deja present -> no-op.
*/
private function linkParent(ClientContact $contact, array $uriVariables): void
{
if (null !== $contact->getClient()) {
return;
}
$clientId = $uriVariables['clientId'] ?? null;
if (null === $clientId) {
return;
}
$client = $clientId instanceof Client
? $clientId
: $this->em->getRepository(Client::class)->find($clientId);
if ($client instanceof Client) {
$contact->setClient($client);
}
}
/**
* Normalisation serveur (RG-1.19 / 1.20 / 1.21). Toutes les methodes du
* normalizer sont null-safe : une chaine vide apres trim devient null.
*/
private function normalize(ClientContact $contact): void
{
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
}
/**
* RG-1.05 : au moins le prenom OU le nom est obligatoire (double garde avec
* le CHECK BDD chk_client_contact_name leve un 422 propre plutot qu'une
* erreur SQL). Joue apres normalisation, donc les chaines vides sont deja
* ramenees a null.
*/
private function validateName(ClientContact $contact): void
{
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Le prénom ou le nom du contact est obligatoire.',
null,
[],
$contact,
'firstName',
null,
));
throw new ValidationException($violations);
}
}
/**
* RG-1.14 : refuse la suppression du dernier contact d'un client (409). La
* collection inclut le contact en cours de suppression : un effectif <= 1
* signifie qu'il ne resterait aucun contact. Sans client rattache (cas
* theorique), on laisse passer.
*/
private function guardLastContactDeletion(ClientContact $contact): void
{
$client = $contact->getClient();
if (null === $client) {
return;
}
if ($client->getContacts()->count() <= 1) {
throw new ConflictHttpException(
'Impossible de supprimer le dernier contact du client : au moins un contact est requis.',
);
}
}
}
@@ -16,7 +16,6 @@ use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use JsonException; use JsonException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -29,22 +28,19 @@ use Symfony\Component\Validator\ConstraintViolationList;
/** /**
* Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 / * Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 /
* § 2.9 / § 4.3 / § 4.4 + RG-1.03 a RG-1.28 (RG-1.01/1.02 supprimees : contact inline retire). * § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28.
* *
* Sequence (POST / PATCH) : * Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet. La security d'operation * 1. Autorisation additionnelle par groupe d'onglet (le `security` de
* du PATCH a ete elargie (ERP-74) a `manage` OU `accounting.manage` pour * l'operation a deja exige commercial.clients.manage) :
* laisser entrer le role Compta ; ce processor re-gate alors finement : * - champ comptable dans le payload -> exige accounting.manage (RG-1.28, 403) ;
* - champ comptable modifie dans le payload -> exige accounting.manage (RG-1.28, 403) ;
* - champ main/information modifie -> exige manage (guardManage, 403) : empeche
* Compta d'editer un autre onglet que la Comptabilite (§ 2.7) ;
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et * - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
* interdit toute autre modification dans la meme requete (RG-1.22, 422). * interdit toute autre modification dans la meme requete (RG-1.22, 422).
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.03 (distributor/broker * 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque), * exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role
* et tout PATCH pour le role Commerciale). * Commerciale).
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null). * 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
* 5. Persistance via le persist_processor Doctrine, avec traduction des * 5. Persistance via le persist_processor Doctrine, avec traduction des
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de * collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
@@ -60,7 +56,8 @@ final class ClientProcessor implements ProcessorInterface
{ {
/** Champs de l'onglet principal (groupe client:write:main). */ /** Champs de l'onglet principal (groupe client:write:main). */
private const array MAIN_FIELDS = [ private const array MAIN_FIELDS = [
'companyName', 'distributor', 'broker', 'triageService', 'categories', 'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary',
'email', 'distributor', 'broker', 'triageService', 'categories',
]; ];
/** Champs de l'onglet Information (groupe client:write:information). */ /** Champs de l'onglet Information (groupe client:write:information). */
@@ -78,23 +75,9 @@ final class ClientProcessor implements ProcessorInterface
/** Champ d'archivage (groupe client:write:archive). */ /** Champ d'archivage (groupe client:write:archive). */
private const string ARCHIVE_FIELD = 'isArchived'; private const string ARCHIVE_FIELD = 'isArchived';
private const string PERM_MANAGE = 'commercial.clients.manage';
private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage'; private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage';
private const string PERM_ARCHIVE = 'commercial.clients.archive'; private const string PERM_ARCHIVE = 'commercial.clients.archive';
/**
* Memoisation du dernier corps de requete decode, clos par le contenu brut.
* payloadKeys() est appele plusieurs fois par requete (writablePayloadKeys,
* categoriesChanged...) : on evite de rejouer json_decode a chaque appel. La
* cle etant le contenu lui-meme et le calcul une fonction pure de ce contenu,
* aucune fuite n'est possible entre requetes sur ce service partage (un meme
* corps redonne les memes cles).
*/
private ?string $decodedContent = null;
/** @var list<string> Cles de premier niveau correspondant au corps memoise. */
private array $decodedPayloadKeys = [];
public function __construct( public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor, private readonly ProcessorInterface $persistProcessor,
@@ -118,14 +101,10 @@ final class ClientProcessor implements ProcessorInterface
$this->normalize($data); $this->normalize($data);
// guardManage apres normalize : la comparaison « change vs etat $this->validateMainContact($data);
// persiste » des champs texte (companyName, email...) se fait sur des
// valeurs normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
$this->validateDistributorBroker($data); $this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data); $this->validateAccountingConsistency($data);
$this->validateInformationCompleteness($data); $this->validateInformationCompleteness($data, $writableKeys);
try { try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
@@ -220,140 +199,6 @@ final class ClientProcessor implements ProcessorInterface
} }
} }
/**
* § 2.7 / RG-1.28 (ERP-74) : la modification effective d'un champ « metier »
* (onglets principal ou Information) exige `commercial.clients.manage`. Sans
* cette permission -> 403 sur l'ensemble du payload (mode strict, miroir de
* guardAccounting). C'est ce qui empeche le role Compta qui entre dans le
* PATCH via `accounting.manage` (security d'operation elargie) — d'editer
* autre chose que l'onglet Comptabilite.
*
* Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est
* deja gardee par la security d'operation `manage`, donc inutile de la
* re-gater ici (et un POST par un porteur de `manage` passerait de toute
* facon).
*/
private function guardManage(Client $data): void
{
if (!$this->em->contains($data)) {
return;
}
$changed = $this->changedBusinessFields($data);
if ([] === $changed) {
return;
}
if (!$this->security->isGranted(self::PERM_MANAGE)) {
throw new AccessDeniedHttpException(sprintf(
'Le champ "%s" requiert la permission "%s".',
$changed[0],
self::PERM_MANAGE,
));
}
}
/**
* Champs « metier » (onglets principal + Information, hors comptabilite et
* archivage) dont la valeur courante differe de l'etat persiste. Memes
* regles de comparaison que changedAccountingFields (scalaires par valeur,
* relations ManyToOne distributor/broker par identite via l'identity map).
*
* Cas particulier `categories` (M2M) : non trace par getOriginalEntityData,
* compare par valeur via le snapshot de la PersistentCollection (cf.
* categoriesChanged) la simple presence dans le payload ne suffit pas, sous
* peine de 403 parasite sur un PATCH representation complete reincluant des
* categories inchangees.
*
* @return list<string>
*/
private function changedBusinessFields(Client $data): array
{
$newValues = [
'companyName' => $data->getCompanyName(),
'distributor' => $data->getDistributor(),
'broker' => $data->getBroker(),
'triageService' => $data->isTriageService(),
'description' => $data->getDescription(),
'competitors' => $data->getCompetitors(),
'foundedAt' => $data->getFoundedAt(),
'employeesCount' => $data->getEmployeesCount(),
'revenueAmount' => $data->getRevenueAmount(),
'directorName' => $data->getDirectorName(),
'profitAmount' => $data->getProfitAmount(),
];
$changed = [];
foreach ($newValues as $field => $newValue) {
if ($this->fieldChanged($data, $field, $newValue)) {
$changed[] = $field;
}
}
if ($this->categoriesChanged($data)) {
$changed[] = 'categories';
}
return $changed;
}
/**
* Vrai si l'ensemble des categories (M2M) differe reellement de l'etat
* persiste. La collection n'etant pas tracee par getOriginalEntityData, on
* compare par identifiants (independamment de l'ordre) le snapshot de la
* PersistentCollection (etat charge depuis la base) a l'etat courant (apres
* application du payload). Symetrique de changedAccountingFields : seul un
* changement effectif compte, pas la simple presence dans le payload.
*
* - POST / entite non geree : fournir des categories est un acte metier
* (comportement historique conserve) branche defensive, guardManage ne
* s'execute de toute facon que sur entite geree.
* - categories absent du payload (PATCH partiel) : aucun changement.
*/
private function categoriesChanged(Client $data): bool
{
if (!$this->em->contains($data)) {
return true;
}
if (!in_array('categories', $this->payloadKeys(), true)) {
return false;
}
$collection = $data->getCategories();
// Hors PersistentCollection (cas limite hors flux PATCH reel) : faute
// d'etat persiste comparable, on se rabat sur la presence payload.
if (!$collection instanceof PersistentCollection) {
return true;
}
return $this->categoryIdSet($collection->toArray())
!== $this->categoryIdSet($collection->getSnapshot());
}
/**
* Ensemble trie des identifiants d'une liste de categories pour une
* comparaison par valeur independante de l'ordre.
*
* @param array<int, object> $categories
*
* @return list<mixed>
*/
private function categoryIdSet(array $categories): array
{
$ids = array_map(
static fn (object $category): mixed => method_exists($category, 'getId')
? $category->getId()
: spl_object_id($category),
array_values($categories),
);
sort($ids);
return $ids;
}
/** /**
* Champs comptables dont la valeur courante differe de l'etat persiste. Les * Champs comptables dont la valeur courante differe de l'etat persiste. Les
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par * relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
@@ -413,24 +258,45 @@ final class ClientProcessor implements ProcessorInterface
} }
/** /**
* Normalisation serveur du formulaire principal (RG-1.18). Seul companyName * Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables
* subsiste cote Client depuis la suppression du contact inline (les champs de * (companyName, email, phonePrimary) ne sont touches que si une valeur est
* contact noms, telephones, email sont normalises par ClientContactProcessor). * presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
* Le setter non-nullable n'est touche que si une valeur est presente, pour ne
* jamais ecraser l'existant lors d'un PATCH partiel.
*/ */
private function normalize(Client $data): void private function normalize(Client $data): void
{ {
if (null !== $data->getCompanyName()) { if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName())); $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
} }
if (null !== $data->getEmail()) {
$data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail()));
}
if (null !== $data->getPhonePrimary()) {
$data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary()));
}
$data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName()));
$data->setLastName($this->normalizer->normalizePersonName($data->getLastName()));
$data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary()));
}
/**
* RG-1.01 : au moins le prenom OU le nom du contact principal.
*/
private function validateMainContact(Client $data): void
{
if (null === $data->getFirstName() && null === $data->getLastName()) {
$this->throwViolation(
'firstName',
'Le prénom ou le nom du contact principal est obligatoire.',
$data,
);
}
} }
/** /**
* RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor * RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor
* doit referencer un client portant la categorie de code DISTRIBUTEUR (idem * doit referencer un client de categorie DISTRIBUTEUR (idem broker ->
* broker -> COURTIER). Depuis ERP-78, le filtrage se fait sur le code de la * COURTIER).
* Category (et non plus sur le type, devenu unique CLIENT).
*/ */
private function validateDistributorBroker(Client $data): void private function validateDistributorBroker(Client $data): void
{ {
@@ -445,7 +311,7 @@ final class ClientProcessor implements ProcessorInterface
); );
} }
if (null !== $distributor && !$this->hasCategoryCode($distributor, 'DISTRIBUTEUR')) { if (null !== $distributor && !$this->hasCategoryType($distributor, 'DISTRIBUTEUR')) {
$this->throwViolation( $this->throwViolation(
'distributor', 'distributor',
'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.', 'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.',
@@ -453,7 +319,7 @@ final class ClientProcessor implements ProcessorInterface
); );
} }
if (null !== $broker && !$this->hasCategoryCode($broker, 'COURTIER')) { if (null !== $broker && !$this->hasCategoryType($broker, 'COURTIER')) {
$this->throwViolation( $this->throwViolation(
'broker', 'broker',
'Le courtier référencé doit être un client de catégorie COURTIER.', 'Le courtier référencé doit être un client de catégorie COURTIER.',
@@ -487,28 +353,29 @@ final class ClientProcessor implements ProcessorInterface
} }
/** /**
* RG-1.04 (durcie ERP-74) : si l'utilisateur porte le role metier * RG-1.04 : si l'utilisateur porte le role metier Commerciale ET que le
* Commerciale, TOUS les champs de l'onglet Information sont obligatoires sur * payload touche l'onglet Information, tous les champs Information sont
* POST comme sur TOUT PATCH independamment des champs reellement envoyes * obligatoires. Dormant tant qu'aucun user ne porte le role `commerciale`.
* (plus de condition d'intersection avec INFORMATION_FIELDS). Garantit qu'un *
* client cree/edite par une Commerciale ne reste jamais avec un onglet * @param list<string> $payloadKeys
* Information incomplet.
*/ */
private function validateInformationCompleteness(Client $data): void private function validateInformationCompleteness(Client $data, array $payloadKeys): void
{ {
if ($this->currentUserIsCommerciale()) { $touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS);
if ($touchesInformation && $this->currentUserIsCommerciale()) {
$this->informationValidator->validate($data); $this->informationValidator->validate($data);
} }
} }
/** /**
* Vrai si au moins une categorie du client porte le code donne. S'appuie sur * Vrai si au moins une categorie du client porte le type donne. S'appuie
* CategoryInterface::getCode() (pas d'import de Category regle ABSOLUE n°1). * sur CategoryInterface::getCategoryTypeCode() (pas d'import de Category).
*/ */
private function hasCategoryCode(Client $client, string $code): bool private function hasCategoryType(Client $client, string $typeCode): bool
{ {
foreach ($client->getCategories() as $category) { foreach ($client->getCategories() as $category) {
if ($category instanceof CategoryInterface && $category->getCode() === $code) { if ($category instanceof CategoryInterface && $category->getCategoryTypeCode() === $typeCode) {
return true; return true;
} }
} }
@@ -561,26 +428,6 @@ final class ClientProcessor implements ProcessorInterface
} }
$content = $request->getContent(); $content = $request->getContent();
// Cache hit : meme corps brut que le dernier decodage -> memes cles.
if ($content === $this->decodedContent) {
return $this->decodedPayloadKeys;
}
$this->decodedContent = $content;
$this->decodedPayloadKeys = $this->extractPayloadKeys($content);
return $this->decodedPayloadKeys;
}
/**
* Decode le corps brut et en extrait les cles de premier niveau (chaines).
* Corps vide ou JSON invalide -> aucune cle.
*
* @return list<string>
*/
private function extractPayloadKeys(string $content): array
{
if ('' === $content) { if ('' === $content) {
return []; return [];
} }
@@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientRib;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
/**
* Processor d'ecriture de la sous-ressource RIB d'un client (M1, § 4.5).
*
* Sequence :
* - POST / PATCH : aucune normalisation specifique. La validite de l'IBAN et du
* BIC est garantie par Assert\Iban / Assert\Bic sur l'entite (jouees en amont
* par API Platform). Aucun #[AuditIgnore] sur iban/bic : la tracabilite
* comptable est volontaire (decision Matthieu 29/05, spec § 6.1).
* - DELETE : RG-1.13 si le client est en reglement LCR, la suppression de son
* DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
*
* La security de l'operation (commercial.clients.accounting.manage) est deja
* appliquee par API Platform en amont : un utilisateur sans cette permission
* recoit 403 sur POST/PATCH/DELETE avant d'atteindre ce processor.
*
* @implements ProcessorInterface<ClientRib, null|ClientRib>
*/
final class ClientRibProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof ClientRib) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
if ($operation instanceof DeleteOperationInterface) {
$this->guardLastRibDeletionUnderLcr($data);
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
$this->linkParent($data, $uriVariables);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
/**
* Rattache le RIB au client parent de la sous-ressource POST
* (/clients/{clientId}/ribs) : la relation n'est pas peuplee automatiquement
* par le Link sur une ecriture. Sur PATCH, no-op.
*/
private function linkParent(ClientRib $rib, array $uriVariables): void
{
if (null !== $rib->getClient()) {
return;
}
$clientId = $uriVariables['clientId'] ?? null;
if (null === $clientId) {
return;
}
$client = $clientId instanceof Client
? $clientId
: $this->em->getRepository(Client::class)->find($clientId);
if ($client instanceof Client) {
$rib->setClient($client);
}
}
/**
* RG-1.13 : un client dont le type de reglement est LCR doit conserver au
* moins un RIB. La collection inclut le RIB en cours de suppression : un
* effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
* type de reglement, les RIBs sont optionnels (suppression libre).
*/
private function guardLastRibDeletionUnderLcr(ClientRib $rib): void
{
$client = $rib->getClient();
if (null === $client) {
return;
}
if ('LCR' === $client->getPaymentType()?->getCode() && $client->getRibs()->count() <= 1) {
throw new ConflictHttpException(
'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
);
}
}
}
@@ -11,6 +11,8 @@ use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Module\Commercial\Domain\Entity\Client; use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface; use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -24,7 +26,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* exclus au M1) RG-1.25 ; * exclus au M1) RG-1.25 ;
* - tri par defaut companyName ASC RG-1.26 ; * - tri par defaut companyName ASC RG-1.26 ;
* - filtres ?search=... (fuzzy companyName + lastName + email) et * - filtres ?search=... (fuzzy companyName + lastName + email) et
* ?categoryCode=<code> (clients ayant >= 1 categorie de ce code ERP-78) ; * ?categoryType=<code> (clients ayant >= 1 categorie de ce type) ;
* - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ; * - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ;
* echappatoire ?pagination=false pour alimenter un <select> sans pagination. * echappatoire ?pagination=false pour alimenter un <select> sans pagination.
* *
@@ -44,6 +46,7 @@ final class ClientProvider implements ProviderInterface
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')] #[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
private readonly ClientRepositoryInterface $repository, private readonly ClientRepositoryInterface $repository,
private readonly Pagination $pagination, private readonly Pagination $pagination,
private readonly EntityManagerInterface $em,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
@@ -64,32 +67,16 @@ final class ClientProvider implements ProviderInterface
{ {
$filters = $context['filters'] ?? []; $filters = $context['filters'] ?? [];
$includeArchived = $this->readBool($filters['includeArchived'] ?? false); $includeArchived = $this->readBool($filters['includeArchived'] ?? false);
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
$search = $filters['search'] ?? 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);
$qb = $this->repository->createListQueryBuilder( $this->applySearch($qb, $filters['search'] ?? null);
$includeArchived, $this->applyCategoryType($qb, $filters['categoryType'] ?? null);
is_string($search) ? $search : null,
$categoryCodes,
$siteIds,
$archivedOnly,
);
// Echappatoire ?pagination=false : collection complete sans Paginator // Echappatoire ?pagination=false : collection complete sans Paginator
// (cf. convention ERP-72 — utile pour un <select> cote front). // (cf. convention ERP-72 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) { if (!$this->pagination->isEnabled($operation, $context)) {
/** @var list<Client> $clients */ // @var list<Client> $result
$clients = $qb->getQuery()->getResult(); return $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); $limit = $this->pagination->getLimit($operation, $context);
@@ -98,13 +85,9 @@ final class ClientProvider implements ProviderInterface
$qb->setFirstResult($offset)->setMaxResults($limit); $qb->setFirstResult($offset)->setMaxResults($limit);
// Le QB de selection ne porte plus de fetch-join to-many (ERP-100) : le // fetchJoinCollection: true pour un COUNT correct des que des JOINs
// COUNT est simple, fetchJoinCollection inutile. On materialise la page // to-many seront ajoutes (sous-collections embarquees en detail).
// puis on hydrate ses collections en lot (memes entites managees). return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
$this->repository->hydrateListCollections(iterator_to_array($paginator));
return $paginator;
} }
/** /**
@@ -131,6 +114,55 @@ final class ClientProvider implements ProviderInterface
return $client; return $client;
} }
/**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
* litteraux.
*/
private function applySearch(QueryBuilder $qb, mixed $search): void
{
if (!is_string($search) || '' === trim($search)) {
return;
}
$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);
}
/**
* Restreint aux clients possedant au moins une categorie du type donne.
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
*/
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
{
if (!is_string($categoryType) || '' === trim($categoryType)) {
return;
}
// Sous-requete construite via l'EntityManager (et non
// $repository->createQueryBuilder()) : createQueryBuilder() n'est pas
// declaree sur ClientRepositoryInterface, l'appeler exposerait un detail
// d'implementation Doctrine hors du contrat (fuite d'abstraction).
$sub = $this->em->createQueryBuilder()
->select('c2.id')
->from(Client::class, 'c2')
->join('c2.categories', 'cat2')
->join('cat2.categoryType', 'ct2')
->where('ct2.code = :categoryType')
;
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
->setParameter('categoryType', trim($categoryType))
;
}
/** /**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1". * Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/ */
@@ -142,44 +174,4 @@ final class ClientProvider implements ProviderInterface
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); 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;
}
} }
@@ -1,245 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Controller;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Export XLSX du repertoire clients (M1, spec-back § 4.6).
*
* Controller Symfony custom (et non operation API Platform) car il produit un
* binaire de fichier, pas une representation Hydra. `priority: 1` est
* OBLIGATOIRE sur la route : sans cela API Platform capterait
* `/api/clients/export.xlsx` comme l'item `GET /api/clients/{id}.{_format}`
* (id="export", _format="xlsx") cf. CLAUDE.md « controller custom sous /api ».
*
* Separation des responsabilites :
* - le COMMENT (generation du fichier) est delegue au service Shared
* {@see SpreadsheetExporterInterface} generique, reutilisable, sans metier ;
* - le QUOI vit ICI : selection des clients (memes filtres que
* `GET /api/clients`, via {@see ClientRepositoryInterface::createListQueryBuilder()})
* et mapping metier des colonnes.
*
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
* `commercial.clients.accounting.view` (gating identique a la lecture).
*/
#[AsController]
final class ClientExportController
{
public function __construct(
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
private readonly ClientRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly Security $security,
) {}
#[Route('/api/clients/export.xlsx', name: 'commercial_clients_export_xlsx', methods: ['GET'], priority: 1)]
#[IsGranted('commercial.clients.view')]
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;
// 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, $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(
'Répertoire clients',
$this->buildHeaders($withSiren),
$this->buildRows($clients, $withSiren),
);
return $this->buildResponse($binary);
}
/**
* 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>
*/
private function buildHeaders(bool $withSiren): array
{
$headers = [
'Nom entreprise',
'Catégories',
'Sites',
];
if ($withSiren) {
$headers[] = 'SIREN';
}
$headers[] = 'Date de création';
return $headers;
}
/**
* @param list<Client> $clients
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $clients, bool $withSiren): iterable
{
foreach ($clients as $client) {
$row = [
$client->getCompanyName(),
$this->formatCategories($client),
$this->formatSites($client),
];
if ($withSiren) {
$row[] = $client->getSiren();
}
$row[] = $client->getCreatedAt()?->format('d/m/Y');
yield $row;
}
}
/**
* Libelles des categories du client, dedupliques, tries, joints par virgule.
*/
private function formatCategories(Client $client): string
{
$names = [];
foreach ($client->getCategories() as $category) {
// @var CategoryInterface $category
$name = $category->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
return $this->joinSorted($names);
}
/**
* Le Client ne porte pas de sites en propre : ils sont rattaches aux
* adresses (RG-1.10). La colonne « Sites » agrege donc l'union distincte des
* sites de toutes les adresses du client (decision validee 01/06).
*/
private function formatSites(Client $client): string
{
$names = [];
foreach ($client->getAddresses() as $address) {
foreach ($address->getSites() as $site) {
// @var SiteInterface $site
$name = $site->getName();
if (null !== $name && '' !== $name) {
$names[$name] = true;
}
}
}
return $this->joinSorted($names);
}
/**
* @param array<string, true> $names ensemble de libelles (cles)
*/
private function joinSorted(array $names): string
{
$list = array_keys($names);
sort($list);
return implode(', ', $list);
}
private function buildResponse(string $binary): Response
{
$filename = sprintf('repertoire-clients-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
$response = new Response($binary);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
return $response;
}
/**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
* Aligne sur ClientProvider pour un comportement identique a la liste.
*/
private function readBool(mixed $raw): bool
{
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;
}
}
@@ -1,491 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures;
use App\Module\Catalog\Infrastructure\DataFixtures\CategoryFixtures;
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Commercial\Domain\Entity\ClientContact;
use App\Module\Commercial\Domain\Entity\ClientRib;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use DateTimeImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Fixtures dev/test du module Commercial : ~14 clients de demonstration couvrant
* l'ensemble des cas metier RG-1.xx du repertoire clients (M1) :
* - client basique ; dependant distributeur / courtier (RG-1.03) ;
* - reglement LCR avec 2 RIB (RG-1.13) ; reglement Cheque sans RIB ;
* - multi-adresses Prospect / Livraison / Facturation (RG-1.06/07/08/11) ;
* - prospect seul ; 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) ;
* - client archive (RG-1.22) ; onglet Information complet ; multi-categories M2M.
*
* Resolution inter-modules conforme a la regle n°1 (pas d'import direct) :
* - categories resolues via le contrat Shared CategoryInterface
* (resolve_target_entities -> Category) ;
* - sites resolus via le contrat Shared SiteProviderInterface.
*
* Normalisation : les valeurs sont fournies BRUTES (casse libre, telephones
* formates) et normalisees par ClientFieldNormalizer avant persist, exactement
* comme le ferait le ClientProcessor via l'API (companyName UPPERCASE,
* first/last Capitalize, telephones chiffres seuls, emails lowercase).
*
* Distributeur / courtier auto-references (RG-1.03) : les tiers referencables
* (GSO distributeur, Cabinet Leonard courtier) sont crees AVANT les clients qui
* les referencent ; un unique flush en fin de load ordonne correctement les
* inserts auto-references.
*
* Idempotence : lookup par companyName normalise (coherent avec l'index unique
* partiel uq_client_company_name_active). Un client deja present n'est pas
* reconstruit (ses sous-collections ne sont pas redupliquees). Rejouable sans
* doublon meme si le purger Doctrine est desactive.
*
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
* les CHECK BDD ET les validators applicatifs (exclusivite Prospect, billingEmail
* ssi facturation, aucune categorie de code DISTRIBUTEUR/COURTIER sur une adresse
* RG-1.29, ERP-78).
*
* Depend de CategoryFixtures (categories), SitesFixtures (sites) et
* CommercialReferentialFixtures (referentiels comptables Bank / PaymentType).
*
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
* la fixture ne charge rien : les tests seedent et nettoient leurs propres
* clients et comptent sur une table `client` vierge y injecter 14 clients de
* demo casserait les comptages de liste et les cleanups. Meme garde-fou que
* CategoryFixtures.
*/
class ClientFixtures extends Fixture implements DependentFixtureInterface
{
/** Cache des categories resolues par nom (evite des requetes repetees). */
private array $categoryCache = [];
/** Cache des sites resolus par nom. */
private array $siteCache = [];
/** ObjectManager courant, capture en debut de load (resolution categories). */
private ObjectManager $manager;
public function __construct(
private readonly ClientFieldNormalizer $normalizer,
private readonly SiteProviderInterface $siteProvider,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
return [
CategoryFixtures::class,
SitesFixtures::class,
CommercialReferentialFixtures::class,
];
}
public function load(ObjectManager $manager): void
{
// Donnees de demo : dev uniquement. En test, on laisse la table vierge.
if ('test' === $this->environment) {
return;
}
$this->manager = $manager;
// === Tiers referencables (RG-1.03) : crees en premier ===
// Distributeur reference par d'autres clients.
[$gso, $gsoIsNew] = $this->ensureClient(
$manager,
companyName: 'Distrib Grand Sud-Ouest',
categoryNames: ['Distributeur'],
);
if ($gsoIsNew) {
$this->addContact($gso, 'Paul', 'Garnier', 'Directeur commercial', '05 56 10 20 30', null, 'paul.garnier@distrib-gso.fr');
$this->addAddress($gso, ['Pommevic'], '82400', 'Pommevic', '1 Av. Jean Duquesne', isDelivery: true, categoryNames: ['Transport/Logistique']);
}
// Courtier reference par d'autres clients.
[$leonard, $leonardIsNew] = $this->ensureClient(
$manager,
companyName: 'Cabinet Léonard Assurances',
categoryNames: ['Courtier'],
);
if ($leonardIsNew) {
$this->addContact($leonard, 'Sophie', 'Léonard', 'Gérante', '05 49 11 22 33', null, 'sophie.leonard@cabinet-leonard.fr');
$this->addAddress($leonard, ['Chatellerault'], '86100', 'Châtellerault', '5 rue des Courtiers', isBilling: true, billingEmail: 'Factures@Cabinet-Leonard.FR');
}
// === Client basique ===
[$dubois, $isNew] = $this->ensureClient(
$manager,
companyName: 'Menuiserie Dubois',
categoryNames: ['BTP'],
);
if ($isNew) {
$dubois->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$dubois->setBank($this->bank($manager, 'SG'));
$this->addContact($dubois, 'Jean', 'Dubois', 'Gérant', '05 49 00 00 01', null, 'jean.dubois@menuiserie-dubois.fr');
$this->addAddress($dubois, ['Chatellerault'], '86100', 'Châtellerault', '12 rue de l\'Atelier', isDelivery: true, categoryNames: ['BTP']);
}
// === Dependant d'un distributeur (RG-1.03) ===
[$garage, $isNew] = $this->ensureClient(
$manager,
companyName: 'Garage Martin',
categoryNames: ['Services'],
);
if ($isNew) {
$garage->setDistributor($gso);
$this->addContact($garage, 'Luc', 'Martin', 'Gérant', '05 56 44 55 66', null, 'luc.martin@garage-martin.fr');
$this->addAddress($garage, ['Pommevic'], '82400', 'Pommevic', '8 route de Moissac', isDelivery: true);
}
// === Dependant d'un courtier (RG-1.03) ===
[$boulangerie, $isNew] = $this->ensureClient(
$manager,
companyName: 'Boulangerie Lemoine',
categoryNames: ['Agro-alimentaire'],
);
if ($isNew) {
$boulangerie->setBroker($leonard);
$this->addContact($boulangerie, 'Marie', 'Lemoine', 'Gérante', '05 49 77 88 99', null, 'marie.lemoine@boulangerie-lemoine.fr');
$this->addAddress($boulangerie, ['Chatellerault'], '86100', 'Châtellerault', '3 place du Marché', isDelivery: true);
}
// === Reglement LCR avec 2 RIB (RG-1.13) ===
[$transports, $isNew] = $this->ensureClient(
$manager,
companyName: 'Transports Rapides',
categoryNames: ['Transport/Logistique'],
);
if ($isNew) {
$transports->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($transports, null, 'Bernard', 'Responsable exploitation', '05 56 12 13 14', null, 'expl@transports-rapides.fr');
$this->addAddress($transports, ['Saint-Jean'], '17400', 'Fontenet', '2 zone industrielle', isDelivery: true, categoryNames: ['Transport/Logistique']);
$this->addRib($transports, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
$this->addRib($transports, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630006000011234567890189', 1);
}
// === Multi-adresses Prospect / Livraison / Facturation (RG-1.06/07/08/11) ===
[$industries, $isNew] = $this->ensureClient(
$manager,
companyName: 'Industries Vertes',
categoryNames: ['Industrie'],
);
if ($isNew) {
$this->addContact($industries, 'Claire', 'Moreau', 'Directrice', '05 49 21 22 23', null, 'claire.moreau@industries-vertes.fr');
// Prospect : exclusif de livraison/facturation (sans billingEmail).
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '1 avenue de la Prospection', isProspect: true, position: 0);
// Livraison.
$this->addAddress($industries, ['Saint-Jean'], '17400', 'Fontenet', '4 rue de la Livraison', isDelivery: true, categoryNames: ['Industrie'], position: 1);
// Facturation : billingEmail obligatoire.
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '7 boulevard des Factures', isBilling: true, billingEmail: 'Compta@Industries-Vertes.FR', position: 2);
}
// === 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) ===
[$agro, $isNew] = $this->ensureClient(
$manager,
companyName: 'Agro Distribution Sud',
categoryNames: ['Agro-alimentaire'],
);
if ($isNew) {
$this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0);
$this->addContact($agro, 'Julie', 'Roux', 'Assistante commerciale', '05 56 31 32 34', null, 'julie.roux@agro-sud.fr', 1);
$this->addContact($agro, 'Marc', 'Girard', 'Logistique', '05 56 31 32 35', null, 'marc.girard@agro-sud.fr', 2);
$this->addAddress($agro, ['Pommevic'], '82400', 'Pommevic', '10 rue des Producteurs', isDelivery: true);
}
// === Client archive (RG-1.22) ===
[$ancienne, $isNew] = $this->ensureClient(
$manager,
companyName: 'Ancienne Société Oubliée',
categoryNames: ['Association'],
isArchived: true,
);
if ($isNew) {
$this->addContact($ancienne, null, 'Durand', 'Ancien contact', '05 49 99 99 99', null, 'contact@ancienne-societe.fr');
$this->addAddress($ancienne, ['Chatellerault'], '86100', 'Châtellerault', '99 rue Fermée', isDelivery: true);
}
// === Reglement Cheque sans RIB ===
[$services, $isNew] = $this->ensureClient(
$manager,
companyName: 'Services Pro Conseil',
categoryNames: ['Services'],
);
if ($isNew) {
$services->setPaymentType($this->paymentType($manager, 'CHEQUE'));
$this->addContact($services, 'Nadia', 'Benali', 'Consultante', '05 49 41 42 43', null, 'nadia.benali@services-pro.fr');
$this->addAddress($services, ['Chatellerault'], '86100', 'Châtellerault', '15 rue du Conseil', isDelivery: true);
}
// === Onglet Information complet (RG-1.04) ===
[$holding, $isNew] = $this->ensureClient(
$manager,
companyName: 'Holding Premium Invest',
categoryNames: ['Industrie'],
);
if ($isNew) {
$holding->setDescription('Holding industrielle diversifiée, présente sur le Grand Sud-Ouest.');
$holding->setCompetitors('Groupe Atlantique, Sud Industries');
$holding->setFoundedAt(new DateTimeImmutable('2005-03-15'));
$holding->setEmployeesCount(240);
$holding->setRevenueAmount('18500000.00');
$holding->setDirectorName('Antoine Lefèvre');
$holding->setProfitAmount('1250000.00');
$this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-premium.fr');
$this->addAddress($holding, ['Pommevic'], '82400', 'Pommevic', '1 allée des Investisseurs', isDelivery: true, categoryNames: ['Industrie']);
}
// === Multi-categories M2M ===
[$conglo, $isNew] = $this->ensureClient(
$manager,
companyName: 'Conglomérat Multi Activités',
categoryNames: ['BTP', 'Industrie', 'Services'],
);
if ($isNew) {
$this->addContact($conglo, 'Hélène', 'Faure', 'Directrice générale', '05 49 61 62 63', null, 'helene.faure@conglomerat-multi.fr');
$this->addAddress($conglo, ['Chatellerault', 'Saint-Jean'], '86100', 'Châtellerault', '20 rue des Activités', isDelivery: true, categoryNames: ['BTP', 'Services']);
}
// === Prospect seul ===
[$prospect, $isNew] = $this->ensureClient(
$manager,
companyName: 'Prospect Futur Client',
categoryNames: ['BTP'],
);
if ($isNew) {
$this->addContact($prospect, 'Olivier', 'Renard', 'Responsable projet', '05 56 71 72 73', null, 'olivier.renard@prospect-futur.fr');
$this->addAddress($prospect, ['Chatellerault'], '86100', 'Châtellerault', '30 rue de la Découverte', isProspect: true);
}
// === Categorie AUTRE ===
[$association, $isNew] = $this->ensureClient(
$manager,
companyName: 'Association des Riverains',
categoryNames: ['Association'],
);
if ($isNew) {
$this->addContact($association, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@asso-riverains.fr');
$this->addAddress($association, ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village', isDelivery: true, categoryNames: ['Association']);
}
$manager->flush();
}
/**
* Cree un client (base normalisee + categories) s'il n'existe pas encore,
* 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}
*/
private function ensureClient(
ObjectManager $manager,
string $companyName,
array $categoryNames,
bool $isArchived = false,
): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
$existing = $manager->getRepository(Client::class)->findOneBy(['companyName' => $normalizedName]);
if ($existing instanceof Client) {
return [$existing, false];
}
$client = new Client();
$client->setCompanyName($normalizedName);
foreach ($categoryNames as $categoryName) {
$client->addCategory($this->category($manager, $categoryName));
}
if ($isArchived) {
$client->setIsArchived(true);
$client->setArchivedAt(new DateTimeImmutable());
}
$manager->persist($client);
return [$client, true];
}
/**
* Ajoute un contact normalise au client (cascade persist via Client.contacts).
* Au moins lastName est toujours fourni (RG-1.05, chk_client_contact_name).
*/
private function addContact(
Client $client,
?string $firstName,
?string $lastName,
?string $jobTitle,
?string $phonePrimary,
?string $phoneSecondary,
?string $email,
int $position = 0,
): void {
$contact = new ClientContact();
$contact->setFirstName($this->normalizer->normalizePersonName($firstName));
$contact->setLastName($this->normalizer->normalizePersonName($lastName));
$contact->setJobTitle($jobTitle);
$contact->setPhonePrimary($this->normalizer->normalizePhone($phonePrimary));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$contact->setEmail($this->normalizer->normalizeEmail($email));
$contact->setPosition($position);
$client->addContact($contact);
}
/**
* Ajoute une adresse au client (cascade persist via Client.addresses). Les
* donnees respectent les validators : exclusivite Prospect, billingEmail ssi
* facturation, aucune categorie de code DISTRIBUTEUR/COURTIER (RG-1.29).
*
* @param list<string> $siteNames au moins un site (RG-1.10)
* @param list<string> $categoryNames categories hors DISTRIBUTEUR/COURTIER (RG-1.29)
*/
private function addAddress(
Client $client,
array $siteNames,
string $postalCode,
string $city,
string $street,
bool $isProspect = false,
bool $isDelivery = false,
bool $isBilling = false,
?string $billingEmail = null,
array $categoryNames = [],
int $position = 0,
): void {
$address = new ClientAddress();
$address->setIsProspect($isProspect);
$address->setIsDelivery($isDelivery);
$address->setIsBilling($isBilling);
$address->setBillingEmail($this->normalizer->normalizeEmail($billingEmail));
$address->setPostalCode($postalCode);
$address->setCity($city);
$address->setStreet($street);
$address->setPosition($position);
foreach ($siteNames as $siteName) {
$address->addSite($this->site($siteName));
}
foreach ($categoryNames as $categoryName) {
$address->addCategory($this->category($this->manager, $categoryName));
}
$client->addAddress($address);
}
/**
* Ajoute un RIB au client (cascade persist via Client.ribs). IBAN/BIC valides
* (Assert\Iban/Bic non rejouee sur persist direct mais donnees coherentes).
*/
private function addRib(Client $client, string $label, string $bic, string $iban, int $position = 0): void
{
$rib = new ClientRib();
$rib->setLabel($label);
$rib->setBic($bic);
$rib->setIban($iban);
$rib->setPosition($position);
$client->addRib($rib);
}
/**
* Resout une categorie par son nom via le contrat Shared CategoryInterface
* (resolve_target_entities -> Category), sans importer le module Catalog
* (regle n°1). Mise en cache par nom.
*/
private function category(ObjectManager $manager, string $name): CategoryInterface
{
if (isset($this->categoryCache[$name])) {
return $this->categoryCache[$name];
}
$category = $manager->getRepository(CategoryInterface::class)->findOneBy([
'name' => $name,
'deletedAt' => null,
]);
if (!$category instanceof CategoryInterface) {
throw new RuntimeException(sprintf(
'Categorie "%s" introuvable : CategoryFixtures doit tourner avant ClientFixtures.',
$name,
));
}
return $this->categoryCache[$name] = $category;
}
/**
* Resout un site par son nom via le contrat Shared SiteProviderInterface,
* sans importer le module Sites (regle n°1). Mise en cache par nom.
*/
private function site(string $name): SiteInterface
{
if (isset($this->siteCache[$name])) {
return $this->siteCache[$name];
}
$site = $this->siteProvider->findByName($name);
if (!$site instanceof SiteInterface) {
throw new RuntimeException(sprintf(
'Site "%s" introuvable : SitesFixtures doit tourner avant ClientFixtures.',
$name,
));
}
return $this->siteCache[$name] = $site;
}
private function paymentType(ObjectManager $manager, string $code): PaymentType
{
$type = $manager->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
if (!$type instanceof PaymentType) {
throw new RuntimeException(sprintf(
'PaymentType "%s" introuvable : CommercialReferentialFixtures doit tourner avant ClientFixtures.',
$code,
));
}
return $type;
}
private function bank(ObjectManager $manager, string $code): Bank
{
$bank = $manager->getRepository(Bank::class)->findOneBy(['code' => $code]);
if (!$bank instanceof Bank) {
throw new RuntimeException(sprintf(
'Bank "%s" introuvable : CommercialReferentialFixtures doit tourner avant ClientFixtures.',
$code,
));
}
return $bank;
}
}

Some files were not shown because too many files have changed in this diff Show More