Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions f406a598eb chore: bump version to v0.1.76
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-06-03 13:58:52 +00:00
matthieu a72a5dd812 docs : refonte du README (dev local seed/no-seed, seed recette/prod, BDD de test) (#56)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte

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

## Contenu

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

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

---------

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

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

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

## Changements

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

## Décisions actées

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

## Vérifications

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

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #55
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 13:52:25 +00:00
18 changed files with 563 additions and 523 deletions
+309 -147
View File
@@ -1,209 +1,362 @@
# Starseed
CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
CRM/ERP en architecture **modular monolith DDD** — Symfony 8 (API Platform 4) + Nuxt 4.
Le backend est la **source de vérité unique** : il décide des modules actifs et de
l'organisation de la sidebar. Le frontend scanne `frontend/modules/*/` comme layers
Nuxt et consomme l'API pour la navigation.
---
## Sommaire
- [Stack](#stack)
- [Prérequis](#prérequis)
- [Démarrage rapide](#démarrage-rapide)
- [Dev local : avec ou sans données de seed](#dev-local--avec-ou-sans-données-de-seed)
- [Comptes (dev)](#comptes-dev)
- [Bases de données : dev et test](#bases-de-données--dev-et-test)
- [Tests](#tests)
- [Déploiement : seed RBAC en recette / prod](#déploiement--seed-rbac-en-recette--prod)
- [Commandes make](#commandes-make)
- [Architecture](#architecture)
- [Structure du dépôt](#structure-du-dépôt)
- [CI/CD](#cicd)
- [Conventions](#conventions)
---
## Stack
- **Backend** : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16
- **Frontend** : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui
- **Auth** : JWT HTTP-only cookie (Lexik)
- **Frontend** : Nuxt 4 (SPA, SSR off), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, @nuxtjs/i18n
- **Auth** : JWT HTTP-only cookie (Lexik), login sur `/login_check`
- **Infra** : Docker Compose (dev + prod multi-stage)
- **CI/CD** : Gitea Actions (auto-tag + build Docker)
## Quick Start
| Service | Port |
|---------------|------|
| API (Nginx) | 8083 |
| Frontend dev | 3004 |
| PostgreSQL | 5437 |
---
## Prérequis
- Docker + Docker Compose
- `make`
- `nvm` (la version de Node est fixée par `.nvmrc`, voir `make node-use`)
Toutes les commandes `make` s'exécutent dans le container PHP (`php-starseed-fpm`) ;
rien n'est requis sur l'hôte hormis Docker — **sauf les tests E2E**, qui tournent sur
l'hôte (navigateur réel, voir [Tests](#tests)).
---
## Démarrage rapide
```bash
make start # Demarrer les containers Docker
make install # Composer, migrations, fixtures, build Nuxt
make start # Démarre les containers Docker
make install # Composer + clés JWT + migrations + permissions + BDD de test
make dev-nuxt # Serveur Nuxt avec hot reload (http://localhost:3004)
```
Dev frontend (hot reload) :
`make install` prépare une base de dev **vierge** (schéma + RBAC structurel, sans
données de démo) et la base de **test**. Pour obtenir des comptes et des données de
démo prêtes à l'emploi, lis la section suivante.
> Override local possible : `make` lit `infra/dev/.env.docker`, surchargé par
> `infra/dev/.env.docker.local` s'il existe (créé automatiquement par `make env-init`).
---
## Dev local : avec ou sans données de seed
Le projet distingue deux états de base de données de dev. Les **fixtures Doctrine sont
en `require-dev`** : elles n'existent qu'en dev, jamais dans le build de prod.
### Sans données de seed (base vierge)
C'est ce que produit `make install`. La base contient :
- le **schéma** complet (toutes les migrations jouées) ;
- les **rôles système** `admin` / `user` (seedés en SQL par la migration RBAC) ;
- le **catalogue de permissions** synchronisé (`app:sync-permissions`).
Mais **aucun compte utilisateur ni donnée métier**. Pour pouvoir te connecter,
crée toi-même un compte :
```bash
make dev-nuxt # Port 3003
make shell
php bin/console app:create-user admin monMotDePasse --admin # compte ROLE_ADMIN
```
## Ports
Optionnel — provisionner les **rôles métier** (bureau / compta / commerciale / usine
+ matrice RBAC § 2.7) sans comptes de démo :
| Service | Port |
|------------|------|
| API (Nginx)| 8083 |
| Frontend | 3004 |
| PostgreSQL | 5437 |
```bash
php bin/console app:seed-rbac
```
## Commandes
Cet état est utile pour repartir d'une base propre, reproduire un bug sur données
minimales, ou tester un parcours d'onboarding réel.
| Commande | Description |
|----------|-------------|
| `make start` | Demarrer les containers |
| `make stop` | Arreter les containers |
| `make restart` | Redemarrer les containers |
| `make install` | Install complet |
| `make reset` | Tout supprimer et reinstaller |
| `make dev-nuxt` | Serveur dev Nuxt (hot reload) |
| `make shell` | Shell dans le container PHP |
| `make cache-clear` | Vider le cache Symfony |
| `make migration-migrate` | Lancer les migrations |
| `make fixtures` | Charger les fixtures |
| `make db-reset` | Reset BDD + migrations + fixtures |
| `make test` | PHPUnit (tests back) |
| `make nuxt-test` | Vitest (tests unitaires front) |
| `make test-e2e` | Playwright (tests E2E front) |
| `make test-e2e-ui` | Playwright UI interactive (debug) |
| `make seed-e2e` | Seed les 6 personas E2E |
| `make install-e2e-deps` | One-time : Chromium + libs systeme (sudo) |
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
| `make logs-dev` | Tail logs Symfony |
### Avec données de seed (base de démo)
`make db-reset` (ou `make fixtures` après un `make install`) recharge la base de dev
avec un jeu complet de données de démonstration, **idempotent** :
```bash
make db-reset # ATTENTION : drop + recrée la base de dev, puis charge tout le seed
```
Ce que les fixtures posent :
- **3 utilisateurs système** : `admin` (ROLE_ADMIN), `alice`, `bob` (ROLE_USER),
rattachés à des sites distincts ;
- **3 sites** : Chatellerault, Saint-Jean, Pommevic ;
- **les comptes de démo RBAC métier** (`bureau`, `compta`, `commerciale`, `usine`,
mot de passe `demo`) avec la matrice § 2.7 attachée ;
- les **référentiels et données métier** des modules (catégories, clients de démo,
référentiels comptables…).
Toutes les fixtures sont rejouables sans effet de bord (lookup par clé naturelle,
aucun doublon).
> Différence avec `make install` : `install` ne charge **pas** les fixtures sur la base
> de dev (il alimente uniquement la base de test). Utilise `make db-reset` ou
> `make fixtures` quand tu veux des données de démo en dev.
---
## Comptes (dev)
Disponibles uniquement après `make db-reset` / `make fixtures` (état « avec seed ») :
| Username | Password | Rôle | RBAC métier |
|---------------|----------|------------|---------------------------------------------------------------|
| `admin` | `admin` | ROLE_ADMIN | bypass complet (`is_admin`) |
| `alice` | `alice` | ROLE_USER | — |
| `bob` | `bob` | ROLE_USER | — |
| `bureau` | `demo` | ROLE_USER | clients : view + manage |
| `compta` | `demo` | ROLE_USER | clients : view + accounting.view / manage |
| `commerciale` | `demo` | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| `usine` | `demo` | ROLE_USER | aucun accès clients |
---
## Bases de données : dev et test
Deux bases distinctes vivent dans le **même container PostgreSQL** (port 5437) :
| Base | Environnement | Construite par | Usage |
|------------|---------------|--------------------------------------|--------------------------------|
| `<db>` | `dev` | `make install` / `make db-reset` | développement manuel, dev-nuxt |
| `<db>_test` | `test` | `make test-db-setup` | PHPUnit (jamais touchée à la main) |
Le suffixe `_test` est appliqué **automatiquement** par Doctrine quand `APP_ENV=test`
(config `when@test` dans `config/packages/doctrine.yaml`). La base de test est donc
totalement **isolée** de la base de dev : lancer `make test` ne touche jamais tes
données de dev.
`make test-db-setup` fait davantage que jouer les migrations, car certaines structures
ne sont pas portées par des migrations « métier » :
1. `doctrine:migrations:migrate` — schéma métier réel ;
2. `doctrine:schema:update --force` — crée les tables mappées en `when@test`
uniquement (entités de test) ;
3. `app:apply-column-comments` — réapplique les `COMMENT ON COLUMN` que
`schema:update` efface sur les tables managées par l'ORM (garde-fou
`ColumnsHaveSqlCommentTest`) ;
4. `fixtures:load``sync-permissions``seed-rbac` — dans cet ordre précis
(le purger des fixtures vide la table `permission`, donc la sync passe après) ;
5. recréation des **index partiels uniques** (`LOWER(...) WHERE ...`) non exprimables
en attributs ORM, indispensables aux tests d'unicité (RG-1.07, RG-1.16, RG-1.03/1.29).
`make install` et `make db-reset` appellent déjà `test-db-setup` : tu n'as à le
relancer à la main que si la base de test diverge (nouvelle migration, nouvelle
permission) sans vouloir reseed la base de dev.
---
## Tests
- **Back** : `make test` (PHPUnit). Fixtures dediees sous `tests/Fixtures/`.
- **Front unitaire** : `make nuxt-test` (Vitest, happy-dom). Composables, utils, stores — rapide, <30s.
- **Front E2E** : `make test-e2e` (Playwright). Couvre login + matrice RBAC sidebar. Suite volontairement minimaliste (11 tests) — voir la regle d'or dans `CLAUDE.md`.
| Suite | Commande | Outil | Où |
|-------------------|------------------|----------------------|-----------------------------------|
| Back | `make test` | PHPUnit | container PHP, base `<db>_test` |
| Front unitaire | `make nuxt-test` | Vitest (happy-dom) | container Node, < 30 s |
| Front E2E | `make test-e2e` | Playwright | **hôte** (navigateur réel requis) |
| Tout (back+front) | `make test-all` | PHPUnit + Vitest | — |
### Tests back (PHPUnit)
**Bootstrap E2E (une fois par poste)** :
```bash
make install-e2e-deps # Telecharge Chromium + libs systeme via apt (sudo)
make test # toute la suite
make test FILES=tests/Module/Commercial # un dossier / fichier ciblé
```
**Workflow E2E** :
PHPUnit force `APP_ENV=test` (`phpunit.dist.xml`) : les tests tournent **toujours**
sur la base `<db>_test`, jamais sur la base de dev. Prérequis : que la base de test
existe — c'est le cas après `make install`. Si elle a divergé, rejoue
`make test-db-setup` (cf. [Bases de données](#bases-de-données--dev-et-test)).
### Tests front unitaires (Vitest)
```bash
# Terminal 1 : containers + dev server
make nuxt-test # composables, utils, stores — rapide et stable
```
C'est la **place par défaut** pour étendre la couverture (cf. règle d'or ci-dessous).
### Tests E2E (Playwright)
Suite volontairement minimaliste (login + matrice RBAC sidebar). **Règle d'or : un
nouveau test E2E ne s'ajoute que si un bug critique est passé en prod** — sinon,
préférer un test Vitest ou étendre un persona existant.
Bootstrap (une fois par poste) :
```bash
make install-e2e-deps # télécharge Chromium + libs système (apt/dnf, sudo)
```
Workflow :
```bash
# Terminal 1 — containers, seed des personas, serveur dev
make start && make seed-e2e && make dev-nuxt
# Terminal 2 : tests
make test-e2e
# Terminal 2 tests
make test-e2e # headless
make test-e2e-ui # UI interactive (debug)
```
> Toute permission testable touche **3 miroirs** à garder alignés : `config/sidebar.php`,
> `frontend/tests/e2e/_fixtures/personas.ts`, `SeedE2ECommand.php`.
---
## Déploiement : seed RBAC en recette / prod
Les fixtures Doctrine étant en `require-dev`, elles sont **absentes du build de prod**.
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente**, jouée dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions (commercial.clients.*, …)
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test. Le mot de
passe est fourni **explicitement** (jamais en dur, jamais committé) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'environnement RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de
compte). Pour créer un premier administrateur en prod :
```bash
php bin/console app:create-user <username> <password> --admin
```
---
## Commandes make
`make` (sans argument) ou `make help` affiche l'aide colorée. Les principales :
| Commande | Description |
|--------------------------------|----------------------------------------------------------|
| `make start` / `stop` / `restart` | Cycle de vie des containers |
| `make install` | Install complet (base dev vierge + base de test) |
| `make reset` | Tout supprimer et réinstaller (**drop la BDD**) |
| `make dev-nuxt` | Serveur Nuxt hot reload (port 3004) |
| `make shell` / `shell-root` | Shell bash dans le container PHP |
| `make migration-migrate` | Jouer les migrations Doctrine |
| `make fixtures` | Charger les fixtures (données de démo dev) |
| `make sync-permissions` | Synchroniser le catalogue RBAC |
| `make seed-rbac` | Seed RBAC métier (rôles + matrice § 2.7) |
| `make db-reset` | Reset base dev : drop + migrate + fixtures + RBAC |
| `make test-db-setup` | (Re)construire la base de test |
| `make test` | PHPUnit (back) |
| `make nuxt-test` | Vitest (front unitaire) |
| `make test-all` | PHPUnit + Vitest |
| `make test-e2e` / `test-e2e-ui`| Playwright (E2E, sur l'hôte) |
| `make seed-e2e` | Seed des 6 personas E2E |
| `make php-cs-fixer-allow-risky`| Fix du code style PHP |
| `make php-cs-fixer-check` | Dry-run du fixer (CI / avant push) |
| `make logs-dev` | Tail des logs Symfony |
---
## Architecture
**Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar.
**Modular Monolith DDD** : chaque module est un bounded context autonome,
activable / désactivable par tenant. Le backend est la seule source de vérité pour
l'activation des modules et l'organisation de la sidebar.
- `config/modules.php` — liste des modules actifs
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
- `GET /api/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees
- Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
- `GET /api/modules` — IDs des modules actifs (public)
- `GET /api/sidebar` — sections filtrées par modules actifs + routes désactivées (public)
Pour desactiver un module : commenter sa ligne dans `config/modules.php`, clear cache. Ses items de sidebar disparaissent et ses routes sont bloquees par le middleware front.
**Désactiver un module** : commenter sa ligne dans `config/modules.php`, vider le cache.
Ses items de sidebar disparaissent et ses routes sont bloquées par le middleware front.
Le code reste dans le bundle (layer auto-détecté) → réactivation instantanée.
Pour reorganiser la sidebar (ex: deplacer un item d'une section a l'autre) : editer `config/sidebar.php` uniquement, le code des modules n'est pas touche.
**Réorganiser la sidebar** : éditer `config/sidebar.php` uniquement le code des
modules n'est pas touché.
## Structure
**Communication inter-modules** : jamais d'import direct d'un module à l'autre. Passer
par `Shared/Domain/Contract/` (interfaces) ou des domain events.
---
## Structure du dépôt
```
src/ # Backend Symfony
Kernel.php
Shared/ # Noyau technique partage
Domain/
ValueObject/ # Email, ...
Event/ # DomainEventInterface
Contract/ # Interfaces inter-modules
Application/
Bus/ # CommandBusInterface, QueryBusInterface
Infrastructure/
ApiPlatform/
Resource/ # AppVersion, ModulesResource, SidebarResource
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
Shared/ # Noyau technique partagé (Domain/, Application/Bus/, Infrastructure/ApiPlatform/)
Module/
Core/ # Module obligatoire (auth, users)
CoreModule.php # Declaration (ID, LABEL, REQUIRED)
Domain/
Entity/ # User
Repository/ # UserRepositoryInterface
Event/ # UserCreated
Application/
DTO/ # UserOutput
Infrastructure/
Doctrine/ # DoctrineUserRepository, Migrations/
ApiPlatform/State/
Provider/ # MeProvider
Processor/ # UserPasswordHasherProcessor
Console/ # CreateUserCommand
DataFixtures/ # AppFixtures
Commercial/ # Autre module (exemple)
CommercialModule.php
Core/ # Module obligatoire (auth, users, RBAC)
CoreModule.php # Déclaration (ID, LABEL, REQUIRED, permissions())
Domain/ Application/ Infrastructure/
Commercial/ Catalog/ Sites/ # Modules métier
config/
modules.php # Source de verite activation
sidebar.php # Source de verite navigation
version.yaml
packages/ # Config Symfony
jwt/ # Cles JWT
migrations/ # Anciennes migrations
modules.php # Source de vérité : activation
sidebar.php # Source de vérité : navigation
packages/ # Config Symfony (doctrine, api_platform, security…)
migrations/ # Migrations d'initialisation (namespace racine : setup, RBAC, seed de base)
frontend/ # App Nuxt 4 (SPA)
app/
layouts/ # default.vue, auth.vue
middleware/ # auth.global.ts, modules.global.ts
shared/ # Code partage (hors modules)
composables/ # useApi, useAppVersion, useSidebar
components/ui/ # AppTopNav, ...
stores/ # auth, ui
services/ # auth
types/ # SidebarSection, UserData
utils/ # api (Hydra)
modules/ # Modules auto-detectes comme layers Nuxt
core/
nuxt.config.ts # Marqueur layer
pages/ # index, login, logout
commercial/
nuxt.config.ts
pages/ # commercial.vue
app.vue
nuxt.config.ts # Scanne modules/*/ automatiquement
i18n/locales/ # Traductions (sidebar.*, etc.)
assets/ # CSS, images
public/ # Fichiers statiques
app/ # Shell : layouts, middlewares (auth.global, modules.global)
shared/ # Code inter-modules (composables, stores, utils, types)
modules/ # Layers Nuxt auto-détectés (core/, commercial/…)
i18n/locales/ # Traductions (sidebar.*, audit.entity.*, …)
infra/
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug, .env.docker)
prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
.claude/
skills/create-module/ # Skill Claude Code pour scaffolder un module
```
---
## CI/CD
- **Auto Tag** : push sur `develop` → bump `config/version.yaml` → tag `vX.Y.Z`
- **Build Docker** : push tag `v*` → build image multi-stage → push Gitea Registry
Secrets requis dans Gitea :
- `RELEASE_TOKEN` — PAT avec droits `write:repository`
- `REGISTRY_TOKEN` — token pour le registry Docker
## Déploiement — seed RBAC (recette / prod)
Le RBAC métier (rôles `bureau` / `compta` / `commerciale` / `usine` + matrice § 2.7)
est seedé par une **commande applicative idempotente** (présente dans le build prod,
contrairement aux fixtures Doctrine en `require-dev`). À jouer dans l'étape de release,
**après** les migrations et la synchronisation des permissions :
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console app:sync-permissions # pose les permissions commercial.clients.*
php bin/console app:seed-rbac # PROD : rôles + matrice § 2.7 (sans comptes démo)
```
En **recette / staging**, ajouter le flag pour disposer de logins de test (mot de passe
fourni explicitement, jamais en dur) :
```bash
php bin/console app:seed-rbac --with-demo-users --password='<mot-de-passe>'
# ou via la variable d'env RBAC_DEMO_PASSWORD
```
La commande est rejouable sans effet de bord (aucun doublon de rôle, de lien ou de compte).
En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes démo).
## Credentials (dev)
| Username | Password | Role | RBAC métier |
|----------|----------|------|-------------|
| admin | admin | ROLE_ADMIN | bypass (is_admin) |
| alice | alice | ROLE_USER | — |
| bob | bob | ROLE_USER | — |
| bureau | demo | ROLE_USER | clients : view + manage |
| compta | demo | ROLE_USER | clients : view + accounting.view/manage |
| commerciale | demo | ROLE_USER | clients : view + manage (Information obligatoire — RG-1.04) |
| usine | demo | ROLE_USER | aucun accès clients |
---
## Conventions
@@ -213,4 +366,13 @@ En dev, `make db-reset` produit le même résultat (rôles + matrice + comptes d
<type>(<scope optionnel>) : <message>
```
Types : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`
Espaces obligatoires autour du `:`. Types : `build`, `chore`, `ci`, `docs`, `feat`,
`fix`, `perf`, `refactor`, `revert`, `style`, `test`.
### Langue
- UI et communication : **français**
- Code (classes, méthodes, variables) : **anglais**
- Commentaires (PHP, TS, Vue) : **français**
> Règles détaillées : `CLAUDE.md` et `.claude/rules/`.
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.74'
app.version: '0.1.76'
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M1 — Suppression du contact principal inline du `Client` (refonte contact).
*
* Modele AVANT : le `Client` portait 5 colonnes de contact principal
* (first_name, last_name, phone_primary, phone_secondary, email) en doublon
* conceptuel de la sous-entite `ClientContact` (onglet Contact).
*
* Modele APRES (decision produit, README refonte-contact) : les contacts vivent
* UNIQUEMENT dans `client_contact`. Les 5 colonnes inline disparaissent du
* `client`. RG-1.01 (firstName OU lastName sur Client) et RG-1.02 (max 2
* telephones sur Client) sont supprimees : leur equivalent vit deja sur
* `client_contact` (RG-1.05 / RG-1.14).
*
* Le code etant deja en prod, la suppression est precedee d'un BACKFILL : pour
* tout client n'ayant encore AUCUN contact, on materialise son contact principal
* inline en une ligne `client_contact` (position 0) avant le DROP, afin de ne
* perdre aucune donnee.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
* Commercial : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
* FQCN alphabetique (AlphabeticalComparator). Une migration
* `App\Module\Commercial\...` trierait AVANT toutes les `DoctrineMigrations\...`
* sur base vide -> ce DROP s'executerait avant le CREATE TABLE client
* (Version20260601000000). Le namespace racine garantit l'ordre par timestamp.
*/
final class Version20260603120000 extends AbstractMigration
{
/** Colonnes de contact inline supprimees du `client`. */
private const array INLINE_CONTACT_COLUMNS = [
'first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email',
];
public function getDescription(): string
{
return 'M1 : suppression du contact inline du Client (backfill vers client_contact puis DROP des 5 colonnes).';
}
public function up(Schema $schema): void
{
// 1. Backfill : tout client SANS contact recoit une ligne client_contact
// (position 0) reprenant ses champs inline. phone_primary / email du
// client sont NOT NULL -> toujours une donnee a reporter. Le CHECK
// chk_client_contact_name (first_name OU last_name) est garanti par le
// fallback company_name si jamais les deux noms etaient null (cas qui ne
// devrait pas exister, RG-1.01 ayant ete appliquee a l'ecriture).
// created_at/updated_at NOT NULL -> NOW() ; created_by/updated_by null
// (backfill hors contexte HTTP, libelle « Systeme » cote front).
$this->addSql(<<<'SQL'
INSERT INTO client_contact (
client_id, first_name, last_name, phone_primary, phone_secondary,
email, position, created_at, updated_at
)
SELECT
c.id,
c.first_name,
CASE
WHEN c.first_name IS NULL AND c.last_name IS NULL THEN c.company_name
ELSE c.last_name
END,
c.phone_primary,
c.phone_secondary,
c.email,
0,
NOW(),
NOW()
FROM client c
WHERE NOT EXISTS (
SELECT 1 FROM client_contact cc WHERE cc.client_id = c.id
)
SQL);
// 2. DROP des 5 colonnes inline (rien a documenter : suppression).
$this->addSql(<<<'SQL'
ALTER TABLE client
DROP COLUMN first_name,
DROP COLUMN last_name,
DROP COLUMN phone_primary,
DROP COLUMN phone_secondary,
DROP COLUMN email
SQL);
}
public function down(Schema $schema): void
{
// Best-effort : on RECREE les 5 colonnes (en NULLABLE — l'etat NOT NULL
// d'origine de phone_primary/email ne peut etre restaure sur une table
// peuplee sans risque) et on retro-alimente depuis le contact principal
// (position minimale) de chaque client. La donnee n'est pas garantie
// identique a l'origine : ce down() sert au rollback technique, pas a une
// restauration fidele.
$this->addSql('ALTER TABLE client ADD COLUMN first_name VARCHAR(120) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN last_name VARCHAR(120) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN phone_primary VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN phone_secondary VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE client ADD COLUMN email VARCHAR(180) DEFAULT NULL');
// Retro-alimentation depuis le contact de position la plus basse.
$this->addSql(<<<'SQL'
UPDATE client c SET
first_name = cc.first_name,
last_name = cc.last_name,
phone_primary = cc.phone_primary,
phone_secondary = cc.phone_secondary,
email = cc.email
FROM (
SELECT DISTINCT ON (client_id)
client_id, first_name, last_name, phone_primary, phone_secondary, email
FROM client_contact
ORDER BY client_id, position ASC, id ASC
) cc
WHERE cc.client_id = c.id
SQL);
// Re-pose des commentaires d'origine (regle ABSOLUE n°12) — dollar-quoting
// Postgres pour eviter tout echappement d apostrophe.
$this->addSql('COMMENT ON COLUMN client.first_name IS $_$Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
$this->addSql('COMMENT ON COLUMN client.last_name IS $_$Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).$_$');
$this->addSql('COMMENT ON COLUMN client.phone_primary IS $_$Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.$_$');
$this->addSql('COMMENT ON COLUMN client.phone_secondary IS $_$Telephone secondaire optionnel — chiffres uniquement (RG-1.20).$_$');
$this->addSql('COMMENT ON COLUMN client.email IS $_$Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).$_$');
}
}
+3 -85
View File
@@ -151,31 +151,9 @@ class Client implements TimestampableInterface, BlamableInterface
#[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null;
// RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor).
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, normalizer: 'trim')]
#[Groups(['client:read', 'client:write:main'])]
private ?string $lastName = null;
#[ORM\Column(length: 20)]
#[Assert\NotBlank]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read', 'client:write:main'])]
private ?string $phoneSecondary = null;
#[ORM\Column(length: 180)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups(['client:read', 'client:write:main'])]
private ?string $email = null;
// Le contact principal n'est plus porte inline par le Client : les contacts
// vivent uniquement dans ClientContact (onglet Contact). RG-1.01 / RG-1.02
// supprimees du Client (equivalent RG-1.05 / RG-1.14 sur ClientContact).
// RG-1.03 : distributor / broker auto-references mutuellement exclusives
// (CHECK chk_client_distrib_or_broker en base).
@@ -326,66 +304,6 @@ class Client implements TimestampableInterface, BlamableInterface
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getPhonePrimary(): ?string
{
return $this->phonePrimary;
}
public function setPhonePrimary(string $phonePrimary): static
{
$this->phonePrimary = $phonePrimary;
return $this;
}
public function getPhoneSecondary(): ?string
{
return $this->phoneSecondary;
}
public function setPhoneSecondary(?string $phoneSecondary): static
{
$this->phoneSecondary = $phoneSecondary;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getDistributor(): ?Client
{
return $this->distributor;
@@ -29,7 +29,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
/**
* Processor d'ecriture du repertoire clients (M1). Cf. spec-back M1 § 2.8 /
* § 2.9 / § 4.3 / § 4.4 + RG-1.01 a RG-1.28.
* § 2.9 / § 4.3 / § 4.4 + RG-1.03 a RG-1.28 (RG-1.01/1.02 supprimees : contact inline retire).
*
* Sequence (POST / PATCH) :
* 1. Autorisation additionnelle par groupe d'onglet. La security d'operation
@@ -41,7 +41,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
* - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et
* interdit toute autre modification dans la meme requete (RG-1.22, 422).
* 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer.
* 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker
* 3. Regles metier : RG-1.03 (distributor/broker
* exclusifs + type de categorie), RG-1.12 (Virement -> banque),
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information exigee sur POST
* et tout PATCH pour le role Commerciale).
@@ -60,8 +60,7 @@ final class ClientProcessor implements ProcessorInterface
{
/** Champs de l'onglet principal (groupe client:write:main). */
private const array MAIN_FIELDS = [
'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary',
'email', 'distributor', 'broker', 'triageService', 'categories',
'companyName', 'distributor', 'broker', 'triageService', 'categories',
];
/** Champs de l'onglet Information (groupe client:write:information). */
@@ -124,7 +123,6 @@ final class ClientProcessor implements ProcessorInterface
// valeurs normalisees des deux cotes (l'etat persiste l'a deja ete).
$this->guardManage($data);
$this->validateMainContact($data);
$this->validateDistributorBroker($data);
$this->validateAccountingConsistency($data);
$this->validateInformationCompleteness($data);
@@ -274,11 +272,6 @@ final class ClientProcessor implements ProcessorInterface
{
$newValues = [
'companyName' => $data->getCompanyName(),
'firstName' => $data->getFirstName(),
'lastName' => $data->getLastName(),
'phonePrimary' => $data->getPhonePrimary(),
'phoneSecondary' => $data->getPhoneSecondary(),
'email' => $data->getEmail(),
'distributor' => $data->getDistributor(),
'broker' => $data->getBroker(),
'triageService' => $data->isTriageService(),
@@ -420,39 +413,17 @@ final class ClientProcessor implements ProcessorInterface
}
/**
* Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables
* (companyName, email, phonePrimary) ne sont touches que si une valeur est
* presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel.
* Normalisation serveur du formulaire principal (RG-1.18). Seul companyName
* subsiste cote Client depuis la suppression du contact inline (les champs de
* contact — noms, telephones, email — sont normalises par ClientContactProcessor).
* Le setter non-nullable n'est touche que si une valeur est presente, pour ne
* jamais ecraser l'existant lors d'un PATCH partiel.
*/
private function normalize(Client $data): void
{
if (null !== $data->getCompanyName()) {
$data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName()));
}
if (null !== $data->getEmail()) {
$data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail()));
}
if (null !== $data->getPhonePrimary()) {
$data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary()));
}
$data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName()));
$data->setLastName($this->normalizer->normalizePersonName($data->getLastName()));
$data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary()));
}
/**
* RG-1.01 : au moins le prenom OU le nom du contact principal.
*/
private function validateMainContact(Client $data): void
{
if (null === $data->getFirstName() && null === $data->getLastName()) {
$this->throwViolation(
'firstName',
'Le prénom ou le nom du contact principal est obligatoire.',
$data,
);
}
}
/**
@@ -86,7 +86,9 @@ final class ClientExportController
}
/**
* Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la
* Colonnes de l'export. Depuis la suppression du contact inline (refonte
* contact, D2), les colonnes de contact principal sont retirees : l'export
* ne porte plus que les donnees propres au Client. SIREN inseree avant la
* date de creation, uniquement si l'utilisateur a accounting.view.
*
* @return list<string>
@@ -95,11 +97,6 @@ final class ClientExportController
{
$headers = [
'Nom entreprise',
'Nom contact principal',
'Prénom',
'Téléphone principal',
'Téléphone secondaire',
'Email',
'Catégories',
'Sites',
];
@@ -123,11 +120,6 @@ final class ClientExportController
foreach ($clients as $client) {
$row = [
$client->getCompanyName(),
$client->getLastName(),
$client->getFirstName(),
$client->getPhonePrimary(),
$client->getPhoneSecondary(),
$client->getEmail(),
$this->formatCategories($client),
$this->formatSites($client),
];
@@ -112,10 +112,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$gso, $gsoIsNew] = $this->ensureClient(
$manager,
companyName: 'Distrib Grand Sud-Ouest',
firstName: 'Paul',
lastName: 'Garnier',
phonePrimary: '05 56 10 20 30',
email: 'contact@distrib-gso.fr',
categoryNames: ['Distributeur'],
);
if ($gsoIsNew) {
@@ -127,10 +123,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$leonard, $leonardIsNew] = $this->ensureClient(
$manager,
companyName: 'Cabinet Léonard Assurances',
firstName: 'Sophie',
lastName: 'Léonard',
phonePrimary: '05 49 11 22 33',
email: 'contact@cabinet-leonard.fr',
categoryNames: ['Courtier'],
);
if ($leonardIsNew) {
@@ -142,10 +134,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$dubois, $isNew] = $this->ensureClient(
$manager,
companyName: 'Menuiserie Dubois',
firstName: 'Jean',
lastName: 'Dubois',
phonePrimary: '05 49 00 00 01',
email: 'contact@menuiserie-dubois.fr',
categoryNames: ['BTP'],
);
if ($isNew) {
@@ -159,10 +147,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$garage, $isNew] = $this->ensureClient(
$manager,
companyName: 'Garage Martin',
firstName: 'Luc',
lastName: 'Martin',
phonePrimary: '05 56 44 55 66',
email: 'accueil@garage-martin.fr',
categoryNames: ['Services'],
);
if ($isNew) {
@@ -175,10 +159,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$boulangerie, $isNew] = $this->ensureClient(
$manager,
companyName: 'Boulangerie Lemoine',
firstName: 'Marie',
lastName: 'Lemoine',
phonePrimary: '05 49 77 88 99',
email: 'bonjour@boulangerie-lemoine.fr',
categoryNames: ['Agro-alimentaire'],
);
if ($isNew) {
@@ -191,10 +171,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$transports, $isNew] = $this->ensureClient(
$manager,
companyName: 'Transports Rapides',
firstName: null,
lastName: 'Bernard',
phonePrimary: '05 56 12 13 14',
email: 'exploitation@transports-rapides.fr',
categoryNames: ['Transport/Logistique'],
);
if ($isNew) {
@@ -209,10 +185,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$industries, $isNew] = $this->ensureClient(
$manager,
companyName: 'Industries Vertes',
firstName: 'Claire',
lastName: 'Moreau',
phonePrimary: '05 49 21 22 23',
email: 'contact@industries-vertes.fr',
categoryNames: ['Industrie'],
);
if ($isNew) {
@@ -229,12 +201,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$agro, $isNew] = $this->ensureClient(
$manager,
companyName: 'Agro Distribution Sud',
firstName: 'Thomas',
lastName: 'Petit',
phonePrimary: '05 56 31 32 33',
email: 'contact@agro-sud.fr',
categoryNames: ['Agro-alimentaire'],
phoneSecondary: '06 01 02 03 04',
);
if ($isNew) {
$this->addContact($agro, 'Thomas', 'Petit', 'Directeur des achats', '05 56 31 32 33', '06 01 02 03 04', 'thomas.petit@agro-sud.fr', 0);
@@ -247,10 +214,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$ancienne, $isNew] = $this->ensureClient(
$manager,
companyName: 'Ancienne Société Oubliée',
firstName: null,
lastName: 'Durand',
phonePrimary: '05 49 99 99 99',
email: 'contact@ancienne-societe.fr',
categoryNames: ['Association'],
isArchived: true,
);
@@ -263,10 +226,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$services, $isNew] = $this->ensureClient(
$manager,
companyName: 'Services Pro Conseil',
firstName: 'Nadia',
lastName: 'Benali',
phonePrimary: '05 49 41 42 43',
email: 'contact@services-pro.fr',
categoryNames: ['Services'],
);
if ($isNew) {
@@ -279,10 +238,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$holding, $isNew] = $this->ensureClient(
$manager,
companyName: 'Holding Premium Invest',
firstName: 'Antoine',
lastName: 'Lefèvre',
phonePrimary: '05 56 51 52 53',
email: 'direction@holding-premium.fr',
categoryNames: ['Industrie'],
);
if ($isNew) {
@@ -301,10 +256,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$conglo, $isNew] = $this->ensureClient(
$manager,
companyName: 'Conglomérat Multi Activités',
firstName: 'Hélène',
lastName: 'Faure',
phonePrimary: '05 49 61 62 63',
email: 'contact@conglomerat-multi.fr',
categoryNames: ['BTP', 'Industrie', 'Services'],
);
if ($isNew) {
@@ -316,10 +267,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$prospect, $isNew] = $this->ensureClient(
$manager,
companyName: 'Prospect Futur Client',
firstName: 'Olivier',
lastName: 'Renard',
phonePrimary: '05 56 71 72 73',
email: 'olivier.renard@prospect-futur.fr',
categoryNames: ['BTP'],
);
if ($isNew) {
@@ -331,10 +278,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
[$association, $isNew] = $this->ensureClient(
$manager,
companyName: 'Association des Riverains',
firstName: null,
lastName: 'Caron',
phonePrimary: '05 49 81 82 83',
email: 'contact@asso-riverains.fr',
categoryNames: ['Association'],
);
if ($isNew) {
@@ -350,6 +293,9 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
* sinon retourne l'existant. Retourne [Client, isNew] : isNew=false bloque la
* reconstruction des sous-collections (idempotence sans doublon).
*
* Le contact principal n'est plus porte par le Client (refonte contact) : les
* coordonnees de contact sont fournies via addContact() dans le bloc isNew.
*
* @param list<string> $categoryNames
*
* @return array{0: Client, 1: bool}
@@ -357,12 +303,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
private function ensureClient(
ObjectManager $manager,
string $companyName,
?string $firstName,
?string $lastName,
string $phonePrimary,
string $email,
array $categoryNames,
?string $phoneSecondary = null,
bool $isArchived = false,
): array {
$normalizedName = (string) $this->normalizer->normalizeCompanyName($companyName);
@@ -374,11 +315,6 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$client = new Client();
$client->setCompanyName($normalizedName);
$client->setFirstName($this->normalizer->normalizePersonName($firstName));
$client->setLastName($this->normalizer->normalizePersonName($lastName));
$client->setPhonePrimary((string) $this->normalizer->normalizePhone($phonePrimary));
$client->setPhoneSecondary($this->normalizer->normalizePhone($phoneSecondary));
$client->setEmail((string) $this->normalizer->normalizeEmail($email));
foreach ($categoryNames as $categoryName) {
$client->addCategory($this->category($manager, $categoryName));
@@ -103,9 +103,11 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
}
/**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
* litteraux.
* Recherche fuzzy insensible a la casse sur companyName (D1, refonte contact).
* Depuis la suppression du contact inline du Client, la recherche ne porte
* plus que sur le nom d'entreprise (les anciens criteres lastName / email
* vivaient sur les colonnes inline disparues). Les metacaracteres LIKE
* (%, _, \) saisis sont echappes pour rester litteraux.
*/
private function applySearch(QueryBuilder $qb, ?string $search): void
{
@@ -116,11 +118,9 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere(
'LOWER(c.companyName) LIKE :search '
.'OR LOWER(c.lastName) LIKE :search '
.'OR LOWER(c.email) LIKE :search',
)->setParameter('search', $pattern);
$qb->andWhere('LOWER(c.companyName) LIKE :search')
->setParameter('search', $pattern)
;
}
/**
@@ -166,14 +166,12 @@ final class ColumnCommentsCatalog
],
'client' => [
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
'id' => 'Identifiant interne auto-incremente.',
'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).',
'first_name' => 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
'last_name' => 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).',
'phone_primary' => 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.',
'phone_secondary' => 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).',
'email' => 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).',
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
'id' => 'Identifiant interne auto-incremente.',
'company_name' => 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).',
// Contact principal inline supprime (refonte contact) : first_name,
// last_name, phone_primary, phone_secondary, email vivent desormais
// uniquement sur client_contact.
'distributor_id' => 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.',
'broker_id' => 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.',
'triage_service' => 'Drapeau service triage active pour le client. Faux par defaut.',
@@ -126,9 +126,6 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
// Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait
// produit le ClientProcessor via l'API.
$client->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$client->setLastName('Seed');
$client->setPhonePrimary('0102030405');
$client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test');
$client->addCategory($this->createCategory($categoryCode));
$client->setIsArchived($isArchived);
if ($isArchived) {
+11 -57
View File
@@ -25,7 +25,7 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
public function testPostNormalizesTextFields(): void
public function testPostNormalizesCompanyName(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
@@ -33,23 +33,18 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$response = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'acme sas',
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
'categories' => ['/api/categories/'.$cat->getId()],
'companyName' => 'acme sas',
'categories' => ['/api/categories/'.$cat->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
// RG-1.18 / 1.19 / 1.20 / 1.21
// RG-1.18 : companyName normalise en MAJUSCULES. Les champs de contact
// inline ont disparu (refonte contact) -> plus de normalisation ici.
self::assertSame('ACME SAS', $data['companyName']);
self::assertSame('Jean', $data['firstName']);
self::assertSame('Dupont', $data['lastName']);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
self::assertArrayNotHasKey('firstName', $data);
self::assertArrayNotHasKey('email', $data);
self::assertFalse($data['isArchived']);
}
@@ -60,41 +55,18 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$iri = '/api/categories/'.$cat->getId();
$payload = [
'companyName' => 'Doublon SARL',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'dup@test.fr',
'categories' => [$iri],
'companyName' => 'Doublon SARL',
'categories' => [$iri],
];
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(201);
// Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16).
$payload['email'] = 'dup2@test.fr';
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
self::assertResponseStatusCodeSame(409);
}
public function testPostWithoutFirstOrLastNameReturns422(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Contact Name',
'phonePrimary' => '0102030405',
'email' => 'nc@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
]);
// RG-1.01
self::assertResponseStatusCodeSame(422);
}
public function testPostWithoutCategoryReturns422(): void
{
$client = $this->createAdminClient();
@@ -102,11 +74,8 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
$client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Category',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'nocat@test.fr',
'categories' => [],
'companyName' => 'No Category',
'categories' => [],
],
]);
@@ -124,9 +93,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Mutex Client',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'mutex@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(),
'broker' => '/api/clients/'.$distributor->getId(),
@@ -147,9 +113,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Bad Distrib Ref',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'baddistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$notDistro->getId(),
],
@@ -169,9 +132,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Client Avec Distrib',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'okdistrib@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'distributor' => '/api/clients/'.$distributor->getId(),
],
@@ -190,9 +150,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Bad Broker Ref',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'badbroker@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'broker' => '/api/clients/'.$notBroker->getId(),
],
@@ -212,9 +169,6 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Client Avec Courtier',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'okbroker@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'broker' => '/api/clients/'.$broker->getId(),
],
@@ -68,9 +68,6 @@ final class ClientAuditTest extends AbstractCommercialApiTestCase
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Blamable Co',
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'blamable@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
@@ -7,12 +7,15 @@ namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
/**
* Tests fonctionnels du formulaire principal — combler les trous (ERP-60).
* Tests fonctionnels du formulaire principal apres la refonte contact.
*
* RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs
* + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les
* reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire),
* non encore testee.
* RG-1.01 (prenom OU nom) et RG-1.02 (telephone secondaire) ont ete SUPPRIMEES
* du Client : le contact principal n'est plus porte inline, il vit uniquement
* dans ClientContact (onglet Contact). Ce fichier verifie que :
* - le formulaire principal se cree avec les seuls champs subsistants
* (companyName + categories), sans aucun champ de contact ;
* - les anciens champs de contact (firstName, lastName, phonePrimary,
* phoneSecondary, email) ne sont plus exposes ni persistes.
*
* @internal
*/
@@ -21,11 +24,10 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
private const string LD = 'application/ld+json';
/**
* RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes
* distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur
* la colonne secondaire.
* Le formulaire principal n'exige plus que companyName + au moins une
* categorie (RG-1.16 / RG sur categories). Aucun champ de contact requis.
*/
public function testPostPersistsSecondaryPhoneNormalized(): void
public function testPostMainFormWithoutContactFields(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
@@ -33,26 +35,28 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
$data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Two Phones SARL',
'firstName' => 'A',
'phonePrimary' => '06.12.34.56.78',
'phoneSecondary' => '05 49 00 11 22',
'email' => 'twophones@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
'companyName' => 'Main Form SARL',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('0549001122', $data['phoneSecondary']);
self::assertSame('MAIN FORM SARL', $data['companyName']);
// Les champs de contact inline ont disparu de la representation.
self::assertArrayNotHasKey('firstName', $data);
self::assertArrayNotHasKey('lastName', $data);
self::assertArrayNotHasKey('phonePrimary', $data);
self::assertArrayNotHasKey('phoneSecondary', $data);
self::assertArrayNotHasKey('email', $data);
}
/**
* RG-1.02 : maximum 2 telephones — le modele n'expose que phonePrimary et
* phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est
* ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero.
* Les anciens champs de contact envoyes par un appel API direct (payload
* historique) sont ignores par le denormaliseur : ils n'apparaissent pas
* dans la representation et ne creent aucune colonne sur le client.
*/
public function testThirdPhoneFieldIsIgnored(): void
public function testLegacyContactFieldsAreIgnored(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
@@ -60,25 +64,25 @@ final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
$data = $client->request('POST', '/api/clients', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Third Phone SARL',
'firstName' => 'A',
'companyName' => 'Legacy Fields SARL',
'firstName' => 'Ignored',
'lastName' => 'Ignored',
'phonePrimary' => '0612345678',
'phoneSecondary' => '0549001122',
'phoneTertiary' => '0700000000',
'email' => 'thirdphone@test.fr',
'email' => 'ignored@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// Le champ inconnu est ignore par le denormaliseur : il n'apparait pas
// dans la representation et n'a pas ete persiste.
self::assertArrayNotHasKey('phoneTertiary', $data);
self::assertArrayNotHasKey('firstName', $data);
self::assertArrayNotHasKey('phonePrimary', $data);
self::assertArrayNotHasKey('email', $data);
// Confirmation cote base : seules les 2 colonnes telephone existent.
// Confirmation cote base : le client cree ne porte aucun contact inline
// (les colonnes n'existent plus, l'entite n'a plus les proprietes).
$persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']);
self::assertNotNull($persisted);
self::assertSame('0612345678', $persisted->getPhonePrimary());
self::assertSame('0549001122', $persisted->getPhoneSecondary());
self::assertSame('LEGACY FIELDS SARL', $persisted->getCompanyName());
}
}
@@ -14,10 +14,41 @@ namespace App\Tests\Module\Commercial\Api;
* - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active
* (RG-1.17) ont ete supprimes / ne sont jamais crees.
*
* Verifie aussi la refonte contact (Version20260603120000) : les 5 colonnes de
* contact principal inline ont ete supprimees de la table `client`.
*
* @internal
*/
final class ClientMigrationTest extends AbstractCommercialApiTestCase
{
/**
* Refonte contact : first_name / last_name / phone_primary / phone_secondary
* / email ne doivent plus exister sur la table `client` (deplaces vers
* client_contact). NB : le backfill de la migration ne s'exerce que sur une
* base portant des donnees pre-refonte ; sur le schema de test (table client
* vierge au moment de la migration) il est un no-op, donc non assertable ici
* au runtime — seul l'etat de schema final est verifie.
*/
public function testInlineContactColumnsAreDropped(): void
{
self::bootKernel();
/** @var list<array{column_name: string}> $columns */
$columns = $this->getEm()->getConnection()->fetchAllAssociative(
"SELECT column_name FROM information_schema.columns "
."WHERE table_schema = 'public' AND table_name = 'client'",
);
$names = array_map(static fn (array $r): string => $r['column_name'], $columns);
foreach (['first_name', 'last_name', 'phone_primary', 'phone_secondary', 'email'] as $dropped) {
self::assertNotContains(
$dropped,
$names,
sprintf('La colonne client.%s aurait du etre supprimee (refonte contact).', $dropped),
);
}
}
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
{
$rows = $this->clientIndexes();
@@ -324,9 +324,9 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
}
/**
* Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ;
* une categorie SECTEUR). Si $categoryId est null, une categorie est creee a
* la volee.
* Payload minimal valide de l'onglet principal (companyName + une categorie
* SECTEUR ; le contact inline a ete supprime). Si $categoryId est null, une
* categorie est creee a la volee.
*
* @return array<string, mixed>
*/
@@ -335,11 +335,8 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
$categoryId ??= $this->createCategory('SECTEUR')->getId();
return [
'companyName' => $companyName,
'firstName' => 'Jean',
'phonePrimary' => '0612345678',
'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test',
'categories' => ['/api/categories/'.$categoryId],
'companyName' => $companyName,
'categories' => ['/api/categories/'.$categoryId],
];
}
}
@@ -232,9 +232,6 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas
$client = new ClientEntity();
$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'));
// Bloc comptable non nul (gating par omission cote Commerciale).
$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
* ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier
* verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee)
* et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques.
* n'est PLUS contraint unique. (L'email RG-1.17 — a disparu du Client avec la
* refonte contact, il vit desormais sur ClientContact.)
*
* @internal
*/
final class ClientUniquenessTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
/**
* RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme
* email principal — aucune contrainte d'unicite (un email peut servir
* plusieurs clients).
*/
public function testDuplicateEmailIsAllowed(): void
{
$client = $this->createAdminClient();
$cat = $this->createCategory('SECTEUR');
$iri = '/api/categories/'.$cat->getId();
$payload = static fn (string $name): array => [
'companyName' => $name,
'firstName' => 'A',
'phonePrimary' => '0102030405',
'email' => 'partage@test.fr',
'categories' => [$iri],
];
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]);
self::assertResponseStatusCodeSame(201);
// Meme email, nom different -> doit passer (pas d'index unique email).
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]);
self::assertResponseStatusCodeSame(201);
}
/**
* RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements
* multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on
@@ -134,14 +134,11 @@ final class ClientProcessorTest extends TestCase
'isArchived' => false,
],
managed: true,
// Etat persiste complet (valeurs normalisees) : sans les champs
// metier, guardManage (ERP-74) les croirait modifies (companyName,
// lastName... compares a null) et leverait un 403 parasite.
// Etat persiste (valeurs normalisees) : sans companyName, guardManage
// (ERP-74) le croirait modifie (compare a null) et leverait un 403
// parasite.
originalData: [
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
@@ -164,13 +161,10 @@ final class ClientProcessorTest extends TestCase
managed: true,
// getOriginalEntityData renvoie tous les champs mappes d'une entite
// geree : isArchived (non-null) y figure toujours, ainsi que les
// champs metier (sinon guardManage les croirait modifies).
// champs metier (companyName) sinon guardManage les croirait modifies.
originalData: [
'siren' => '123456789',
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
@@ -193,9 +187,6 @@ final class ClientProcessorTest extends TestCase
managed: true,
originalData: [
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
@@ -220,9 +211,6 @@ final class ClientProcessorTest extends TestCase
originalData: [
'siren' => '111111111',
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
@@ -324,9 +312,6 @@ final class ClientProcessorTest extends TestCase
managed: true,
originalData: [
'companyName' => 'TEST CO',
'lastName' => 'Dupont',
'phonePrimary' => '0102030405',
'email' => 't@test.fr',
'triageService' => false,
'isArchived' => false,
],
@@ -401,16 +386,14 @@ final class ClientProcessorTest extends TestCase
}
/**
* Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) suffisant
* pour atteindre les validations testees.
* Client minimal companyName seul depuis la suppression du contact inline.
* Suffisant pour atteindre les validations testees (le contact vit desormais
* dans ClientContact, hors scope du ClientProcessor).
*/
private function minimalClient(): Client
{
$client = new Client();
$client->setCompanyName('Test Co');
$client->setLastName('Dupont');
$client->setPhonePrimary('0102030405');
$client->setEmail('t@test.fr');
return $client;
}