Compare commits

...

16 Commits

Author SHA1 Message Date
malio c1708cd8f5 Merge branch 'develop' into refactor/refonte-contact-suppression-inline-back
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m54s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m12s
2026-06-03 13:47:23 +00:00
Matthieu 74f0f981d8 refactor(commercial) : suppression du contact principal inline du Client (M1)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m57s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m13s
Le contact principal (firstName, lastName, phonePrimary, phoneSecondary,
email) n'est plus porte par l'entite Client : les contacts vivent uniquement
dans ClientContact (onglet Contact). RG-1.01 et RG-1.02 supprimees du Client
(equivalent RG-1.05 / RG-1.14 sur ClientContact).

- Migration (namespace racine DoctrineMigrations, ordre par timestamp) :
  backfill des clients sans contact vers client_contact (position 0) puis
  DROP des 5 colonnes inline. down() best-effort documente.
- Entite Client : retrait des 5 props + getters/setters + groupes.
- ClientProcessor : MAIN_FIELDS / changedBusinessFields / normalize alleges,
  validateMainContact (RG-1.01) supprimee.
- Recherche repertoire : companyName seul (D1).
- Export XLSX : colonnes de contact retirees (D2).
- Fixtures + catalogue de commentaires SQL alignes.
- Tests fonctionnels et unitaires mis a jour.
2026-06-03 15:31:01 +02:00
gitea-actions 1b924ba0fd chore: bump version to v0.1.74
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 19s
2026-06-03 13:16:17 +00:00
matthieu 8fae987e15 docs(commercial) : refonte contact — suppression du contact inline (specs M1 + M2) (#54)
Auto Tag Develop / tag (push) Successful in 6s
Acte la décision refonte-contact dans les specs : le contact principal inline (firstName/lastName/phonePrimary/phoneSecondary/email) est retiré des entités tiers (Client, Supplier). Les contacts vivent uniquement dans ClientContact / SupplierContact (onglet Contacts). Garantie « >=1 contact nommé » préservée par RG-1.05/1.14 (M1) et RG-2.04/2.13 (M2).

- M1 (spec-back/spec-front/cahier) : modèle Client sans contact inline ; RG-1.01/1.02 supprimées ; D1 (recherche) / D2 (export) décrites ; version V1.
- M2 (spec-back/spec-front) : FICHIERS NOUVEAUX (non versionnés sur develop), introduits déjà corrigés (Supplier sans contact inline, RG-2.01/2.02 supprimées) ; version V0.2.
- docs/specs/M1-clients/refonte-contact/ : décision (README) + tickets (M1 back/front/specs, M2 specs) + prompts + amendement des tickets M2.

Lesstime : tâches #103 (M1 back), #104 (M1 front), #105 (M1 specs), #106 (M2 specs) ; tickets M2 #85-#97 amendés.
---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #54
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 13:16:11 +00:00
gitea-actions 6f977d387d chore: bump version to v0.1.73
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 51s
2026-06-03 12:56:58 +00:00
matthieu 1888b70623 fix(rbac) : référentiels /categories et /sites lisibles par les rôles métier (ERP-102) (#53)
Auto Tag Develop / tag (push) Successful in 11s
## Contexte

ERP-102 — Découvert pendant ERP-64. Connecté avec un rôle **métier** (bureau / compta / commerciale), `GET /api/categories` et `GET /api/sites` renvoient **403**, alors que `/tva_modes`, `/payment_delays`, `/payment_types`, `/banks` renvoient 200.

Conséquences : page **Création client** inutilisable (le `Promise.all` rejetait → **tous** les selects vides) et **filtres Catégories/Sites vides** au répertoire.

## Cause

La `security` des `GetCollection`/`Get` de `Category` et `Site` exigeait `catalog.categories.view` / `sites.view` — permissions d'**administration** du Catalogue / des Sites. Or ces référentiels sont **transverses** : tout rôle qui gère un tiers doit pouvoir les lire.

## Correctif back — Option C (permission de lecture-référentiel dédiée)

Choix d'archi retenu parmi les 3 du ticket :

- **Pourquoi pas A** (`... or is_granted('commercial.clients.view')`) : coupler `Category`/`Site` à une permission **Commercial** viole l'esprit de la règle ABSOLUE n°1 et ne scale pas (M2 Fournisseurs devrait rajouter un OR).
- **Pourquoi pas B** (donner `.view` aux rôles métier) : `.view` = accès admin → items sidebar admin Catégories/Sites exposés à une commerciale.
- **C** : nouvelle permission `catalog.categories.read_ref` / `sites.read_ref`, distincte de `.view` (pas d'item sidebar) et de `.manage`. Chaque permission appartient à **son** module → isolement inter-module préservé, **réutilisable tel quel par M2 Fournisseurs**. C'est la « permission référentiel lisible » que le ticket pointe lui-même.

Détail :
- `CatalogModule` / `SitesModule` : déclaration des deux permissions `read_ref`.
- `Category` / `Site` : security lecture (liste + item) = `view OR read_ref`.
- `RbacSeeder` (matrice § 2.7) : `read_ref` attaché à bureau / compta / commerciale ; usine reste sans accès.

## Durcissement front (résilience — requis dans tous les cas)

`useClientReferentials.loadCommon` : `Promise.all` → **`Promise.allSettled`** avec affectation isolée par référentiel. L'échec d'un endpoint ne vide plus que **son** select, plus la totalité du formulaire.

## Tests (TDD)

- `ClientRBACMatrixTest::testBusinessRolesCanReadCategoriesAndSitesReferentials` — bureau/compta/commerciale listent `/categories` et `/sites` (200), usine reste 403.
- `SitesModuleTest` — set de permissions porté à 4 codes.
- `useClientReferentials.spec` (Vitest) — un référentiel en échec ne vide que son select.

## Vérifications

- `make test` (back) : **467/467** ✓
- `make nuxt-test` (front) : **131/131** ✓
- `make php-cs-fixer` : conforme ✓

## Note miroirs RBAC

`config/sidebar.php` / `personas.ts` / `SeedE2ECommand.php` **non touchés** : `read_ref` n'ajoute aucun item sidebar, le persona E2E `user-full` lit déjà via `.view`, et aucun persona ne modélise un rôle métier seul. Pas de nouveau test E2E (règle n°7 : bug attrapé avant prod). La source de vérité de la matrice (`RbacSeeder`) est mise à jour et couverte par `ClientRBACMatrixTest`.

Closes ERP-102.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #53
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 12:56:46 +00:00
gitea-actions 1961bc62c8 chore: bump version to v0.1.72
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 37s
2026-06-03 10:51:43 +00:00
tristan bc7c8f6f83 feat(front) : page modification client + patch par onglet (ERP-65) (#51)
Auto Tag Develop / tag (push) Successful in 8s
## ERP-65 — Page Modification client (1.12)

Écran d'édition client à plat `/clients/[id]/edit`, pré-rempli depuis `GET /clients/{id}` (via `useClient`), édition **indépendante par onglet** avec PATCH **scopé au groupe de sérialisation dédié** (mode strict ERP-74).

### Périmètre
- **Bloc principal conservé** (décision produit) : éditable, PATCH `/clients/{id}` scopé `client:write:main`.
- Onglets **Information** / **Comptabilité** : PATCH `/clients/{id}` scopés à leur groupe ; **Contacts / Adresses / RIBs** via leurs sous-ressources (POST nouveau / PATCH existant / DELETE retiré).
- **Gating readonly par permission** : `manage` → bloc principal + Info/Contact/Adresse éditables ; Comptabilité visible ssi `accounting.view`, éditable ssi `accounting.manage`. Garde de route si ni `manage` ni `accounting.manage`.
- **Pas de miroir RG-1.04 côté front** (cohérent avec la création — le 422 serveur remonte au toast).
- **Chargement résilient des référentiels** (`loadCommon` → `Promise.allSettled`) + options en **union avec l'embed**, pour que les selects comptables de Compta se chargent malgré les 403 sur `/categories`+`/sites`, et que les valeurs courantes s'affichent toujours.

### Tests / vérifications
- Vitest : 22 nouveaux tests (`clientEdit.spec.ts` — scoping strict par groupe + gating par rôle + mappers) ; suite **180/180 OK**, aucune régression.
- ESLint propre.
- Golden path navigateur (Admin + Compta) : pré-remplissage, PATCH Information strictement scopé (corps = 7 champs information), gating readonly Compta, référentiels comptables chargés malgré 403 categories/sites, PATCH comptable Compta OK (200).

### À signaler (hors périmètre)
Les rôles métier (Bureau/Commerciale/Compta) n'ont pas `catalog.categories.view`/`sites.view` → 403 sur `/categories`/`/sites`. La page se dégrade proprement (valeurs courantes via embed) mais **ajouter une nouvelle catégorie/site** est impossible pour ces rôles (même limite que la création). Correctif = ticket RBAC backend (3 miroirs).

Reviewed-on: #51
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-03 10:51:33 +00:00
gitea-actions 7833ff32e6 chore: bump version to v0.1.71
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 46s
2026-06-03 09:59:46 +00:00
tristan 6fee9f6bd6 [ERP-64] Page Consultation client (lecture seule + Modifier / Archiver) (#49)
Auto Tag Develop / tag (push) Successful in 9s
## ERP-64 — Page Consultation client (lecture seule)

Route **`/clients/[id]`** : consultation client en lecture seule, porte vers Modification + actions Archiver / Restaurer.

### Périmètre (front uniquement)
- **`useClient(id)`** : charge le détail (embed contacts / adresses / ribs), `archive()` / `restore()` via `PATCH { isArchived }` **seul**, puis **refetch complet** (la réponse du PATCH ne porte pas l'embed). Le **409** de conflit d'homonyme à la restauration (RG-1.23) est propagé → toast dédié.
- **Page** : formulaire principal + **8 onglets** readonly en **navigation libre** (4 actifs + 4 placeholders). Onglet **Comptabilité** visible **uniquement avec `accounting.view`**.
- **Boutons** : **Modifier** si `manage` OU `accounting.manage` ; **Archiver** si `archive` et client actif ; **Restaurer** si `archive` et client archivé.
- Téléphones affichés formatés `XX XX XX XX XX`.
- Réutilise `ClientContactBlock` / `ClientAddressBlock` / `TabPlaceholderBlank` (ERP-63) en mode `readonly`.

### Libellés issus de l'embed (role-independant)
`GET /api/categories` et `/api/sites` renvoient **403 pour les rôles métier non-admin**. La page lit donc tous les libellés (catégories, sites, référentiels comptables) **directement dans le payload embarqué** — affichage correct pour tous les rôles, sans dépendre d'un `GET` de référentiel.

### Correctifs `ClientAddressBlock` (lecture seule)
- la **ville** courante est toujours présente dans les options (sinon `MalioSelect` n'affiche rien) ;
- la **rue** s'affiche en champ texte readonly (`MalioInputAutocomplete` ne réaffiche pas sa valeur liée).

### Pas de changement back
L'embed `GET /api/clients/{id}` (contacts/adresses/ribs + sites + codes catégories, gating `accounting.view`, 409 restauration) **était déjà livré par ERP-62 (#44)** — vérifié sur l'API réelle et couvert par `ClientApiTest::testGetDetailEmbedsSubCollections`, `ClientReadGroupContextBuilderTest`, `ClientArchiveTest::testRestoreConflictReturns409`.

### Tests
- Vitest : **+29 tests** (mapping payload→brouillons, options embed, permissions, archive/restore/409). Suite complète **158 OK**.
- `nuxi typecheck` : 0 erreur sur les fichiers ajoutés.
- Golden path navigateur (admin + commerciale) : readonly complet, onglet Compta + RIBs selon `accounting.view`, boutons selon rôle, bascule Archiver ↔ Restaurer.

### ⚠️ À investiguer (hors périmètre)
Le 403 sur `/categories` et `/sites` impacte aussi `useClientReferentials.loadCommon()` (un `Promise.all` qui rejette en entier) → potentiellement le **formulaire de création ERP-63 cassé pour la Commerciale** (impossible de choisir catégories/sites). À confirmer dans un ticket dédié.

Reviewed-on: #49
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-03 09:57:14 +00:00
gitea-actions 276f242b10 chore: bump version to v0.1.70
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-06-03 09:47:31 +00:00
matthieu 97301dcd6c refactor(commercial) : découpler l'hydratation des collections de la sélection clients (ERP-100) (#50)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Issu de la review ERP-62 (#44). `DoctrineClientRepository::createListQueryBuilder()` portait 3 `leftJoin+addSelect` to-many imbriqués (`categories × addresses × addresses.sites`) **partagés** entre :
- la **liste paginée** (`ClientProvider`) — bornée, OK ;
- l'**export XLSX** et **`?pagination=false`** — `getResult()` sans pagination → hydratation du **produit cartésien sur tout le référentiel** (1 client à 5 cat × 4 adr × 3 sites = 60 lignes SQL, × N clients).

Défaut d'altitude : un « QueryBuilder de liste » (contrat = filtres) imposait une stratégie d'hydratation à tout appelant.

## Changements
- **`createListQueryBuilder()`** redevient **filtres + tri seuls** — conforme au contrat de l'interface.
- Nouvelle méthode **`hydrateListCollections(array $clients)`** : recharge les collections en **2 requêtes `WHERE id IN(...)` séparées** (catégories d'un côté, adresses+sites de l'autre) via l'identity map Doctrine. Casse le triple cartésien en `cat + (addr × site)`.
- **3 appelants** branchés sur cette stratégie unique :
  - liste paginée : `fetchJoinCollection: false` (COUNT simple) + hydratation de la page ;
  - `?pagination=false` : hydratation après `getResult()` ;
  - export XLSX : hydratation après `getResult()`.

## Tests
- `make test` : **465 OK**.
- Nouveau test `ClientExportControllerTest::testExportPopulatesCategoryAndSiteColumns` : garde-fou sur les valeurs Catégories/Sites de l'export (qu'un oubli d'hydratation rendrait silencieusement vides).
- `php-cs-fixer` : 0 correction.

## Notes
- Benchmark « 1000+ clients » non exécuté (pas de jeu de données à cette échelle en dev) ; le cartésien est supprimé structurellement.
- `addr × site` reste un join imbriqué (inévitable pour agréger les sites par adresse), désormais non multiplié par les catégories.

Closes ERP-100.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #50
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 09:44:31 +00:00
gitea-actions daeb8b3003 chore: bump version to v0.1.69
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 19s
2026-06-03 09:14:20 +00:00
matthieu 9c311cb58b fix(tests) : fiabilise la suite PHPUnit contre la derive d'horloge (ERP-98) (#47)
Auto Tag Develop / tag (push) Successful in 11s
## Probleme (ERP-98)

Suite PHPUnit flaky ~1 run sur 2 -> hook pre-commit qui plante, recours au `--no-verify` sur des commits sains.

## Cause racine

Une seule cause commune : l'horloge `CLOCK_REALTIME` du conteneur n'est pas monotone sous WSL2/Docker (saut arriere sous charge), alors que le code et les tests supposaient une horloge stable.

- **401 « Invalid JWT Token »** : lexik validait `iat`/`nbf`/`exp` avec `clock_skew: 0` (`LooseValidAt(.., PT0S)` cote lcobucci). Un recul d'horloge apres `/login_check` rend le token « dans le futur » -> rejet.
- **Horodatages « meme seconde »** (`1780402904 > 1780402904`) : colonnes `TIMESTAMP(0)` + `sleep(1)` reel. L'ecart floor-seconde n'est nul que si l'horloge recule.

## Correctifs

| Fichier | Modif |
|---|---|
| `config/packages/lexik_jwt_authentication.yaml` | `clock_skew: 15` -> tolere la derive (benefice prod aussi) |
| `TimestampableBlamableSubscriber` | injection `ClockInterface` (prod inchange via NativeClock) |
| `CategoryTimestampableBlamableTest` | `ClockSensitiveTrait` + MockClock fige/avance, suppression des `sleep(1)` |
| `TimestampableBlamableSubscriberTest` | MockClock injecte dans les 4 instanciations |

**Subtilite** : `mockTime()` cree un MockClock en UTC ; les colonnes `TIMESTAMP WITHOUT TIME ZONE` round-trippent via le fuseau PHP (Europe/Paris) -> decalage 2h. Le mock est seede dans le fuseau par defaut (comme le NativeClock prod).

## Verifications

- `make test` : **464 tests verts**, 0 echec / 0 erreur
- Test timestamp cible : **5/5 deterministe** (et plus rapide, sleeps reels supprimes)
- `make php-cs-fixer-allow-risky` : 0 fichier a corriger
- Deprecations/notices PHPUnit preexistantes (hors perimetre)

Pas de migration, pas de changement front, RBAC intact.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #47
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 09:14:09 +00:00
gitea-actions 5a33815584 chore: bump version to v0.1.68
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 39s
2026-06-03 09:09:46 +00:00
matthieu 052a39092b fix(audit) : libellés i18n des types d'entité + garde-fou (ERP-99) (#48)
Auto Tag Develop / tag (push) Successful in 8s
## Contexte
Le filtre « Type d'entité » de l'audit-log est dynamique (`GET /audit-log-entity-types`). Toute entité `#[Auditable]` dont la clé i18n manquait s'affichait en **type technique brut** (ex: `commercial.Client`), le rendu retombant **silencieusement** sur le fallback.

## Décisions (cœur du ticket ERP-99)
- **Schéma de clé** : flat `audit.entity.<module>_<entity>` (inchangé, zéro régression).
- **Emplacement** : centralisé dans `frontend/i18n/locales/fr.json` (migration per-module = ticket infra i18n dédié).
- **Source de vérité** : `entity_type` = `strtolower(module).Entity` (confirmé dans `AuditListener::formatEntityType`).

## Changements
- **Complétude** : ajout des clés `audit.entity.*` manquantes (catalog + commercial) → 9 entités `#[Auditable]` couvertes.
- **Convention** : `.claude/rules/backend.md` § Audit — ajouter sa clé de libellé audit fait partie de la définition de fini d'une entité auditée.
- **Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entités `#[Auditable]` et échoue si une clé `audit.entity.*` manque ou est vide (rend le manque bloquant en CI).

## Vérifications
- Suite PHPUnit complète : **465 tests OK** (1604 assertions).
- Garde-fou : vert (9 entités) + test négatif confirmé rouge (clé retirée → échec actionnable).
- JSON `fr.json` valide, php-cs-fixer OK.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #48
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 09:09:37 +00:00
60 changed files with 5632 additions and 506 deletions
+18
View File
@@ -98,6 +98,24 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire - 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 :
@@ -3,6 +3,14 @@ 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
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.67' app.version: '0.1.74'
+2 -2
View File
@@ -18,8 +18,8 @@ merge de la stack.
| RG | Intitulé | Test(s) | Source | | RG | Intitulé | Test(s) | Source |
|----|----------|---------|--------| |----|----------|---------|--------|
| RG-1.01 | Prénom OU nom obligatoire → 422 | `ClientApiTest::testPostWithoutFirstOrLastNameReturns422` ; `ClientProcessorTest` (unit) | ERP-55 | | ~~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 | phoneSecondary persisté ; max 2 téléphones | `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` ; `::testThirdPhoneFieldIsIgnored` | **ERP-60** | | ~~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.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** | | RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 | | RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
@@ -0,0 +1,135 @@
# M1 · Ticket 1/3 (Backend) — Supprimer le contact inline du `Client`
## 1. Objectif
Retirer de l'entité `Client` (et de la table `client`) les **5 champs du contact
principal inline** : `firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`.
La gestion des contacts passe désormais **exclusivement** par la sous-entité
`ClientContact` (onglet « Contacts »), déjà en place et déjà porteuse des mêmes champs.
Le code M1 est **déjà livré en prod** : ce ticket inclut donc une **migration de données**
(backfill) pour ne perdre aucune information de contact existante avant de supprimer les
colonnes.
Contexte et justification : voir `README.md` du dossier `refonte-contact`.
## 2. Périmètre
### IN
- Migration Doctrine : **backfill puis suppression** des 5 colonnes de `client`.
- `Client` (entité) : supprimer les 5 propriétés, getters/setters, annotations ORM /
`Assert` / `Groups`.
- `ClientProcessor` : retirer les 5 champs de `MAIN_FIELDS`, `changedBusinessFields()`,
`normalize()` ; supprimer `validateMainContact()` (RG-1.01 — n'a plus d'objet).
- `DoctrineClientRepository::applySearch()` : trancher D1 (recherche) et l'appliquer.
- `ClientExportController` : trancher D2 (colonnes export) et l'appliquer.
- `ClientFixtures` : retirer les 5 paramètres inline de `ensureClient()` ; garantir que
chaque client seedé possède au moins 1 `ClientContact` (déjà géré par `addContact()`).
- Tests PHPUnit : mettre à jour / retirer les cas qui exercent ces 5 champs sur `Client`.
### OUT
- Toute modification de `ClientContact` / `ClientContactProcessor` : **inchangés** (c'est la
cible, les champs y restent). `ClientFieldNormalizer` reste tel quel (toujours appelé par
`ClientContactProcessor`).
- Le front (formulaires, vues, types, i18n) → **ticket 2/3**.
- Les specs (`spec-back.md`, `spec-front.md`, cahier de test) → **ticket 3/3**.
## 3. Fichiers à modifier
| Fichier | Action |
|---|---|
| `src/Module/Commercial/Domain/Entity/Client.php` | Supprimer props `firstName` (~l.158), `lastName` (~l.163), `phonePrimary` (~l.168), `phoneSecondary` (~l.172), `email` (~l.178) + leurs getters/setters (~l.329-382) + groupes `client:read`/`client:write:main` + `Assert\*`. |
| `src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php` | Retirer les 5 clés de `MAIN_FIELDS` (~l.63) ; de `changedBusinessFields()` (~l.277-281) ; les 6 lignes de `normalize()` qui touchent email/phone/first/last/secondary (~l.433-441) ; supprimer `validateMainContact()` (~l.447-456) et son appel. |
| `src/Module/Commercial/Infrastructure/Doctrine/DoctrineClientRepository.php` | `applySearch()` (~l.110-124) : appliquer **D1**. |
| `src/Module/Commercial/Infrastructure/Controller/ClientExportController.php` | `buildHeaders()` (~l.94-114) + `buildRows()` (~l.121-143) : appliquer **D2**. |
| `src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php` | `ensureClient()` (~l.357-395) : retirer firstName/lastName/phonePrimary/phoneSecondary/email ; conserver `addContact()`. |
| `migrations/Version<timestamp>.php` (NOUVELLE) | Backfill + `DROP COLUMN` (cf. § 4). |
| `tests/Module/Commercial/**` | Voir § 5. |
## 4. Migration Doctrine — backfill puis suppression
> Migration **modulaire** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : ce
> n'est PAS une migration d'initialisation, le schéma `client` / `client_contact` existe
> déjà (règle ABSOLUE n°11).
### `up()`
1. **Backfill — ne créer un contact que pour les clients qui n'en ont aucun**, afin de ne
pas dupliquer le contact déjà recopié à la création (`prefillFirstContact`) :
```sql
INSERT INTO client_contact
(client_id, first_name, last_name, phone_primary, phone_secondary, email, position, created_at, updated_at)
SELECT c.id, c.first_name, c.last_name, c.phone_primary, c.phone_secondary, c.email, 0, NOW(), NOW()
FROM client c
WHERE NOT EXISTS (SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id)
AND (c.first_name IS NOT NULL OR c.last_name IS NOT NULL);
```
> Le `WHERE ... first_name OU last_name IS NOT NULL` respecte le CHECK
> `chk_client_contact_name`. Les rares clients sans nom de contact ET sans contact
> existant ne reçoivent pas de ligne (cas théorique : `phone_primary`/`email` étaient
> `NOT NULL` mais les noms nullables).
2. **Supprimer les 5 colonnes** :
```sql
ALTER TABLE client
DROP COLUMN first_name,
DROP COLUMN last_name,
DROP COLUMN phone_primary,
DROP COLUMN phone_secondary,
DROP COLUMN email;
```
> Pas de `COMMENT ON COLUMN` à poser (on supprime). Vérifier qu'aucun index ne portait
> sur `email` (l'index unique `uq_client_email_active` a déjà été supprimé — décision Q4 /
> RG-1.17, cf. `ClientMigrationTest`).
### `down()` (best-effort)
1. Recréer les 5 colonnes (`phone_primary`/`email` en `NOT NULL` impose un défaut transitoire
ou un re-remplissage depuis le contact `position = 0`).
2. Re-remplir depuis `client_contact` (`position = 0`) si possible.
3. Reposer les `COMMENT ON COLUMN` d'origine (textes RG-1.19/1.20/1.21/1.01/1.17 — cf.
`migrations/Version20260601000000.php` l.251-255).
> `down()` ne peut pas restaurer parfaitement les données (ambiguïté si plusieurs contacts).
> Documenter cette limite dans le docblock de la migration.
## 5. Tests à mettre à jour
| Fichier | Action |
|---|---|
| `tests/Module/Commercial/Api/ClientApiTest.php` | Retirer firstName/lastName/phone/email des payloads POST/PATCH `client` et des assertions JSON. |
| `tests/.../ClientFormulaireMainTest.php` | Supprimer les tests RG-1.01 (firstName/lastName) et RG-1.02 (téléphones) **côté Client** — ils basculent côté `ClientContact` (couverts ailleurs). |
| `tests/.../ClientExportControllerTest.php` | Aligner les en-têtes/lignes attendus sur **D2**. |
| `tests/.../ClientMigrationTest.php` | Asserter que les 5 colonnes **n'existent plus** sur `client` ; vérifier le backfill (un client sans contact obtient bien 1 `client_contact`). |
| `tests/.../ClientFieldNormalizerTest.php` | Conserver les tests du normalizer (toujours utilisé par `ClientContact`) ; retirer les cas spécifiques aux champs `Client` s'il y en a. |
| RG-1.01/1.02 (matrice) | Ne plus tester sur `Client` ; vérifier qu'ils restent couverts sur `ClientContact` (RG-1.05). |
## 6. Décisions à trancher (cf. README § 3)
- **D1 — recherche** : recommandé = `LEFT JOIN client_contact` (fuzzy sur
`companyName` + contact `first_name`/`last_name`/`email`). Attention au `DISTINCT` /
risque de doublons de lignes si plusieurs contacts matchent (grouper par `client.id`).
- **D2 — export** : recommandé = alimenter les colonnes contact depuis le contact de plus
petit `position` (fetch-join `contacts` pour éviter le N+1).
## 7. Critères d'acceptation (DoD)
- [ ] Les colonnes `first_name`, `last_name`, `phone_primary`, `phone_secondary`, `email`
n'existent plus sur la table `client`.
- [ ] La migration est jouable sur une base seedée sans perte de contact (backfill vérifié)
et `down()` documenté comme best-effort.
- [ ] `Client`, `ClientProcessor`, `DoctrineClientRepository`, `ClientExportController`,
`ClientFixtures` ne référencent plus les 5 champs.
- [ ] D1 et D2 implémentées conformément à la décision validée.
- [ ] `ClientContact` / `ClientContactProcessor` / `ClientFieldNormalizer` inchangés.
- [ ] `make test` vert (notamment `tests/Architecture/ColumnsHaveSqlCommentTest` et
`EntitiesAreTimestampableBlamableTest`).
- [ ] `make php-cs-fixer-allow-risky` ne signale rien sur les fichiers touchés.
- [ ] Aucune régression du contrat de sérialisation : capturer le JSON réel de
`GET /api/clients/{id}` et vérifier l'absence des 5 champs (réflexe RETEX M1).
@@ -0,0 +1,58 @@
# Prompt d'implémentation — M1 · Ticket 1/3 (Backend)
Tu travailles sur le projet **Starseed** (Symfony 8 / API Platform 4 / Doctrine / PostgreSQL).
Lis `CLAUDE.md` et `.claude/rules/backend.md` avant de coder. Commentaires en français,
code en anglais, `declare(strict_types=1);` partout.
## Mission
Supprimer le **contact principal inline** de l'entité `Client` : les 5 champs
`firstName`, `lastName`, `phonePrimary`, `phoneSecondary`, `email`. Les contacts sont gérés
uniquement via la sous-entité `ClientContact` (onglet Contacts), déjà en place. Le code est
déjà en prod → migration avec **backfill** avant `DROP`.
La spec détaillée du ticket est dans `docs/specs/M1-clients/refonte-contact/M1-ticket-01-back.md`.
Lis-la en entier, ainsi que le `README.md` du même dossier (décision + RG impactées + D1/D2).
## Étapes
1. **Explorer** : `Client.php`, `ClientProcessor.php`, `DoctrineClientRepository.php`,
`ClientExportController.php`, `ClientFixtures.php`, et `ClientContact.php` (pour confirmer
que la cible porte bien les mêmes champs).
2. **Demander la validation des décisions D1 (recherche) et D2 (export)** avant de coder —
défauts recommandés : D1 = LEFT JOIN sur `client_contact`, D2 = colonnes export depuis le
contact `position` minimal. Ne pas inventer un autre comportement.
3. **Migration** (`src/Module/Commercial/Infrastructure/Doctrine/Migrations/`) : backfill
`INSERT INTO client_contact ... WHERE NOT EXISTS(...)` puis `ALTER TABLE client DROP COLUMN ...`
(les 5). `down()` best-effort documenté. Voir le SQL exact dans la spec § 4.
4. **Entité** : retirer les 5 props + getters/setters + `#[ORM\Column]` + `#[Assert\*]` +
`#[Groups(['client:read','client:write:main'])]`.
5. **Processor** : retirer de `MAIN_FIELDS`, `changedBusinessFields()`, `normalize()` ;
supprimer `validateMainContact()` et son appel.
6. **Repository** : `applySearch()` selon D1.
7. **Export** : `buildHeaders()` / `buildRows()` selon D2.
8. **Fixtures** : alléger `ensureClient()` ; garder `addContact()`.
9. **Tests** : mettre à jour `ClientApiTest`, `ClientFormulaireMainTest`,
`ClientExportControllerTest`, `ClientMigrationTest`, `ClientFieldNormalizerTest`
(cf. spec § 5). Ajouter une assertion que le backfill crée bien un contact pour un client
qui n'en avait pas.
## Garde-fous
- Ne touche **pas** `ClientContact`, `ClientContactProcessor`, `ClientFieldNormalizer`.
- Respecte les règles ABSOLUES : pagination, `#[Auditable]`, COMMENT ON COLUMN (ici on
supprime → pas de commentaire à poser, mais ne pas casser le garde-fou).
- Les RG-1.01 et RG-1.02 disparaissent **du Client** : leur équivalent (RG-1.05 / RG-1.14)
vit déjà sur `ClientContact`, ne le duplique pas.
## Vérification finale (obligatoire avant de dire « fini »)
```bash
make db-reset && make migration-migrate # migration rejouable sur base fraîche
make test # PHPUnit vert
make php-cs-fixer-allow-risky # lint
```
Puis capture le JSON réel de `GET /api/clients/{id}` (avec un JWT) et confirme que les 5
champs ont disparu de la réponse et que `contacts[]` porte bien l'info (réflexe RETEX M1 :
on valide sur le contrat réel, pas sur les annotations).
@@ -0,0 +1,74 @@
# M1 · Ticket 2/3 (Frontend) — Retirer le bloc contact principal des écrans Client
## 1. Objectif
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
trois écrans Client — **création**, **consultation**, **modification** — ainsi que des
types, mappeurs, validations et clés i18n associés. La saisie des contacts se fait
désormais uniquement dans l'**onglet « Contacts »** (composant `ClientContactBlock`, déjà
en place et inchangé).
Dépend du **ticket 1/3 (back)** : l'API ne renvoie/n'accepte plus ces 5 champs sur `client`.
Contexte : voir `README.md` du dossier `refonte-contact`.
## 2. Périmètre
### IN — fichiers `frontend/modules/commercial/`
| Fichier | Action |
|---|---|
| `pages/clients/new.vue` | Supprimer le bloc principal Nom/Prénom/Téléphones/Email (~l.27-63), l'état `main.firstName/lastName/email`, `mainPhones` (~l.445-459), la fonction `prefillFirstContact()` (~l.658-665) et son appel, le mapping payload POST `phonePrimary/phoneSecondary` (~l.513-524). Adapter `isMainValid` (~l.479-493) : la validation principale ne porte plus que sur `companyName` (+ relation/catégories selon RG existantes). L'onglet **Contacts** devient le point de saisie des coordonnées ; garantir au moins un `ClientContactBlock` vide au départ. |
| `pages/clients/[id]/edit.vue` | Supprimer les 5 champs du bloc principal (~l.32-73). `mapMainDraft()` et `buildMainPayload()` ne portent plus ces champs. L'onglet Contacts reste éditable. |
| `pages/clients/[id]/index.vue` | Supprimer l'affichage lecture seule des 5 champs du bloc principal (~l.49-104, partie contact). Conserver l'onglet Contacts (lecture seule). |
| `types/clientForm.ts` | `MainFormDraft` : retirer `firstName`, `lastName`, `email`, `phonePrimary`, `phoneSecondary`, `hasSecondaryPhone`. Garder `ContactFormDraft` (inchangé). |
| `types/clientConsultation.ts` | `ClientDetail` : retirer `firstName/lastName/phonePrimary/phoneSecondary/email` (les commentaires « Contact principal »). Garder `ContactRead`. |
| `utils/clientEdit.ts` | `mapMainDraft()` et `buildMainPayload()` : retirer les 5 champs. Garder `buildContactPayload()`. |
| `utils/clientConsultation.ts` | Retirer toute lecture des 5 champs inline du client (garder `mapContactToDraft`, `contactOptionsOf`). |
| `i18n/locales/fr.json` | Retirer `commercial.clients.form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone`. **Conserver** tout le bloc `commercial.clients.form.contact.*`. Vérifier qu'aucune autre vue ne référence les clés retirées. |
| `**/__tests__/*.spec.ts` | Mettre à jour `clientFormRules.spec.ts`, `clientEdit.spec.ts`, `clientConsultation.spec.ts` (cf. § 4). |
### OUT
- `ClientContactBlock.vue`, l'onglet Contacts, `useClient`, la liste/répertoire
(`pages/clients/index.vue` — ses colonnes n'affichent déjà pas le contact inline) :
**inchangés**.
- Le back → ticket 1/3. Les specs → ticket 3/3.
## 3. Comportement attendu après modification
- **Création** : le formulaire principal demande l'entreprise (et relation/catégories selon
l'existant), plus de Nom/Prénom/Téléphone/Email inline. L'utilisateur renseigne les
coordonnées dans l'onglet **Contacts**. La création reste valide tant qu'il y a
`companyName` **et** ≥ 1 bloc Contact valide (Nom OU Prénom) — RG-1.05/RG-1.14 inchangées.
- **Consultation** : plus de bloc contact principal ; l'onglet Contacts affiche les
contacts.
- **Modification** : idem ; le PATCH du groupe `client:write:main` n'envoie plus les 5
champs.
## 4. Tests Vitest à mettre à jour
- `clientFormRules.spec.ts` : la validité du « principal » ne dépend plus de
firstName/email/phone ; conserver `isContactNamed()` (RG-1.05) sur les blocs Contacts.
- `clientEdit.spec.ts` : `buildMainPayload()` ne contient plus les 5 champs ; `mapMainDraft()`
non plus.
- `clientConsultation.spec.ts` : retirer les assertions sur les 5 champs inline.
## 5. Tips & rappels projet
- `useApi()` obligatoire (jamais `$fetch`/`ofetch`). Composants `Malio*` obligatoires.
- État de tableau jamais dans l'URL (règle inchangée).
- Les valeurs sont **normalisées côté serveur** (Capitalize / chiffres / lowercase) : le
front envoie la saisie et réaffiche la valeur renvoyée — ne pas réintroduire de
normalisation front.
- Ne pas créer de clé i18n orpheline ni laisser de clé `form.main.*` morte.
## 6. Critères d'acceptation (DoD)
- [ ] Les 3 écrans n'affichent plus Nom/Prénom/Téléphone/Téléphone 2/Email en bloc principal.
- [ ] Le parcours de création fonctionne avec `companyName` + onglet Contacts (≥ 1 contact).
- [ ] `MainFormDraft` / `ClientDetail` ne déclarent plus les 5 champs ; `mapMainDraft` /
`buildMainPayload` non plus.
- [ ] Aucune clé i18n `form.main.firstName/lastName/email/phone*` restante ni référencée.
- [ ] `make nuxt-test` vert.
- [ ] Vérification visuelle du golden path (`make dev-nuxt`, port 3004) : création →
consultation → modification d'un client sans bloc contact inline.
@@ -0,0 +1,47 @@
# Prompt d'implémentation — M1 · Ticket 2/3 (Frontend)
Projet **Starseed** (Nuxt 4 / Vue 3 Composition API / TypeScript / @malio/layer-ui).
Lis `CLAUDE.md` et `.claude/rules/frontend.md` avant de coder. Commentaires en français,
code en anglais, 4 espaces d'indentation.
## Mission
Retirer le **bloc « contact principal »** (Nom, Prénom, Téléphone, Téléphone 2, Email) des
écrans Client (création / consultation / modification) et de tout le code associé (types,
mappeurs, validations, i18n). Les contacts restent gérés par l'onglet **Contacts**
(`ClientContactBlock`, inchangé).
Spec détaillée : `docs/specs/M1-clients/refonte-contact/M1-ticket-02-front.md` (lis-la en
entier + le `README.md` du dossier). Ce ticket dépend du ticket back (l'API ne porte plus
les 5 champs sur `client`).
## Étapes
1. Explorer `frontend/modules/commercial/` : `pages/clients/new.vue`, `[id]/edit.vue`,
`[id]/index.vue`, `types/clientForm.ts`, `types/clientConsultation.ts`,
`utils/clientEdit.ts`, `utils/clientConsultation.ts`, `i18n/locales/fr.json`.
2. Supprimer le bloc principal des 3 écrans + l'état réactif `main.firstName/lastName/email`,
`mainPhones`, `prefillFirstContact()`.
3. Adapter `isMainValid` : ne dépend plus que de `companyName` (+ relation/catégories selon
l'existant). La garantie « ≥ 1 contact valide » reste portée par l'onglet Contacts.
4. Nettoyer les types (`MainFormDraft`, `ClientDetail`) et les mappeurs (`mapMainDraft`,
`buildMainPayload`, `clientConsultation`).
5. Retirer les clés i18n `form.main.firstName/lastName/email/phonePrimary/phoneSecondary/addPhone` ;
vérifier par recherche qu'aucune vue ne les utilise plus. **Garder** `form.contact.*`.
6. Mettre à jour les specs Vitest (`clientFormRules`, `clientEdit`, `clientConsultation`).
## Garde-fous
- `useApi()` uniquement ; composants `Malio*` uniquement ; pas d'état tableau dans l'URL.
- Ne touche pas `ClientContactBlock.vue`, l'onglet Contacts, ni la liste/répertoire.
- Pas de normalisation front (le serveur normalise).
## Vérification finale
```bash
make nuxt-test # Vitest vert
make dev-nuxt # port 3004 — golden path manuel
```
Golden path à vérifier dans le navigateur : créer un client (entreprise + 1 contact dans
l'onglet Contacts), le consulter, le modifier — sans aucun bloc contact inline.
@@ -0,0 +1,51 @@
# M1 · Ticket 3/3 (Specs) — Acter la suppression du contact inline dans les specs M1
## 1. Objectif
Mettre à jour la **documentation fonctionnelle/technique M1** pour refléter la décision :
le contact principal inline est supprimé du `Client`, les contacts vivent uniquement dans
`ClientContact`. Les specs sont la **source de vérité** du projet (cf. `workflow.md`) : elles
doivent décrire le modèle cible, pas l'ancien.
> Idéalement réalisé **avant** les tickets 1 et 2 (la spec guide le code), mais peut être
> fait en parallèle. À minima, ne pas merger le code sans aligner la spec.
## 2. Fichiers à modifier
| Fichier | Sections concernées |
|---|---|
| `docs/specs/M1-clients/spec-back.md` | § 3.1 diagramme E-R (retirer les 5 colonnes du bloc `client`) ; § 3.2 migration SQL `CREATE TABLE client` (retirer `first_name`/`last_name`/`phone_primary`/`phone_secondary`/`email` + leurs COMMENT) ; § 3.4 squelette entité `Client` (retirer les 5 props) ; § 4.3 exemple payload `POST /api/clients` (retirer les 5 champs) ; § 4.1 filtre `?search=` (refléter D1) ; § 4.6 export (refléter D2) ; § 7 RG (voir § 3 ci-dessous) ; § 8 cahier de tests (déplacer RG-1.01/1.02 vers ClientContact). |
| `docs/specs/M1-clients/spec-front.md` | « Formulaire principal » (l.85-103) : retirer les lignes Nom/Prénom/Téléphone/Téléphone 2/Email ; écrans Consultation / Modification ; règles de formatage. Préciser que les coordonnées se saisissent dans l'onglet Contact. |
| `docs/specs/M1-clients/cahier-test-back-M1.md` | Retirer / requalifier les lignes RG-1.01 et RG-1.02 (désormais couvertes par RG-1.05 sur `ClientContact`). |
## 3. Traitement des règles de gestion
- **RG-1.01** (firstName OU lastName obligatoire sur Client) → marquer **supprimée** :
« Remplacée par RG-1.05 (≥ 1 contact valide) + RG-1.14 (≥ 1 bloc Contact). Le contact
principal inline n'existe plus. »
- **RG-1.02** (max 2 téléphones sur Client) → marquer **supprimée du Client** (reste
applicable aux blocs `ClientContact`).
- **RG-1.19 / RG-1.20 / RG-1.21** (normalisation) → préciser que le **scope `Client`
disparaît** ; la normalisation reste sur `ClientContact` (et `ClientAddress.billingEmail`
pour RG-1.21).
- Ne **pas renuméroter** les RG existantes (éviter le drift avec le code/tests) : marquer
« supprimée / requalifiée » en place, avec date et renvoi à la décision.
## 4. Forme
- Bumper la version des deux specs (`version: V0``V1`) dans le frontmatter, avec une
entrée d'historique : date `2026-06-03`, motif « Suppression du contact inline du Client
(refonte-contact) », auteur.
- Ajouter un encadré « Décision » en tête de la section modèle de données, renvoyant au
`README.md` du dossier `refonte-contact`.
- Conserver le style des specs (sections numérotées, tableaux RG, exemples JSON).
## 5. Critères d'acceptation (DoD)
- [ ] `spec-back.md` : aucune mention des 5 colonnes inline dans le modèle `client`
(E-R + SQL + entité + payload) ; RG-1.01/1.02 marquées supprimées ; D1/D2 décrites.
- [ ] `spec-front.md` : le formulaire principal ne liste plus les champs de contact ;
l'onglet Contact est présenté comme seul lieu de saisie des coordonnées.
- [ ] `cahier-test-back-M1.md` : RG-1.01/1.02 retirées/requalifiées.
- [ ] Versions bumpées (V1) + historique daté dans les deux specs.
- [ ] Cohérence vérifiée avec les tickets 1 et 2 (mêmes décisions D1/D2).
@@ -0,0 +1,38 @@
# Prompt d'implémentation — M1 · Ticket 3/3 (Specs)
Projet **Starseed**. Tâche **documentaire** : mettre à jour les specs M1 Clients pour acter
la suppression du contact principal inline du `Client`. Les specs sont la source de vérité ;
elles doivent décrire le modèle cible.
## Mission
Modifier `docs/specs/M1-clients/spec-back.md`, `spec-front.md` et `cahier-test-back-M1.md`
pour retirer le contact inline du `Client` (5 champs `firstName/lastName/phonePrimary/
phoneSecondary/email`) — les contacts vivent uniquement dans `ClientContact`.
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M1-ticket-03-specs.md` (lis-la + le
`README.md` du dossier, qui contient la décision, les RG impactées et les décisions D1/D2).
## Étapes
1. Lire les 3 fichiers de specs M1 visés, repérer toutes les occurrences des 5 champs
(diagramme E-R, CREATE TABLE client, squelette entité, payload POST, filtre search,
export, RG, cahier de test).
2. Retirer les 5 colonnes du modèle `client` (E-R + SQL + entité + exemple JSON).
3. Marquer **supprimées** RG-1.01 et RG-1.02 (renvoi à RG-1.05/RG-1.14 sur `ClientContact`),
restreindre le scope de RG-1.19/1.20/1.21 à `ClientContact`. **Ne pas renuméroter** les RG.
4. Refléter les décisions D1 (recherche) et D2 (export) une fois tranchées.
5. Côté `spec-front.md` : retirer les champs de contact du formulaire principal ; présenter
l'onglet Contact comme seul lieu de saisie.
6. Bumper `version: V0 → V1` + ajouter une entrée d'historique datée (2026-06-03).
## Garde-fous
- Ne touche pas au code, uniquement aux `.md` de specs.
- Garde le style existant (sections numérotées, tableaux RG, exemples JSON).
- Cohérence stricte avec les tickets 1 (back) et 2 (front) : mêmes décisions D1/D2.
## Vérification
Relire les 3 fichiers : plus aucune mention des 5 champs inline dans le modèle `client` ;
RG-1.01/1.02 marquées supprimées ; versions à V1 avec historique.
@@ -0,0 +1,57 @@
# Amendement des tickets M2 existants — suppression du contact inline du `Supplier`
Les 14 tickets M2 (n° 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).
@@ -0,0 +1,55 @@
# M2 · Ticket Specs — Retirer le contact inline du `Supplier` dans les specs M2
## 1. Objectif
Mettre à jour les specs **M2 Fournisseurs** déjà rédigées pour **ne plus inclure** le contact
principal inline sur le `Supplier`. M2 est le jumeau strict de M1 (`Supplier` /
`SupplierContact` / `SupplierAddress` / `SupplierRib`) et n'est **pas encore codé** : il faut
donc corriger la conception **en amont**, pour que les 14 tickets M2 « prêts à dev » soient
implémentés directement sans les 5 colonnes inline.
> Pendant de M1 ticket 3/3, mais côté M2 : **aucune migration de suppression ni backfill** —
> on retire simplement le contact inline du modèle cible. Contexte : `README.md` du dossier
> `refonte-contact`.
## 2. Fichiers à modifier
| Fichier | Sections concernées |
|---|---|
| `docs/specs/M2-suppliers/spec-back.md` | § 3.1 diagramme E-R (l.175-179 : retirer les 5 colonnes du bloc `supplier`) ; § 3.2 `CREATE TABLE supplier` (l.227-231) ; § 3.4 squelette entité `Supplier` (l.496-517 : props + `Assert\Callback` RG-2.01) ; § 4 exemples payload POST/GET (l.782-805, 867-871) ; recherche `?search=` (l.847 : refléter D1) ; export (refléter D2) ; § contrat de sérialisation (l.725, 729) ; § 7 RG (voir § 3). |
| `docs/specs/M2-suppliers/spec-front.md` | « Formulaire principal » (l.105-117 : retirer Nom/Prénom/Téléphone/Téléphone 2/Email) ; onglet « Contact » (l.140-157 : retirer la phrase de pré-remplissage depuis le formulaire principal, l.142) ; écrans Consultation/Modification ; règles de formatage (l.283-285) ; recherche (l.76 : refléter D1). |
## 3. Traitement des règles de gestion M2
- **RG-2.01** (firstName OU lastName obligatoire sur Supplier) → **supprimée** : remplacée
par RG-2.04 (≥ 1 contact valide) + RG-2.13 (≥ 1 bloc Contact). Le contact inline n'existe
plus sur `Supplier`.
- **RG-2.02** (max 2 téléphones sur Supplier) → **supprimée du Supplier** (reste sur
`SupplierContact`).
- **RG-2.12** (normalisation Capitalize / chiffres / lowercase) → restreindre le scope :
s'applique à `companyName` (UPPERCASE) et aux champs de `SupplierContact` ; **plus** aux
champs inline du `Supplier` (qui disparaissent).
- Ne pas renuméroter les RG : marquer « supprimée / requalifiée » en place, avec date.
## 4. Forme
- Bumper la version des deux specs M2 + entrée d'historique datée (2026-06-03, motif
« Suppression du contact inline du Supplier — alignement refonte-contact M1 »).
- Encadré « Décision » renvoyant au `README.md` du dossier `refonte-contact`.
- Garder le style des specs M2.
## 5. Lien avec les tickets M2 existants
La mise à jour des specs doit être cohérente avec l'**amendement des tickets M2** (voir
`M2-amendement-tickets.md`) : tickets back #85/#86/#87/#91/#92 et front #94/#95/#96 (+ #93/#97
légers). Specs et tickets décrivent le **même** modèle cible (sans contact inline).
## 6. Critères d'acceptation (DoD)
- [ ] `spec-back.md` M2 : aucune mention des 5 colonnes inline dans le modèle `supplier`
(E-R + SQL + entité + payloads + sérialisation) ; RG-2.01/2.02 marquées supprimées ;
D1/D2 décrites.
- [ ] `spec-front.md` M2 : formulaire principal sans champs de contact ; onglet Contact
présenté comme seul lieu de saisie (sans pré-remplissage depuis le principal).
- [ ] Versions bumpées + historique daté.
- [ ] Cohérence avec l'amendement des tickets M2.
@@ -0,0 +1,36 @@
# Prompt d'implémentation — M2 · Ticket Specs
Projet **Starseed**. Tâche **documentaire**. Mettre à jour les specs M2 Fournisseurs
(`docs/specs/M2-suppliers/spec-back.md` + `spec-front.md`) pour retirer le contact principal
inline du `Supplier` (5 champs `firstName/lastName/phonePrimary/phoneSecondary/email`).
M2 n'est **pas encore codé** : on corrige la conception en amont, **sans** migration ni
backfill (contrairement à M1). Les contacts vivent uniquement dans `SupplierContact`.
Spec du ticket : `docs/specs/M1-clients/refonte-contact/M2-ticket-specs.md` (lis-la + le
`README.md` du dossier).
## Étapes
1. Lire `spec-back.md` et `spec-front.md` M2 ; repérer toutes les occurrences des 5 champs
(E-R l.175-179, CREATE TABLE supplier l.227-231, entité l.496-517, payloads l.782-805 /
867-871, sérialisation l.725-729, RG-2.01/2.02/2.12, recherche, export, formulaire
principal front l.105-117, pré-remplissage onglet Contact l.142).
2. Retirer les 5 colonnes du modèle `supplier`.
3. Marquer **supprimées** RG-2.01 et RG-2.02 (renvoi RG-2.04/RG-2.13) ; restreindre RG-2.12
à `companyName` + `SupplierContact`. Ne pas renuméroter.
4. Refléter D1 (recherche : LEFT JOIN supplier_contact recommandé) et D2 (export depuis le
contact principal recommandé).
5. Front : retirer les champs de contact du formulaire principal ; retirer la phrase de
pré-remplissage du 1er bloc Contact ; présenter l'onglet Contact comme seul lieu de saisie.
6. Bumper la version + historique daté (2026-06-03).
## Garde-fous
- Uniquement les `.md` de specs M2. Style existant conservé.
- Cohérence stricte avec l'amendement des tickets M2 et avec la décision M1 (jumeau).
## Vérification
Relire les 2 specs : plus aucune mention des 5 champs inline dans le modèle `supplier` ;
RG-2.01/2.02 supprimées ; versions bumpées.
@@ -0,0 +1,84 @@
# Refonte « contact » — suppression du contact inline des tiers (Client M1 + Supplier M2)
> Dossier de tickets transverse. Source de vérité de la décision et de son découpage.
> Rédigé le 2026-06-03. Owner : Matthieu.
## 1. Décision
Le **contact « principal » inline** (les 5 colonnes plates `first_name`, `last_name`,
`phone_primary`, `phone_secondary`, `email`) est **supprimé de l'entité tier** (`Client`,
puis `Supplier`). La gestion des contacts passe **exclusivement** par la sous-entité
dédiée (`ClientContact` / `SupplierContact`), c.-à-d. l'**onglet « Contacts »**.
### Pourquoi
- **Modèle unique, zéro duplication.** Aujourd'hui le contact est saisi deux fois : une
fois dans le bloc principal (inline sur le tier) et une fois dans l'onglet Contacts
(sous-entité). À la création, le front recopie même l'un dans l'autre
(`prefillFirstContact`). Deux sources pour la même information = risque de divergence.
- **Cohérence métier.** Un tier peut avoir plusieurs contacts ; il n'y a pas de raison
qu'un seul soit « privilégié » au niveau de la table tier. La notion de contact
appartient à la collection de contacts.
- **Garantie préservée.** L'invariant « il y a toujours au moins un contact » est déjà
assuré par la sous-entité : RG-1.05/RG-1.14 (M1) et RG-2.04/RG-2.13 (M2) imposent
**≥ 1 bloc Contact valide** (Nom OU Prénom). Supprimer le contact inline ne crée donc
aucun trou : le contact reste obligatoire, mais au bon endroit.
### Règles de gestion impactées
| RG | Avant | Après |
|---|---|---|
| RG-1.01 / RG-2.01 (firstName OU lastName obligatoire **sur le tier**) | sur `Client` / `Supplier` | **supprimée** du tier — équivalent assuré par RG-1.05 / RG-2.04 sur la sous-entité |
| RG-1.02 / RG-2.02 (max 2 téléphones **sur le tier**) | sur le tier | **supprimée** du tier — reste sur la sous-entité |
| RG-1.19/1.20/1.21 — RG-2.12 (normalisation Capitalize / chiffres / lowercase) | appliquée aux champs **du tier ET** de la sous-entité | ne s'applique plus aux champs du tier (qui n'existent plus) — **inchangée** sur la sous-entité |
## 2. Périmètre & découpage
### M1 — Clients (code DÉJÀ livré → suppression + migration de données)
| # | Ticket | Tag | Effort |
|---|--------|-----|--------|
| 1 | `M1-ticket-01-back` — supprimer le contact inline du `Client` (migration + backfill + entité + processor + provider + export + fixtures + tests) | Backend | M |
| 2 | `M1-ticket-02-front` — retirer le bloc contact principal des écrans création / consultation / modification | Frontend | M |
| 3 | `M1-ticket-03-specs` — acter la décision dans les specs M1 (back + front + cahier de test) | Maintenance | S |
### M2 — Fournisseurs (NON codé → on retire le contact inline dès la conception)
| # | Action | Tag | Effort |
|---|--------|-----|--------|
| 4 | `M2-ticket-specs` — mettre à jour les specs M2 déjà écrites (back + front) pour retirer le contact inline du `Supplier` | Maintenance | S |
| — | `M2-amendement-tickets` — amender les tickets M2 existants (n° 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.
+26 -53
View File
@@ -5,8 +5,11 @@ nom: "Répertoire clients"
ecran: repertoire-clients ecran: repertoire-clients
owner_spec: Matthieu owner_spec: Matthieu
backup_spec: Tristan backup_spec: Tristan
version: V0 version: V1
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
@@ -203,11 +206,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 |
| first_name | +-----------------------+ +--------------+ | (contact inline | +-----------------------+ +--------------+
| last_name |--1:n-->| client_contact | | site | | retiré V1 — |--1:n-->| client_contact | | site |
| phone_primary | +-----------------------+ | (Sites) | | firstName, | +-----------------------+ | (Sites) |
| phone_secondary | +--------------+ | lastName, phones,| +--------------+
| 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 | |
@@ -302,11 +305,8 @@ 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,
first_name VARCHAR(120), -- Contact inline retiré (V1, refonte-contact) : first_name / last_name / phone_primary /
last_name VARCHAR(120), -- phone_secondary / email vivent désormais uniquement dans client_contact (onglet Contacts).
phone_primary VARCHAR(20) NOT NULL,
phone_secondary VARCHAR(20),
email VARCHAR(180) NOT NULL,
distributor_id INT REFERENCES client(id) ON DELETE SET NULL, distributor_id INT REFERENCES client(id) ON DELETE SET NULL,
broker_id INT REFERENCES client(id) ON DELETE SET NULL, broker_id INT REFERENCES client(id) ON DELETE SET NULL,
triage_service BOOLEAN NOT NULL DEFAULT FALSE, triage_service BOOLEAN NOT NULL DEFAULT FALSE,
@@ -580,32 +580,9 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
// RG-1.01 — first_name OU last_name obligatoire (validation Assert\Callback // Contact inline retiré (V1, refonte-contact) : firstName / lastName / phonePrimary /
// au niveau de l'entite, levee dans le Processor). // phoneSecondary / email ne sont plus portés par Client — ils vivent dans ClientContact
#[ORM\Column(length: 120, nullable: true)] // (onglet Contacts). La garantie « ≥ 1 contact nommé » est portée par RG-1.05 + RG-1.14.
#[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).
@@ -749,7 +726,7 @@ class Client implements TimestampableInterface, BlamableInterface
- **Query params** : - **Query params** :
- `includeArchived=true|false` (default `false`) - `includeArchived=true|false` (default `false`)
- `categoryCode=<code>` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`) - `categoryCode=<code>` (filtre les clients ayant ≥ 1 `Category` de ce code stable — ERP-78 ; ex. `DISTRIBUTEUR`, `COURTIER`)
- `search=<text>` (recherche fuzzy sur companyName + lastName + email) - `search=<text>` (recherche fuzzy sur companyName + contacts liés `client_contact` (firstName / lastName / email) via LEFT JOIN groupé par `client.id` — décision D1, refonte-contact)
- **Tri par défaut** : `companyName ASC` - **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`).
@@ -768,10 +745,6 @@ 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,
@@ -783,7 +756,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 : ni firstName ni lastName - (RG-1.01 supprimée V1 — la complétude du contact est portée par l'onglet Contacts : RG-1.05 / RG-1.14)
- RG-1.03 : distributor + broker remplis simultanément - RG-1.03 : distributor + broker remplis simultanément
- Catégories vides (Assert\Count min=1) - Catégories vides (Assert\Count min=1)
@@ -885,8 +858,8 @@ Cf. § 2.6. Pattern Shared standard.
### Formulaire principal ### Formulaire principal
- **RG-1.01** : Au moins l'un des champs `firstName` (Prénom du contact principal) ou `lastName` (Nom du contact principal) doit être renseigné. Sinon → 422. - ~~**RG-1.01**~~ _(SUPPRIMÉE — V1, 2026-06-03, refonte-contact)_ : le contact principal inline est retiré du `Client`. La garantie « au moins un contact nommé » est désormais portée par **RG-1.05** (bloc Contact valide) + **RG-1.14** (≥ 1 bloc Contact) sur `ClientContact`.
- **RG-1.02** : 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.02**~~ _(SUPPRIMÉE du Client — V1, refonte-contact)_ : plus de téléphones inline sur le `Client`. Le « maximum 2 téléphones » reste applicable aux blocs `ClientContact` (normalisation RG-1.20).
- **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. Un `distributor` référencé doit porter une **`Category` de code `DISTRIBUTEUR`** ; un `broker` une **`Category` de code `COURTIER`** — sinon 422. _(Refonte ERP-78 : le filtrage se fait sur le `code` de la `Category`, plus sur le type — `ClientProcessor::hasCategoryCode`.)_ La liste front de `distributor` = clients ayant une catégorie de code `DISTRIBUTEUR`, via `GET /api/clients?categoryCode=DISTRIBUTEUR` ; idem `broker` avec `COURTIER`. - **RG-1.03** : Les champs `distributor` et `broker` sont **mutuellement exclusifs** (au plus une seule des deux est renseignée). Tentative d'envoyer les deux → 422. Contrainte CHECK en base également : `NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)`. Un `distributor` référencé doit porter une **`Category` de code `DISTRIBUTEUR`** ; un `broker` une **`Category` de code `COURTIER`** — sinon 422. _(Refonte ERP-78 : le filtrage se fait sur le `code` de la `Category`, plus sur le type — `ClientProcessor::hasCategoryCode`.)_ La liste front de `distributor` = clients ayant une catégorie de code `DISTRIBUTEUR`, via `GET /api/clients?categoryCode=DISTRIBUTEUR` ; idem `broker` avec `COURTIER`.
### Onglet Information ### Onglet Information
@@ -929,9 +902,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 `Client` et `ClientContact`) 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 `ClientContact` ; scope `Client` retiré en V1) sont **capitalize**-és serveur (`mb_convert_case(trim($v), MB_CASE_TITLE, 'UTF-8')`). Exemple : `JEAN dupont``Jean Dupont`.
- **RG-1.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `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.20** : Les champs téléphone (`phonePrimary`, `phoneSecondary` sur `ClientContact` ; scope `Client` retiré en V1) sont **normalisés à chiffres uniquement** côté serveur (`preg_replace('/\D+/', '', $v)`). Stockage : `0612345678`. Le **format affichage `XX XX XX XX XX`** est de la responsabilité du front via un filter Vue dédié (cf. spec-front).
- **RG-1.21** : `email` (`Client.email`, `ClientAddress.billingEmail`, `ClientContact.email`) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`). - **RG-1.21** : `email` (`ClientAddress.billingEmail`, `ClientContact.email` ; `Client.email` retiré en V1) est **lowercase** intégralement côté serveur (`mb_strtolower(trim($v), 'UTF-8')`).
### Archivage ### Archivage
@@ -960,8 +933,8 @@ Cf. § 2.6. Pattern Shared standard.
### 8.1 Cas à couvrir (back — PHPUnit) ### 8.1 Cas à couvrir (back — PHPUnit)
- [ ] **RG-1.01** : POST sans firstName ni lastName → 422 - [ ] ~~RG-1.01~~ _(supprimée V1)_ : la complétude du contact est couverte par RG-1.05 / RG-1.14 sur `ClientContact`
- [ ] **RG-1.02** : 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.02~~ _(supprimée du Client V1)_ : plus de téléphones inline sur le Client (téléphones testés sur `ClientContact`)
- [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201 - [ ] **RG-1.03** : POST avec distributor ET broker → 422 ; POST distributor seul → 201
- [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`) - [ ] **RG-1.03** : POST distributor référençant un client SANS catégorie de code DISTRIBUTEUR → 422 (validation custom `ClientProcessor::hasCategoryCode`)
- [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200 - [ ] **RG-1.04** : PATCH onglet Information par un user Commerciale avec champs incomplets → 422 ; même PATCH par Admin → 200
@@ -975,9 +948,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"` → persiste `"Jean"`, `"Dupont"` - [ ] **RG-1.19** : POST `firstName="JEAN"`, `lastName="dupont"` (via un bloc `ClientContact`) → persiste `"Jean"`, `"Dupont"`
- [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` → persiste `"0612345678"` - [ ] **RG-1.20** : POST `phonePrimary="06.12.34.56.78"` (via un bloc `ClientContact`) → persiste `"0612345678"`
- [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` → persiste `"jean.dupont@acme.fr"` - [ ] **RG-1.21** : POST `email="Jean.DUPONT@ACME.FR"` (via `ClientContact` ou `ClientAddress.billingEmail`) → persiste `"jean.dupont@acme.fr"`
- [ ] **RG-1.22/23** : PATCH isArchived=true par Bureau (sans `archive`) → 403 ; par Admin → 200 + archivedAt rempli ; PATCH isArchived=false sur un client archivé dont le SIREN a été repris → 409 - [ ] **RG-1.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
+9 -12
View File
@@ -5,7 +5,10 @@ nom: "Répertoire clients"
ecran: repertoire-clients ecran: repertoire-clients
owner_spec: Matthieu owner_spec: Matthieu
backup_spec: Tristan backup_spec: Tristan
version: V0 version: V1
# Historique : V1 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
# (Nom/Prénom/Téléphone/Téléphone 2/Email retirés du formulaire principal et des écrans).
# Saisie via l'onglet Contacts uniquement. Cf. docs/specs/M1-clients/refonte-contact/README.md
date_redaction: 2026-05-28 date_redaction: 2026-05-28
# === LIENS === # === LIENS ===
@@ -68,9 +71,6 @@ 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,12 @@ Création par **onglets successifs avec validation incrémentale** : pour pouvoi
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles. C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
> **V1 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent désormais dans l'onglet **Contacts** (RG-1.05 / RG-1.14). Le formulaire principal ne contient plus que Entreprise + Catégorie + relation Distributeur/Courtier.
| Champ | Type composant | Obligatoire | Règle | | Champ | Type composant | Obligatoire | Règle |
|---|---|---|---| |---|---|---|---|
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) | | **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
| **Nom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
| **Prénom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category | | **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
| **Téléphone principal** | `<MalioInputText>` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) |
| **Téléphone secondaire** | `<MalioInputText>` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. |
| **Email** | `<MalioInputText>` type email | Oui | RG-1.21 (lowercase) |
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. | | **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de **code** `DISTRIBUTEUR` (ERP-78), via `GET /api/clients?categoryCode=DISTRIBUTEUR`. RG-1.03. | | **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de **code** `DISTRIBUTEUR` (ERP-78), via `GET /api/clients?categoryCode=DISTRIBUTEUR`. RG-1.03. |
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de **code** `COURTIER` (ERP-78), via `GET /api/clients?categoryCode=COURTIER`. RG-1.03. | | **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de **code** `COURTIER` (ERP-78), via `GET /api/clients?categoryCode=COURTIER`. RG-1.03. |
@@ -120,7 +117,7 @@ Saisir les informations de l'entreprise.
### Onglet « Contact » ### Onglet « Contact »
Saisir un ou plusieurs contacts associés au client. Le 1er bloc est **pré-rempli** depuis les champs du formulaire principal (Nom, Prénom, Téléphone, Email — édition autorisée). Saisir un ou plusieurs contacts associés au client. **(V1 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-1.14).
**Bloc Contact** : **Bloc Contact** :
@@ -250,7 +247,7 @@ Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.
|---|---|---| |---|---|---|
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE | | Nom 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 (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) | | Téléphone (téléphones des blocs `ClientContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
| Email | lowercase intégral | identique | | 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()`.
@@ -275,7 +272,7 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. | | 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. | | 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
| 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. | | 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. |
| 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. | | 8 | Téléphones (max 2) | Sur les blocs `ClientContact` (`phone_primary` + `phone_secondary`). _(V1 : retirés du Client — refonte-contact.)_ |
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. | | 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
@@ -0,0 +1,331 @@
---
# === IDENTITÉ ===
module: M2
nom: "Répertoire fournisseurs"
ecran: repertoire-fournisseurs
owner_spec: Matthieu
backup_spec: Tristan
version: V0.2
date_redaction: 2026-06-02
# Historique : V0.2 (2026-06-03) — Refonte contact : suppression du bloc contact principal inline
# du formulaire Supplier (Nom/Prénom/Téléphone/Téléphone 2/Email). Saisie via l'onglet Contacts.
# Aligné sur M1. Cf. docs/specs/M1-clients/refonte-contact/README.md
# === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-36987&p=f&m=dev"
regles_metier: [RG-2.01, RG-2.02, RG-2.03, RG-2.04, RG-2.05, RG-2.06, RG-2.07, RG-2.08, RG-2.09, RG-2.10, RG-2.11, RG-2.12, RG-2.13, RG-2.14, RG-2.15, RG-2.16, RG-2.17]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT ===
client_validation_1:
statut: validee
date: 2026-05-22
version: V0
valide_par: "Matthieu (CP MALIO)"
client_validation_2:
statut: validee
date: 2026-06-01
version: V0.1
valide_par: "Matthieu (CP MALIO)"
resume: "Module 2 — Répertoire fournisseurs. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders 'À venir')."
trace_archivee: "uploads/M2-reportoire-fournisseurs.docx (V0.1) + M2-reportoire-fournisseurs-V01.pdf"
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 26
lesstime_project_id: 6
statut_global: a_dev
---
# Module 2 — Répertoire fournisseurs (V0.1 front)
> **Origine** : spec front livrée le 22/05/2026 (V0), amendée le 01/06/2026 (V0.1) — `M2-reportoire-fournisseurs.docx`. Restitution Markdown pour intégration au workflow MALIO. Le contenu fonctionnel original n'est pas modifié ; toute décision technique (back) vit dans [`spec-back.md`](./spec-back.md). Le M2 réutilise massivement le pattern et les composants posés au [M1 clients](../M1-clients/spec-front.md).
## But
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des fournisseurs** de l'organisation : consultation, création, modification, archivage. C'est la **deuxième porte d'entrée du module Commercial** (aux côtés des Clients).
## Accès
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire fournisseurs » (route `/suppliers`).
- **Rôles autorisés** :
| Rôle | Consultation | Création / Modification | Archivage |
|---|---|---|---|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
| **Usine** | ❌ (pas d'accès) | ❌ | ❌ |
> **Note** : RBAC identique au M1, transposée sur `commercial.suppliers.*`. Compta édite uniquement l'onglet Comptabilité (SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) d'un fournisseur existant ; Compta ne peut pas **créer** un fournisseur. **L'archivage est réservé à Admin** (cf. tableau du docx).
## Navigation
Page d'entrée du module **Commercial** (route `/suppliers`). Titre : « **Répertoire fournisseurs** ».
- Affichage principal : un **datatable** listant tous les fournisseurs **actifs** (les archivés sont masqués par défaut — toggle UI dédié).
- **Clic sur une ligne** → écran **Consultation fournisseur** (page dédiée).
- **Bouton « + Ajouter »** (haut droite) → écran **Ajouter un fournisseur**.
- **Bouton « Filtrer »** (haut droite, **à côté de « + Ajouter »**) → ouvre le **panneau de filtres** (cf. ci-dessous). Un badge/compteur indique le nombre de filtres actifs ; un bouton « Réinitialiser » les vide.
- **Bouton « Exporter »** (haut droite) → télécharge un **XLSX** des fournisseurs **affichés** (cf. filtres actifs). Format dans [`spec-back.md § 4.6`](./spec-back.md).
### Panneau de filtres (bouton « Filtrer »)
Ouvre un drawer/popover (composant à confirmer côté équipe front — réutiliser le pattern M1 s'il existe). Filtres proposés, branchés sur les query params de `GET /api/suppliers` (cf. [`spec-back.md § 4.1`](./spec-back.md)) :
| Filtre | Composant | Query param back |
|---|---|---|
| **Recherche** (nom entreprise / contact / email — recherche contact via `supplier_contact`, décision D1) | `<MalioInputText>` | `?search=` |
| **Catégorie** | `<MalioSelectCheckbox>` (multi, type FOURNISSEUR) | `?categoryCode=` |
| **Site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | `?siteId=` |
| **Inclure les archivés** | `<MalioCheckbox>` | `?includeArchived=true` |
- À l'application des filtres → `setFilters(...)` de `usePaginatedList` (retombe en **page 1**), qui relance `GET /api/suppliers` avec les params.
- **État 100 % local** (jamais dans l'URL — règle ABSOLUE n°6). Le bouton « Filtrer » + son panneau remplacent/regroupent l'ancien toggle « archivés » isolé.
## Datatable du Répertoire
Composant : `<MalioDataTable>` branché sur `usePaginatedList<Supplier>({ url: '/suppliers' })` (règle frontend obligatoire — pagination Hydra, état 100 % local). Colonnes **conformes à la maquette Figma** (4 colonnes) :
| Colonne | Source | Tri |
|---|---|---|
| **Nom** | `supplier.companyName` | ASC par défaut |
| **Catégories** | `supplier.categories[].name` (embarquées en liste — cohérence M1/ERP-62 ; libellé = `name`, pas `label`) | Non |
| **Site** | `supplier.sites[].name` (agrégat des adresses via `getSites()` ; `Site` n'a pas de `code`) | Non |
| **Dernière activité** | `supplier.updatedAt` (format `JJ-MM-AAAA`) — exposé dans `supplier:read` | Oui |
> **Clic sur une ligne** (texte en bleu lien) → écran Consultation.
> **Filtres** : regroupés dans le panneau du bouton « Filtrer » (cf. section précédente), dont l'inclusion des archivés (désactivée par défaut). **État local** (jamais dans l'URL — règle ABSOLUE n°6).
> **Pagination** : `<MalioDataTable>` + `usePaginatedList`, options **standard Starseed 10 / 25 / 50 (défaut 10)** — on **n'applique pas** le « Ligne : 20 » de la maquette (décision Matthieu : on reste sur le standard). Tri serveur `companyName ASC` par défaut.
## Écran « Ajouter un fournisseur »
Création par **onglets successifs avec validation incrémentale** : pour passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant** ; les champs validés passent en lecture seule + bouton « Valider » désactivé (disabled). Cf. [`spec-back.md § 2.10`](./spec-back.md) (PATCH partiels par groupe de sérialisation).
**Barre d'onglets en création (5 onglets, conforme maquette)** : `Information` · `Contacts` · `Adresses` · `Transport` · `Comptabilité`. L'onglet `Information` est actif par défaut juste après validation du formulaire principal. Les onglets `Statistiques`, `Rapports` et `Échanges` **n'apparaissent PAS dans le flux de création** — ils ne sont présents qu'en Consultation / Modification.
### Formulaire principal (pré-onglets)
1er bloc à remplir. Sans validation, les onglets ne sont pas accessibles. Une fois validé → POST `/api/suppliers`, puis bascule sur l'onglet Information ; les champs passent en readonly.
> **V0.2 — refonte-contact** : le contact principal (Nom / Prénom / Téléphone / Téléphone 2 / Email) a été **retiré** du formulaire principal. Les coordonnées se saisissent dans l'onglet **Contacts** (RG-2.04 / RG-2.13). Le formulaire principal ne contient plus que Entreprise + Catégorie.
| Champ | Type composant | Obligatoire | Règle |
|---|---|---|---|
| **Nom du fournisseur (Entreprise)** | `<MalioInputText>` | Oui | RG-2.12 (UPPERCASE serveur) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | `Category` de **type FOURNISSEUR** via `GET /api/categories?typeCode=FOURNISSEUR` (RG-2.10). Libellé affiché = `category.name`. ⚠️ Le type + le filtre `?typeCode=` sont **à créer** côté back (n'existent pas en prod — cf. spec-back § 2.4). |
**Action** : « Valider » (`<MalioButton>`) → POST `/api/suppliers` ([`spec-back.md § 4.3`](./spec-back.md)). Succès → onglet « Information ».
### Onglet « Information »
Saisir les informations du fournisseur.
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-2.03 (obligatoire rôle Commerciale) |
| **Concurrent** | `<MalioInputText>` | Conditionnel | RG-2.03 |
| **Date création** (entreprise) | `<input type="date">` (exception Malio — `// TODO migrer`) | Conditionnel | RG-2.03 |
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-2.03 |
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-2.03 |
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-2.03 |
| **Volume Prévisionnel** | `<MalioInputNumber>` | Conditionnel | RG-2.03 (champ spécifique fournisseur) |
> **Disposition maquette** : 3 colonnes — ligne 1 (Description / Concurrent / Date création), ligne 2 (Nombre de salariés / CA / Dirigeant), ligne 3 (Résultat / Volume Prévisionnel).
**Action** : « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:information`).
### Onglet « Contact »
Saisir un ou plusieurs contacts. **(V0.2 — refonte-contact : plus de pré-remplissage depuis le formulaire principal ; les coordonnées du contact se saisissent directement ici.)** Au moins un bloc Contact valide est requis (RG-2.13).
**Bloc Contact** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Nom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-2.04 + RG-2.12 (Capitalize) |
| **Fonction** | `<MalioInputText>` | Non | — |
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-2.12 (format) |
| **Email** | `<MalioInputText>` type email | Non | RG-2.12 (lowercase) |
**RG-2.04 / RG-2.13** : au moins 1 bloc Contact valide (Nom OU Prénom rempli) pour valider l'onglet — l'onglet Contact ne peut pas être finalisé vide.
**Actions** :
- « + Nouveau contact » : ajoute un bloc. **Désactivé tant que le bloc précédent n'a pas Prénom OU Nom** (RG-2.04).
- « Supprimer » (icône) : modal de confirmation, puis suppression du bloc.
- « Valider » → PATCH `/api/suppliers/{id}/contacts`.
### Onglet « Adresse »
Saisir une ou plusieurs adresses, rattachées à un ou plusieurs sites (86 / 17 / 82) et à des contacts.
**Bloc Adresse** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Type d'adresse** | `<MalioRadioButton>``Prospect` / `Départ` / `Rendu` | Oui | RG-2.09 (exclusif, enum `PROSPECT`/`DEPART`/`RENDU`) |
| **Pays** | `<MalioSelect>` (saisie assistée — préremplie « France ») | Oui | — |
| **Code postal** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — déclenche autocomplete ville (BAN) |
| **Ville** | `<MalioSelect>` (saisie assistée) | Oui | RG-2.05 — alimentée par api-adresse.data.gouv.fr suivant le CP |
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-2.05 — autocomplete BAN |
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
| **Sélecteur de site** | `<MalioSelectCheckbox>` (86 / 17 / 82) | Oui | RG-2.06 — ≥ 1 site. Les 3 cases = les 3 `Site` fixes ; libellés « 86/17/82 » = **préfixe du `postalCode`** (86100/17400/82400), pas un `Site.code` (qui n'existe pas). La sélection stocke des **IDs de Site** (M2M). |
| **Catégories** | `<MalioSelectCheckbox>` (multi) | Oui | Catégories de type FOURNISSEUR (RG-2.10), liées aux catégories du fournisseur |
| **Contact** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
| **Benne(s)** | `<MalioInputNumber>` (stepper /+ , défaut 0) | Non | Champ spécifique fournisseur |
| **Prestation de triage** | `<MalioCheckbox>` | Non | Champ spécifique fournisseur (porté par l'adresse — colonne back `triage_provider`) |
> **Disposition maquette par bloc** : ligne 1 = radio (Prospect / Départ / Rendu) + Pays + Code postal ; ligne 2 = Ville + Adresse + Adresse complémentaire ; ligne 3 = sites (86 / 17 / 82) + Catégories + Contact ; ligne 4 = Benne(s) + Prestation de triage. Icône corbeille en haut à droite de chaque bloc pour le supprimer.
**Actions** :
- « + Nouvelle Adresse » : ajoute un bloc identique au premier.
- « Supprimer » : modal de confirmation puis suppression.
- « Valider » → PATCH `/api/suppliers/{id}/addresses`.
### Onglet « Transport »
🚧 **Onglet placeholder minimal au M2.** Conforme à la maquette : la frame est **vide** (aucun champ, aucun bouton de validation, aucune API back). L'onglet reste navigable. Un libellé discret « À venir » est toléré mais non requis (la maquette ne l'affiche pas). Cet onglet **fait partie de la barre de création** (entre Adresses et Comptabilité).
### Onglet « Comptabilité »
**Accessible aux rôles avec `commercial.suppliers.accounting.view`** (Admin + Compta au M2). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer** cet onglet (`accounting.manage`). Compta ne peut pas créer un fournisseur (pas de `manage` global).
**Champs comptables** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | 9 chiffres. **Pas d'unicité** (cf. § 2.6) |
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` (référentiel M1) |
| **N° de TVA** | `<MalioInputText>` | Oui | — |
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
| **Banque** | `<MalioSelect>` | Conditionnel | RG-2.07 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks` (SG / CIC / CA). |
**Bloc RIB** (0..n, présence obligatoire conditionnée par RG-2.08) :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-2.08 |
**Actions** :
- « + RIB » : ajoute un bloc.
- « Supprimer » (icône) : modal de confirmation.
- « Valider » → PATCH `/api/suppliers/{id}` (groupe `supplier:write:accounting`) + sous-ressource RIBs.
### Onglets « Statistiques » / « Rapports » / « Échanges »
🚧 **Placeholders minimaux au M2 — uniquement en Consultation / Modification** (ils n'apparaissent **pas** dans le flux de création, cf. maquette). Frames vides, pas de validation, pas d'API.
## Écran « Consultation fournisseur »
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans `+` pour ajouter des blocs.
- **Flèche retour** (gauche) → revient au Répertoire.
- **Bouton « Modifier »** (droite, visible si `commercial.suppliers.manage`) → écran Modification.
- **Bouton « Archiver »** (droite, visible **uniquement Admin** via `commercial.suppliers.archive`) → modal de confirmation, puis PATCH `/api/suppliers/{id}` `{ "isArchived": true }`.
> Un fournisseur archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé.
### Onglets affichés en consultation
Information / Contacts / Adresses / Transport / Statistiques / Rapports / Échanges / Comptabilité (les 4 derniers métiers en placeholder « À venir », Comptabilité selon permission). L'utilisateur navigue **librement** entre les onglets (pas de séquence forcée en consultation).
## Écran « Modification fournisseur »
Comportement identique à l'écran Ajouter sauf :
- **Pas de formulaire principal** réaffiché (champs principaux édités via les onglets correspondants).
- Les champs sont **pré-remplis** avec les valeurs actuelles.
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` (ou `accounting.manage`) restent en **lecture seule** (pas de bouton Valider, pas d'icône suppression).
- Les onglets placeholders « À venir » restent non éditables.
## Composants UI à utiliser (`@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (+ `usePaginatedList`)
- **Input texte** : `<MalioInputText>`
- **Input numérique** : `<MalioInputNumber>` (Nombre de salariés, Volume prévisionnel, Bennes)
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
- **TextArea** : `<MalioInputTextArea>` (Description)
- **Select simple** : `<MalioSelect>` (Pays, Ville, référentiels comptables)
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
- **Radio** : `<MalioRadioButton>` (Type d'adresse Prospect / Départ / Rendu — RG-2.09)
- **Checkbox** : `<MalioCheckbox>` (Prestataire de triage)
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
- **Toasts** : standards via `useApi()`
**Exceptions autorisées** (commenter `// TODO migrer quand Malio couvre`) :
- `<input type="date">` pour « Date Création » (`MalioDate` non couvert).
- Modal de confirmation : `<MalioModal>` ou wrapper partagé dans `frontend/shared/` (réutiliser celui du M1 si présent).
## Composables & appels API
- `usePaginatedList<Supplier>({ url: '/suppliers' })` — liste paginée (obligatoire, règle frontend). La liste consomme `categories[]` (libellé = `name`) et `sites[]` (libellé = `name`, pas de `code`) **embarqués** + `updatedAt` (cohérence M1/ERP-62, cf. [`spec-back.md § 2.12 / § 4.0`](./spec-back.md)). Côté back, fetch-joins anti-N+1.
- `useSupplier(id)` — charge le détail via `GET /api/suppliers/{id}`, qui **embarque** `contacts`, `addresses` (avec `sites` / `categories` / `contacts` imbriqués) et, si permission, `ribs` + scalaires compta. Les écrans Consultation et Modification se peuplent depuis cette seule réponse (RETEX M1 §2 : embed borné, pas de N+1 d'appels). **DoD avant intégration** : vérifier que le JSON réel contient bien ces blocs (cf. [`spec-back.md § 4.0.bis`](./spec-back.md)).
- `useSupplierForm()` — workflow par onglet (POST principal + PATCH partiels par groupe), miroir de `useClientForm()`.
- `useAddressAutocomplete()`**réutilisé du M1** (BAN), pas de réécriture.
- `usePermissions()` — masque l'onglet Comptabilité et le bouton Archiver.
- Tous les appels passent par `useApi()` (jamais `$fetch` direct — règle ABSOLUE n°4).
- Filter `formatPhoneFR()`**réutilisé du M1** pour l'affichage `XX XX XX XX XX`.
## Règles de formatage et normalisation
Le serveur normalise systématiquement (RG-2.12 — cf. [`spec-back.md`](./spec-back.md)) :
| Champ | Normalisation serveur | Affichage front |
|---|---|---|
| Nom fournisseur (`companyName`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize | identique |
| Téléphones (blocs `SupplierContact`) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` (filter Vue) |
| Email | lowercase intégral | identique |
> Le front **ne normalise pas** : il envoie la valeur saisie, le serveur normalise et renvoie la valeur normalisée que l'UI affiche. Cohérent avec `useApi()`.
## API adresse postale
Code postal + Ville + Adresse branchés sur **api-adresse.data.gouv.fr** (BAN) via le composable `useAddressAutocomplete()` **déjà créé au M1** (réutilisé tel quel) :
- À la saisie du CP (5 chiffres) : `GET https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville.
- À la saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions.
- Cas dégradé (timeout / offline) : Ville en `<MalioInputText>` libre + toast d'avertissement.
## Différences notables avec le M1 (clients)
| Zone | M1 clients | M2 fournisseurs |
|---|---|---|
| Distributeur / Courtier | Auto-référence Client (RG-1.03) | **Absent** |
| Prestation de triage | Booléen sur le client (formulaire principal) | **Booléen sur l'adresse** (`triage_provider`) |
| Type d'adresse | 3 checkboxes Prospect / Livraison / Facturation | **Radio exclusif** Prospect / Départ / Rendu (RG-2.09) |
| Email facturation sur adresse | Oui (conditionnel) | **Absent** |
| Champ adresse « Bennes » | — | **Présent** (nombre) |
| Onglet Information | 7 champs | **8 champs** (ajout « Volume prévisionnel ») |
| Catégories | type unique `CLIENT` (codes ERP-78) | **nouveau type `FOURNISSEUR`** |
| Archivage | Admin | **Admin uniquement** (idem) |
| Onglets « À venir » | frames blanches | **placeholder « À venir »** (minimal) |
## Points résolus côté back
| # | Zone d'ombre | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Catégorie multi-select | M2M `supplier_category`, `Category` de type **FOURNISSEUR** (RG-2.10) |
| 2 | Type d'adresse Prospect/Départ/Rendu | Enum exclusif `address_type` (RG-2.09) |
| 3 | Onglet Comptabilité : qui édite ? | Admin + Compta (`accounting.manage`) ; Bureau/Commerciale ne le voient pas |
| 4 | Workflow par onglet | Sauvegarde incrémentale (POST principal + PATCH partiels) — pas d'état « draft » |
| 5 | Onglets « À venir » | Placeholder minimal « À venir » (Transport / Stats / Rapports / Échanges) |
| 6 | Archive vs delete | Flag `is_archived` séparé de `deleted_at` ; archivage Admin seul ; soft delete = HP |
| 7 | Unicité métier | Nom de fournisseur uniquement (à valider — § 2.6). SIREN/email non uniques |
| 8 | Référentiels comptables | Réutilisés du M1 (zéro duplication) |
| 9 | API code postal | BAN via `useAddressAutocomplete()` du M1 |
| 10 | Format export | XLSX uniquement (CSV = HP) |
---
## 📦 Tickets Lesstime
**TaskGroup Lesstime** : à créer — `M2 — Répertoire fournisseurs` (projet `ERP / Starseed`, projectId=6).
> Détail complet et action manuelle → voir [`spec-back.md § Tickets Lesstime`](./spec-back.md#-tickets-lesstime-à-découper).
+36 -5
View File
@@ -87,7 +87,33 @@
"archiveSuccess": "Client archivé avec succès", "archiveSuccess": "Client archivé avec succès",
"restoreSuccess": "Client restauré avec succès", "restoreSuccess": "Client restauré avec succès",
"error": "Une erreur est survenue. Réessayez.", "error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du répertoire clients a échoué. Réessayez." "exportError": "L'export du répertoire clients a échoué. Réessayez.",
"restoreConflict": "Impossible de restaurer : un client actif portant ce nom existe déjà."
},
"consultation": {
"title": "Consultation client",
"back": "Retour au répertoire",
"loading": "Chargement du client…",
"notFound": "Client introuvable.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"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.",
"emptyContacts": "Aucun contact enregistré.",
"emptyAddresses": "Aucune adresse enregistrée.",
"save": "Valider"
}, },
"validation": { "validation": {
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.", "informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
@@ -222,10 +248,15 @@
"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",
@@ -88,9 +88,11 @@
sur l'input interne, pas sur la cellule de grille. Le wrapper porte sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). --> le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2"> <div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) ou libre en mode degrade. --> <!-- 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 <MalioInputAutocomplete
v-if="!degraded" v-if="!degraded && !readonly"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
@@ -197,8 +199,21 @@ const model = computed(() => props.modelValue)
// Mode degrade : service BAN indisponible Ville/Adresse en saisie libre. // Mode degrade : service BAN indisponible Ville/Adresse en saisie libre.
const degraded = ref(false) const degraded = ref(false)
const cityOptions = ref<RefOption[]>([]) // Villes proposees par la BAN (alimentees a la saisie du code postal).
const banCityOptions = ref<RefOption[]>([])
const addressOptions = ref<RefOption[]>([]) const addressOptions = 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
})
const addressLoading = ref(false) const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
@@ -248,7 +263,7 @@ async function onPostalCodeChange(value: string): Promise<void> {
} }
try { try {
const suggestions = await autocomplete.searchCity(digits) const suggestions = await autocomplete.searchCity(digits)
cityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city })) banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
} }
catch { catch {
enterDegraded() enterDegraded()
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
const mockGet = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
const { useClient } = await import('../useClient')
const SAMPLE = { '@id': '/api/clients/42', id: 42, companyName: 'ACME', isArchived: false }
describe('useClient', () => {
beforeEach(() => {
mockGet.mockReset()
mockPatch.mockReset()
mockGet.mockResolvedValue(SAMPLE)
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
})
it('charge le detail via GET /clients/{id} en Hydra, sans toast', async () => {
const { client, load } = useClient(42)
await load()
expect(mockGet).toHaveBeenCalledWith(
'/clients/42',
{},
expect.objectContaining({
headers: { Accept: 'application/ld+json' },
toast: false,
}),
)
expect(client.value).toEqual(SAMPLE)
})
it('bascule loading pendant le chargement et le retombe a false', async () => {
const { loading, load } = useClient(42)
const promise = load()
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
})
it('marque error et laisse client null si le GET echoue (404...)', async () => {
mockGet.mockRejectedValueOnce(new Error('not found'))
const { client, error, load } = useClient(99)
await load()
expect(error.value).toBe(true)
expect(client.value).toBeNull()
})
it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
// 1er GET = chargement initial, 2e GET = rechargement post-archivage.
mockGet.mockResolvedValueOnce(SAMPLE)
mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
const { client, load, archive } = useClient(42)
await load()
await archive()
expect(mockPatch).toHaveBeenCalledWith(
'/clients/42',
{ isArchived: true },
expect.objectContaining({ toast: false }),
)
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
expect(mockGet).toHaveBeenCalledTimes(2)
expect(client.value?.isArchived).toBe(true)
})
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
const { load, restore } = useClient(42)
await load()
await restore()
expect(mockPatch).toHaveBeenCalledWith(
'/clients/42',
{ isArchived: false },
expect.objectContaining({ toast: false }),
)
})
it('propage l\'erreur (ex: 409 conflit homonyme RG-1.23) au lieu de l\'avaler', async () => {
const conflict = { response: { status: 409 } }
mockPatch.mockRejectedValueOnce(conflict)
const { load, restore } = useClient(42)
await load()
await expect(restore()).rejects.toBe(conflict)
})
})
@@ -0,0 +1,72 @@
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' }] })
}
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.
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
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' }] })
}
return Promise.resolve({ member: [] })
})
const refs = useClientReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
])
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
})
})
@@ -0,0 +1,70 @@
import { ref } from 'vue'
import type { ClientDetail } from '~/modules/commercial/utils/clientConsultation'
/**
* Chargement et actions d'archivage d'un client unique (ecran « Consultation
* client », 1.11). Lit le detail embarque via `GET /api/clients/{id}` (contacts /
* adresses / ribs sous `client:item:read` / `client:read:accounting`) et expose
* les bascules d'archivage (PATCH `isArchived` SEUL tout autre champ => 422).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
* Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
*
* Etat 100 % local a l'instance (refs) aucune persistance URL. Les erreurs
* d'archivage/restauration (notamment le 409 RG-1.23 : homonyme actif a la
* restauration) sont PROPAGEES a l'appelant, qui decide du toast a afficher.
*/
export function useClient(id: number | string) {
const api = useApi()
const client = ref<ClientDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
function fetchDetail(): Promise<ClientDetail> {
return api.get<ClientDetail>(
`/clients/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le detail du client. En cas d'echec : `error = true`, `client = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
client.value = await fetchDetail()
}
catch {
error.value = true
client.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL tout autre champ => 422),
* puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
* `client:read` (ni l'embed contacts/adresses/ribs ni les libelles des
* referentiels comptables), un simple merge laisserait l'affichage incoherent.
* Toute erreur (notamment le 409 d'homonyme actif a la restauration, RG-1.23)
* est propagee a l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/clients/${id}`, { isArchived }, { toast: false })
client.value = await fetchDetail()
}
return {
client,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -85,26 +85,32 @@ export function useClientReferentials() {
/** /**
* Charge en parallele les referentiels communs (hors distributeurs/courtiers, * Charge en parallele les referentiels communs (hors distributeurs/courtiers,
* charges a la demande selon la relation choisie). Les selects compta ne sont * charges a la demande selon la relation choisie).
* pertinents que si l'utilisateur a acces a l'onglet, mais le cout est *
* negligeable et simplifie l'orchestration. * Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole.
* 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> { async function loadCommon(): Promise<void> {
const [cats, sitesList, tva, delays, types, banksList] = await Promise.all([ await Promise.allSettled([
fetchAll<CategoryMember>('/categories'), fetchAll<CategoryMember>('/categories')
fetchAll<SiteMember>('/sites'), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<ReferentialMember>('/tva_modes'), fetchAll<SiteMember>('/sites')
fetchAll<ReferentialMember>('/payment_delays'), .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }),
fetchAll<ReferentialMember>('/payment_types'), fetchAll<ReferentialMember>('/tva_modes')
fetchAll<ReferentialMember>('/banks'), .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 })) }),
]) ])
categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code }))
sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name }))
tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label }))
paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label }))
paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code }))
banks.value = banksList.map(b => ({ value: b['@id'], label: b.label }))
} }
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */ /** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
@@ -0,0 +1,909 @@
<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"
/>
<MalioInputText
v-model="main.lastName"
:label="t('commercial.clients.form.main.lastName')"
:readonly="businessReadonly"
/>
<MalioInputText
v-model="main.firstName"
:label="t('commercial.clients.form.main.firstName')"
:readonly="businessReadonly"
/>
<MalioSelectCheckbox
: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)"
/>
<MalioInputPhone
v-model="main.phonePrimary"
:label="t('commercial.clients.form.main.phonePrimary')"
:mask="PHONE_MASK"
:required="true"
:readonly="businessReadonly"
add-icon-name="mdi:plus"
:addable="!main.hasSecondaryPhone && !businessReadonly"
:add-button-label="t('commercial.clients.form.main.addPhone')"
@add="main.hasSecondaryPhone = true"
/>
<MalioInputPhone
v-if="main.hasSecondaryPhone"
v-model="main.phoneSecondary"
:label="t('commercial.clients.form.main.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="businessReadonly"
/>
<MalioInputEmail
v-model="main.email"
:label="t('commercial.clients.form.main.email')"
:required="true"
:readonly="businessReadonly"
/>
<MalioSelect
:model-value="main.relationType"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
: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)"
/>
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyContacts') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<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"
/>
<p v-if="addresses.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.edit.emptyAddresses') }}
</p>
<div v-if="!businessReadonly" class="flex justify-center gap-6">
<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-3 gap-x-[80px] gap-y-5">
<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-3 gap-x-[80px] gap-y-5">
<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><TabPlaceholderBlank /></template>
<template #statistics><TabPlaceholderBlank /></template>
<template #reports><TabPlaceholderBlank /></template>
<template #exchanges><TabPlaceholderBlank /></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 PHONE_MASK = '## ## ## ## ##'
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)
// 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)
&& filled(main.email)
&& filled(main.phonePrimary)
&& (filled(main.firstName) || filled(main.lastName))
&& 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)
})
}
/**
* 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 && (!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)
})
}
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)
})
}
/**
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting,
* exige accounting.manage cote back) PUIS DELETE/POST/PATCH des RIB sur la
* sous-ressource. Aucun champ main/information dans le payload (mode strict
* RG-1.28 : sinon 403 sur tout le payload).
*/
async function submitAccounting(): Promise<void> {
if (accountingReadonly.value || !canValidateAccounting.value || tabSubmitting.value) return
tabSubmitting.value = true
try {
await api.patch(`/clients/${clientId}`, buildAccountingPayload(accounting, isBankRequired.value), { toast: false })
for (const id of removedRibIds.value) {
await api.delete(`/client_ribs/${id}`, {}, { toast: false })
}
removedRibIds.value = []
for (const rib of ribs.value) {
if (!ribIsComplete(rib)) continue
const body = buildRibPayload(rib)
if (rib.id === null) {
const created = await api.post<{ id: number }>(
`/clients/${clientId}/ribs`,
body,
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
rib.id = created.id
}
else {
await api.patch(`/client_ribs/${rib.id}`, body, { toast: false })
}
}
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
}
catch (e) {
showError(e)
}
finally {
tabSubmitting.value = false
}
}
// Modal de confirmation generique
const confirmModal = reactive({
open: false,
message: '',
action: null as null | (() => void),
})
function askConfirm(message: string, action: () => void): void {
confirmModal.message = message
confirmModal.action = action
confirmModal.open = true
}
function runConfirm(): void {
confirmModal.action?.()
confirmModal.action = null
confirmModal.open = false
}
useHead({ title: headerTitle })
onMounted(async () => {
// Referentiels en best-effort (echec non bloquant : l'embed alimente les
// libelles des valeurs courantes).
referentials.loadCommon().catch(() => {})
await load()
if (client.value) hydrate(client.value)
})
</script>
@@ -0,0 +1,481 @@
<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
/>
<MalioInputText
:model-value="client.lastName"
:label="t('commercial.clients.form.main.lastName')"
readonly
/>
<MalioInputText
:model-value="client.firstName"
:label="t('commercial.clients.form.main.firstName')"
readonly
/>
<MalioSelectCheckbox
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
disabled
/>
<MalioInputPhone
v-for="(phone, index) in mainPhones"
:key="index"
:model-value="phone"
:label="index === 0 ? t('commercial.clients.form.main.phonePrimary') : t('commercial.clients.form.main.phoneSecondary')"
:mask="PHONE_MASK"
readonly
/>
<MalioInputEmail
:model-value="client.email"
:label="t('commercial.clients.form.main.email')"
readonly
/>
<MalioSelect
v-if="relation.type"
:model-value="relation.type"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
disabled
/>
<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
/>
<p v-if="contacts.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyContacts') }}
</p>
</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="view.siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
readonly
/>
<p v-if="addressViews.length === 0" class="text-center text-black/60">
{{ t('commercial.clients.consultation.emptyAddresses') }}
</p>
</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-3 gap-x-[80px] gap-y-5">
<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-3 gap-x-[80px] gap-y-5">
<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><TabPlaceholderBlank /></template>
<template #statistics><TabPlaceholderBlank /></template>
<template #reports><TabPlaceholderBlank /></template>
<template #exchanges><TabPlaceholderBlank /></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 { formatPhoneFR } from '~/shared/utils/phone'
// Masques d'affichage (purement visuels, la donnee reste celle du serveur).
const PHONE_MASK = '## ## ## ## ##'
const SIREN_MASK = '#########'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
// 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']))
// Telephones du formulaire principal, formates XX XX XX XX XX (RG d'affichage).
const mainPhones = computed(() =>
[client.value?.phonePrimary, client.value?.phoneSecondary]
.filter((p): p is string => Boolean(p))
.map(formatPhoneFR),
)
const information = computed(() => ({
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,
}))
const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft))
// Vue par adresse : brouillon + options (sites/categories) propres a l'adresse.
const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView))
const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft))
// 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))
const relationOptions = computed<SelectOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
const countryOptions: SelectOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
const paymentDelayOptions = computed(() => referentialOptionOf(client.value?.paymentDelay))
const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
// Onglets : navigation LIBRE (pas de sequence forcee en consultation)
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
contact: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline',
transport: 'mdi:truck-delivery-outline',
accounting: 'mdi:bank-circle-outline',
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
key,
label: t(`commercial.clients.tab.${key}`),
icon: TAB_ICONS[key],
})))
const activeTab = ref('information')
// Navigation
function goBack(): void {
router.push('/clients')
}
function goEdit(): void {
router.push(`/clients/${clientId}/edit`)
}
// Archivage / Restauration
const confirmOpen = ref(false)
const toggling = ref(false)
function askToggleArchive(): void {
confirmOpen.value = true
}
/**
* Confirme l'archivage ou la restauration (PATCH isArchived seul). Gere le 409
* de conflit d'homonyme actif a la restauration (RG-1.23) avec un message dedie.
*/
async function confirmToggleArchive(): Promise<void> {
if (toggling.value) return
toggling.value = true
const restoring = isArchived.value
try {
if (restoring) {
await restore()
toast.success({ title: t('commercial.clients.toast.restoreSuccess') })
}
else {
await archive()
toast.success({ title: t('commercial.clients.toast.archiveSuccess') })
}
confirmOpen.value = false
}
catch (e) {
const status = (e as { response?: { status?: number } })?.response?.status
toast.error({
title: t('commercial.clients.toast.error'),
message: restoring && status === 409
? t('commercial.clients.toast.restoreConflict')
: t('commercial.clients.toast.error'),
})
}
finally {
toggling.value = false
}
}
useHead({ title: headerTitle })
onMounted(load)
</script>
@@ -0,0 +1,235 @@
import { describe, expect, it } from 'vitest'
import {
canEditClient,
categoryOptionsOf,
contactOptionsOf,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
mapAddressView,
mapContactToDraft,
mapRibToDraft,
referentialOptionOf,
relationOf,
showArchiveAction,
showRestoreAction,
siteOptionsOf,
type ClientDetail,
} from '../clientConsultation'
describe('iriOf', () => {
it('retourne l\'@id d\'une relation embarquee (objet)', () => {
expect(iriOf({ '@id': '/api/payment_types/10', code: 'LCR' })).toBe('/api/payment_types/10')
})
it('retourne la chaine telle quelle si la relation est deja un IRI', () => {
expect(iriOf('/api/banks/3')).toBe('/api/banks/3')
})
it('retourne null pour une relation absente (null / undefined / skip_null_values)', () => {
expect(iriOf(null)).toBeNull()
expect(iriOf(undefined)).toBeNull()
})
})
describe('relationOf', () => {
it('detecte une relation distributeur et expose son nom', () => {
const client = { distributor: { '@id': '/api/clients/15', companyName: 'DISTRIB GRAND SUD-OUEST' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'distributeur', name: 'DISTRIB GRAND SUD-OUEST' })
})
it('detecte une relation courtier et expose son nom', () => {
const client = { broker: { '@id': '/api/clients/16', companyName: 'CABINET LEONARD' } } as ClientDetail
expect(relationOf(client)).toEqual({ type: 'courtier', name: 'CABINET LEONARD' })
})
it('retourne type null quand aucune relation n\'est posee (cles omises)', () => {
expect(relationOf({} as ClientDetail)).toEqual({ type: null, name: null })
})
})
describe('mapContactToDraft', () => {
it('formate les telephones en XX XX XX XX XX et conserve l\'iri', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/18',
id: 18,
firstName: 'Sophie',
lastName: 'Léonard',
jobTitle: 'Gérante',
phonePrimary: '0549112233',
email: 'sophie@x.fr',
})
expect(draft.id).toBe(18)
expect(draft.iri).toBe('/api/client_contacts/18')
expect(draft.phonePrimary).toBe('05 49 11 22 33')
expect(draft.hasSecondaryPhone).toBe(false)
})
it('revele le 2e telephone quand phoneSecondary est present', () => {
const draft = mapContactToDraft({
'@id': '/api/client_contacts/19',
id: 19,
phonePrimary: '0600000000',
phoneSecondary: '0611111111',
})
expect(draft.hasSecondaryPhone).toBe(true)
expect(draft.phoneSecondary).toBe('06 11 11 11 11')
})
})
describe('mapAddressToDraft', () => {
it('extrait les iris de sites / categories / contacts (objets ou chaines)', () => {
const draft = mapAddressToDraft({
'@id': '/api/client_addresses/18',
id: 18,
country: 'France',
postalCode: '86100',
city: 'Châtellerault',
street: '5 rue des Courtiers',
billingEmail: 'factures@x.fr',
isProspect: false,
isDelivery: false,
isBilling: true,
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#056CF2' }],
categories: [{ '@id': '/api/categories/3', code: 'SECTEUR' }],
contacts: [{ '@id': '/api/client_contacts/18' }, '/api/client_contacts/20'],
})
expect(draft.siteIris).toEqual(['/api/sites/4'])
expect(draft.categoryIris).toEqual(['/api/categories/3'])
expect(draft.contactIris).toEqual(['/api/client_contacts/18', '/api/client_contacts/20'])
expect(draft.isBilling).toBe(true)
expect(draft.city).toBe('Châtellerault')
expect(draft.country).toBe('France')
})
it('tolere les sous-collections absentes (defaut tableau vide, pays France)', () => {
const draft = mapAddressToDraft({ '@id': '/api/client_addresses/9', id: 9 })
expect(draft.siteIris).toEqual([])
expect(draft.categoryIris).toEqual([])
expect(draft.contactIris).toEqual([])
expect(draft.country).toBe('France')
expect(draft.isBilling).toBe(false)
})
})
describe('mapRibToDraft', () => {
it('mappe label / bic / iban et l\'id serveur', () => {
const draft = mapRibToDraft({ '@id': '/api/client_ribs/3', id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
expect(draft).toEqual({ id: 3, label: 'Compte', bic: 'BNPAFRPPXXX', iban: 'FR14...' })
})
})
describe('mapAccountingDraft', () => {
it('mappe les scalaires et resout les iris des referentiels embarques', () => {
const acc = mapAccountingDraft({
'@id': '/api/clients/1',
id: 1,
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/1' },
paymentDelay: { '@id': '/api/payment_delays/2' },
paymentType: { '@id': '/api/payment_types/10', code: 'LCR' },
bank: { '@id': '/api/banks/3' },
} as ClientDetail)
expect(acc).toEqual({
siren: '123456789',
accountNumber: '411000',
nTva: 'FR123',
tvaModeIri: '/api/tva_modes/1',
paymentDelayIri: '/api/payment_delays/2',
paymentTypeIri: '/api/payment_types/10',
bankIri: '/api/banks/3',
})
})
it('renvoie des null quand les champs comptables sont absents (sans accounting.view)', () => {
const acc = mapAccountingDraft({} as ClientDetail)
expect(acc).toEqual({
siren: null,
accountNumber: null,
nTva: null,
tvaModeIri: null,
paymentDelayIri: null,
paymentTypeIri: null,
bankIri: null,
})
})
})
describe('options construites depuis l\'embed (role-independantes)', () => {
it('categoryOptionsOf expose value=IRI, label=nom, code', () => {
expect(categoryOptionsOf([{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }])).toEqual([
{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' },
])
})
it('siteOptionsOf expose value=IRI, label=nom', () => {
expect(siteOptionsOf([{ '@id': '/api/sites/4', name: 'Chatellerault', color: '#000' }])).toEqual([
{ value: '/api/sites/4', label: 'Chatellerault' },
])
})
it('contactOptionsOf compose le libelle (nom complet, sinon email)', () => {
expect(contactOptionsOf([
{ '@id': '/api/client_contacts/1', id: 1, firstName: 'Jean', lastName: 'Dupont' },
{ '@id': '/api/client_contacts/2', id: 2, email: 'a@b.fr' },
])).toEqual([
{ value: '/api/client_contacts/1', label: 'Jean Dupont' },
{ value: '/api/client_contacts/2', label: 'a@b.fr' },
])
})
it('referentialOptionOf : option unique depuis l\'embed, vide pour IRI nu / absent', () => {
expect(referentialOptionOf({ '@id': '/api/payment_types/10', label: 'LCR' })).toEqual([
{ value: '/api/payment_types/10', label: 'LCR' },
])
expect(referentialOptionOf('/api/banks/3')).toEqual([])
expect(referentialOptionOf(null)).toEqual([])
})
it('mapAddressView assemble brouillon + options propres a l\'adresse', () => {
const view = mapAddressView({
'@id': '/api/client_addresses/18',
id: 18,
city: 'Châtellerault',
sites: [{ '@id': '/api/sites/4', name: 'Chatellerault' }],
categories: [{ '@id': '/api/categories/3', name: 'Secteur', code: 'SECTEUR' }],
})
expect(view.draft.id).toBe(18)
expect(view.siteOptions).toEqual([{ value: '/api/sites/4', label: 'Chatellerault' }])
expect(view.categoryOptions).toEqual([{ value: '/api/categories/3', label: 'Secteur', code: 'SECTEUR' }])
})
})
describe('canEditClient', () => {
const can = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c))
it('visible pour manage', () => {
expect(canEditClient(can(['commercial.clients.manage']))).toBe(true)
})
it('visible pour accounting.manage (role Compta)', () => {
expect(canEditClient(can(['commercial.clients.accounting.manage']))).toBe(true)
})
it('masque sans aucune des deux permissions (role Usine)', () => {
expect(canEditClient(can(['commercial.clients.view']))).toBe(false)
})
})
describe('showArchiveAction / showRestoreAction', () => {
const can = (granted: string[]) => (code: string) => granted.includes(code)
it('Archiver : visible avec la permission archive ET client non archive', () => {
expect(showArchiveAction(can(['commercial.clients.archive']), false)).toBe(true)
expect(showArchiveAction(can(['commercial.clients.archive']), true)).toBe(false)
expect(showArchiveAction(can([]), false)).toBe(false)
})
it('Restaurer : visible avec la permission archive ET client archive', () => {
expect(showRestoreAction(can(['commercial.clients.archive']), true)).toBe(true)
expect(showRestoreAction(can(['commercial.clients.archive']), false)).toBe(false)
expect(showRestoreAction(can([]), true)).toBe(false)
})
})
@@ -0,0 +1,255 @@
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',
firstName: 'Jean',
lastName: 'Dupont',
email: 'jean@acme.fr',
phonePrimary: '05 49 11 22 33',
phoneSecondary: null,
hasSecondaryPhone: false,
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).
const MAIN_KEYS = [
'companyName', 'firstName', 'lastName', 'email', 'phonePrimary',
'phoneSecondary', '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()
})
it('telephone secondaire non revele : envoie null meme si une valeur traine', () => {
const payload = buildMainPayload(mainDraft({ hasSecondaryPhone: false, phoneSecondary: '06 00 00 00 00' }))
expect(payload.phoneSecondary).toBeNull()
})
})
describe('buildInformationPayload — scoping strict groupe client:write:information', () => {
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('formate les telephones, resout la relation et extrait les IRI', () => {
const client = {
'@id': '/api/clients/1', id: 1,
companyName: 'ACME', firstName: 'Jean', lastName: 'Dupont', email: 'jean@acme.fr',
phonePrimary: '0549112233', phoneSecondary: '0600000000', triageService: true,
categories: [{ '@id': '/api/categories/1', code: 'SECTEUR' }],
distributor: { '@id': '/api/clients/9', companyName: 'DISTRIB' },
} as ClientDetail
const draft = mapMainDraft(client)
expect(draft.phonePrimary).toBe('05 49 11 22 33')
expect(draft.phoneSecondary).toBe('06 00 00 00 00')
expect(draft.hasSecondaryPhone).toBe(true)
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.hasSecondaryPhone).toBe(false)
expect(draft.categoryIris).toEqual([])
expect(draft.relationType).toBeNull()
expect(draft.triageService).toBe(false)
})
})
describe('mapInformationDraft — pre-remplissage onglet Information', () => {
it('tronque foundedAt en YYYY-MM-DD et stringifie employeesCount', () => {
const draft = mapInformationDraft({
'@id': '/api/clients/1', id: 1,
foundedAt: '2010-05-01T00:00:00+00:00', employeesCount: 42, revenueAmount: '1000000',
} as ClientDetail)
expect(draft.foundedAt).toBe('2010-05-01')
expect(draft.employeesCount).toBe('42')
expect(draft.revenueAmount).toBe('1000000')
})
it('cles omises -> null', () => {
const draft = mapInformationDraft({ '@id': '/api/clients/1', id: 1 } as ClientDetail)
expect(draft.foundedAt).toBeNull()
expect(draft.employeesCount).toBeNull()
expect(draft.description).toBeNull()
})
})
describe('mapAccountingFormDraft — pre-remplissage onglet Comptabilite', () => {
it('extrait les scalaires et les IRI des referentiels embarques', () => {
const draft = mapAccountingFormDraft({
'@id': '/api/clients/1', id: 1,
siren: '123456789', accountNumber: 'C-001', nTva: 'FR123',
tvaMode: { '@id': '/api/tva_modes/2', label: 'Normal' },
paymentType: '/api/payment_types/3',
} as ClientDetail)
expect(draft.siren).toBe('123456789')
expect(draft.tvaModeIri).toBe('/api/tva_modes/2')
expect(draft.paymentTypeIri).toBe('/api/payment_types/3')
expect(draft.bankIri).toBeNull()
})
})
describe('resolveTabEditability — gating par role (matrice § 2.7)', () => {
it('Admin : tout editable', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: true, accountingVisible: true, accountingEditable: true })
})
it('Bureau / Commerciale (manage seul) : metier editable, Comptabilite masquee', () => {
expect(resolveTabEditability({ canManage: true, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: true, accountingVisible: false, accountingEditable: false })
})
it('Compta (accounting seul) : metier readonly, Comptabilite editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: true, canAccountingManage: true }))
.toEqual({ businessEditable: false, accountingVisible: true, accountingEditable: true })
})
it('Sans permission d\'edition : rien d\'editable', () => {
expect(resolveTabEditability({ canManage: false, canAccountingView: false, canAccountingManage: false }))
.toEqual({ businessEditable: false, accountingVisible: false, accountingEditable: false })
})
})
@@ -0,0 +1,321 @@
/**
* 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
firstName?: string | null
lastName?: string | null
phonePrimary?: string | null
phoneSecondary?: string | null
email?: string | null
triageService?: boolean
isArchived?: boolean
categories?: CategoryRead[]
distributor?: RelatedClientRead | string | null
broker?: RelatedClientRead | string | null
contacts?: ContactRead[]
addresses?: AddressRead[]
ribs?: RibRead[]
// Onglet Information
description?: string | null
competitors?: string | null
foundedAt?: string | null
employeesCount?: number | null
revenueAmount?: string | null
profitAmount?: string | null
directorName?: string | null
// Onglet Comptabilite (present ssi accounting.view)
siren?: string | null
accountNumber?: string | null
nTva?: string | null
tvaMode?: Relation
paymentDelay?: Relation
paymentType?: Relation
bank?: Relation
}
/** Etat « plat » de l'onglet Comptabilite (miroir lecture du formulaire 1.10). */
export interface AccountingDraft {
siren: string | null
accountNumber: string | null
nTva: string | null
tvaModeIri: string | null
paymentDelayIri: string | null
paymentTypeIri: string | null
bankIri: string | null
}
/** Relation Distributeur/Courtier resolue pour l'affichage en lecture seule. */
export interface ClientRelation {
type: 'distributeur' | 'courtier' | null
name: string | null
}
/** Option de select ({ value, label }) construite a partir de l'embed. */
export interface SelectOption {
value: string
label: string
}
/** Option de categorie enrichie de son code (compatible CategoryOption des blocs). */
export interface CategorySelectOption extends SelectOption {
code: string
}
/**
* Vue d'une adresse pour la consultation : le brouillon + ses options de select
* construites a partir de l'embed (sites/categories propres a CETTE adresse).
*/
export interface AddressView {
draft: AddressFormDraft
siteOptions: SelectOption[]
categoryOptions: CategorySelectOption[]
}
/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */
export function iriOf(relation: Relation): string | null {
if (relation === null || relation === undefined) {
return null
}
if (typeof relation === 'string') {
return relation
}
return relation['@id'] ?? null
}
/**
* Resout la relation Distributeur/Courtier (RG-1.03 : mutuellement exclusives).
* Le nom est lu sur l'objet embarque (`companyName`) ; null si la relation est
* un IRI nu ou absente.
*/
export function relationOf(client: ClientDetail): ClientRelation {
const nameOf = (rel: RelatedClientRead | string | null | undefined): string | null =>
rel && typeof rel === 'object' ? (rel.companyName ?? null) : null
if (client.distributor) {
return { type: 'distributeur', name: nameOf(client.distributor) }
}
if (client.broker) {
return { type: 'courtier', name: nameOf(client.broker) }
}
return { type: null, name: null }
}
/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */
export function mapContactToDraft(contact: ContactRead): ContactFormDraft {
const phoneSecondary = contact.phoneSecondary ?? null
return {
id: contact.id,
iri: contact['@id'] ?? null,
firstName: contact.firstName ?? null,
lastName: contact.lastName ?? null,
jobTitle: contact.jobTitle ?? null,
phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
email: contact.email ?? null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
}
}
/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */
export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
return {
id: address.id,
isProspect: address.isProspect ?? false,
isDelivery: address.isDelivery ?? false,
isBilling: address.isBilling ?? false,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
street: address.street ?? null,
streetComplement: address.streetComplement ?? null,
categoryIris: (address.categories ?? []).map(c => c['@id']),
siteIris: (address.sites ?? []).map(s => s['@id']),
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
billingEmail: address.billingEmail ?? null,
}
}
/** Mappe un RIB embarque vers un brouillon. */
export function mapRibToDraft(rib: RibRead): RibFormDraft {
return {
id: rib.id,
label: rib.label ?? null,
bic: rib.bic ?? null,
iban: rib.iban ?? null,
}
}
/** Mappe les champs comptables du client (scalaires + IRI des referentiels). */
export function mapAccountingDraft(client: ClientDetail): AccountingDraft {
return {
siren: client.siren ?? null,
accountNumber: client.accountNumber ?? null,
nTva: client.nTva ?? null,
tvaModeIri: iriOf(client.tvaMode),
paymentDelayIri: iriOf(client.paymentDelay),
paymentTypeIri: iriOf(client.paymentType),
bankIri: iriOf(client.bank),
}
}
/**
* Options de categories (value=IRI, label=nom, code) construites depuis l'embed.
* Source role-independante : evite de dependre de `GET /categories` (403 pour les
* roles metier non-admin), qui laisserait les libelles vides.
*/
export function categoryOptionsOf(categories: CategoryRead[] | undefined): CategorySelectOption[] {
return (categories ?? []).map(c => ({
value: c['@id'],
label: c.name ?? c.code ?? c['@id'],
code: c.code ?? '',
}))
}
/** Options de sites (value=IRI, label=nom) construites depuis l'embed d'une adresse. */
export function siteOptionsOf(sites: SiteRead[] | undefined): SelectOption[] {
return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] }))
}
/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed client. */
export function contactOptionsOf(contacts: ContactRead[] | undefined): SelectOption[] {
return (contacts ?? []).map(c => ({
value: c['@id'],
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']),
}))
}
/**
* Liste a une seule option (ou vide) construite depuis un referentiel embarque
* (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en
* lecture seule. Le libelle vient de l'embed (`label` ou `name`), jamais d'un
* `GET` de referentiel l'affichage reste correct quel que soit le role.
*/
export function referentialOptionOf(relation: Relation): SelectOption[] {
if (!relation || typeof relation === 'string') {
return []
}
const label = (relation.label as string | undefined)
?? (relation.name as string | undefined)
?? relation['@id']
return [{ value: relation['@id'], label }]
}
/** Vue d'une adresse (brouillon + options de select propres a l'adresse). */
export function mapAddressView(address: AddressRead): AddressView {
return {
draft: mapAddressToDraft(address),
siteOptions: siteOptionsOf(address.sites),
categoryOptions: categoryOptionsOf(address.categories),
}
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
* doit pouvoir ouvrir l'edition pour son onglet Comptabilite). Le readonly fin
* par onglet est gere sur l'ecran d'edition (1.12).
*/
export function canEditClient(canAny: (codes: string[]) => boolean): boolean {
return canAny(['commercial.clients.manage', 'commercial.clients.accounting.manage'])
}
/** Bouton « Archiver » : permission archive ET client encore actif. */
export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.clients.archive') && !isArchived
}
/** Bouton « Restaurer » : permission archive ET client deja archive. */
export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
return can('commercial.clients.archive') && isArchived
}
@@ -0,0 +1,266 @@
/**
* 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'
import { formatPhoneFR } from '~/shared/utils/phone'
/**
* Etat « plat » du bloc principal (groupe client:write:main). Distinct des
* brouillons Contact : ces champs vivent sur le Client lui-meme (companyName,
* contact principal, telephones, email, categories, relation, triage), pas sur
* une sous-ressource ClientContact.
*/
export interface MainFormDraft {
companyName: string | null
firstName: string | null
lastName: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
/** UI : le 2e numero a ete revele (ou existait deja au chargement). */
hasSecondaryPhone: boolean
/** IRI des categories rattachees (M2M). */
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. Les telephones
* sont reformates XX XX XX XX XX (RG d'affichage). 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)
const phoneSecondary = client.phoneSecondary ?? null
return {
companyName: client.companyName ?? null,
firstName: client.firstName ?? null,
lastName: client.lastName ?? null,
email: client.email ?? null,
phonePrimary: client.phonePrimary ? formatPhoneFR(client.phonePrimary) : null,
phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null,
hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '',
categoryIris: (client.categories ?? []).map(c => c['@id']),
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,
firstName: main.firstName || null,
lastName: main.lastName || null,
email: main.email,
phonePrimary: main.phonePrimary || null,
phoneSecondary: main.hasSecondaryPhone ? (main.phoneSecondary || null) : null,
categories: main.categoryIris,
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,
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M1 Suppression du contact principal inline du `Client` (refonte contact).
*
* Modele AVANT : le `Client` portait 5 colonnes de contact principal
* (first_name, last_name, phone_primary, phone_secondary, email) en doublon
* conceptuel de la sous-entite `ClientContact` (onglet Contact).
*
* Modele APRES (decision produit, README refonte-contact) : les contacts vivent
* UNIQUEMENT dans `client_contact`. Les 5 colonnes inline disparaissent du
* `client`. RG-1.01 (firstName OU lastName sur Client) et RG-1.02 (max 2
* telephones sur Client) sont supprimees : leur equivalent vit deja sur
* `client_contact` (RG-1.05 / RG-1.14).
*
* Le code etant deja en prod, la suppression est precedee d'un BACKFILL : pour
* tout client n'ayant encore AUCUN contact, on materialise son contact principal
* inline en une ligne `client_contact` (position 0) avant le DROP, afin de ne
* perdre aucune donnee.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
* Commercial : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
* FQCN alphabetique (AlphabeticalComparator). Une migration
* `App\Module\Commercial\...` trierait AVANT toutes les `DoctrineMigrations\...`
* sur base vide -> ce DROP s'executerait avant le CREATE TABLE client
* (Version20260601000000). Le namespace racine garantit l'ordre par timestamp.
*/
final class Version20260603120000 extends AbstractMigration
{
/** Colonnes de contact inline supprimees du `client`. */
private const array INLINE_CONTACT_COLUMNS = [
'first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email',
];
public function getDescription(): string
{
return 'M1 : suppression du contact inline du Client (backfill vers client_contact puis DROP des 5 colonnes).';
}
public function up(Schema $schema): void
{
// 1. Backfill : tout client SANS contact recoit une ligne client_contact
// (position 0) reprenant ses champs inline. phone_primary / email du
// client sont NOT NULL -> toujours une donnee a reporter. Le CHECK
// chk_client_contact_name (first_name OU last_name) est garanti par le
// fallback company_name si jamais les deux noms etaient null (cas qui ne
// devrait pas exister, RG-1.01 ayant ete appliquee a l'ecriture).
// created_at/updated_at NOT NULL -> NOW() ; created_by/updated_by null
// (backfill hors contexte HTTP, libelle « Systeme » cote front).
$this->addSql(<<<'SQL'
INSERT INTO client_contact (
client_id, first_name, last_name, phone_primary, phone_secondary,
email, position, created_at, updated_at
)
SELECT
c.id,
c.first_name,
CASE
WHEN c.first_name IS NULL AND c.last_name IS NULL THEN c.company_name
ELSE c.last_name
END,
c.phone_primary,
c.phone_secondary,
c.email,
0,
NOW(),
NOW()
FROM client c
WHERE NOT EXISTS (
SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id
)
SQL);
// 2. DROP des 5 colonnes inline (rien a documenter : suppression).
$this->addSql(<<<'SQL'
ALTER TABLE client
DROP COLUMN first_name,
DROP COLUMN last_name,
DROP COLUMN phone_primary,
DROP COLUMN phone_secondary,
DROP COLUMN email
SQL);
}
public function down(Schema $schema): void
{
// Best-effort : on RECREE les 5 colonnes (en NULLABLE — l'etat NOT NULL
// d'origine de phone_primary/email ne peut etre restaure sur une table
// peuplee sans risque) et on retro-alimente depuis le contact principal
// (position minimale) de chaque client. La donnee n'est pas garantie
// identique a l'origine : ce down() sert au rollback technique, pas a une
// restauration fidele.
$this->addSql('ALTER TABLE client ADD COLUMN first_name VARCHAR(120) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN last_name VARCHAR(120) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN phone_primary VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN phone_secondary VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN email VARCHAR(180) DEFAULT NULL');
// Retro-alimentation depuis le contact de position la plus basse.
$this->addSql(<<<'SQL'
UPDATE client c SET
first_name = cc.first_name,
last_name = cc.last_name,
phone_primary = cc.phone_primary,
phone_secondary = cc.phone_secondary,
email = cc.email
FROM (
SELECT DISTINCT ON (client_id)
client_id, first_name, last_name, phone_primary, phone_secondary, email
FROM client_contact
ORDER BY client_id, position ASC, id ASC
) cc
WHERE cc.client_id = c.id
SQL);
// Re-pose des commentaires d'origine (regle ABSOLUE n°12) — dollar-quoting
// Postgres pour eviter tout echappement d apostrophe.
$this->addSql('COMMENT ON COLUMN client.first_name IS $_$Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
$this->addSql('COMMENT ON COLUMN client.last_name IS $_$Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
$this->addSql('COMMENT ON COLUMN client.phone_primary IS $_$Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.$_$');
$this->addSql('COMMENT ON COLUMN client.phone_secondary IS $_$Telephone secondaire optionnel — chiffres uniquement (RG-1.20).$_$');
$this->addSql('COMMENT ON COLUMN client.email IS $_$Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).$_$');
}
}
+5
View File
@@ -38,6 +38,11 @@ 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)'],
]; ];
} }
} }
@@ -42,13 +42,19 @@ 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')", security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
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')", security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
normalizationContext: ['groups' => ['category:read', 'default:read']], normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class, provider: CategoryProvider::class,
), ),
+3 -85
View File
@@ -151,31 +151,9 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
// RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor). // Le contact principal n'est plus porte inline par le Client : les contacts
#[ORM\Column(length: 120, nullable: true)] // vivent uniquement dans ClientContact (onglet Contact). RG-1.01 / RG-1.02
#[Assert\Length(max: 120, normalizer: 'trim')] // supprimees du Client (equivalent RG-1.05 / RG-1.14 sur ClientContact).
#[Groups(['client:read', 'client:write:main'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $lastName = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups(['client:read', 'client:write:main'])]
private ?string $email = null;
// RG-1.03 : distributor / broker auto-references mutuellement exclusives // RG-1.03 : distributor / broker auto-references mutuellement exclusives
// (CHECK chk_client_distrib_or_broker en base). // (CHECK chk_client_distrib_or_broker en base).
@@ -326,66 +304,6 @@ class Client implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getPhonePrimary(): ?string
{
return $this->phonePrimary;
}
public function setPhonePrimary(string $phonePrimary): static
{
$this->phonePrimary = $phonePrimary;
return $this;
}
public function getPhoneSecondary(): ?string
{
return $this->phoneSecondary;
}
public function setPhoneSecondary(?string $phoneSecondary): static
{
$this->phoneSecondary = $phoneSecondary;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getDistributor(): ?Client public function getDistributor(): ?Client
{ {
return $this->distributor; return $this->distributor;
@@ -33,6 +33,12 @@ interface ClientRepositoryInterface
* la liste paginee (ClientProvider) et l'export (ClientExportController) * la liste paginee (ClientProvider) et l'export (ClientExportController)
* partagent strictement la meme logique de selection. * 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<string> $categoryCodes
* @param list<int> $siteIds * @param list<int> $siteIds
*/ */
@@ -43,4 +49,19 @@ interface ClientRepositoryInterface
array $siteIds = [], array $siteIds = [],
bool $archivedOnly = false, bool $archivedOnly = false,
): QueryBuilder; ): 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;
} }
@@ -29,7 +29,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
/** /**
* Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 / * Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 /
* § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28. * § 2.9 / § 4.3 / § 4.4 + RG-1.03 a RG-1.28 (RG-1.01/1.02 supprimees : contact inline retire).
* *
* Sequence (POST / PATCH) : * Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet. La security d'operation * 1. Autorisation additionnelle par groupe d'onglet. La security d'operation
@@ -41,7 +41,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et * - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
* interdit toute autre modification dans la meme requete (RG-1.22, 422). * interdit toute autre modification dans la meme requete (RG-1.22, 422).
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker * 3. Regles metier : RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque), * exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
* et tout PATCH pour le role Commerciale). * et tout PATCH pour le role Commerciale).
@@ -60,8 +60,7 @@ final class ClientProcessor implements ProcessorInterface
{ {
/** Champs de l'onglet principal (groupe client:write:main). */ /** Champs de l'onglet principal (groupe client:write:main). */
private const array MAIN_FIELDS = [ private const array MAIN_FIELDS = [
'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'companyName', 'distributor', 'broker', 'triageService', 'categories',
'email', 'distributor', 'broker', 'triageService', 'categories',
]; ];
/** Champs de l'onglet Information (groupe client:write:information). */ /** Champs de l'onglet Information (groupe client:write:information). */
@@ -124,7 +123,6 @@ final class ClientProcessor implements ProcessorInterface
// valeurs normalisees des deux cotes (l'etat persiste l'a deja ete). // valeurs normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data); $this->guardManage($data);
$this->validateMainContact($data);
$this->validateDistributorBroker($data); $this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data); $this->validateAccountingConsistency($data);
$this->validateInformationCompleteness($data); $this->validateInformationCompleteness($data);
@@ -274,11 +272,6 @@ final class ClientProcessor implements ProcessorInterface
{ {
$newValues = [ $newValues = [
'companyName' => $data->getCompanyName(), 'companyName' => $data->getCompanyName(),
'firstName' => $data->getFirstName(),
'lastName' => $data->getLastName(),
'phonePrimary' => $data->getPhonePrimary(),
'phoneSecondary' => $data->getPhoneSecondary(),
'email' => $data->getEmail(),
'distributor' => $data->getDistributor(), 'distributor' => $data->getDistributor(),
'broker' => $data->getBroker(), 'broker' => $data->getBroker(),
'triageService' => $data->isTriageService(), 'triageService' => $data->isTriageService(),
@@ -420,39 +413,17 @@ final class ClientProcessor implements ProcessorInterface
} }
/** /**
* Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables * Normalisation serveur du formulaire principal (RG-1.18). Seul companyName
* (companyName, email, phonePrimary) ne sont touches que si une valeur est * subsiste cote Client depuis la suppression du contact inline (les champs de
* presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel. * contact noms, telephones, email sont normalises par ClientContactProcessor).
* Le setter non-nullable n'est touche que si une valeur est presente, pour ne
* jamais ecraser l'existant lors d'un PATCH partiel.
*/ */
private function normalize(Client $data): void private function normalize(Client $data): void
{ {
if (null !== $data->getCompanyName()) { if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName())); $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
} }
if (null !== $data->getEmail()) {
$data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail()));
}
if (null !== $data->getPhonePrimary()) {
$data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary()));
}
$data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName()));
$data->setLastName($this->normalizer->normalizePersonName($data->getLastName()));
$data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary()));
}
/**
* RG-1.01 : au moins le prenom OU le nom du contact principal.
*/
private function validateMainContact(Client $data): void
{
if (null === $data->getFirstName() && null === $data->getLastName()) {
$this->throwViolation(
'firstName',
'Le prénom ou le nom du contact principal est obligatoire.',
$data,
);
}
} }
/** /**
@@ -83,8 +83,13 @@ final class ClientProvider implements ProviderInterface
// 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> $result /** @var list<Client> $clients */
return $qb->getQuery()->getResult(); $clients = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (cf. ERP-100) : evite
// le N+1 si la serialisation touche categories/sites, sans cartesien.
$this->repository->hydrateListCollections($clients);
return $clients;
} }
$limit = $this->pagination->getLimit($operation, $context); $limit = $this->pagination->getLimit($operation, $context);
@@ -93,9 +98,13 @@ final class ClientProvider implements ProviderInterface
$qb->setFirstResult($offset)->setMaxResults($limit); $qb->setFirstResult($offset)->setMaxResults($limit);
// fetchJoinCollection: true pour un COUNT correct des que des JOINs // Le QB de selection ne porte plus de fetch-join to-many (ERP-100) : le
// to-many seront ajoutes (sous-collections embarquees en detail). // COUNT est simple, fetchJoinCollection inutile. On materialise la page
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true)); // puis on hydrate ses collections en lot (memes entites managees).
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
$this->repository->hydrateListCollections(iterator_to_array($paginator));
return $paginator;
} }
/** /**
@@ -69,6 +69,11 @@ final class ClientExportController
->getResult() ->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'); $withSiren = $this->security->isGranted('commercial.clients.accounting.view');
$binary = $this->exporter->export( $binary = $this->exporter->export(
@@ -81,7 +86,9 @@ final class ClientExportController
} }
/** /**
* Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la * Colonnes de l'export. Depuis la suppression du contact inline (refonte
* contact, D2), les colonnes de contact principal sont retirees : l'export
* ne porte plus que les donnees propres au Client. SIREN inseree avant la
* date de creation, uniquement si l'utilisateur a accounting.view. * date de creation, uniquement si l'utilisateur a accounting.view.
* *
* @return list<string> * @return list<string>
@@ -90,11 +97,6 @@ final class ClientExportController
{ {
$headers = [ $headers = [
'Nom entreprise', 'Nom entreprise',
'Nom contact principal',
'Prénom',
'Téléphone principal',
'Téléphone secondaire',
'Email',
'Catégories', 'Catégories',
'Sites', 'Sites',
]; ];
@@ -118,11 +120,6 @@ final class ClientExportController
foreach ($clients as $client) { foreach ($clients as $client) {
$row = [ $row = [
$client->getCompanyName(), $client->getCompanyName(),
$client->getLastName(),
$client->getFirstName(),
$client->getPhonePrimary(),
$client->getPhoneSecondary(),
$client->getEmail(),
$this->formatCategories($client), $this->formatCategories($client),
$this->formatSites($client), $this->formatSites($client),
]; ];
@@ -112,10 +112,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$gso, $gsoIsNew] = $this->ensureClient( [$gso, $gsoIsNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Distrib Grand Sud-Ouest', companyName: 'Distrib Grand Sud-Ouest',
firstName: 'Paul',
lastName: 'Garnier',
phonePrimary: '05 56 10 20 30',
email: 'contact@distrib-gso.fr',
categoryNames: ['Distributeur'], categoryNames: ['Distributeur'],
); );
if ($gsoIsNew) { if ($gsoIsNew) {
@@ -127,10 +123,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$leonard, $leonardIsNew] = $this->ensureClient( [$leonard, $leonardIsNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Cabinet Léonard Assurances', companyName: 'Cabinet Léonard Assurances',
firstName: 'Sophie',
lastName: 'Léonard',
phonePrimary: '05 49 11 22 33',
email: 'contact@cabinet-leonard.fr',
categoryNames: ['Courtier'], categoryNames: ['Courtier'],
); );
if ($leonardIsNew) { if ($leonardIsNew) {
@@ -142,10 +134,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$dubois, $isNew] = $this->ensureClient( [$dubois, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Menuiserie Dubois', companyName: 'Menuiserie Dubois',
firstName: 'Jean',
lastName: 'Dubois',
phonePrimary: '05 49 00 00 01',
email: 'contact@menuiserie-dubois.fr',
categoryNames: ['BTP'], categoryNames: ['BTP'],
); );
if ($isNew) { if ($isNew) {
@@ -159,10 +147,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$garage, $isNew] = $this->ensureClient( [$garage, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Garage Martin', companyName: 'Garage Martin',
firstName: 'Luc',
lastName: 'Martin',
phonePrimary: '05 56 44 55 66',
email: 'accueil@garage-martin.fr',
categoryNames: ['Services'], categoryNames: ['Services'],
); );
if ($isNew) { if ($isNew) {
@@ -175,10 +159,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$boulangerie, $isNew] = $this->ensureClient( [$boulangerie, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Boulangerie Lemoine', companyName: 'Boulangerie Lemoine',
firstName: 'Marie',
lastName: 'Lemoine',
phonePrimary: '05 49 77 88 99',
email: 'bonjour@boulangerie-lemoine.fr',
categoryNames: ['Agro-alimentaire'], categoryNames: ['Agro-alimentaire'],
); );
if ($isNew) { if ($isNew) {
@@ -191,10 +171,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$transports, $isNew] = $this->ensureClient( [$transports, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Transports Rapides', companyName: 'Transports Rapides',
firstName: null,
lastName: 'Bernard',
phonePrimary: '05 56 12 13 14',
email: 'exploitation@transports-rapides.fr',
categoryNames: ['Transport/Logistique'], categoryNames: ['Transport/Logistique'],
); );
if ($isNew) { if ($isNew) {
@@ -209,10 +185,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$industries, $isNew] = $this->ensureClient( [$industries, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Industries Vertes', companyName: 'Industries Vertes',
firstName: 'Claire',
lastName: 'Moreau',
phonePrimary: '05 49 21 22 23',
email: 'contact@industries-vertes.fr',
categoryNames: ['Industrie'], categoryNames: ['Industrie'],
); );
if ($isNew) { if ($isNew) {
@@ -229,12 +201,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$agro, $isNew] = $this->ensureClient( [$agro, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Agro Distribution Sud', companyName: 'Agro Distribution Sud',
firstName: 'Thomas',
lastName: 'Petit',
phonePrimary: '05 56 31 32 33',
email: 'contact@agro-sud.fr',
categoryNames: ['Agro-alimentaire'], categoryNames: ['Agro-alimentaire'],
phoneSecondary: '06 01 02 03 04',
); );
if ($isNew) { if ($isNew) {
$this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0); $this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0);
@@ -247,10 +214,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$ancienne, $isNew] = $this->ensureClient( [$ancienne, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Ancienne Société Oubliée', companyName: 'Ancienne Société Oubliée',
firstName: null,
lastName: 'Durand',
phonePrimary: '05 49 99 99 99',
email: 'contact@ancienne-societe.fr',
categoryNames: ['Association'], categoryNames: ['Association'],
isArchived: true, isArchived: true,
); );
@@ -263,10 +226,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$services, $isNew] = $this->ensureClient( [$services, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Services Pro Conseil', companyName: 'Services Pro Conseil',
firstName: 'Nadia',
lastName: 'Benali',
phonePrimary: '05 49 41 42 43',
email: 'contact@services-pro.fr',
categoryNames: ['Services'], categoryNames: ['Services'],
); );
if ($isNew) { if ($isNew) {
@@ -279,10 +238,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$holding, $isNew] = $this->ensureClient( [$holding, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Holding Premium Invest', companyName: 'Holding Premium Invest',
firstName: 'Antoine',
lastName: 'Lefèvre',
phonePrimary: '05 56 51 52 53',
email: 'direction@holding-premium.fr',
categoryNames: ['Industrie'], categoryNames: ['Industrie'],
); );
if ($isNew) { if ($isNew) {
@@ -301,10 +256,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$conglo, $isNew] = $this->ensureClient( [$conglo, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Conglomérat Multi Activités', companyName: 'Conglomérat Multi Activités',
firstName: 'Hélène',
lastName: 'Faure',
phonePrimary: '05 49 61 62 63',
email: 'contact@conglomerat-multi.fr',
categoryNames: ['BTP', 'Industrie', 'Services'], categoryNames: ['BTP', 'Industrie', 'Services'],
); );
if ($isNew) { if ($isNew) {
@@ -316,10 +267,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$prospect, $isNew] = $this->ensureClient( [$prospect, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Prospect Futur Client', companyName: 'Prospect Futur Client',
firstName: 'Olivier',
lastName: 'Renard',
phonePrimary: '05 56 71 72 73',
email: 'olivier.renard@prospect-futur.fr',
categoryNames: ['BTP'], categoryNames: ['BTP'],
); );
if ($isNew) { if ($isNew) {
@@ -331,10 +278,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$association, $isNew] = $this->ensureClient( [$association, $isNew] = $this->ensureClient(
$manager, $manager,
companyName: 'Association des Riverains', companyName: 'Association des Riverains',
firstName: null,
lastName: 'Caron',
phonePrimary: '05 49 81 82 83',
email: 'contact@asso-riverains.fr',
categoryNames: ['Association'], categoryNames: ['Association'],
); );
if ($isNew) { if ($isNew) {
@@ -350,6 +293,9 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
* sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la * sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la
* reconstruction des sous-collections (idempotence sans doublon). * reconstruction des sous-collections (idempotence sans doublon).
* *
* Le contact principal n'est plus porte par le Client (refonte contact) : les
* coordonnees de contact sont fournies via addContact() dans le bloc isNew.
*
* @param list<string> $categoryNames * @param list<string> $categoryNames
* *
* @return array{0: Client, 1: bool} * @return array{0: Client, 1: bool}
@@ -357,12 +303,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
private function ensureClient( private function ensureClient(
ObjectManager $manager, ObjectManager $manager,
string $companyName, string $companyName,
?string $firstName,
?string $lastName,
string $phonePrimary,
string $email,
array $categoryNames, array $categoryNames,
?string $phoneSecondary = null,
bool $isArchived = false, bool $isArchived = false,
): array { ): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName); $normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
@@ -374,11 +315,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$client = new Client(); $client = new Client();
$client->setCompanyName($normalizedName); $client->setCompanyName($normalizedName);
$client->setFirstName($this->normalizer->normalizePersonName($firstName));
$client->setLastName($this->normalizer->normalizePersonName($lastName));
$client->setPhonePrimary((string) $this->normalizer->normalizePhone($phonePrimary));
$client->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$client->setEmail((string) $this->normalizer->normalizeEmail($email));
foreach ($categoryNames as $categoryName) { foreach ($categoryNames as $categoryName) {
$client->addCategory($this->category($manager, $categoryName)); $client->addCategory($this->category($manager, $categoryName));
@@ -38,16 +38,12 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
array $siteIds = [], array $siteIds = [],
bool $archivedOnly = false, bool $archivedOnly = false,
): QueryBuilder { ): QueryBuilder {
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
// L'hydratation des collections affichees (Catégories / Site(s)) est
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
// imposer un produit cartesien aux chemins non pagines (export,
// ?pagination=false) — ERP-100.
$qb = $this->createQueryBuilder('c') $qb = $this->createQueryBuilder('c')
// Jointures + addSelect pour hydrater en une seule requete les
// collections affichees par le Repertoire (colonnes Catégories /
// Site(s)) : sans cela, la serialisation declenche un N+1 (une
// requete par client, puis par adresse). Le Paginator ORM
// (fetchJoinCollection: true, cf. ClientProvider) gere le COUNT
// malgre ces jointures to-many.
->leftJoin('c.categories', 'cat')->addSelect('cat')
->leftJoin('c.addresses', 'addr')->addSelect('addr')
->leftJoin('addr.sites', 'site')->addSelect('site')
->andWhere('c.deletedAt IS NULL') ->andWhere('c.deletedAt IS NULL')
->orderBy('c.companyName', 'ASC') ->orderBy('c.companyName', 'ASC')
; ;
@@ -66,10 +62,52 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
return $qb; return $qb;
} }
public function hydrateListCollections(array $clients): void
{
if ([] === $clients) {
return;
}
// Ids des clients deja charges (entites managees). On rehydrate leurs
// collections via l'identity map : les requetes ci-dessous renvoient les
// MEMES instances Client, dont les collections sont alors remplies.
$ids = [];
foreach ($clients as $client) {
$id = $client->getId();
if (null !== $id) {
$ids[] = $id;
}
}
if ([] === $ids) {
return;
}
// 1re passe : categories (colonne « Catégories »). Produit c x cat seul.
$this->createQueryBuilder('c')
->leftJoin('c.categories', 'cat')->addSelect('cat')
->where('c.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
// 2e passe : adresses + sites (colonne « Site(s) », sites portes par les
// adresses — RG-1.10). Le join addr -> site reste imbrique mais n'est
// plus multiplie par les categories : le cartesien global est casse.
$this->createQueryBuilder('c')
->leftJoin('c.addresses', 'addr')->addSelect('addr')
->leftJoin('addr.sites', 'site')->addSelect('site')
->where('c.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
}
/** /**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email. * Recherche fuzzy insensible a la casse sur companyName (D1, refonte contact).
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester * Depuis la suppression du contact inline du Client, la recherche ne porte
* litteraux. * plus que sur le nom d'entreprise (les anciens criteres lastName / email
* vivaient sur les colonnes inline disparues). Les metacaracteres LIKE
* (%, _, \) saisis sont echappes pour rester litteraux.
*/ */
private function applySearch(QueryBuilder $qb, ?string $search): void private function applySearch(QueryBuilder $qb, ?string $search): void
{ {
@@ -80,11 +118,9 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere( $qb->andWhere('LOWER(c.companyName) LIKE :search')
'LOWER(c.companyName) LIKE :search ' ->setParameter('search', $pattern)
.'OR LOWER(c.lastName) LIKE :search ' ;
.'OR LOWER(c.email) LIKE :search',
)->setParameter('search', $pattern);
} }
/** /**
@@ -62,6 +62,9 @@ final class RbacSeeder
'permissions' => [ 'permissions' => [
'commercial.clients.view', 'commercial.clients.view',
'commercial.clients.manage', 'commercial.clients.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
], ],
], ],
self::ROLE_COMPTA => [ self::ROLE_COMPTA => [
@@ -70,6 +73,9 @@ final class RbacSeeder
'commercial.clients.view', 'commercial.clients.view',
'commercial.clients.accounting.view', 'commercial.clients.accounting.view',
'commercial.clients.accounting.manage', 'commercial.clients.accounting.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
], ],
], ],
self::ROLE_COMMERCIALE => [ self::ROLE_COMMERCIALE => [
@@ -77,6 +83,9 @@ final class RbacSeeder
'permissions' => [ 'permissions' => [
'commercial.clients.view', 'commercial.clients.view',
'commercial.clients.manage', 'commercial.clients.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
], ],
], ],
self::ROLE_USINE => [ self::ROLE_USINE => [
+7 -2
View File
@@ -40,13 +40,18 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
// Lecture (liste + item) : permission d'administration `sites.view` OU
// permission de lecture-referentiel transverse `sites.read_ref` (ERP-102).
// Le referentiel sites alimente les selects d'adresse des modules Tiers :
// tout role qui gere des tiers doit pouvoir le lire sans porter l'acces
// admin des Sites.
new GetCollection( new GetCollection(
normalizationContext: ['groups' => ['site:read']], normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')", security: "is_granted('sites.view') or is_granted('sites.read_ref')",
), ),
new Get( new Get(
normalizationContext: ['groups' => ['site:read']], normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')", security: "is_granted('sites.view') or is_granted('sites.read_ref')",
), ),
new Post( new Post(
normalizationContext: ['groups' => ['site:read']], normalizationContext: ['groups' => ['site:read']],
@@ -30,6 +30,8 @@ use function sprintf;
* - resource != Site::class no-op (les autres resources sont * - resource != Site::class no-op (les autres resources sont
* gerees par SiteScopedQueryExtension) ; * gerees par SiteScopedQueryExtension) ;
* - is_granted('sites.bypass_scope') pas de filtre (admin / bypass) ; * - is_granted('sites.bypass_scope') pas de filtre (admin / bypass) ;
* - is_granted('sites.read_ref') pas de filtre (lecture-referentiel
* transverse complet, ERP-102) ;
* - user non authentifie no-op (API Platform renvoie 401 avant) ; * - user non authentifie no-op (API Platform renvoie 401 avant) ;
* - user sans aucun site WHERE 1 = 0 (aucun acces) ; * - user sans aucun site WHERE 1 = 0 (aucun acces) ;
* - cas normal WHERE site.id IN (:allowedSites). * - cas normal WHERE site.id IN (:allowedSites).
@@ -84,6 +86,16 @@ final class SiteCollectionScopedExtension implements QueryCollectionExtensionInt
return; return;
} }
// 2bis) Lecture-referentiel transverse (ERP-102) : `sites.read_ref` donne
// acces a la LISTE COMPLETE des sites (selects d'adresse des modules Tiers).
// Sans ce bypass, le cloisonnement par site rattache reduirait le select
// aux seuls sites de l'utilisateur (voire a rien s'il n'en a aucun) et le
// referentiel ne serait plus "transverse". `read_ref` est une lecture seule :
// il ouvre la visibilite sans permettre la moindre ecriture.
if ($this->security->isGranted('sites.read_ref')) {
return;
}
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont). // 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
$user = $this->security->getUser(); $user = $this->security->getUser();
if (!$user instanceof User) { if (!$user instanceof User) {
+5
View File
@@ -33,6 +33,11 @@ final class SitesModule
['code' => 'sites.view', 'label' => 'Voir les sites'], ['code' => 'sites.view', 'label' => 'Voir les sites'],
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'], ['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'], ['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
// Lecture-referentiel transverse (ERP-102) : permet de LISTER les sites
// pour alimenter les selects des modules Tiers (adresses client...), sans
// donner l'acces d'administration `.view` (qui ouvre la page Sites dans la
// sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
['code' => 'sites.read_ref', 'label' => 'Lire le referentiel sites (transverse, lecture seule)'],
]; ];
} }
} }
@@ -166,14 +166,12 @@ final class ColumnCommentsCatalog
], ],
'client' => [ 'client' => [
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).', '_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).', 'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).',
'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', // Contact principal inline supprime (refonte contact) : first_name,
'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).', // last_name, phone_primary, phone_secondary, email vivent desormais
'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.', // uniquement sur client_contact.
'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).',
'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).',
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.', 'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.', 'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.', 'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
@@ -6,12 +6,12 @@ namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs; use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/** /**
@@ -30,12 +30,19 @@ use Symfony\Component\Security\Core\User\UserInterface;
#[AsDoctrineListener(event: Events::preUpdate)] #[AsDoctrineListener(event: Events::preUpdate)]
final class TimestampableBlamableSubscriber final class TimestampableBlamableSubscriber
{ {
public function __construct(private readonly Security $security) {} // L'horloge est injectee (et non un `new DateTimeImmutable()` direct) pour
// que les tests puissent figer/avancer le temps de facon deterministe via
// ClockSensitiveTrait (cf. ERP-98). En prod, le service `clock` delegue a
// l'horloge systeme reelle.
public function __construct(
private readonly Security $security,
private readonly ClockInterface $clock,
) {}
public function prePersist(PrePersistEventArgs $args): void public function prePersist(PrePersistEventArgs $args): void
{ {
$entity = $args->getObject(); $entity = $args->getObject();
$now = new DateTimeImmutable(); $now = $this->clock->now();
$user = $this->security->getUser(); $user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) { if ($entity instanceof TimestampableInterface) {
@@ -55,7 +62,7 @@ final class TimestampableBlamableSubscriber
$user = $this->security->getUser(); $user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) { if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new DateTimeImmutable()); $entity->setUpdatedAt($this->clock->now());
} }
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) { if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use App\Shared\Domain\Attribute\Auditable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
use function is_string;
use function sprintf;
use const JSON_THROW_ON_ERROR;
/**
* Garde-fou architecture : toute entite `#[Auditable]` doit avoir son libelle
* i18n dans le bloc `audit.entity` du `fr.json` du shell.
*
* Pourquoi : le filtre « Type d'entite » de l'audit-log est dynamique
* (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents
* en base). Des qu'un module audite une entite, un nouveau type apparait. Le
* rendu front (`formatEntityType`, audit-log.vue) construit la cle
* `audit.entity.<module>_<entity>` et, faute de traduction, retombe
* SILENCIEUSEMENT sur le type technique brut (ex: `commercial.Client`). Le
* manque passe donc inapercu jusqu'a observation dans l'UI.
*
* Ce test rend le manque BLOQUANT (meme esprit que ColumnsHaveSqlCommentTest) :
* il scanne les entites `#[Auditable]` sous `src/Module/<m>/Domain/Entity/`,
* derive la cle attendue comme le fait le front, et echoue si elle est absente
* du `fr.json`.
*
* Derivation de la cle (miroir exact de AuditListener::formatEntityType + de
* formatEntityType cote front) :
* FQCN `App\Module\Commercial\Domain\Entity\ClientAddress`
* -> entity_type `commercial.ClientAddress` (module en minuscules, Entity intacte)
* -> cle i18n `commercial_clientaddress` (tout en minuscules, `.` -> `_`)
*
* @internal
*/
final class AuditableEntitiesHaveI18nLabelTest extends TestCase
{
/**
* Chemin du fichier de traductions FR du shell. Source unique des libelles
* d'entite audit (decision ERP-99 : emplacement centralise, schema flat).
*/
private const LOCALE_FILE = __DIR__.'/../../frontend/i18n/locales/fr.json';
public function testEveryAuditableEntityHasAnI18nLabel(): void
{
$labels = $this->loadAuditEntityLabels();
$finder = new Finder()
->files()
->in(__DIR__.'/../../src/Module')
->path('Domain/Entity')
->name('*.php')
;
// Garde : si le scan ne trouve rien, le chemin est casse — le test
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?');
$checked = 0;
foreach ($finder as $file) {
$fqcn = $this->extractFqcn($file->getRealPath());
if (null === $fqcn) {
continue;
}
$reflection = new ReflectionClass($fqcn);
// On ne s'interesse qu'aux entites reellement auditees.
if ($reflection->isAbstract() || [] === $reflection->getAttributes(Auditable::class)) {
continue;
}
$key = $this->deriveI18nKey($fqcn);
self::assertNotNull(
$key,
sprintf('Entite %s hors structure modulaire attendue (App\Module\<M>\Domain\Entity\<E>).', $fqcn),
);
self::assertArrayHasKey(
$key,
$labels,
sprintf(
'L\'entite auditable %s n\'a pas de libelle i18n. Ajouter "%s" dans le bloc '
.'`audit.entity` de frontend/i18n/locales/fr.json (sinon le filtre audit-log '
.'affiche le type technique brut). Cf. ERP-99 + .claude/rules/backend.md § Audit.',
$fqcn,
$key,
),
);
self::assertNotSame('', trim($labels[$key]), sprintf('Le libelle audit "%s" est vide.', $key));
++$checked;
}
// Garde : au moins une entite auditable doit avoir ete verifiee, sinon
// la detection de l'attribut est cassee (faux positif vert).
self::assertGreaterThan(0, $checked, 'Aucune entite #[Auditable] detectee : detection d\'attribut cassee ?');
}
/**
* Charge le bloc `audit.entity` du fr.json sous forme de map cle -> libelle.
*
* @return array<string, string>
*/
private function loadAuditEntityLabels(): array
{
$raw = file_get_contents(self::LOCALE_FILE);
self::assertIsString($raw, sprintf('Fichier de locale introuvable : %s', self::LOCALE_FILE));
/** @var array<string, mixed> $json */
$json = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
$entity = $json['audit']['entity'] ?? null;
self::assertIsArray($entity, 'Bloc `audit.entity` absent ou invalide dans fr.json.');
$labels = [];
foreach ($entity as $key => $value) {
if (is_string($key) && is_string($value)) {
$labels[$key] = $value;
}
}
return $labels;
}
/**
* Derive la cle i18n `<module>_<entity>` depuis le FQCN, en miroir de
* AuditListener::formatEntityType (module en minuscules) suivi de
* l'aplatissement front (tout en minuscules, `.` -> `_`).
*
* Retourne null si le FQCN ne respecte pas la structure modulaire.
*/
private function deriveI18nKey(string $fqcn): ?string
{
if (1 !== preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $fqcn, $m)) {
return null;
}
return strtolower($m['module']).'_'.strtolower($m['entity']);
}
/**
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
* source, sans charger le fichier.
*/
private function extractFqcn(string $path): ?string
{
$source = file_get_contents($path);
if (false === $source) {
return null;
}
if (
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
) {
return null;
}
return trim($nsMatch[1]).'\\'.$classMatch[1];
}
}
@@ -7,6 +7,8 @@ namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category; use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\Test\ClockSensitiveTrait;
/** /**
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir * Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
@@ -20,12 +22,39 @@ use DateTimeImmutable;
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE * - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
* Doctrine declenche le subscriber) * Doctrine declenche le subscriber)
* *
* ERP-98 : ces tests pilotent une horloge mockee (ClockSensitiveTrait) plutot
* que de dependre d'un `sleep(1)` reel. Le subscriber lit le service `clock`,
* que `self::mockTime()` remplace par un MockClock fige au niveau du process
* ce qui survit aux reboots de kernel entre requetes (POST admin / PATCH bob)
* et reste insensible a la derive d'horloge WSL2 a l'origine des flakes.
*
* @internal * @internal
*/ */
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
{ {
use ClockSensitiveTrait;
/**
* Fige l'horloge globale sur l'instant courant DANS LE FUSEAU PHP par
* defaut, et la retourne pour la piloter (`sleep()`).
*
* Subtilite : `self::mockTime()` cree par defaut un MockClock en UTC, or
* les colonnes `TIMESTAMP WITHOUT TIME ZONE` round-trippent via le fuseau
* PHP (Europe/Paris). Un MockClock UTC decalerait createdAt de l'offset
* (2h) au rechargement. On seede donc avec `new DateTimeImmutable()`
* (fuseau par defaut), exactement comme le NativeClock en prod.
*/
private function freezeClock(): ClockInterface
{
return self::mockTime(new DateTimeImmutable());
}
public function testCreatedByAdminOnPost(): void public function testCreatedByAdminOnPost(): void
{ {
// Horloge figee : le subscriber posera createdAt/updatedAt sur cet
// instant exact, insensible a tout decalage d'horloge reel.
$clock = $this->freezeClock();
$type = $this->createCategoryType(); $type = $this->createCategoryType();
/** @var User $admin */ /** @var User $admin */
@@ -33,9 +62,7 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
self::assertNotNull($admin); self::assertNotNull($admin);
$adminId = $admin->getId(); $adminId = $admin->getId();
$before = new DateTimeImmutable(); $before = $clock->now();
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
sleep(1);
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [ $response = $client->request('POST', '/api/categories', [
@@ -103,6 +130,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
public function testPatchUpdatesUpdatedFieldsOnly(): void public function testPatchUpdatesUpdatedFieldsOnly(): void
{ {
$clock = $this->freezeClock();
// Etape 1 : creation par admin pour figer createdBy=admin. // Etape 1 : creation par admin pour figer createdBy=admin.
$type = $this->createCategoryType(); $type = $this->createCategoryType();
$adminClient = $this->createAdminClient(); $adminClient = $this->createAdminClient();
@@ -127,9 +156,9 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
$initialUpdatedAt = $initial->getUpdatedAt(); $initialUpdatedAt = $initial->getUpdatedAt();
$initialCreatedById = $initial->getCreatedBy()->getId(); $initialCreatedById = $initial->getCreatedBy()->getId();
// Decalage temporel suffisant pour que la precision PG (seconde) // Avance deterministe de l'horloge mockee : garantit un updatedAt
// capte un updatedAt different. // strictement superieur cote PG (precision seconde) sans sleep reel.
sleep(1); $clock->sleep(1);
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob". // Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
$manage = $this->createManageClient(); $manage = $this->createManageClient();
@@ -180,6 +209,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
{ {
$clock = $this->freezeClock();
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber // RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt. // doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
$type = $this->createCategoryType(); $type = $this->createCategoryType();
@@ -202,7 +233,8 @@ final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
$initial = $em->getRepository(Category::class)->find($createdId); $initial = $em->getRepository(Category::class)->find($createdId);
$initialUpdatedAt = $initial->getUpdatedAt(); $initialUpdatedAt = $initial->getUpdatedAt();
sleep(1); // Avance deterministe de l'horloge mockee (cf. testPatch).
$clock->sleep(1);
// Soft delete par un manager non-admin. // Soft delete par un manager non-admin.
$manage = $this->createManageClient(); $manage = $this->createManageClient();
@@ -126,9 +126,6 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
// Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait // Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait
// produit le ClientProcessor via l'API. // produit le ClientProcessor via l'API.
$client->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); $client->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$client->setLastName('Seed');
$client->setPhonePrimary('0102030405');
$client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test');
$client->addCategory($this->createCategory($categoryCode)); $client->addCategory($this->createCategory($categoryCode));
$client->setIsArchived($isArchived); $client->setIsArchived($isArchived);
if ($isArchived) { if ($isArchived) {
+11 -57
View File
@@ -25,7 +25,7 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
{ {
private const string LD = 'application/ld+json'; private const string LD = 'application/ld+json';
public function testPostNormalizesTextFields(): void public function testPostNormalizesCompanyName(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR'); $cat = $this->createCategory('SECTEUR');
@@ -33,23 +33,18 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$response = $client->request('POST', '/api/clients', [ $response = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'acme sas', 'companyName' => 'acme sas',
'firstName' => 'JEAN', 'categories' => ['/api/categories/'.$cat->getId()],
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
'categories' => ['/api/categories/'.$cat->getId()],
], ],
]); ]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
$data = $response->toArray(); $data = $response->toArray();
// RG-1.18 / 1.19 / 1.20 / 1.21 // RG-1.18 : companyName normalise en MAJUSCULES. Les champs de contact
// inline ont disparu (refonte contact) -> plus de normalisation ici.
self::assertSame('ACME SAS', $data['companyName']); self::assertSame('ACME SAS', $data['companyName']);
self::assertSame('Jean', $data['firstName']); self::assertArrayNotHasKey('firstName', $data);
self::assertSame('Dupont', $data['lastName']); self::assertArrayNotHasKey('email', $data);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
self::assertFalse($data['isArchived']); self::assertFalse($data['isArchived']);
} }
@@ -60,41 +55,18 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$iri = '/api/categories/'.$cat->getId(); $iri = '/api/categories/'.$cat->getId();
$payload = [ $payload = [
'companyName' => 'Doublon SARL', 'companyName' => 'Doublon SARL',
'firstName' => 'A', 'categories' => [$iri],
'phonePrimary' => '0102030405',
'email' => 'dup@test.fr',
'categories' => [$iri],
]; ];
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
// Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16). // Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16).
$payload['email'] = 'dup2@test.fr';
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(409); self::assertResponseStatusCodeSame(409);
} }
public function testPostWithoutFirstOrLastNameReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Contact Name',
'phonePrimary' => '0102030405',
'email' => 'nc@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
]);
// RG-1.01
self::assertResponseStatusCodeSame(422);
}
public function testPostWithoutCategoryReturns422(): void public function testPostWithoutCategoryReturns422(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
@@ -102,11 +74,8 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients', [ $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'No Category', 'companyName' => 'No Category',
'firstName' => 'A', 'categories' => [],
'phonePrimary' => '0102030405',
'email' => 'nocat@test.fr',
'categories' => [],
], ],
]); ]);
@@ -124,9 +93,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Mutex Client', 'companyName' => 'Mutex Client',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'mutex@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(), 'distributor' => '/api/clients/'.$distributor->getId(),
'broker' => '/api/clients/'.$distributor->getId(), 'broker' => '/api/clients/'.$distributor->getId(),
@@ -147,9 +113,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Bad Distrib Ref', 'companyName' => 'Bad Distrib Ref',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'baddistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$notDistro->getId(), 'distributor' => '/api/clients/'.$notDistro->getId(),
], ],
@@ -169,9 +132,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Client Avec Distrib', 'companyName' => 'Client Avec Distrib',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'okdistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(), 'distributor' => '/api/clients/'.$distributor->getId(),
], ],
@@ -190,9 +150,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Bad Broker Ref', 'companyName' => 'Bad Broker Ref',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'badbroker@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'broker' => '/api/clients/'.$notBroker->getId(), 'broker' => '/api/clients/'.$notBroker->getId(),
], ],
@@ -212,9 +169,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Client Avec Courtier', 'companyName' => 'Client Avec Courtier',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'okbroker@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
'broker' => '/api/clients/'.$broker->getId(), 'broker' => '/api/clients/'.$broker->getId(),
], ],
@@ -68,9 +68,6 @@ final class ClientAuditTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Blamable Co', 'companyName' => 'Blamable Co',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'blamable@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
])->toArray(); ])->toArray();
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api; namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Sites\Domain\Entity\Site;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
/** /**
@@ -88,6 +90,39 @@ final class ClientExportControllerTest extends AbstractCommercialApiTestCase
self::assertNotContains('SECTEUR CO', $names); self::assertNotContains('SECTEUR CO', $names);
} }
/**
* ERP-100 : depuis le decouplage hydratation/selection, le QueryBuilder de
* liste ne fetch-join plus les collections l'export les recharge en lot via
* hydrateListCollections(). Ce test garde que les colonnes « Catégories » et
* « Site(s) » restent peuplees (un oubli d'hydratation les rendrait vides
* sans erreur).
*/
public function testExportPopulatesCategoryAndSiteColumns(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Hydrate Co', false, 'DISTRIBUTEUR');
$em = $this->getEm();
$site = $em->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).');
$address = new ClientAddress();
$address->setClient($seed);
$address->setPostalCode('86100');
$address->setCity('Châtellerault');
$address->setStreet('1 rue du Test');
$address->addSite($site);
$em->persist($address);
$em->flush();
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie du client (getName()).
self::assertStringContainsString('test_cli_cat_distributeur', $flat);
// Colonne « Site(s) » : site agrege depuis l'adresse (RG-1.10).
self::assertStringContainsString((string) $site->getName(), $flat);
}
public function testSirenColumnPresentWithAccountingView(): void public function testSirenColumnPresentWithAccountingView(): void
{ {
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN. // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
@@ -7,12 +7,15 @@ namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity; use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
/** /**
* Tests fonctionnels du formulaire principal combler les trous (ERP-60). * Tests fonctionnels du formulaire principal apres la refonte contact.
* *
* RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs * RG-1.01 (prenom OU nom) et RG-1.02 (telephone secondaire) ont ete SUPPRIMEES
* + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les * du Client : le contact principal n'est plus porte inline, il vit uniquement
* reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire), * dans ClientContact (onglet Contact). Ce fichier verifie que :
* non encore testee. * - le formulaire principal se cree avec les seuls champs subsistants
* (companyName + categories), sans aucun champ de contact ;
* - les anciens champs de contact (firstName, lastName, phonePrimary,
* phoneSecondary, email) ne sont plus exposes ni persistes.
* *
* @internal * @internal
*/ */
@@ -21,11 +24,10 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
private const string LD = 'application/ld+json'; private const string LD = 'application/ld+json';
/** /**
* RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes * Le formulaire principal n'exige plus que companyName + au moins une
* distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur * categorie (RG-1.16 / RG sur categories). Aucun champ de contact requis.
* la colonne secondaire.
*/ */
public function testPostPersistsSecondaryPhoneNormalized(): void public function testPostMainFormWithoutContactFields(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR'); $cat = $this->createCategory('SECTEUR');
@@ -33,26 +35,28 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
$data = $client->request('POST', '/api/clients', [ $data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Two Phones SARL', 'companyName' => 'Main Form SARL',
'firstName' => 'A', 'categories' => ['/api/categories/'.$cat->getId()],
'phonePrimary' => '06.12.34.56.78',
'phoneSecondary' => '05 49 00 11 22',
'email' => 'twophones@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
], ],
])->toArray(); ])->toArray();
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
self::assertSame('0612345678', $data['phonePrimary']); self::assertSame('MAIN FORM SARL', $data['companyName']);
self::assertSame('0549001122', $data['phoneSecondary']);
// Les champs de contact inline ont disparu de la representation.
self::assertArrayNotHasKey('firstName', $data);
self::assertArrayNotHasKey('lastName', $data);
self::assertArrayNotHasKey('phonePrimary', $data);
self::assertArrayNotHasKey('phoneSecondary', $data);
self::assertArrayNotHasKey('email', $data);
} }
/** /**
* RG-1.02 : maximum 2 telephones le modele n'expose que phonePrimary et * Les anciens champs de contact envoyes par un appel API direct (payload
* phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est * historique) sont ignores par le denormaliseur : ils n'apparaissent pas
* ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero. * dans la representation et ne creent aucune colonne sur le client.
*/ */
public function testThirdPhoneFieldIsIgnored(): void public function testLegacyContactFieldsAreIgnored(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR'); $cat = $this->createCategory('SECTEUR');
@@ -60,25 +64,25 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
$data = $client->request('POST', '/api/clients', [ $data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [ 'json' => [
'companyName' => 'Third Phone SARL', 'companyName' => 'Legacy Fields SARL',
'firstName' => 'A', 'firstName' => 'Ignored',
'lastName' => 'Ignored',
'phonePrimary' => '0612345678', 'phonePrimary' => '0612345678',
'phoneSecondary' => '0549001122', 'phoneSecondary' => '0549001122',
'phoneTertiary' => '0700000000', 'email' => 'ignored@test.fr',
'email' => 'thirdphone@test.fr',
'categories' => ['/api/categories/'.$cat->getId()], 'categories' => ['/api/categories/'.$cat->getId()],
], ],
])->toArray(); ])->toArray();
self::assertResponseStatusCodeSame(201); self::assertResponseStatusCodeSame(201);
// Le champ inconnu est ignore par le denormaliseur : il n'apparait pas self::assertArrayNotHasKey('firstName', $data);
// dans la representation et n'a pas ete persiste. self::assertArrayNotHasKey('phonePrimary', $data);
self::assertArrayNotHasKey('phoneTertiary', $data); self::assertArrayNotHasKey('email', $data);
// Confirmation cote base : seules les 2 colonnes telephone existent. // Confirmation cote base : le client cree ne porte aucun contact inline
// (les colonnes n'existent plus, l'entite n'a plus les proprietes).
$persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']); $persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']);
self::assertNotNull($persisted); self::assertNotNull($persisted);
self::assertSame('0612345678', $persisted->getPhonePrimary()); self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName());
self::assertSame('0549001122', $persisted->getPhoneSecondary());
} }
} }
@@ -14,10 +14,41 @@ namespace App\Tests\Module\Commercial\Api;
* - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active * - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active
* (RG-1.17) ont ete supprimes / ne sont jamais crees. * (RG-1.17) ont ete supprimes / ne sont jamais crees.
* *
* Verifie aussi la refonte contact (Version20260603120000) : les 5 colonnes de
* contact principal inline ont ete supprimees de la table `client`.
*
* @internal * @internal
*/ */
final class ClientMigrationTest extends AbstractCommercialApiTestCase final class ClientMigrationTest extends AbstractCommercialApiTestCase
{ {
/**
* Refonte contact : first_name / last_name / phone_primary / phone_secondary
* / email ne doivent plus exister sur la table `client` (deplaces vers
* client_contact). NB : le backfill de la migration ne s'exerce que sur une
* base portant des donnees pre-refonte ; sur le schema de test (table client
* vierge au moment de la migration) il est un no-op, donc non assertable ici
* au runtime seul l'etat de schema final est verifie.
*/
public function testInlineContactColumnsAreDropped(): void
{
self::bootKernel();
/** @var list<array{column_name: string}> $columns */
$columns = $this->getEm()->getConnection()->fetchAllAssociative(
"SELECT column_name FROM information_schema.columns "
."WHERE table_schema = 'public' AND table_name = 'client'",
);
$names = array_map(static fn (array $r): string => $r['column_name'], $columns);
foreach (['first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email'] as $dropped) {
self::assertNotContains(
$dropped,
$names,
sprintf('La colonne client.%s aurait du etre supprimee (refonte contact).', $dropped),
);
}
}
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
{ {
$rows = $this->clientIndexes(); $rows = $this->clientIndexes();
@@ -6,6 +6,7 @@ namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client; use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures; use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\NullOutput;
@@ -272,15 +273,60 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(200); self::assertResponseStatusCodeSame(200);
} }
public function testBusinessRolesCanReadCategoriesAndSitesReferentials(): void
{
// ERP-102 : /categories et /sites sont des referentiels TRANSVERSES.
// Tout role qui gere des clients (bureau / compta / commerciale) doit
// pouvoir les LISTER pour alimenter les selects de creation/filtre client,
// via la permission de lecture-referentiel dediee (catalog.categories.read_ref
// / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter
// la permission d'administration `.view`. Usine, sans aucune permission,
// reste interdit.
// Le referentiel /sites est TRANSVERSE et COMPLET : le cloisonnement par
// site rattache (SiteCollectionScopedExtension) est neutralise par
// `sites.read_ref` (ERP-102). Les comptes demo ne sont rattaches qu'a un
// seul site (Chatellerault) alors que la base en compte plusieurs : on
// verifie donc que le role voit la TOTALITE du referentiel, pas son seul
// site rattache. Sans le bypass de scope, totalItems vaudrait 1.
$totalSites = $this->getEm()->getRepository(Site::class)->count([]);
self::assertGreaterThan(
1,
$totalSites,
'Pre-requis du test : la base doit contenir plusieurs sites pour distinguer scope et bypass.',
);
foreach (['bureau', 'compta', 'commerciale'] as $role) {
$client = $this->authAs($role);
$client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role));
$response = $client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role));
self::assertSame(
$totalSites,
$response->toArray()['totalItems'] ?? null,
sprintf('Le role %s doit voir tout le referentiel sites (%d), pas seulement son site rattache', $role, $totalSites),
);
}
// Usine : aucune permission -> reste a 403 sur les referentiels.
$usine = $this->authAs('usine');
$usine->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /categories');
$usine->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /sites');
}
private function authAs(string $role): Client private function authAs(string $role): Client
{ {
return $this->authenticatedClient($role, self::PWD); return $this->authenticatedClient($role, self::PWD);
} }
/** /**
* Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ; * Payload minimal valide de l'onglet principal (companyName + une categorie
* une categorie SECTEUR). Si $categoryId est null, une categorie est creee a * SECTEUR ; le contact inline a ete supprime). Si $categoryId est null, une
* la volee. * categorie est creee a la volee.
* *
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@@ -289,11 +335,8 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
$categoryId ??= $this->createCategory('SECTEUR')->getId(); $categoryId ??= $this->createCategory('SECTEUR')->getId();
return [ return [
'companyName' => $companyName, 'companyName' => $companyName,
'firstName' => 'Jean', 'categories' => ['/api/categories/'.$categoryId],
'phonePrimary' => '0612345678',
'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test',
'categories' => ['/api/categories/'.$categoryId],
]; ];
} }
} }
@@ -232,9 +232,6 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas
$client = new ClientEntity(); $client = new ClientEntity();
$client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); $client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$client->setLastName('Complet');
$client->setPhonePrimary('0102030405');
$client->setEmail('complet'.$suffix.'@seed.test');
$client->addCategory($this->createCategory('SECTEUR')); $client->addCategory($this->createCategory('SECTEUR'));
// Bloc comptable non nul (gating par omission cote Commerciale). // Bloc comptable non nul (gating par omission cote Commerciale).
$client->setSiren('123456789'); $client->setSiren('123456789');
@@ -12,41 +12,13 @@ use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
* RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par * RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par
* ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier * ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier
* verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee) * verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee)
* et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques. * n'est PLUS contraint unique. (L'email RG-1.17 a disparu du Client avec la
* refonte contact, il vit desormais sur ClientContact.)
* *
* @internal * @internal
*/ */
final class ClientUniquenessTest extends AbstractCommercialApiTestCase final class ClientUniquenessTest extends AbstractCommercialApiTestCase
{ {
private const string LD = 'application/ld+json';
/**
* RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme
* email principal aucune contrainte d'unicite (un email peut servir
* plusieurs clients).
*/
public function testDuplicateEmailIsAllowed(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$iri = '/api/categories/'.$cat->getId();
$payload = static fn (string $name): array => [
'companyName' => $name,
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'partage@test.fr',
'categories' => [$iri],
];
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]);
self::assertResponseStatusCodeSame(201);
// Meme email, nom different -> doit passer (pas d'index unique email).
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]);
self::assertResponseStatusCodeSame(201);
}
/** /**
* RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements * RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements
* multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on * multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on
@@ -134,14 +134,11 @@ final class ClientProcessorTest extends TestCase
'isArchived' => false, 'isArchived' => false,
], ],
managed: true, managed: true,
// Etat persiste complet (valeurs normalisees) : sans les champs // Etat persiste (valeurs normalisees) : sans companyName, guardManage
// metier, guardManage (ERP-74) les croirait modifies (companyName, // (ERP-74) le croirait modifie (compare a null) et leverait un 403
// lastName... compares a null) et leverait un 403 parasite. // parasite.
originalData: [ originalData: [
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -164,13 +161,10 @@ final class ClientProcessorTest extends TestCase
managed: true, managed: true,
// getOriginalEntityData renvoie tous les champs mappes d'une entite // getOriginalEntityData renvoie tous les champs mappes d'une entite
// geree : isArchived (non-null) y figure toujours, ainsi que les // geree : isArchived (non-null) y figure toujours, ainsi que les
// champs metier (sinon guardManage les croirait modifies). // champs metier (companyName) sinon guardManage les croirait modifies.
originalData: [ originalData: [
'siren' => '123456789', 'siren' => '123456789',
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -193,9 +187,6 @@ final class ClientProcessorTest extends TestCase
managed: true, managed: true,
originalData: [ originalData: [
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -220,9 +211,6 @@ final class ClientProcessorTest extends TestCase
originalData: [ originalData: [
'siren' => '111111111', 'siren' => '111111111',
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -324,9 +312,6 @@ final class ClientProcessorTest extends TestCase
managed: true, managed: true,
originalData: [ originalData: [
'companyName' => 'TEST CO', 'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false, 'triageService' => false,
'isArchived' => false, 'isArchived' => false,
], ],
@@ -401,16 +386,14 @@ final class ClientProcessorTest extends TestCase
} }
/** /**
* Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) suffisant * Client minimal companyName seul depuis la suppression du contact inline.
* pour atteindre les validations testees. * Suffisant pour atteindre les validations testees (le contact vit desormais
* dans ClientContact, hors scope du ClientProcessor).
*/ */
private function minimalClient(): Client private function minimalClient(): Client
{ {
$client = new Client(); $client = new Client();
$client->setCompanyName('Test Co'); $client->setCompanyName('Test Co');
$client->setLastName('Dupont');
$client->setPhonePrimary('0102030405');
$client->setEmail('t@test.fr');
return $client; return $client;
} }
+4 -3
View File
@@ -16,17 +16,18 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
*/ */
final class SitesModuleTest extends KernelTestCase final class SitesModuleTest extends KernelTestCase
{ {
public function testPermissionsSetContainsExactlyThreeCodes(): void public function testPermissionsSetContainsExactlyFourCodes(): void
{ {
// Garde-fou : si quelqu'un ajoute une permission sans ajuster les // Garde-fou : si quelqu'un ajoute une permission sans ajuster les
// tests ou la doc, ce test casse explicitement. Si au contraire une // tests ou la doc, ce test casse explicitement. Si au contraire une
// permission disparait (ex: bypass_scope retire par erreur), meme // permission disparait (ex: bypass_scope retire par erreur), meme
// effet. Le set de 3 permissions est fige par ce test. // effet. Le set de permissions est fige par ce test.
// `sites.read_ref` ajoutee en ERP-102 (lecture-referentiel transverse).
$codes = array_column(SitesModule::permissions(), 'code'); $codes = array_column(SitesModule::permissions(), 'code');
sort($codes); sort($codes);
self::assertSame( self::assertSame(
['sites.bypass_scope', 'sites.manage', 'sites.view'], ['sites.bypass_scope', 'sites.manage', 'sites.read_ref', 'sites.view'],
$codes, $codes,
); );
} }
@@ -14,6 +14,7 @@ use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
/** /**
@@ -30,7 +31,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPrePersistWithUser(): void public function testPrePersistWithUser(): void
{ {
$user = $this->createStub(UserInterface::class); $user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user)); $subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
$entity = new FullAuditableFixture(); $entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity)); $subscriber->prePersist($this->prePersistArgs($entity));
@@ -45,7 +46,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPrePersistWithoutUser(): void public function testPrePersistWithoutUser(): void
{ {
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null)); $subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null), new MockClock());
$entity = new FullAuditableFixture(); $entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity)); $subscriber->prePersist($this->prePersistArgs($entity));
@@ -59,8 +60,13 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPreUpdate(): void public function testPreUpdate(): void
{ {
$user = $this->createStub(UserInterface::class); $user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user)); // Horloge figee 1s apres le createdAt simule : updatedAt doit avancer
// de facon deterministe, sans dependre de l'heure reelle.
$subscriber = new TimestampableBlamableSubscriber(
$this->securityReturning($user),
new MockClock(new DateTimeImmutable('2020-01-01 10:00:01')),
);
// On simule une entite deja persistee : createdAt fige dans le passe, // On simule une entite deja persistee : createdAt fige dans le passe,
// createdBy positionne par une creation anterieure. // createdBy positionne par une creation anterieure.
@@ -80,7 +86,7 @@ final class TimestampableBlamableSubscriberTest extends TestCase
public function testPartialEntityTimestampableOnly(): void public function testPartialEntityTimestampableOnly(): void
{ {
$user = $this->createStub(UserInterface::class); $user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user)); $subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user), new MockClock());
$entity = new TimestampableOnlyFixture(); $entity = new TimestampableOnlyFixture();
// Entite Timestampable mais NON Blamable : seules les dates sont posees, // Entite Timestampable mais NON Blamable : seules les dates sont posees,