Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9507664bd0 | |||
| 0c9b563cae | |||
| b495e4030a | |||
| 56cf492dcc | |||
| d9023ec9b9 | |||
| ddf874a4e1 | |||
| a25ddac466 | |||
| df8e44fcfa | |||
| 5bdd63cc6c | |||
| ad20d1f4c9 | |||
| 0c6919201e | |||
| 3e46394be1 | |||
| 1d91b4dea9 | |||
| c402418937 | |||
| 2866fb8865 | |||
| 0ed131ce57 | |||
| a948eed9b6 | |||
| fc78f434d1 | |||
| 53e19d61ac | |||
| ece8146c03 | |||
| 58589e93d0 | |||
| e0d59962d6 | |||
| 3ce40a707f | |||
| 9613857650 | |||
| 2a0918bbfe |
@@ -13,6 +13,64 @@
|
||||
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
|
||||
- **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
||||
|
||||
## Pagination (obligatoire)
|
||||
|
||||
**Regle** : toute collection API DOIT etre paginee. Aucun retour de collection complete cote serveur.
|
||||
|
||||
### Standard global
|
||||
|
||||
Pose dans `config/packages/api_platform.yaml` (section `defaults:`) et heritee par toutes les ressources :
|
||||
|
||||
| Cle | Valeur | Effet |
|
||||
|---|---|---|
|
||||
| `pagination_enabled` | `true` | Pagination Hydra active par defaut. |
|
||||
| `pagination_items_per_page` | `10` | Taille de page par defaut, aligne sur l'UI `MalioDataTable`. |
|
||||
| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramene a 50. Anti deep-fetch. |
|
||||
| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornee par le max). |
|
||||
| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT recuperer (echappatoire selects). |
|
||||
|
||||
### Override par ressource (rare)
|
||||
|
||||
Si une ressource a besoin d'un autre defaut (ex: payload lourd), utiliser les attributs sur l'operation. **JAMAIS `paginationEnabled: false`** sans whitelist explicite dans `tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`.
|
||||
|
||||
```php
|
||||
new GetCollection(
|
||||
paginationItemsPerPage: 5, // override taille par defaut
|
||||
paginationMaximumItemsPerPage: 20, // override borne max
|
||||
)
|
||||
```
|
||||
|
||||
### Selects et autocompletions
|
||||
|
||||
Pour alimenter un `<select>` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe :
|
||||
|
||||
```ts
|
||||
useApi().get('/api/roles?pagination=false')
|
||||
```
|
||||
|
||||
Le serveur retourne toute la collection, sans `view`. C'est l'echappatoire prevue par `pagination_client_enabled: true`. Sur les ressources a forte volumetrie, preferer une saisie assistee (recherche serveur via `?q=`) — a planifier dans un ticket dedie.
|
||||
|
||||
Les tests fonctionnels qui exercent ce comportement doivent egalement passer `?pagination=false` (cf. `CategoryListTest`, `PermissionApiTest`).
|
||||
|
||||
### Providers customs et pagination
|
||||
|
||||
Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface` **court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportes :
|
||||
|
||||
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
|
||||
- **DBAL** : implementer un paginator local conforme a `PaginatorInterface`. Exemple : `DbalPaginator` (Core) + `AuditLogProvider`.
|
||||
|
||||
Gerer l'echappatoire `?pagination=false` :
|
||||
|
||||
```php
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult(); // tout retourner
|
||||
}
|
||||
```
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/CollectionsArePaginatedTest` scanne reflexivement toutes les classes `#[ApiResource]` sous `src/` et echoue si une `GetCollection` pose `paginationEnabled: false` hors whitelist `EXCLUDED`. Ajouter une entree a la whitelist requiert une justification courte + un ticket Lesstime ouvert.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
|
||||
@@ -74,3 +132,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
|
||||
## PostgreSQL
|
||||
|
||||
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
||||
|
||||
## Migrations Doctrine
|
||||
|
||||
### Documentation SQL obligatoire (`COMMENT ON COLUMN`)
|
||||
|
||||
**Toute migration qui cree ou modifie une colonne d'une table metier doit poser un `COMMENT ON COLUMN` decrivant le champ.** La description est stockee dans `pg_description` et visible dans tous les outils d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir a lire les annotations PHP.
|
||||
|
||||
**Format de la description** :
|
||||
- En francais
|
||||
- ≤ 200 caracteres
|
||||
- Semantique du champ — contraintes / lien RG si pertinent
|
||||
- Pour les colonnes d'identifiant ou FK, mentionner la cible
|
||||
|
||||
Exemples :
|
||||
|
||||
```php
|
||||
// Migration : creation d'une colonne avec son commentaire dans la meme migration
|
||||
$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL");
|
||||
$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'");
|
||||
|
||||
// Cas FK : preciser la cible
|
||||
$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'");
|
||||
|
||||
// Cas booleen : preciser le sens et la valeur par defaut
|
||||
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
|
||||
|
||||
// Bonus : decrire la table elle-meme
|
||||
$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'");
|
||||
```
|
||||
|
||||
### Helper Timestampable/Blamable
|
||||
|
||||
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutees par `TimestampableBlamableTrait` recoivent une description **standardisee** via le helper centralise pour eviter la duplication. Helper a creer ou appeler :
|
||||
|
||||
```php
|
||||
// Dans la migration, apres avoir ajoute les 4 colonnes :
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'client');
|
||||
```
|
||||
|
||||
L'implementation du helper applique :
|
||||
- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). »
|
||||
- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations hors HTTP (CLI, migration, fixture). FK -> user.id, ON DELETE SET NULL. »
|
||||
- `updated_by` : « ID de l'utilisateur ayant modifie la ligne en dernier — null pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. »
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtre sur le schema `public` et echoue si **une seule colonne** n'a pas de `col_description`. Seules les tables system (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de justification + ticket Lesstime ouvert pour le retrofit) sont tolerees.
|
||||
|
||||
Conclusion : si tu crees une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
|
||||
|
||||
@@ -53,6 +53,53 @@ Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer
|
||||
|
||||
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
|
||||
|
||||
## Listes paginees (standard) — usePaginatedList obligatoire
|
||||
|
||||
**Toute liste qui consomme une `GetCollection` API doit passer par `usePaginatedList`** (`frontend/shared/composables/usePaginatedList.ts`). Le composable est le pendant front de la regle ABSOLUE n°13 (« toute collection est paginee cote back ») : il consomme l'envelope Hydra (`member` / `totalItems` / `view`) et expose un etat reactif a brancher directement sur `MalioDataTable`.
|
||||
|
||||
Pattern de reference :
|
||||
|
||||
```ts
|
||||
const {
|
||||
items,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadList,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<MyEntity>({ url: '/my-resources' })
|
||||
|
||||
onMounted(loadList)
|
||||
```
|
||||
|
||||
```vue
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:empty-message="t('foo.empty')"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
```
|
||||
|
||||
Garanties offertes par le composable :
|
||||
- Force `Accept: application/ld+json` → API Platform 4 renvoie bien `member` / `totalItems` (sans Accept, retour tableau plat sans pagination).
|
||||
- Defaut 10 items/page, choix client 10 / 25 / 50, aligne sur le defaut serveur.
|
||||
- Mutation `setFilters` / `setSort` / `setItemsPerPage` → retombe systematiquement en page 1.
|
||||
- Cas limite « page hors borne apres filtre » : retombe automatiquement sur la derniere page valide (`tests/usePaginatedList.test.ts`).
|
||||
- Etat 100 % local (refs internes a l'instance) — **jamais reflete dans l'URL**, conformement a la regle « Etat des tableaux — pas de persistance URL » ci-dessous.
|
||||
|
||||
A NE PAS faire :
|
||||
- Charger une collection complete via `?itemsPerPage=999` pour bypasser la pagination. Le seul cas legitime de retour complet est l'alimentation d'un `<select>` sur un referentiel ≤ quelques dizaines d'entrees, et il passe par `?pagination=false` (echappatoire prevue par `pagination_client_enabled: true`).
|
||||
- Reimplementer la pagination prev/next a la main au-dessus de `MalioDataTable` — le composant porte deja le selecteur items/page et les boutons Prev/Next.
|
||||
- Persister `page`/`tri`/`filtre` dans la query string — meme regle que pour `<MalioDataTable>` brut (cf. section suivante).
|
||||
|
||||
## Etat des tableaux — pas de persistance URL
|
||||
|
||||
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
|
||||
|
||||
@@ -73,12 +73,23 @@ jobs:
|
||||
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||
|
||||
- name: Bootstrap test database
|
||||
# Aligne sur la cible `test-db-setup` du makefile : apres
|
||||
# `schema:update --force`, on RECREE manuellement l'index unique
|
||||
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
|
||||
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
|
||||
# deleted_at IS NULL) et `schema:update` les considere comme
|
||||
# orphelins et les DROP — collisions non detectees, tests d'unicite
|
||||
# qui attendent 409 recoivent 201.
|
||||
run: |
|
||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||
# Rejoue le catalogue COMMENT ON apres schema:update (cf. ERP-67) :
|
||||
# schema:update drop les commentaires des tables managees par l'ORM.
|
||||
php bin/console app:apply-column-comments --env=test --no-interaction
|
||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||
php bin/console app:sync-permissions --env=test --no-interaction
|
||||
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||
|
||||
@@ -24,6 +24,9 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
||||
9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation.
|
||||
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
||||
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
||||
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
|
||||
13. **Toujours paginer toute collection exposee par l'API.** Aucun retour de collection complete (pas de provider qui retourne un array brut). Standard pose dans `config/packages/api_platform.yaml` : 10 items par defaut, max 50, le client peut basculer entre 10/25/50 et peut envoyer `?pagination=false` pour alimenter un select. Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist. Details et exemples : @.claude/rules/backend.md § Pagination.
|
||||
14. **`symfony.lock` est versionne** (au meme titre que `composer.lock`) — ne JAMAIS le `.gitignore`. C'est le registre des recipes Flex appliquees : sans lui, chaque `composer require` rejoue toutes les recipes et repollue `.env`, `config/bundles.php`, `docker-compose.yml` et recree du scaffolding parasite (`src/Entity/`, `src/Controller/`...). Le regenerer si besoin via `composer recipes:install --force`.
|
||||
|
||||
## Conventions
|
||||
@.claude/rules/architecture.md
|
||||
@@ -52,6 +55,7 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
|
||||
## A NE PAS faire
|
||||
|
||||
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
|
||||
- Pas de provider API Platform qui retourne un array brut sur une `GetCollection` — court-circuite la pagination Hydra (`totalItems` / `view` absents). Utiliser `ApiPlatform\Doctrine\Orm\Paginator` (ORM) ou un paginator implementant `PaginatorInterface` (DBAL — cf. `DbalPaginator`).
|
||||
- Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur).
|
||||
- Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`.
|
||||
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
|
||||
|
||||
@@ -21,3 +21,18 @@ api_platform:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
# === Pagination Hydra (regle projet : toute collection DOIT etre paginee) ===
|
||||
# Standard datatable : 10 items par defaut, choix client 10 / 25 / 50.
|
||||
# Borne dure cote serveur a 50 pour prevenir tout `?itemsPerPage=999999`
|
||||
# (attaque memoire / deep-fetch). Le client peut neanmoins desactiver la
|
||||
# pagination via `?pagination=false` pour alimenter un <select> ou autre
|
||||
# vue "tout-en-un" — c'est l'echappatoire prevue pour les ressources
|
||||
# servant a la fois de datatable et de source de select (Role,
|
||||
# Permission, Site, CategoryType). Override par ressource possible via
|
||||
# `paginationItemsPerPage` / `paginationMaximumItemsPerPage` /
|
||||
# `paginationEnabled` sur l'attribut #[ApiResource] ou sur une operation.
|
||||
pagination_enabled: true
|
||||
pagination_items_per_page: 10
|
||||
pagination_maximum_items_per_page: 50
|
||||
pagination_client_items_per_page: true
|
||||
pagination_client_enabled: true
|
||||
|
||||
@@ -37,6 +37,10 @@ doctrine:
|
||||
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
|
||||
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
|
||||
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
|
||||
# Cible des ManyToMany Client.categories / ClientAddress.categories (M1).
|
||||
# Permet au module Commercial de referencer une Category via le contrat
|
||||
# Shared sans importer la classe concrete du module Catalog (regle n°1).
|
||||
App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category
|
||||
mappings:
|
||||
Core:
|
||||
type: attribute
|
||||
@@ -66,6 +70,16 @@ doctrine:
|
||||
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
|
||||
prefix: 'App\Module\Catalog\Domain\Entity'
|
||||
alias: Catalog
|
||||
# Mapping inconditionnel du module Commercial (meme logique que Catalog) :
|
||||
# les tables (client, sous-collections, referentiels comptables) creees
|
||||
# par la migration M1 (Version20260601000000) doivent etre connues de
|
||||
# l'ORM. L'activation fonctionnelle passe par config/modules.php.
|
||||
Commercial:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Commercial/Domain/Entity'
|
||||
prefix: 'App\Module\Commercial\Domain\Entity'
|
||||
alias: Commercial
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.48'
|
||||
app.version: '0.1.59'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M1
|
||||
nom: "Répertoire clients"
|
||||
ecran: repertoire-clients
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0
|
||||
date_redaction: 2026-05-28
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
|
||||
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT #1 ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-22
|
||||
canal: ecrit
|
||||
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||
resume: "Module 1 — Répertoire clients. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders blancs)."
|
||||
trace_archivee: "uploads/4a1b026f-M1-reportoire-clients.docx (V0 d'origine .docx)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 23
|
||||
lesstime_project_id: 6
|
||||
statut_global: en_dev
|
||||
---
|
||||
|
||||
# Module 1 — Répertoire clients (V0 front)
|
||||
|
||||
> **Origine** : spec front V0 livrée le 22/05/2026 (`M1-reportoire-clients.docx`). Restitution Markdown pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute précision et toute décision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||
|
||||
## But
|
||||
|
||||
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des clients** de l'organisation : consultation, création, modification, archivage. Cette page est la **porte d'entrée du module Commercial**.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire clients »
|
||||
- **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** | ❌ | ❌ | ❌ |
|
||||
|
||||
> **Note** : aligné sur le docx d'origine — Compta édite uniquement l'onglet Comptabilité (champs SIREN / TVA / Délai de règlement / Type de règlement / Banque / RIBs). Compta ne peut pas **créer** un client (pas de droit `manage` général), mais peut éditer la partie comptable d'un client existant créé par Admin ou Bureau.
|
||||
|
||||
## Navigation
|
||||
|
||||
L'écran est la page d'entrée du module **Commercial**. Titre : « **Répertoire clients** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les clients **actifs** de l'organisation (les clients archivés sont masqués par défaut — filtre UI dédié pour les voir).
|
||||
- **Clic sur une ligne** → bascule sur l'écran **Consultation client** (page dédiée, pas un drawer — cf. maquette Figma).
|
||||
- **Bouton « + Ajouter »** (en haut à droite) → bascule sur l'écran **Ajouter un client**.
|
||||
- **Bouton « Exporter »** (en haut à droite) → télécharge un **fichier XLSX** des clients **affichés** (cf. filtre actif). Format détaillé dans [`spec-back.md` § Export](./spec-back.md).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maquette) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom entreprise** | `client.companyName` | ASC par défaut |
|
||||
| **Contact principal** | `firstName + lastName` | Oui |
|
||||
| **Téléphone principal** | `phonePrimary` (formaté `XX XX XX XX XX`) | Non |
|
||||
| **Email principal** | `email` | Oui |
|
||||
| **Catégories** | liste des codes catégories séparés par `,` | Non |
|
||||
| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
|
||||
|
||||
> **Filtre archivés** : toggle UI en haut du datatable. Désactivé par défaut. État local (pas dans l'URL — cf. règle ABSOLUE Starseed n°6).
|
||||
|
||||
> **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible — quelques centaines). Tri serveur `companyName ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un client »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour pouvoir passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant**, et les champs de l'onglet validé passent en lecture seule + bouton « Valider » désactivé (disabled).
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
|
||||
| **Nom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Prénom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
|
||||
| **Téléphone principal** | `<MalioInputText>` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) |
|
||||
| **Téléphone secondaire** | `<MalioInputText>` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. |
|
||||
| **Email** | `<MalioInputText>` type email | Oui | RG-1.21 (lowercase) |
|
||||
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
|
||||
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de type `DISTRIBUTEUR`. RG-1.03. |
|
||||
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de type `COURTIER`. RG-1.03. |
|
||||
| **Prestation de triage** | `<MalioCheckbox>` | Non | — |
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ».
|
||||
|
||||
### Onglet « Information »
|
||||
|
||||
Saisir les informations de l'entreprise.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
|
||||
| **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
|
||||
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
|
||||
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
|
||||
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
|
||||
|
||||
### 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).
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-1.20 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-1.21 (lowercase) |
|
||||
|
||||
**RG-1.14 (renforcement validée par Tristan le 28/05)** : **au moins 1 bloc Contact valide** (au moins Nom OU Prénom rempli) est obligatoire pour valider l'onglet. Donc l'onglet Contact ne peut pas être finalisé vide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. Bouton **désactivé tant que le bloc précédent n'a pas Prénom OU Nom rempli** (RG-1.05).
|
||||
- « Supprimer » (icône) sur un bloc : modal de confirmation (`<MalioButton>` Annuler / Confirmer). Si Oui → suppression du bloc.
|
||||
- « Valider » → PATCH `/api/clients/{id}/contacts` (création/mise à jour de la collection).
|
||||
|
||||
### Onglet « Adresse »
|
||||
|
||||
Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites Starseed (Châtellerault 86 / Saint-Jean 17 / Pommevic 82) et à des contacts.
|
||||
|
||||
**Bloc Adresse** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Prospect** | `<MalioCheckbox>` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché |
|
||||
| **Adresse de livraison** | `<MalioCheckbox>` | Non | RG-1.07 — masque Prospect si coché |
|
||||
| **Facturation** | `<MalioCheckbox>` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de **type SECTEUR + AUTRE** uniquement (cf. décision Q5 — DISTRIBUTEUR et COURTIER qualifient une relation entre clients, pas un lieu) |
|
||||
| **Pays** | `<MalioSelect>` | Oui | Préremplie « France » |
|
||||
| **Code postal** | `<MalioInputText>` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN |
|
||||
| **Ville** | `<MalioSelect>` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-1.09 — autocomplete BAN |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
| **Sites Starseed** | `<MalioSelectCheckbox>` (multi-checkbox 86 / 17 / 82) | Oui | RG-1.10 — ≥ 1 site obligatoire |
|
||||
| **Contact(s) rattaché(s)** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||
| **Email (facturation)** | `<MalioInputText>` type email | Conditionnel | RG-1.11 — visible/obligatoire uniquement si « Facturation » coché |
|
||||
|
||||
**Actions** :
|
||||
- « + Nouvelle Adresse » : ajoute un bloc identique.
|
||||
- « Supprimer » : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/clients/{id}/addresses`.
|
||||
|
||||
### Onglet « Transport »
|
||||
|
||||
🚧 **Placeholder blanc au M1.** Frame vide. Aucun champ. Aucun bouton de validation. L'utilisateur passe automatiquement à l'onglet suivant. **Pas de mention « En cours »** — c'est juste blanc (décision Tristan 28/05).
|
||||
|
||||
### Onglet « Comptabilité »
|
||||
|
||||
⚠ **Accessible aux rôles avec `commercial.clients.accounting.manage`** (Admin + Compta au M1). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer cet onglet** (champs SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) — cf. décision Q1, aligné docx. Compta ne peut pas créer un client (pas de `manage` général).
|
||||
|
||||
**Champs comptables** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | Format 9 chiffres. **Pas d'unicité** (décision Q4) |
|
||||
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` |
|
||||
| **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-1.12 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks`. |
|
||||
|
||||
**Bloc RIB** (0..n blocs, présence obligatoire conditionnée par RG-1.13) :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 |
|
||||
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
|
||||
**Actions** :
|
||||
- « + RIB » : ajoute un bloc.
|
||||
- « Supprimer » (icône) : modal de confirmation.
|
||||
- « Valider » → PATCH `/api/clients/{id}/accounting`.
|
||||
|
||||
### Onglets « Statistiques » / « Rapports » / « Échanges »
|
||||
|
||||
🚧 **Placeholders blancs au M1.** Mêmes règles que Transport (frames vides, pas de validation).
|
||||
|
||||
## Écran « Consultation client »
|
||||
|
||||
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans bouton `+` pour ajouter des blocs Contact / Adresse / RIB.
|
||||
|
||||
- **Flèche retour** (à gauche) → revient au Répertoire.
|
||||
- **Bouton « Modifier »** (à droite, visible si l'utilisateur a la permission `commercial.clients.manage`) → bascule sur l'écran Modification.
|
||||
- **Bouton « Archiver »** (à droite, visible **uniquement pour Admin** via permission `commercial.clients.archive`) → ouvre une modal de confirmation, puis PATCH `/api/clients/{id}` `{ "isArchived": true }`. Le client passe en archivé (cf. flag `is_archived`).
|
||||
|
||||
> Le client archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. Décision validée Tristan 28/05.
|
||||
|
||||
### Onglets affichés en consultation
|
||||
|
||||
Mêmes onglets qu'en création, **plus** les 4 placeholders blancs. L'utilisateur navigue librement entre les onglets (pas de séquence forcée en consultation).
|
||||
|
||||
## Écran « Modification client »
|
||||
|
||||
Comportement identique à l'écran Ajouter sauf :
|
||||
- **Pas de formulaire principal** (les champs principaux sont é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` restent en lecture seule (pas de bouton Valider, pas d'icône suppression de bloc).
|
||||
- Les onglets placeholders restent inaccessibles à l'édition (blancs).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (Répertoire)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input numérique** : `<MalioInputNumber>`
|
||||
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
|
||||
- **TextArea** : `<MalioInputTextArea>` (Description)
|
||||
- **Select simple** : `<MalioSelect>` (Pays, Ville, distributeur/courtier, refs comptables)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Prospect, Adresse livraison, Facturation, Prestation de triage)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
|
||||
**Exceptions autorisées** (à commenter `// TODO migrer quand Malio couvre`) :
|
||||
- `<input type="date">` pour « Date de création » (composant `MalioDate` non couvert)
|
||||
- Modal de confirmation : composant à confirmer côté équipe front (probablement `<MalioModal>` ou un wrapper à créer dans `frontend/shared/`)
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
|
||||
| Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
|
||||
| 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()`.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.data.gouv.fr** (Base Adresse Nationale, gratuite, française).
|
||||
|
||||
- Composable dédié `useAddressAutocomplete()` (à créer en M1).
|
||||
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back.
|
||||
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse.
|
||||
- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre.
|
||||
|
||||
## Points laissés ouverts par la V0 (résolus côté back)
|
||||
|
||||
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. CategoryType seedé avec `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` (HP-3 du M0 levé). |
|
||||
| 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. |
|
||||
| 3 | Onglet « Comptabilité » : qui édite ? | **Admin et Compta** peuvent éditer l'onglet Comptabilité (`commercial.clients.accounting.manage`). Bureau / Commerciale ne voient pas l'onglet. Compta ne peut pas créer un client (pas de `manage` global), mais peut éditer la partie comptable d'un client existant. |
|
||||
| 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». |
|
||||
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
|
||||
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
|
||||
| 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. |
|
||||
| 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. |
|
||||
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. |
|
||||
| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). |
|
||||
| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6).
|
||||
|
||||
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||
@@ -1,9 +1,17 @@
|
||||
<!--
|
||||
Valeurs en dur issues de la maquette Figma (design Starseed) :
|
||||
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
|
||||
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
|
||||
- bande blanche sticky sous la navbar : 47px (h-[47px])
|
||||
A faire evoluer uniquement avec une mise a jour de maquette.
|
||||
-->
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<MalioSidebar
|
||||
v-model="ui.sidebarCollapsed"
|
||||
:sections="translatedSections"
|
||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||
>
|
||||
<template #logo>
|
||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||
@@ -16,10 +24,10 @@
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<SiteSelector v-if="showSiteSelector"/>
|
||||
<main
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/>
|
||||
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/>
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -88,12 +88,19 @@
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
"error": {
|
||||
"title": "Erreur",
|
||||
"message": "Impossible de charger le journal d'audit. Vérifiez les filtres ou réessayez."
|
||||
},
|
||||
"timeline": {
|
||||
"empty": "Aucun historique",
|
||||
"load_more": "Voir plus"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser",
|
||||
"date_range": "Date à date",
|
||||
"date_from": "Du",
|
||||
"date_to": "Au",
|
||||
"entity_type": "Type d'entité",
|
||||
@@ -223,6 +230,39 @@
|
||||
"updated": "Site mis à jour avec succès",
|
||||
"deleted": "Site supprimé avec succès"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"title": "Gestion des catégories",
|
||||
"newCategory": "Ajouter",
|
||||
"editCategory": "Modifier la catégorie",
|
||||
"createCategory": "Créer une catégorie",
|
||||
"viewCategory": "Détail de la catégorie",
|
||||
"noCategories": "Aucune catégorie pour l'instant.",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"type": "Type"
|
||||
},
|
||||
"form": {
|
||||
"name": "Nom",
|
||||
"type": "Type de catégorie",
|
||||
"typePlaceholder": "Sélectionner un type"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire.",
|
||||
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
|
||||
"typeRequired": "Le type de catégorie est obligatoire."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer la catégorie",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer la catégorie \"{name}\" ? Cette action est irréversible."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Catégorie créée avec succès",
|
||||
"updated": "Catégorie mise à jour avec succès",
|
||||
"deleted": "Catégorie supprimée avec succès",
|
||||
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
|
||||
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<MalioModal
|
||||
:model-value="modelValue"
|
||||
modal-class="max-w-md"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('admin.categories.delete.title') }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ t('admin.categories.delete.message', { name: categoryName }) }}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="secondary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="loading"
|
||||
@click="emit('confirm')"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
categoryName: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{ headerLabel }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
||||
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
||||
<MalioInputText
|
||||
v-model="form.name.value"
|
||||
:label="t('admin.categories.form.name')"
|
||||
input-class="w-full"
|
||||
:max-length="120"
|
||||
:error="form.errors.value.name"
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
||||
number (categoryType id) ; conversion en IRI au moment du save
|
||||
par le composable useCategoryForm. -->
|
||||
<MalioSelect
|
||||
v-model="form.categoryTypeId.value"
|
||||
:options="typeOptions"
|
||||
:label="t('admin.categories.form.type')"
|
||||
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
||||
:error="form.errors.value.categoryType"
|
||||
:disabled="loadingTypes"
|
||||
/>
|
||||
|
||||
<!-- Erreur transverse (typiquement reseau / 5xx) — separe des
|
||||
erreurs de validation par champ. -->
|
||||
<p v-if="form.errors.value._global" class="text-sm text-red-600">
|
||||
{{ form.errors.value._global }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="canShowDelete"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canShowSave"
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="form.submitting.value || loadingTypes"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
|
||||
// Instance dediee de form pour ce drawer — state isole (cf. useCategoryForm
|
||||
// n'est pas singleton, contrairement a useCategoriesAdmin).
|
||||
const form = useCategoryForm()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
category: Category | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Mode du drawer (dérivé du composable `useCategoryForm`) :
|
||||
* - 'create' : pas de category prop, formulaire vide, POST au save.
|
||||
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
|
||||
* jusqu'a ce que l'utilisateur modifie un champ.
|
||||
* - 'edit' : category prop set et formulaire « dirty » (au moins un
|
||||
* champ different de l'original), PATCH au save.
|
||||
*/
|
||||
type DrawerMode = 'create' | 'view' | 'edit'
|
||||
|
||||
const isCreateMode = computed(() => props.category === null)
|
||||
|
||||
const mode = computed<DrawerMode>(() => {
|
||||
if (isCreateMode.value) return 'create'
|
||||
return form.isDirty.value ? 'edit' : 'view'
|
||||
})
|
||||
|
||||
const headerLabel = computed(() => {
|
||||
if (mode.value === 'create') return t('admin.categories.createCategory')
|
||||
if (mode.value === 'edit') return t('admin.categories.editCategory')
|
||||
return t('admin.categories.viewCategory')
|
||||
})
|
||||
|
||||
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
|
||||
// existante et seulement pour les users ayant la permission manage. En mode
|
||||
// creation on affiche un bouton Annuler a la place.
|
||||
const canShowDelete = computed(
|
||||
() => !isCreateMode.value && can('catalog.categories.manage'),
|
||||
)
|
||||
|
||||
// Save : visible en creation, ou en edition (apres modification d'un champ).
|
||||
// Masque en view tant que rien n'a change.
|
||||
const canShowSave = computed(
|
||||
() => mode.value === 'create' || mode.value === 'edit',
|
||||
)
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
types.value.map(ct => ({
|
||||
label: ct.label,
|
||||
value: ct.id,
|
||||
})),
|
||||
)
|
||||
|
||||
// Re-initialise le form quand la categorie selectionnee change (clic sur une
|
||||
// autre ligne sans fermer le drawer entre-temps).
|
||||
watch(() => props.category, (cat) => {
|
||||
form.loadFrom(cat)
|
||||
}, { immediate: true })
|
||||
|
||||
// A chaque ouverture du drawer : reload du form + refresh des types (au cas
|
||||
// ou un type aurait ete ajoute en arriere-plan depuis le dernier fetch — pas
|
||||
// d'optimisation cache au M0, le referentiel est petit).
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
form.loadFrom(props.category)
|
||||
fetchTypes()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
|
||||
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
|
||||
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
|
||||
* refresh la liste.
|
||||
*/
|
||||
async function handleSave(): Promise<void> {
|
||||
let result: Category | null = null
|
||||
if (mode.value === 'create') {
|
||||
result = await form.submitCreate()
|
||||
} else if (mode.value === 'edit' && props.category) {
|
||||
result = await form.submitUpdate(props.category.id)
|
||||
}
|
||||
if (result) {
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
|
||||
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
||||
// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour
|
||||
// eviter de charger Pinia et la vraie store (pas necessaire ici).
|
||||
vi.mock('~/shared/stores/auth', () => ({
|
||||
onAuthSessionCleared: vi.fn(),
|
||||
}))
|
||||
|
||||
// Le client API est un auto-import Nuxt. On le remplace par un stub
|
||||
// global pour intercepter les appels et controler les reponses dans
|
||||
// chaque test (cf. pattern utilise dans useCurrentSite.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 vi.mock / vi.stubGlobal : le module n'est evalue qu'a
|
||||
// ce moment-la, donc le mock auth est bien actif au top-level.
|
||||
const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
|
||||
|
||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
|
||||
function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||
return {
|
||||
totalItems: items.length,
|
||||
member: items,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apres ERP-73, `useCategoriesAdmin` ne porte plus la liste paginee des
|
||||
* categories (elle est geree par `usePaginatedList<Category>` cote page).
|
||||
* Le composable se concentre sur le referentiel CategoryType (lecture
|
||||
* seule, ≤ 5 entrees connues) charge en une fois via `?pagination=false`.
|
||||
*/
|
||||
describe('useCategoriesAdmin', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// Reset systematique du state singleton entre tests : sans ca,
|
||||
// les types charges dans un test fuiteraient dans le suivant.
|
||||
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||
resetCategoriesAdmin()
|
||||
})
|
||||
|
||||
describe('fetchTypes', () => {
|
||||
it('appelle GET /category_types avec ?pagination=false (echappatoire selects)', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/category_types',
|
||||
{ pagination: 'false' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('peuple types.value depuis le champ Hydra member', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT]))
|
||||
const { fetchTypes, types } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT])
|
||||
})
|
||||
|
||||
it('peuple error.value et vide types en cas d echec', async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error('500'))
|
||||
const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin()
|
||||
types.value = [TYPE_VENTE]
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
expect(error.value).toContain('500')
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('passe loadingTypes a true pendant la requete et false apres', async () => {
|
||||
let resolveRequest: (v: HydraCollection<CategoryType>) => void = () => {}
|
||||
mockGet.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const { fetchTypes, loadingTypes } = useCategoriesAdmin()
|
||||
|
||||
const pending = fetchTypes()
|
||||
expect(loadingTypes.value).toBe(true)
|
||||
|
||||
resolveRequest(makeHydra<CategoryType>([]))
|
||||
await pending
|
||||
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
totalItems: 0,
|
||||
} as unknown as HydraCollection<CategoryType>)
|
||||
const { fetchTypes, types } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetCategoriesAdmin', () => {
|
||||
it('vide types, loadingTypes et error', () => {
|
||||
const { resetCategoriesAdmin, types, loadingTypes, error }
|
||||
= useCategoriesAdmin()
|
||||
// Pre-peuple le state pour verifier la purge effective.
|
||||
types.value = [TYPE_VENTE]
|
||||
loadingTypes.value = true
|
||||
error.value = 'oops'
|
||||
|
||||
resetCategoriesAdmin()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('singleton', () => {
|
||||
it('deux appels a useCategoriesAdmin() partagent la meme ref types', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||
// doivent etre rigoureusement les memes (state au niveau module).
|
||||
expect(a.types).toBe(b.types)
|
||||
expect(a.loadingTypes).toBe(b.loadingTypes)
|
||||
expect(a.error).toBe(b.error)
|
||||
})
|
||||
|
||||
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
a.types.value = [TYPE_VENTE]
|
||||
|
||||
expect(b.types.value).toEqual([TYPE_VENTE])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,454 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import { useCategoryForm } from '../useCategoryForm'
|
||||
|
||||
// Stubs des auto-imports Nuxt consommes par le composable.
|
||||
const mockGet = vi.hoisted(() => vi.fn())
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockDelete = vi.hoisted(() => vi.fn())
|
||||
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useApi', () => ({
|
||||
get: mockGet,
|
||||
post: mockPost,
|
||||
put: vi.fn(),
|
||||
patch: mockPatch,
|
||||
delete: mockDelete,
|
||||
}))
|
||||
vi.stubGlobal('useToast', () => ({
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
}))
|
||||
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
|
||||
// Quand le composable passe des params (ex: doublon), on les serialise pour
|
||||
// pouvoir verifier que l'interpolation a bien recu le bon nom.
|
||||
vi.stubGlobal('useI18n', () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||
}))
|
||||
|
||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
||||
|
||||
const CAT: Category = {
|
||||
id: 42,
|
||||
name: 'Vis',
|
||||
categoryType: TYPE_VENTE,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-01-01T10:00:00+00:00',
|
||||
updatedAt: '2026-01-01T10:00:00+00:00',
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
describe('useCategoryForm', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
mockPost.mockReset()
|
||||
mockPatch.mockReset()
|
||||
mockDelete.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
})
|
||||
|
||||
describe('loadFrom', () => {
|
||||
it('pre-remplit le formulaire depuis une categorie existante', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom(CAT)
|
||||
|
||||
expect(form.name.value).toBe('Vis')
|
||||
expect(form.categoryTypeId.value).toBe(1)
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
})
|
||||
|
||||
it('vide le formulaire en mode creation (null)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'old'
|
||||
form.categoryTypeId.value = 99
|
||||
|
||||
form.loadFrom(null)
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
|
||||
const form = useCategoryForm()
|
||||
|
||||
form.loadFrom(CAT)
|
||||
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDirty', () => {
|
||||
it('passe a true des qu une valeur diverge du snapshot initial', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
expect(form.isDirty.value).toBe(false)
|
||||
|
||||
form.name.value = 'Vis modifie'
|
||||
|
||||
expect(form.isDirty.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validate', () => {
|
||||
it('signale une erreur si name est vide (RG-1.02)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||
})
|
||||
|
||||
it('signale erreur si name est whitespace-only (trim → vide)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' '
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
|
||||
})
|
||||
|
||||
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'A'.repeat(121)
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
|
||||
})
|
||||
|
||||
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = null
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
|
||||
})
|
||||
|
||||
it('passe quand name et categoryType sont valides', () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const ok = form.validate()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
})
|
||||
|
||||
it('reinitialise les erreurs avant chaque validation', () => {
|
||||
const form = useCategoryForm()
|
||||
// Erreur prealable.
|
||||
form.errors.value._global = 'erreur ancienne'
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
form.validate()
|
||||
|
||||
expect(form.errors.value._global).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitCreate', () => {
|
||||
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ' Vis '
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/categories',
|
||||
{ name: 'Vis', categoryType: '/api/category_types/1' },
|
||||
{ toast: false },
|
||||
)
|
||||
expect(result).toEqual(CAT)
|
||||
})
|
||||
|
||||
it('ne declenche aucun appel API si la validation client echoue', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.name.value = ''
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('declenche un toast de succes en cas de creation reussie', async () => {
|
||||
mockPost.mockResolvedValueOnce(CAT)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
message: 'admin.categories.toast.created',
|
||||
})
|
||||
})
|
||||
|
||||
it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: { status: 409, _data: {} },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(result).toBeNull()
|
||||
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
|
||||
// les params i18n (stub serialise les params).
|
||||
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.value.name).toContain('"name":"Vis"')
|
||||
expect(mockToastError).toHaveBeenCalledTimes(1)
|
||||
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
|
||||
expect(toastArg.message).toContain('Vis')
|
||||
})
|
||||
|
||||
it('mappe un 422 violations sur les champs concernes (errors.name)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
violations: [
|
||||
{ propertyPath: 'name', message: 'name should not be blank.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const result = await form.submitCreate()
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(form.errors.value.name).toBe('name should not be blank.')
|
||||
// Pas de toast quand on a mappe les violations : l erreur est
|
||||
// affichee inline sous le champ concerne.
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: {
|
||||
'hydra:violations': [
|
||||
{ propertyPath: 'categoryType', message: 'Type invalide.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.value.categoryType).toBe('Type invalide.')
|
||||
})
|
||||
|
||||
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
await form.submitCreate()
|
||||
|
||||
expect(form.errors.value._global).toBe('Boom server')
|
||||
expect(mockToastError).toHaveBeenCalledWith({
|
||||
title: 'Erreur',
|
||||
message: 'Boom server',
|
||||
})
|
||||
})
|
||||
|
||||
it('passe submitting a true pendant la requete et a false apres', async () => {
|
||||
let resolveRequest: (v: Category) => void = () => {}
|
||||
mockPost.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { resolveRequest = resolve }),
|
||||
)
|
||||
const form = useCategoryForm()
|
||||
form.name.value = 'Vis'
|
||||
form.categoryTypeId.value = 1
|
||||
|
||||
const pending = form.submitCreate()
|
||||
expect(form.submitting.value).toBe(true)
|
||||
|
||||
resolveRequest(CAT)
|
||||
await pending
|
||||
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitUpdate', () => {
|
||||
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Vis V2' // categoryTypeId inchange
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ name: 'Vis V2' }, // pas de categoryType car non modifie
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('envoie categoryType en IRI quand seul le type a change', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.categoryTypeId.value = 2
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/categories/42',
|
||||
{ categoryType: '/api/category_types/2' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('court-circuite l appel API si aucun champ n a change', async () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
// Aucune modification — isDirty=false, patch payload vide.
|
||||
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
expect(mockPatch).not.toHaveBeenCalled()
|
||||
expect(result).toBeNull()
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('declenche un toast de succes au PATCH reussi', async () => {
|
||||
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Vis V2'
|
||||
|
||||
await form.submitUpdate(42)
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
message: 'admin.categories.toast.updated',
|
||||
})
|
||||
})
|
||||
|
||||
it('mappe le 409 sur errors.name en mode update aussi', async () => {
|
||||
mockPatch.mockRejectedValueOnce({
|
||||
response: { status: 409, _data: {} },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'Doublon'
|
||||
|
||||
const result = await form.submitUpdate(42)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
|
||||
expect(form.errors.value.name).toContain('"name":"Doublon"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitDelete', () => {
|
||||
it('appelle DELETE /categories/{id} et declenche un toast succes', async () => {
|
||||
mockDelete.mockResolvedValueOnce(undefined)
|
||||
const form = useCategoryForm()
|
||||
|
||||
const ok = await form.submitDelete(42)
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
|
||||
expect(ok).toBe(true)
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith({
|
||||
title: 'Succès',
|
||||
message: 'admin.categories.toast.deleted',
|
||||
})
|
||||
})
|
||||
|
||||
it('retourne false et toast erreur en cas d echec', async () => {
|
||||
mockDelete.mockRejectedValueOnce({
|
||||
response: { status: 500, _data: { detail: 'down' } },
|
||||
})
|
||||
const form = useCategoryForm()
|
||||
|
||||
const ok = await form.submitDelete(42)
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(form.errors.value._global).toBe('down')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('vide le formulaire et les erreurs', () => {
|
||||
const form = useCategoryForm()
|
||||
form.loadFrom(CAT)
|
||||
form.name.value = 'edit'
|
||||
form.errors.value._global = 'erreur'
|
||||
form.submitting.value = true
|
||||
|
||||
form.reset()
|
||||
|
||||
expect(form.name.value).toBe('')
|
||||
expect(form.categoryTypeId.value).toBeNull()
|
||||
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
|
||||
expect(form.submitting.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isolation', () => {
|
||||
it('deux instances useCategoryForm() ont des states independants', () => {
|
||||
const a = useCategoryForm()
|
||||
const b = useCategoryForm()
|
||||
|
||||
a.name.value = 'A'
|
||||
b.name.value = 'B'
|
||||
|
||||
expect(a.name.value).toBe('A')
|
||||
expect(b.name.value).toBe('B')
|
||||
// Les refs sont distinctes (pas singleton — chaque drawer son state).
|
||||
expect(a.name).not.toBe(b.name)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Composable de chargement du referentiel CategoryType (M0 — Gestion des
|
||||
* categories).
|
||||
*
|
||||
* Apres ERP-73 (composable de liste paginee), la liste des categories
|
||||
* elle-meme passe par `usePaginatedList<Category>` directement dans
|
||||
* `admin/categories.vue` — c'est un etat propre a la page (pagination,
|
||||
* filtres, tri locaux). Ce composable se concentre donc sur le
|
||||
* referentiel CategoryType : petite collection lue une fois et reutilisee
|
||||
* dans le drawer (select de type) → singleton volontaire pour eviter de
|
||||
* la recharger a chaque ouverture du drawer.
|
||||
*
|
||||
* State singleton au niveau module : reset automatique au logout via
|
||||
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
|
||||
* explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import type { CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* CategoryType est un referentiel lecture-seule (RG-1.06) avec une
|
||||
* cardinalite minuscule (≤ 5 entrees connues). On force `pagination=false`
|
||||
* pour recuperer toutes les entrees en un appel et alimenter le select du
|
||||
* drawer sans pagination — echappatoire prevue par
|
||||
* `pagination_client_enabled: true` cote API Platform.
|
||||
*/
|
||||
const NO_PAGINATION_QUERY = { pagination: 'false' } as const
|
||||
|
||||
const types = ref<CategoryType[]>([])
|
||||
const loadingTypes = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function resetCategoriesAdminState(): void {
|
||||
types.value = []
|
||||
loadingTypes.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// Auto-enregistrement singleton : purge le state sur 401/clearSession
|
||||
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
|
||||
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
|
||||
// appelle directement `resetCategoriesAdmin()` ci-dessous.
|
||||
onAuthSessionCleared(resetCategoriesAdminState)
|
||||
|
||||
export function useCategoriesAdmin() {
|
||||
const api = useApi()
|
||||
|
||||
/**
|
||||
* Charge le referentiel CategoryType. Appele a l'ouverture de la page
|
||||
* admin pour que le select du drawer ait deja les options pretes au
|
||||
* moment de la creation/edition.
|
||||
*
|
||||
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
||||
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||
*/
|
||||
async function fetchTypes(): Promise<void> {
|
||||
loadingTypes.value = true
|
||||
try {
|
||||
const data = await api.get<HydraCollection<CategoryType>>(
|
||||
'/category_types',
|
||||
NO_PAGINATION_QUERY,
|
||||
{ toast: false },
|
||||
)
|
||||
types.value = data.member ?? []
|
||||
} catch (e) {
|
||||
types.value = []
|
||||
error.value = (e as Error)?.message ?? 'Erreur de chargement des types'
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
|
||||
* pour garantir que la prochaine session reparte sur un state propre
|
||||
* meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||
*/
|
||||
function resetCategoriesAdmin(): void {
|
||||
resetCategoriesAdminState()
|
||||
}
|
||||
|
||||
return {
|
||||
types,
|
||||
loadingTypes,
|
||||
error,
|
||||
fetchTypes,
|
||||
resetCategoriesAdmin,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Composable de formulaire categorie (M0 — Gestion des categories).
|
||||
*
|
||||
* Centralise la logique de validation client + appels API (POST / PATCH /
|
||||
* DELETE) du drawer de creation/edition. Contrairement a
|
||||
* `useCategoriesAdmin` qui porte un state singleton partage entre composants,
|
||||
* ce composable est instancie par formulaire (les refs vivent dans la
|
||||
* fonction `useCategoryForm()`) — chaque drawer ouvert a son propre state
|
||||
* isole.
|
||||
*
|
||||
* Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) :
|
||||
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
|
||||
* revalide toujours (defense en profondeur).
|
||||
*
|
||||
* Mapping erreurs API :
|
||||
* - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
|
||||
* - 422 (violations API Platform) → mapping sur les champs concernes
|
||||
* - autre → erreur globale `_global` + toast generique
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
|
||||
|
||||
/**
|
||||
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
|
||||
* (status et payload data) pour eviter de typer toute la lib.
|
||||
*/
|
||||
interface ApiFetchError {
|
||||
response?: {
|
||||
status?: number
|
||||
_data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function useCategoryForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
|
||||
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
|
||||
const name = ref('')
|
||||
const categoryTypeId = ref<number | null>(null)
|
||||
|
||||
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
|
||||
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
|
||||
// que rien n'a change en mode consultation).
|
||||
const initialName = ref('')
|
||||
const initialCategoryTypeId = ref<number | null>(null)
|
||||
|
||||
const errors = ref<{
|
||||
name: string
|
||||
categoryType: string
|
||||
_global: string
|
||||
}>({
|
||||
name: '',
|
||||
categoryType: '',
|
||||
_global: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
name.value !== initialName.value
|
||||
|| categoryTypeId.value !== initialCategoryTypeId.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pre-remplit le formulaire a partir d'une categorie existante (mode
|
||||
* consultation/edition) ou vide (mode creation). Reinitialise les
|
||||
* erreurs et le snapshot initial pour repartir d'un etat propre.
|
||||
*/
|
||||
function loadFrom(category: Category | null): void {
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
if (category) {
|
||||
name.value = category.name
|
||||
categoryTypeId.value = category.categoryType.id
|
||||
initialName.value = category.name
|
||||
initialCategoryTypeId.value = category.categoryType.id
|
||||
} else {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation client miroir des RG back. Renvoie true si tout passe et
|
||||
* peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03)
|
||||
* mais le serveur retrim de toute facon — pas de risque de divergence.
|
||||
*/
|
||||
function validate(): boolean {
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
const trimmedName = name.value.trim()
|
||||
|
||||
// RG-1.02 — name obligatoire (vide / whitespace-only).
|
||||
if (trimmedName === '') {
|
||||
errors.value.name = t('admin.categories.validation.nameRequired')
|
||||
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
|
||||
// RG-1.04 — longueur 2-120 apres trim.
|
||||
errors.value.name = t('admin.categories.validation.nameLength')
|
||||
}
|
||||
|
||||
// RG-1.05 — categoryType obligatoire.
|
||||
if (categoryTypeId.value === null) {
|
||||
errors.value.categoryType = t('admin.categories.validation.typeRequired')
|
||||
}
|
||||
|
||||
return errors.value.name === '' && errors.value.categoryType === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload POST a partir du state. Le `categoryType` est
|
||||
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
|
||||
* Platform pour referencer une ressource liee. Retourne un object literal
|
||||
* compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
|
||||
* `CategoryCreateInput` ne serait pas assignable a `Record<string, unknown>`
|
||||
* en TS strict).
|
||||
*/
|
||||
function buildCreatePayload(): Record<string, unknown> {
|
||||
return {
|
||||
name: name.value.trim(),
|
||||
categoryType: `/api/category_types/${categoryTypeId.value}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
|
||||
* Renvoie true des qu'au moins une violation a ete posee — false sinon
|
||||
* (payload sans violations exploitables, ou tous les `propertyPath` hors
|
||||
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
|
||||
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
|
||||
* sur les futurs drawers de formulaire.
|
||||
*/
|
||||
function mapServerViolations(data: unknown): boolean {
|
||||
const violations = extractApiViolations(data)
|
||||
if (violations.length === 0) return false
|
||||
let mapped = false
|
||||
for (const v of violations) {
|
||||
if (v.propertyPath === 'name') {
|
||||
errors.value.name = v.message
|
||||
mapped = true
|
||||
} else if (v.propertyPath === 'categoryType') {
|
||||
errors.value.categoryType = v.message
|
||||
mapped = true
|
||||
}
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite une erreur API : mappe selon le status, declenche les toasts
|
||||
* appropries. Centralise la logique entre create/update.
|
||||
*
|
||||
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
|
||||
* le nom soumis.
|
||||
* - 422 : tentative de mapping fin via les violations API Platform — si au
|
||||
* moins une violation est mappee, pas de toast (erreur affichee inline
|
||||
* sous le champ concerne).
|
||||
* - autre : message global + toast generique. Le toast natif d'useApi
|
||||
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
|
||||
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
|
||||
*
|
||||
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
|
||||
* false sinon (fallback generique).
|
||||
*/
|
||||
function handleApiError(e: unknown, attemptedName: string): boolean {
|
||||
const status = (e as ApiFetchError)?.response?.status
|
||||
const data = (e as ApiFetchError)?.response?._data
|
||||
|
||||
if (status === 409) {
|
||||
const duplicateMessage = t('admin.categories.toast.duplicate', {
|
||||
name: attemptedName,
|
||||
})
|
||||
errors.value.name = duplicateMessage
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: duplicateMessage,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (status === 422 && mapServerViolations(data)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const extracted = extractApiErrorMessage(data)
|
||||
errors.value._global = extracted || 'Une erreur est survenue.'
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: errors.value._global,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/categories. Renvoie la categorie creee, ou `null` si la
|
||||
* validation client a echoue ou si le serveur a renvoye une erreur. Le
|
||||
* caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert).
|
||||
*/
|
||||
async function submitCreate(): Promise<Category | null> {
|
||||
if (!validate()) return null
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
const payload = buildCreatePayload()
|
||||
try {
|
||||
const created = await api.post<Category>('/categories', payload, {
|
||||
toast: false,
|
||||
})
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: t('admin.categories.toast.created'),
|
||||
})
|
||||
return created
|
||||
} catch (e) {
|
||||
handleApiError(e, String(payload.name))
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
|
||||
* coller a la semantique merge-patch (Content-Type pose par useApi).
|
||||
* Renvoie la categorie mise a jour, ou `null` en cas d'echec.
|
||||
*/
|
||||
async function submitUpdate(id: number): Promise<Category | null> {
|
||||
if (!validate()) return null
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (name.value !== initialName.value) {
|
||||
payload.name = name.value.trim()
|
||||
}
|
||||
if (categoryTypeId.value !== initialCategoryTypeId.value) {
|
||||
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
|
||||
}
|
||||
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
|
||||
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
|
||||
// on protege le composable contre un appel direct mal utilise.
|
||||
if (Object.keys(payload).length === 0) {
|
||||
submitting.value = false
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const updated = await api.patch<Category>(`/categories/${id}`, payload, {
|
||||
toast: false,
|
||||
})
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: t('admin.categories.toast.updated'),
|
||||
})
|
||||
return updated
|
||||
} catch (e) {
|
||||
const attemptedName = typeof payload.name === 'string'
|
||||
? payload.name
|
||||
: name.value.trim()
|
||||
handleApiError(e, attemptedName)
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
|
||||
* `deleted_at = now()` et retourne 204. Renvoie true en cas de succes,
|
||||
* false sinon (avec toast erreur deja affiche).
|
||||
*/
|
||||
async function submitDelete(id: number): Promise<boolean> {
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
try {
|
||||
await api.delete(`/categories/${id}`, {}, { toast: false })
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: t('admin.categories.toast.deleted'),
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
handleApiError(e, name.value)
|
||||
return false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset complet du formulaire — utilise par le drawer apres save ou
|
||||
* fermeture pour ne pas garder de donnees stale entre deux ouvertures.
|
||||
*/
|
||||
function reset(): void {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
name,
|
||||
categoryTypeId,
|
||||
errors,
|
||||
submitting,
|
||||
isDirty,
|
||||
// Methods
|
||||
loadFrom,
|
||||
validate,
|
||||
submitCreate,
|
||||
submitUpdate,
|
||||
submitDelete,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('admin.categories.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
:label="t('admin.categories.newCategory')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des categories. Tri serveur (name ASC, RG-1.10) +
|
||||
pagination serveur via usePaginatedList (#73). Le composable
|
||||
remplace l'ancien chargement « tout en un coup » a volumetrie
|
||||
cible ≤ 300 — la pagination est desormais alignee sur la regle
|
||||
projet (toute collection paginee, regle ABSOLUE n°13). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="categoryItems"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="true"
|
||||
:empty-message="t('admin.categories.noCategories')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
|
||||
<!-- Drawer creation / consultation / edition. -->
|
||||
<CategoryDrawer
|
||||
v-model="drawerOpen"
|
||||
:category="selectedCategory"
|
||||
@saved="onCategorySaved"
|
||||
@delete="onDeleteRequest"
|
||||
/>
|
||||
|
||||
<!-- Modale de confirmation suppression (soft delete cote serveur). -->
|
||||
<CategoryDeleteModal
|
||||
v-model="deleteModalOpen"
|
||||
:category-name="categoryToDelete?.name ?? ''"
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
const { submitDelete } = useCategoryForm()
|
||||
|
||||
useHead({ title: t('admin.categories.title') })
|
||||
|
||||
const canManage = computed(() => can('catalog.categories.manage'))
|
||||
|
||||
// Pagination serveur via le composable partage (#73). Le CategoryProvider
|
||||
// applique deja name ASC (RG-1.10) — pas besoin de defaultSort cote front
|
||||
// tant qu'aucun OrderFilter n'est expose.
|
||||
const {
|
||||
items: categories,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: fetchCategories,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<Category>({ url: '/categories' })
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedCategory = ref<Category | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const categoryToDelete = ref<Category | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
|
||||
// on aplatit en label lisible pour l'affichage.
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.categories.table.name') },
|
||||
{ key: 'typeLabel', label: t('admin.categories.table.type') },
|
||||
]
|
||||
|
||||
const categoryItems = computed(() =>
|
||||
categories.value.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
typeLabel: cat.categoryType?.label ?? '',
|
||||
})),
|
||||
)
|
||||
|
||||
function getCategoryById(id: number): Category | undefined {
|
||||
return categories.value.find(c => c.id === id)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const category = getCategoryById(item.id as number)
|
||||
if (category) openEditDrawer(category)
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedCategory.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(category: Category) {
|
||||
selectedCategory.value = category
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteRequest() {
|
||||
if (!selectedCategory.value) return
|
||||
categoryToDelete.value = selectedCategory.value
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete via le composable de form (qui gere toast + erreur). Refresh
|
||||
* de la liste a la fin pour retirer la ligne. L'index unique partiel
|
||||
* autorise une recreation ulterieure avec le meme couple (name, type) —
|
||||
* RG-1.07.
|
||||
*/
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (!categoryToDelete.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
const ok = await submitDelete(categoryToDelete.value.id)
|
||||
if (ok) {
|
||||
deleteModalOpen.value = false
|
||||
categoryToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await fetchCategories()
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCategorySaved() {
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
||||
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Types front du module Catalog (M0 — Gestion des categories).
|
||||
*
|
||||
* Contrats API consommes :
|
||||
* - GET /api/categories → HydraCollection<Category>
|
||||
* - GET /api/categories/{id} → Category
|
||||
* - POST /api/categories → body { name, categoryType: IRI }
|
||||
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
|
||||
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
|
||||
* - GET /api/category_types → HydraCollection<CategoryType>
|
||||
*
|
||||
* Notes :
|
||||
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
|
||||
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
|
||||
* proprietes de CategoryType, cf. spec-back § 3.4).
|
||||
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
|
||||
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reference legere d'un user, telle qu'embarquee dans Category.createdBy /
|
||||
* updatedBy. Volontairement minimaliste : on n'a besoin que de l'identifiant
|
||||
* et de l'username pour l'affichage courant.
|
||||
*/
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference du referentiel CategoryType (lecture seule au M0).
|
||||
*/
|
||||
export interface CategoryType {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie metier — telle qu'elle est lue depuis l'API. L'entite porte le
|
||||
* pattern Timestampable+Blamable (cf. spec-back § 2.8).
|
||||
*/
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
categoryType: CategoryType
|
||||
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
|
||||
deletedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy: User | null
|
||||
updatedBy: User | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en POST /api/categories. `categoryType` est envoye en
|
||||
* IRI Hydra (ex. `/api/category_types/3`).
|
||||
*/
|
||||
export interface CategoryCreateInput {
|
||||
name: string
|
||||
categoryType: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en PATCH /api/categories/{id}. Tous les champs sont
|
||||
* optionnels (modification partielle).
|
||||
*/
|
||||
export interface CategoryUpdateInput {
|
||||
name?: string
|
||||
categoryType?: string
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1>
|
||||
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||
<PageHeader>{{ $t('commercial.title') }}</PageHeader>
|
||||
<p class="text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<!-- Accordeon de permissions groupees par module : un panneau par module,
|
||||
avec compteur (selectionnees/total) dans le titre, case "Tout selectionner"
|
||||
et liste des permissions individuelles. Source unique de cette UX, utilisee
|
||||
par RoleDrawer (permissions du role) et UserRbacDrawer (permissions directes). -->
|
||||
<MalioAccordion v-model="openModules">
|
||||
<MalioAccordionItem
|
||||
v-for="group in groupsByModule"
|
||||
:key="group.module"
|
||||
:value="group.module"
|
||||
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
|
||||
header-class="capitalize"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<!-- Tout selectionner pour ce module -->
|
||||
<MalioCheckbox
|
||||
:id="`${idPrefix}-group-${group.module}`"
|
||||
:label="t('admin.roles.permissions.selectAll')"
|
||||
:model-value="allSelectedFor(group)"
|
||||
label-class="font-semibold text-sm text-neutral-700"
|
||||
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="perm in group.permissions"
|
||||
:id="`${idPrefix}-perm-${perm.id}`"
|
||||
:key="perm.id"
|
||||
:label="perm.label"
|
||||
:model-value="selectedIds.has(perm.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => emit('toggle', perm.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PermissionModule } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
/** Groupes de permissions a afficher, un par module. */
|
||||
groupsByModule: PermissionModule[]
|
||||
/** Ids des permissions actuellement selectionnees. */
|
||||
selectedIds: Set<number>
|
||||
/** Prefixe pour les ids HTML : evite les collisions si plusieurs accordeons coexistent (ex: "role" vs "direct"). */
|
||||
idPrefix: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [permissionId: number, selected: boolean]
|
||||
'toggle-all': [module: string, selected: boolean]
|
||||
}>()
|
||||
|
||||
// Modules ouverts dans l'accordeon (mode multiple). Etat local : chaque instance
|
||||
// du composant garde sa propre liste, pas de partage entre drawers.
|
||||
const openModules = ref<string[]>([])
|
||||
|
||||
// Nombre de permissions selectionnees pour un module donne.
|
||||
function selectedCountFor(group: PermissionModule): number {
|
||||
return group.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||
}
|
||||
|
||||
// Vrai si toutes les permissions du module sont selectionnees.
|
||||
function allSelectedFor(group: PermissionModule): boolean {
|
||||
return group.permissions.length > 0 && selectedCountFor(group) === group.permissions.length
|
||||
}
|
||||
</script>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 overflow-hidden">
|
||||
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
|
||||
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
|
||||
<MalioCheckbox
|
||||
:id="`group-${module}`"
|
||||
:label="moduleLabel"
|
||||
:model-value="allSelected"
|
||||
label-class="font-semibold text-sm text-neutral-700 capitalize"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
<span class="ml-auto text-xs text-neutral-400">
|
||||
{{ selectedCount }}/{{ permissions.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Liste des permissions individuelles -->
|
||||
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
|
||||
<MalioCheckbox
|
||||
v-for="perm in permissions"
|
||||
:key="perm.id"
|
||||
:id="`perm-${perm.id}`"
|
||||
:label="perm.label"
|
||||
:model-value="selectedIds.has(perm.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission } from '~/shared/types/rbac'
|
||||
|
||||
const props = defineProps<{
|
||||
module: string
|
||||
moduleLabel: string
|
||||
permissions: Permission[]
|
||||
selectedIds: Set<number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [permissionId: number, selected: boolean]
|
||||
toggleAll: [module: string, selected: boolean]
|
||||
}>()
|
||||
|
||||
// Nombre de permissions selectionnees dans ce groupe
|
||||
const selectedCount = computed(() =>
|
||||
props.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||
)
|
||||
|
||||
// Vrai si toutes les permissions du groupe sont selectionnees
|
||||
const allSelected = computed(() =>
|
||||
props.permissions.length > 0 && selectedCount.value === props.permissions.length
|
||||
)
|
||||
|
||||
// Emet l'evenement de bascule pour une permission individuelle
|
||||
function togglePermission(id: number, selected: boolean) {
|
||||
emit('toggle', id, selected)
|
||||
}
|
||||
|
||||
// Emet l'evenement de bascule pour toutes les permissions du groupe
|
||||
function toggleAll(selected: boolean) {
|
||||
emit('toggleAll', props.module, selected)
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||
<!-- Champs du role -->
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -44,55 +50,51 @@
|
||||
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
<PermissionAccordion
|
||||
v-else
|
||||
:groups-by-module="permissionsByModule"
|
||||
:selected-ids="selectedPermissionIds"
|
||||
id-prefix="role"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="role?.isSystem"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || permissionsLoadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="role?.isSystem"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || permissionsLoadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role } from '~/shared/types/rbac'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
import type { Permission, PermissionModule, Role } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
|
||||
drawer-class="w-full max-w-lg"
|
||||
drawer-class="w-full max-w-[450px]"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
|
||||
</h2>
|
||||
</template>
|
||||
<div class="flex flex-col space-y-4 py-4">
|
||||
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
||||
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
||||
<div
|
||||
@@ -35,11 +41,13 @@
|
||||
/>
|
||||
|
||||
<!-- Section Roles -->
|
||||
<div>
|
||||
<!-- !mt-0 : la MalioCheckbox au-dessus expose son slot message (16px),
|
||||
qui couvre deja l'ecart attendu — pas besoin du space-y-4 ici. -->
|
||||
<div class="!mt-0">
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.rolesSection') }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="role in allRoles"
|
||||
:key="role.id"
|
||||
@@ -60,18 +68,14 @@
|
||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedDirectPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
<PermissionAccordion
|
||||
v-else
|
||||
:groups-by-module="permissionsByModule"
|
||||
:selected-ids="selectedDirectPermissionIds"
|
||||
id-prefix="direct"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
||||
@@ -82,7 +86,7 @@
|
||||
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.sites.noSites') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="site in allSites"
|
||||
:id="`site-${site.id}`"
|
||||
@@ -103,33 +107,32 @@
|
||||
<EffectivePermissions :permissions="effectivePermissions" />
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || loadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || loadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
||||
import type { Permission, PermissionModule, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -1,95 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.auditLog.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
|
||||
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
|
||||
leur `label` flottant interne pour ne pas mixer deux patterns de label.
|
||||
A revoir une fois le composant calendar Malio développé -->
|
||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
|
||||
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
|
||||
exposera un datetime picker. Cf. exception documentee dans
|
||||
CLAUDE.md (section "Composants formulaires"). -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.date_from') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.performedAtAfter"
|
||||
type="datetime-local"
|
||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||
>
|
||||
</div>
|
||||
<!-- TODO(malio-ui): idem ci-dessus. -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.date_to') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.performedAtBefore"
|
||||
type="datetime-local"
|
||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.entity_type') }}
|
||||
</label>
|
||||
<div class="[&>div>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedEntityTypes"
|
||||
:options="entityTypeOptions"
|
||||
:display-select-all="true"
|
||||
:display-tag="true"
|
||||
min-width="w-full"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.user') }}
|
||||
</label>
|
||||
<MalioInputText
|
||||
v-model="performedByInput"
|
||||
icon-name="mdi:account-search"
|
||||
input-class="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.action') }}
|
||||
</label>
|
||||
<div class="[&>div>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="actionValue"
|
||||
:options="actionOptions"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<PageHeader>
|
||||
{{ t('admin.auditLog.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="text-xs"
|
||||
@click="resetFilters"
|
||||
:label="t('audit.filters.title')"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
button-class="w-[184px] justify-start gap-4 text-black"
|
||||
@click="openFilters"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Tableau -->
|
||||
<MalioDataTable
|
||||
class="mt-4"
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
@@ -123,12 +50,103 @@
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer de filtres : etat brouillon, applique uniquement au clic sur
|
||||
"Voir les resultats". `body-class="p-0"` pour que l'accordeon aille
|
||||
bord a bord (les items portent leur propre px-7). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('audit.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
|
||||
<!-- pb-4 sur les labels Du/Au : simule le slot message
|
||||
du MalioDateTime voisin pour qu'items-center recentre
|
||||
le label sur le centre visible du champ. -->
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3">
|
||||
<span class="pb-4">{{ t('audit.filters.date_from') }}</span>
|
||||
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
|
||||
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
|
||||
<MalioDateTime
|
||||
v-model="draftDateFrom"
|
||||
:max="draftDateTo ?? undefined"
|
||||
/>
|
||||
<span class="pb-4">{{ t('audit.filters.date_to') }}</span>
|
||||
<MalioDateTime
|
||||
v-model="draftDateTo"
|
||||
:min="draftDateFrom ?? undefined"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Type d'entite : cases a cocher (multi-selection) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in entityTypeOptions"
|
||||
:id="`filter-entity-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftEntityTypes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleEntity(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Action : boutons radio (selection unique, '' = toutes) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.action')" value="action">
|
||||
<MalioRadioButton
|
||||
v-for="opt in actionOptions"
|
||||
:key="opt.value"
|
||||
v-model="draftAction"
|
||||
name="audit-action"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
group-class="mt-0"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Utilisateur : recherche texte (ILIKE partiel cote backend) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.user')" value="user">
|
||||
<MalioInputText
|
||||
v-model="draftPerformedBy"
|
||||
icon-name="mdi:account-search"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('audit.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
|
||||
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||
<MalioDrawer
|
||||
v-model="drawerOpen"
|
||||
:title="drawerTitle"
|
||||
drawer-class="max-w-2xl"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ drawerTitle }}
|
||||
</h2>
|
||||
</template>
|
||||
<div v-if="selectedEntry">
|
||||
<AuditLogDetail :entry="selectedEntry" />
|
||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||
@@ -149,12 +167,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
||||
const toast = useToast()
|
||||
|
||||
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
||||
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
||||
@@ -173,8 +192,11 @@ if (!can('core.audit_log.view')) {
|
||||
|
||||
useHead({ title: t('admin.auditLog.title') })
|
||||
|
||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
||||
// Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
|
||||
// persiste dans l'URL (cf. regle CLAUDE.md "Tableau : pas de persistance URL").
|
||||
// `performedAtAfter`/`performedAtBefore` stockent une date+heure ISO naive
|
||||
// (`YYYY-MM-DDTHH:MM:00`, fournie par MalioDateTime), convertie en ISO UTC
|
||||
// au moment du fetch.
|
||||
const filters = reactive<AuditLogFilters>({
|
||||
performedAtAfter: undefined,
|
||||
performedAtBefore: undefined,
|
||||
@@ -185,26 +207,23 @@ const filters = reactive<AuditLogFilters>({
|
||||
itemsPerPage: 10,
|
||||
})
|
||||
|
||||
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
||||
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
||||
const selectedEntityTypes = ref<(string | number)[]>([])
|
||||
// Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
|
||||
// uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
|
||||
// fermant le drawer sans relancer de requete.
|
||||
const filterDrawerOpen = ref(false)
|
||||
const draftDateFrom = ref<string | null>(null)
|
||||
const draftDateTo = ref<string | null>(null)
|
||||
const draftEntityTypes = ref<string[]>([])
|
||||
const draftAction = ref<string>('')
|
||||
const draftPerformedBy = ref<string>('')
|
||||
|
||||
// Liste des entity types (distincts) pour alimenter les cases a cocher.
|
||||
const entityTypes = ref<string[]>([])
|
||||
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
|
||||
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
|
||||
const entityTypeOptions = computed(() =>
|
||||
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
||||
)
|
||||
|
||||
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
||||
// pas binder directement un `string | undefined` reactive.
|
||||
const performedByInput = ref<string>('')
|
||||
|
||||
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
|
||||
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
|
||||
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
|
||||
// car MalioSelect grise visuellement les options dont la valeur est `null`
|
||||
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
|
||||
const actionValue = ref<string>('')
|
||||
// Actions : '' = "toutes". Sert d'options aux boutons radio.
|
||||
const actionOptions = [
|
||||
{ value: '', label: t('audit.filters.all_actions') },
|
||||
{ value: 'create', label: t('audit.action.create') },
|
||||
@@ -259,29 +278,55 @@ const isFiltered = computed(() =>
|
||||
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||
let requestToken = 0
|
||||
|
||||
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
||||
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
||||
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
||||
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
||||
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
||||
// explicitement apres la liberation.
|
||||
let watchersSuspended = false
|
||||
// Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
|
||||
// reouverture reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftDateFrom.value = filters.performedAtAfter ?? null
|
||||
draftDateTo.value = filters.performedAtBefore ?? null
|
||||
draftEntityTypes.value = Array.isArray(filters.entityType)
|
||||
? [...filters.entityType]
|
||||
: (filters.entityType ? [filters.entityType] : [])
|
||||
draftAction.value = filters.action ?? ''
|
||||
draftPerformedBy.value = filters.performedBy ?? ''
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
// Bascule un type d'entite dans le brouillon (multi-selection). Les valeurs
|
||||
// sont uniques par construction (v-for sur entityTypeOptions), pas besoin de Set.
|
||||
function toggleEntity(value: string, selected: boolean): void {
|
||||
draftEntityTypes.value = selected
|
||||
? [...draftEntityTypes.value, value]
|
||||
: draftEntityTypes.value.filter(v => v !== value)
|
||||
}
|
||||
|
||||
// "Reinitialiser" : vide le brouillon ET les filtres actifs, puis recharge.
|
||||
// La remise a zero s'applique immediatement (la table revient a la liste
|
||||
// complete) ; le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftDateFrom.value = null
|
||||
draftDateTo.value = null
|
||||
draftEntityTypes.value = []
|
||||
draftAction.value = ''
|
||||
draftPerformedBy.value = ''
|
||||
|
||||
async function resetFilters(): Promise<void> {
|
||||
watchersSuspended = true
|
||||
filters.performedAtAfter = undefined
|
||||
filters.performedAtBefore = undefined
|
||||
filters.entityType = undefined
|
||||
filters.performedBy = undefined
|
||||
filters.action = undefined
|
||||
filters.performedBy = undefined
|
||||
filters.page = 1
|
||||
selectedEntityTypes.value = []
|
||||
performedByInput.value = ''
|
||||
actionValue.value = ''
|
||||
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
||||
// leur execution avec le flag `true`, puis on libere.
|
||||
await nextTick()
|
||||
watchersSuspended = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
// "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
filters.performedAtAfter = draftDateFrom.value ?? undefined
|
||||
filters.performedAtBefore = draftDateTo.value ?? undefined
|
||||
filters.entityType = draftEntityTypes.value.length > 0 ? [...draftEntityTypes.value] : undefined
|
||||
filters.action = draftAction.value === '' ? undefined : draftAction.value
|
||||
filters.performedBy = draftPerformedBy.value.trim() === '' ? undefined : draftPerformedBy.value.trim()
|
||||
filters.page = 1
|
||||
filterDrawerOpen.value = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
@@ -291,7 +336,8 @@ async function loadEntries(): Promise<void> {
|
||||
try {
|
||||
const data = await fetchLogsCached({
|
||||
...filters,
|
||||
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
|
||||
// MalioDateTime fournit une date+heure sans fuseau (heure locale) ;
|
||||
// on la convertit en ISO UTC pour l'API (bornes exactes, intervalle inclusif).
|
||||
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
||||
})
|
||||
@@ -300,13 +346,19 @@ async function loadEntries(): Promise<void> {
|
||||
if (token !== requestToken) return
|
||||
entries.value = data.member ?? []
|
||||
totalItems.value = data.totalItems ?? 0
|
||||
} catch {
|
||||
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
|
||||
// laisser l'utilisateur croire que les donnees affichees sont a jour.
|
||||
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
|
||||
} catch (err) {
|
||||
// useAuditLog appelle useApi avec { toast: false } pour ne pas multiplier
|
||||
// les toasts, donc c'est ici qu'on fait remonter l'erreur. Sans ce log+toast,
|
||||
// une RangeError de `toIso` (date invalide) ou une 500 API laissait l'utilisateur
|
||||
// devant une table vide indistinguable d'un filtre a zero resultat.
|
||||
if (token === requestToken) {
|
||||
entries.value = []
|
||||
totalItems.value = 0
|
||||
console.error('[audit-log] loadEntries failed', err)
|
||||
toast.error({
|
||||
title: t('audit.error.title'),
|
||||
message: t('audit.error.message'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (token === requestToken) {
|
||||
@@ -315,14 +367,9 @@ async function loadEntries(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
|
||||
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
|
||||
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
|
||||
const debouncedReload = debounce(() => loadEntries(), 300)
|
||||
|
||||
function toIso(localDateTime: string): string {
|
||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
||||
// laisse le navigateur generer l'ISO via Date().
|
||||
// MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
|
||||
// on laisse Date() generer l'ISO UTC correspondant pour l'API.
|
||||
return new Date(localDateTime).toISOString()
|
||||
}
|
||||
|
||||
@@ -368,53 +415,16 @@ function onPerPageChange(value: number): void {
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
|
||||
watch(selectedEntityTypes, values => {
|
||||
if (watchersSuspended) return
|
||||
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
// Sync MalioSelect action -> filters.action.
|
||||
watch(actionValue, value => {
|
||||
if (watchersSuspended) return
|
||||
filters.action = value === '' ? undefined : value
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
|
||||
// refetch par caractere. Le reset passe par debouncedReload egalement pour
|
||||
// coalescer si plusieurs watchers tirent en meme temps.
|
||||
watch(performedByInput, value => {
|
||||
if (watchersSuspended) return
|
||||
filters.performedBy = value === '' ? undefined : value
|
||||
filters.page = 1
|
||||
debouncedReload()
|
||||
})
|
||||
|
||||
// Synchronisation reactive : tout changement de dates declenche un fetch +
|
||||
// reset de la pagination a la page 1.
|
||||
watch(
|
||||
() => [filters.performedAtAfter, filters.performedAtBefore],
|
||||
() => {
|
||||
if (watchersSuspended) return
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Charge les entity types en parallele de la liste principale : un
|
||||
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
||||
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
||||
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
||||
try {
|
||||
entityTypes.value = await fetchEntityTypes()
|
||||
} catch {
|
||||
entityTypes.value = []
|
||||
}
|
||||
await loadEntries()
|
||||
// Charge les entity types ET la liste principale en parallele (TTFD divise
|
||||
// par 2 sur un backend lent). Le `.catch` du premier garantit qu'un echec
|
||||
// de /audit-log-entity-types ne bloque pas l'affichage du tableau —
|
||||
// l'utilisateur perd juste le filtre, pas la page entiere.
|
||||
await Promise.all([
|
||||
fetchEntityTypes()
|
||||
.then(types => { entityTypes.value = types })
|
||||
.catch(() => { entityTypes.value = [] }),
|
||||
loadEntries(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.roles.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('core.roles.manage')"
|
||||
:label="t('admin.roles.newRole')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
<PageHeader>
|
||||
{{ t('admin.roles.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="can('core.roles.manage')"
|
||||
:label="t('admin.roles.newRole')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des roles -->
|
||||
<!-- Table des roles — pagination serveur via usePaginatedList (#73). -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="roleItems"
|
||||
:total-items="roles.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.roles.noRoles')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-code="{ item }">
|
||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||
@@ -68,8 +71,17 @@ const canManage = computed(() => can('core.roles.manage'))
|
||||
|
||||
useHead({ title: t('admin.roles.title') })
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
// Pagination serveur via le composable partage (#73).
|
||||
const {
|
||||
items: roles,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadRoles,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<Role>({ url: '/roles' })
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: t('admin.roles.table.label') },
|
||||
@@ -104,25 +116,6 @@ const deleteModalOpen = ref(false)
|
||||
const roleToDelete = ref<Role | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Charger la liste des roles
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: Role[] }>(
|
||||
'/roles',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
roles.value = data.member
|
||||
} catch {
|
||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
||||
// requete reussie avant une perte reseau ou 403).
|
||||
roles.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedRole.value = null
|
||||
drawerOpen.value = true
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.users.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||
|
||||
<!-- Table des utilisateurs -->
|
||||
<!-- Table des utilisateurs — pagination serveur via usePaginatedList (#73). -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="userItems"
|
||||
:total-items="users.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.users.noUsers')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-admin="{ item }">
|
||||
<span
|
||||
@@ -40,15 +39,26 @@
|
||||
import type { UserListItem } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.users.title') })
|
||||
|
||||
const canManage = computed(() => can('core.users.manage'))
|
||||
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
// Pagination serveur via le composable partage (#73). Le payload `users`
|
||||
// reste leger (pas de detail RBAC dans la liste — cf. commentaire colonne
|
||||
// "Sites" plus bas) ce qui rend la pagination 10/25/50 par page confortable.
|
||||
const {
|
||||
items: users,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadUsers,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<UserListItem>({ url: '/users' })
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedUser = ref<UserListItem | null>(null)
|
||||
|
||||
@@ -73,21 +83,6 @@ const userItems = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
|
||||
users.value = usersData.member
|
||||
} catch {
|
||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
||||
// requete reussie avant une perte reseau ou 403). Pas de toast par
|
||||
// design ici : on laisse la liste vide parler d'elle-meme.
|
||||
users.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getUserById(id: number): UserListItem | undefined {
|
||||
return users.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
||||
<p class="mt-4 text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
||||
<PageHeader>{{ $t('dashboard.title') }}</PageHeader>
|
||||
<p class="text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
|
||||
</span>
|
||||
<form
|
||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
class="mt-8 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<MalioInputText
|
||||
@@ -30,7 +30,7 @@
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
<p class="mt-6 font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar()
|
||||
const { resetModules } = useModules()
|
||||
const { resetCurrentSite } = useCurrentSite()
|
||||
const { resetAuditLog } = useAuditLog()
|
||||
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -27,6 +28,7 @@ onMounted(async () => {
|
||||
resetModules()
|
||||
resetCurrentSite()
|
||||
resetAuditLog()
|
||||
resetCategoriesAdmin()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="t('admin.sites.form.name')"
|
||||
@@ -59,41 +65,51 @@
|
||||
input-class="w-full font-mono"
|
||||
required
|
||||
/>
|
||||
<span
|
||||
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
|
||||
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
|
||||
:class="{ 'border-dashed': !isValidHex }"
|
||||
/>
|
||||
<!-- pb-4 sur le wrapper : simule le slot message du
|
||||
MalioInputText voisin pour qu'items-center recentre
|
||||
la puce sur le centre visible de l'input. -->
|
||||
<div class="shrink-0 pb-4">
|
||||
<span
|
||||
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
|
||||
class="inline-block size-10 rounded-lg border border-neutral-200"
|
||||
:class="{ 'border-dashed': !isValidHex }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
|
||||
{{ t('admin.sites.form.colorInvalid') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || !isValidHex"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || !isValidHex"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.sites.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('sites.manage')"
|
||||
:label="t('admin.sites.newSite')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
<PageHeader>
|
||||
{{ t('admin.sites.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="can('sites.manage')"
|
||||
:label="t('admin.sites.newSite')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des sites -->
|
||||
<!-- Table des sites — pagination serveur via usePaginatedList (#73). -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="siteItems"
|
||||
:total-items="sites.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.sites.noSites')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
@@ -69,8 +72,20 @@ const canManage = computed(() => can('sites.manage'))
|
||||
|
||||
useHead({ title: t('admin.sites.title') })
|
||||
|
||||
const sites = ref<Site[]>([])
|
||||
const loading = ref(false)
|
||||
// Pagination serveur via le composable partage (#73). Aucun OrderFilter
|
||||
// declare cote API Platform sur Site, donc on s'appuie sur le tri par
|
||||
// defaut du repository (id ASC). Le composable est neanmoins pret a
|
||||
// recevoir un `defaultSort` ou des filtres le jour ou l'API les expose.
|
||||
const {
|
||||
items: sites,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadSites,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<Site>({ url: '/sites' })
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.sites.table.name') },
|
||||
@@ -109,24 +124,6 @@ const deleteModalOpen = ref(false)
|
||||
const siteToDelete = ref<Site | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function loadSites() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: Site[] }>(
|
||||
'/sites',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
sites.value = data.member
|
||||
} catch {
|
||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
||||
// requete reussie avant une perte reseau ou 403).
|
||||
sites.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedSite.value = null
|
||||
drawerOpen.value = true
|
||||
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.5.0",
|
||||
"@malio/layer-ui": "^1.7.3",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz",
|
||||
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==",
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
|
||||
"integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.5.0",
|
||||
"@malio/layer-ui": "^1.7.3",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<!-- Entete de page standard : source unique du style des titres.
|
||||
Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
|
||||
<div class="mb-[44px] flex items-center justify-between gap-4">
|
||||
<h1 class="text-[32px] font-semibold text-primary-500">
|
||||
<slot/>
|
||||
</h1>
|
||||
<div v-if="$slots.actions" class="shrink-0">
|
||||
<slot name="actions"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,412 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { usePaginatedList } from '../usePaginatedList'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests du composable `usePaginatedList`.
|
||||
*
|
||||
* Couvre les invariants critiques :
|
||||
* - parse Hydra (member / totalItems)
|
||||
* - navigation page (goToPage / next / prev / bornes)
|
||||
* - changement items/page → retour page 1
|
||||
* - mutation filtres / tri → retour page 1
|
||||
* - cas limite : page courante hors borne apres filtre → derniere page valide
|
||||
* - liste vide / page unique
|
||||
* - reset → defaults
|
||||
* - swallow d'erreur reseau (la promesse `fetch` ne reject jamais)
|
||||
* - header `Accept: application/ld+json` toujours envoye (besoin du
|
||||
* paginator Hydra cote API Platform 4).
|
||||
*/
|
||||
describe('usePaginatedList', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
})
|
||||
|
||||
function mockResponse(member: unknown[], totalItems: number): void {
|
||||
mockApiGet.mockResolvedValueOnce({ member, totalItems })
|
||||
}
|
||||
|
||||
it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => {
|
||||
mockResponse([{ id: 1 }, { id: 2 }], 42)
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/sites' })
|
||||
|
||||
await list.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/sites')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
})
|
||||
expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }])
|
||||
expect(list.totalItems.value).toBe(42)
|
||||
expect(list.totalPages.value).toBe(5)
|
||||
expect(list.isEmpty.value).toBe(false)
|
||||
expect(list.isSinglePage.value).toBe(false)
|
||||
})
|
||||
|
||||
it('itemsPerPage personnalise est respecte au premier fetch', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 })
|
||||
await list.fetch()
|
||||
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 })
|
||||
expect(list.itemsPerPage.value).toBe(25)
|
||||
})
|
||||
|
||||
it('goToPage(N) declenche un nouvel appel avec page=N', async () => {
|
||||
mockResponse([{ id: 1 }], 30) // page 1
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([{ id: 2 }], 30) // page 2
|
||||
await list.goToPage(2)
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 })
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
})
|
||||
|
||||
it('goToPage hors borne sup. est clampe a totalPages', async () => {
|
||||
mockResponse([], 30) // totalPages = 3
|
||||
const list = usePaginatedList({ url: '/roles' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.goToPage(999)
|
||||
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
})
|
||||
|
||||
it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => {
|
||||
mockResponse([], 30)
|
||||
const list = usePaginatedList({ url: '/roles' })
|
||||
await list.fetch()
|
||||
|
||||
mockApiGet.mockClear()
|
||||
await list.goToPage(-5)
|
||||
|
||||
// Deja en page 1 -> aucun nouvel appel.
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
})
|
||||
|
||||
it('nextPage / prevPage avancent et reculent dans les bornes', async () => {
|
||||
mockResponse([], 30) // page 1, totalPages 3
|
||||
const list = usePaginatedList({ url: '/roles' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.nextPage()
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.nextPage()
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
|
||||
// En derniere page -> no-op
|
||||
mockApiGet.mockClear()
|
||||
await list.nextPage()
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.prevPage()
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
})
|
||||
|
||||
it('setItemsPerPage revient en page 1 et refetch', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
// place-toi page 3
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(3)
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setItemsPerPage(25)
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
expect(list.itemsPerPage.value).toBe(25)
|
||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||
})
|
||||
|
||||
it('setItemsPerPage no-op si meme valeur', async () => {
|
||||
mockResponse([], 10)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
mockApiGet.mockClear()
|
||||
await list.setItemsPerPage(10)
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it('setFilters fusionne et retombe en page 1', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { name?: string; active?: boolean }>({
|
||||
url: '/users',
|
||||
defaultFilters: { active: true },
|
||||
})
|
||||
await list.fetch()
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(2)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ name: 'alice' })
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
expect(list.filters.value).toEqual({ active: true, name: 'alice' })
|
||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||
page: 1,
|
||||
active: true,
|
||||
name: 'alice',
|
||||
})
|
||||
})
|
||||
|
||||
it('setFilters({ key: undefined }) supprime la cle', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { name?: string }>({
|
||||
url: '/users',
|
||||
defaultFilters: { name: 'alice' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ name: undefined })
|
||||
|
||||
expect(list.filters.value).toEqual({})
|
||||
// Le query envoye ne contient plus `name` (compactQuery elimine
|
||||
// aussi les valeurs vides).
|
||||
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(q.name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setFilters({ replace: true }) remplace integralement', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { a?: string; b?: string }>({
|
||||
url: '/users',
|
||||
defaultFilters: { a: 'x' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ b: 'y' }, { replace: true })
|
||||
|
||||
expect(list.filters.value).toEqual({ b: 'y' })
|
||||
})
|
||||
|
||||
it('setSort envoie order[field]=direction et reset page', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(2)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setSort({ field: 'username', direction: 'desc' })
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(q['order[username]']).toBe('desc')
|
||||
})
|
||||
|
||||
it('setSort(null) retire le tri', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList({
|
||||
url: '/users',
|
||||
defaultSort: { field: 'name', direction: 'asc' },
|
||||
})
|
||||
await list.fetch()
|
||||
// Le tri initial est applique
|
||||
let q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(q['order[name]']).toBe('asc')
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setSort(null)
|
||||
q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(q['order[name]']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setFilters retombe en page 1 (cas standard, pas le hors-borne)', async () => {
|
||||
// setFilters remet toujours page=1 avant de refetcher : ce n'est
|
||||
// donc PAS le chemin de retry hors-borne (couvert par le test
|
||||
// suivant via un refetch a page constante). On verifie juste le
|
||||
// reset de page ici.
|
||||
mockResponse([], 50) // 5 pages
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
mockResponse([], 50)
|
||||
await list.goToPage(5)
|
||||
expect(list.currentPage.value).toBe(5)
|
||||
|
||||
mockResponse([{ id: 1 }, { id: 2 }], 12)
|
||||
await list.setFilters({ active: true } as never)
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1 })
|
||||
})
|
||||
|
||||
it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
|
||||
// Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux
|
||||
// filtres mais entre deux fetchs la donnee a change cote serveur,
|
||||
// la page courante peut devenir hors borne. On force le scenario
|
||||
// en montant manuellement currentPage via goToPage borne, puis en
|
||||
// simulant une reponse plus petite.
|
||||
mockResponse([], 50) // 5 pages
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 50)
|
||||
await list.goToPage(5)
|
||||
expect(list.currentPage.value).toBe(5)
|
||||
|
||||
// Maintenant simule : refetch -> totalItems chute a 12 (2 pages),
|
||||
// le composable doit refetcher sur page=2.
|
||||
mockApiGet.mockReset()
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide
|
||||
.mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2
|
||||
|
||||
await list.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 })
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }])
|
||||
})
|
||||
|
||||
it('liste vide : isEmpty true, isSinglePage true', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
expect(list.totalItems.value).toBe(0)
|
||||
expect(list.isEmpty.value).toBe(true)
|
||||
expect(list.isSinglePage.value).toBe(true)
|
||||
expect(list.totalPages.value).toBe(1)
|
||||
})
|
||||
|
||||
it('isEmpty est faux avant le premier fetch (etat indetermine)', () => {
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
expect(list.isEmpty.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reset revient aux defaults', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { a?: string }>({
|
||||
url: '/users',
|
||||
defaultItemsPerPage: 10,
|
||||
defaultFilters: { a: 'x' },
|
||||
defaultSort: { field: 'name', direction: 'asc' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setItemsPerPage(50)
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ a: 'y' })
|
||||
mockResponse([], 100)
|
||||
await list.setSort({ field: 'id', direction: 'desc' })
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(2)
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.reset()
|
||||
|
||||
expect(list.itemsPerPage.value).toBe(10)
|
||||
expect(list.filters.value).toEqual({ a: 'x' })
|
||||
expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' })
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
})
|
||||
|
||||
it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
|
||||
const boom = new Error('boom')
|
||||
mockApiGet.mockRejectedValueOnce(boom)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
|
||||
await expect(list.fetch()).resolves.toBeUndefined()
|
||||
expect(list.items.value).toEqual([])
|
||||
expect(list.totalItems.value).toBe(0)
|
||||
expect(list.loading.value).toBe(false)
|
||||
// L'erreur est consideree comme un fetch consume -> isEmpty=true.
|
||||
expect(list.isEmpty.value).toBe(true)
|
||||
// ... mais `error` est expose pour distinguer « vide » d'« echec ».
|
||||
expect(list.error.value).toBe(boom)
|
||||
})
|
||||
|
||||
it('error est remis a null des qu\'un fetch ulterieur reussit', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
expect(list.error.value).toBeInstanceOf(Error)
|
||||
|
||||
mockResponse([{ id: 1 }], 1)
|
||||
await list.fetch()
|
||||
expect(list.error.value).toBeNull()
|
||||
expect(list.items.value).toEqual([{ id: 1 }])
|
||||
})
|
||||
|
||||
it('ignore une reponse periemee : la derniere requete *demandee* gagne', async () => {
|
||||
// Deux fetch concurrents : le 1er resout APRES le 2eme. Sans garde
|
||||
// de sequence, la reponse arrivee en dernier (token 1) ecraserait
|
||||
// les donnees plus fraiches du token 2. Avec la garde, token 2 fait
|
||||
// foi quel que soit l'ordre d'arrivee reseau.
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||
|
||||
let resolveSlow!: (v: unknown) => void
|
||||
const slow = new Promise((r) => { resolveSlow = r })
|
||||
// 1er appel : reponse lente (en vol).
|
||||
mockApiGet.mockReturnValueOnce(slow)
|
||||
// 2eme appel : reponse immediate avec des donnees plus fraiches.
|
||||
mockApiGet.mockResolvedValueOnce({ member: [{ id: 2 }], totalItems: 30 })
|
||||
|
||||
const p1 = list.fetch() // token 1, en vol
|
||||
const p2 = list.fetch() // token 2, resout tout de suite
|
||||
await p2
|
||||
expect(list.items.value).toEqual([{ id: 2 }])
|
||||
|
||||
// La reponse lente du token 1 arrive enfin : elle doit etre ignoree.
|
||||
resolveSlow({ member: [{ id: 1 }], totalItems: 30 })
|
||||
await p1
|
||||
expect(list.items.value).toEqual([{ id: 2 }])
|
||||
// Le spinner reste eteint (la requete recente l'avait deja coupe).
|
||||
expect(list.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList({
|
||||
url: '/categories',
|
||||
extraQuery: { includeDeleted: 'true' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' })
|
||||
})
|
||||
|
||||
it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList<unknown, { name?: string; q?: string }>({
|
||||
url: '/users',
|
||||
defaultFilters: { name: '', q: undefined } as never,
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
const q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(q.name).toBeUndefined()
|
||||
expect(q.q).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refresh() est un alias de fetch()', async () => {
|
||||
mockResponse([{ id: 1 }], 1)
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||
|
||||
await list.refresh()
|
||||
expect(list.items.value).toEqual([{ id: 1 }])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FetchOptions , FetchError } from 'ofetch'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
|
||||
|
||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||
const data = responseData ?? (error as FetchError)?.data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string) ||
|
||||
(record.detail as string) ||
|
||||
(record.message as string) ||
|
||||
(record.error as string) ||
|
||||
(record.title as string) ||
|
||||
(record['hydra:title'] as string) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
const msg = extractApiErrorMessage(data)
|
||||
if (msg) return msg
|
||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
|
||||
/**
|
||||
* Composable generique de liste paginee serveur.
|
||||
*
|
||||
* Responsabilites :
|
||||
* - centraliser l'etat tableau (page courante, items/page, tri, filtres,
|
||||
* totalItems, items, loading, error) cote local — JAMAIS dans l'URL,
|
||||
* conformement a la regle ABSOLUE n°6 du CLAUDE.md (« Jamais persister
|
||||
* l'etat de tableau dans l'URL »).
|
||||
* - dialoguer avec une ressource API Platform 4 (Hydra) en passant
|
||||
* `page`, `itemsPerPage` et le tri/filtres en query params.
|
||||
* - exposer une API simple a brancher sur `MalioDataTable`
|
||||
* (props page/perPage/totalItems + events update:page / update:per-page).
|
||||
*
|
||||
* Volontairement **par-instance** (state local a chaque appel) : a la
|
||||
* difference de `useAuditLog` / `useCategoriesAdmin` qui sont des
|
||||
* singletons module-level partages, une liste paginee est propre a son
|
||||
* ecran et ne doit pas etre partagee entre pages (sinon un retour
|
||||
* arriere reprendrait la pagination d'une autre liste).
|
||||
*
|
||||
* Pas de gestion URL : si une page veut un deep link (ex : ouvrir un
|
||||
* detail), elle le fait via sa propre route, pas via la query string
|
||||
* de pagination. Derogation possible uniquement si l'utilisateur le
|
||||
* demande explicitement, cf. CLAUDE.md.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Direction de tri serveur. API Platform 4 attend `asc` ou `desc` via la
|
||||
* syntaxe `?order[field]=asc`.
|
||||
*/
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
/**
|
||||
* Specification de tri : un seul champ trie a la fois cote front (la
|
||||
* majorite des tableaux Malio n'expose pas le multi-tri). Si null, aucun
|
||||
* `order[...]` n'est envoye et l'API applique son tri par defaut.
|
||||
*/
|
||||
export interface SortSpec {
|
||||
field: string
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Type des filtres : un dictionnaire de valeurs serialisables en query
|
||||
* params. Le caller decide du mapping (ex : `{ active: true }`,
|
||||
* `{ 'name[ilike]': 'a' }`). Valeurs `null` / `undefined` / chaines vides
|
||||
* sont automatiquement omises au moment de la requete.
|
||||
*/
|
||||
export type PaginatedListFilters = Record<string, string | number | boolean | string[] | null | undefined>
|
||||
|
||||
export interface UsePaginatedListOptions<F extends PaginatedListFilters = PaginatedListFilters> {
|
||||
/** URL relative au prefix `/api` (ex : `/sites`, `/categories`). */
|
||||
url: string
|
||||
/** Items par page initial. Defaut 10 (aligne avec le defaut serveur). */
|
||||
defaultItemsPerPage?: number
|
||||
/** Options proposees dans le selecteur items/page. Defaut [10, 25, 50]. */
|
||||
itemsPerPageOptions?: number[]
|
||||
/** Filtres initiaux. */
|
||||
defaultFilters?: F
|
||||
/** Tri initial. */
|
||||
defaultSort?: SortSpec | null
|
||||
/**
|
||||
* Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
|
||||
* `groups[]=foo`) injectes a chaque requete. **Snapshot statique** : l'objet
|
||||
* est lu tel quel a chaque `fetch()`, ses valeurs ne sont pas deballees. Ne
|
||||
* pas y passer de `ref` / `computed` (elles seraient serialisees comme objet,
|
||||
* pas comme valeur) — pour un extra reactif, muter les filtres via
|
||||
* `setFilters` ou ouvrir un ticket pour un support `MaybeRefOrGetter`.
|
||||
*/
|
||||
extraQuery?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UsePaginatedListReturn<T, F extends PaginatedListFilters = PaginatedListFilters> {
|
||||
/** Items de la page courante. */
|
||||
items: Ref<T[]>
|
||||
/** Total d'items (toutes pages) renvoye par Hydra. */
|
||||
totalItems: Ref<number>
|
||||
/** Page courante (1-based). */
|
||||
currentPage: Ref<number>
|
||||
/** Taille de page courante. */
|
||||
itemsPerPage: Ref<number>
|
||||
/** Options exposees au selecteur items/page. */
|
||||
itemsPerPageOptions: Ref<number[]>
|
||||
/** Nombre total de pages (≥ 1). */
|
||||
totalPages: Ref<number>
|
||||
/** Indicateur de chargement (vrai pendant `fetch()`). */
|
||||
loading: Ref<boolean>
|
||||
/**
|
||||
* Derniere erreur de `fetch()` (null si le dernier appel a abouti).
|
||||
* Permet a la page de distinguer « liste reellement vide » d'un echec
|
||||
* reseau / 403 : sans ca, `isEmpty` confond les deux cas (la liste
|
||||
* tombe a 0 item dans les deux situations). La page decide de l'UX
|
||||
* (bandeau, bouton reessayer) — le composable ne toaste pas.
|
||||
*/
|
||||
error: Ref<unknown | null>
|
||||
/** Vrai apres au moins un fetch reussi avec 0 item. */
|
||||
isEmpty: Ref<boolean>
|
||||
/** Vrai si la collection tient en une seule page (totalPages <= 1). */
|
||||
isSinglePage: Ref<boolean>
|
||||
/** Filtres courants (mutation via `setFilters`). */
|
||||
filters: Ref<F>
|
||||
/** Tri courant (mutation via `setSort`). */
|
||||
sort: Ref<SortSpec | null>
|
||||
/** Lance un fetch contre l'API et met a jour items/totalItems. */
|
||||
fetch: () => Promise<void>
|
||||
/** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */
|
||||
goToPage: (page: number) => Promise<void>
|
||||
/** Page suivante (no-op si deja en derniere page). */
|
||||
nextPage: () => Promise<void>
|
||||
/** Page precedente (no-op si deja en premiere page). */
|
||||
prevPage: () => Promise<void>
|
||||
/** Change la taille de page et revient en page 1. */
|
||||
setItemsPerPage: (value: number) => Promise<void>
|
||||
/** Applique de nouveaux filtres et revient en page 1. */
|
||||
setFilters: (next: Partial<F>, options?: { replace?: boolean }) => Promise<void>
|
||||
/** Change le tri et revient en page 1. */
|
||||
setSort: (next: SortSpec | null) => Promise<void>
|
||||
/** Reinitialise filtres + tri + page sur les valeurs par defaut. */
|
||||
reset: () => Promise<void>
|
||||
/** Alias de `fetch()` (intention plus claire dans certains contextes). */
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Force `application/ld+json` : sous `application/json`, API Platform 4
|
||||
* renvoie un tableau plat sans envelope de pagination — on ne pourrait pas
|
||||
* lire `totalItems` ni `view`. Voir aussi `useAuditLog.ts`.
|
||||
*/
|
||||
const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
|
||||
|
||||
/**
|
||||
* Filtre les entrees nulles/undefined/vides d'un objet de query : evite
|
||||
* d'envoyer `?foo=&bar=null` a l'API qui declencherait parfois des erreurs
|
||||
* de filtre cote Symfony (`FilterInterface::apply` strict).
|
||||
*/
|
||||
function compactQuery(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (value === null || value === undefined) continue
|
||||
if (typeof value === 'string' && value === '') continue
|
||||
if (Array.isArray(value) && value.length === 0) continue
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedListFilters>(
|
||||
options: UsePaginatedListOptions<F>,
|
||||
): UsePaginatedListReturn<T, F> {
|
||||
const api = useApi()
|
||||
|
||||
const defaultItemsPerPage = options.defaultItemsPerPage ?? 10
|
||||
const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F
|
||||
const initialSort = options.defaultSort ?? null
|
||||
|
||||
const items = ref<T[]>([]) as Ref<T[]>
|
||||
const totalItems = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(defaultItemsPerPage)
|
||||
const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
|
||||
const loading = ref(false)
|
||||
const error = ref<unknown | null>(null)
|
||||
// Jeton de sequence : incremente a chaque `fetch()`. Une reponse dont
|
||||
// le jeton n'est plus le dernier est ignoree (protection contre les
|
||||
// reponses periemes quand l'utilisateur enchaine page / tri / filtres
|
||||
// plus vite que le reseau ne repond — sinon la derniere reponse
|
||||
// *arrivee* gagnerait au lieu de la derniere *demandee*).
|
||||
let fetchToken = 0
|
||||
// `hasFetched` evite que `isEmpty` retourne `true` avant le premier
|
||||
// chargement (etat initial = 0 items mais on ne sait pas encore si la
|
||||
// ressource est vide ou en cours de chargement). Un appel reseau au
|
||||
// moins doit avoir abouti pour qu'on annonce une liste « vide ».
|
||||
const hasFetched = ref(false)
|
||||
const filters = ref({ ...initialFilters }) as Ref<F>
|
||||
const sort = ref<SortSpec | null>(initialSort ? { ...initialSort } : null)
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (totalItems.value <= 0 || itemsPerPage.value <= 0) return 1
|
||||
return Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
|
||||
})
|
||||
const isEmpty = computed(() => hasFetched.value && totalItems.value === 0)
|
||||
const isSinglePage = computed(() => totalPages.value <= 1)
|
||||
|
||||
/**
|
||||
* Construit l'objet query envoye a l'API : extras + filtres, puis
|
||||
* pagination + tri. Les cles reservees (`page`, `itemsPerPage`,
|
||||
* `order[...]`) sont assignees **en dernier** pour qu'un filtre ou un
|
||||
* extra portant le meme nom ne puisse pas ecraser silencieusement la
|
||||
* pagination. Les filtres `null`/`undefined`/'' sont elimines pour ne
|
||||
* pas polluer l'URL.
|
||||
*/
|
||||
function buildQuery(): Record<string, unknown> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (options.extraQuery) {
|
||||
Object.assign(query, options.extraQuery)
|
||||
}
|
||||
Object.assign(query, filters.value)
|
||||
// Cles reservees en dernier : priorite a la pagination/au tri.
|
||||
query.page = currentPage.value
|
||||
query.itemsPerPage = itemsPerPage.value
|
||||
if (sort.value) {
|
||||
// Format API Platform : ?order[field]=asc
|
||||
query[`order[${sort.value.field}]`] = sort.value.direction
|
||||
}
|
||||
return compactQuery(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance un fetch et applique la borne haute si necessaire. Si la page
|
||||
* courante depasse `totalPages` apres l'application des filtres (cas
|
||||
* « j'etais en page 5, je filtre, il ne reste qu'une page »), on
|
||||
* rappelle l'API sur la derniere page valide. Un seul niveau de retry
|
||||
* pour eviter une boucle si l'API renvoie des resultats incoherents.
|
||||
*/
|
||||
async function fetch(): Promise<void> {
|
||||
const token = ++fetchToken
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await api.get<HydraCollection<T>>(
|
||||
options.url,
|
||||
buildQuery(),
|
||||
{ toast: false, headers: JSONLD_HEADERS },
|
||||
)
|
||||
// Une requete plus recente a ete lancee entre-temps : on jette
|
||||
// cette reponse pour ne pas ecraser des donnees plus fraiches.
|
||||
if (token !== fetchToken) return
|
||||
items.value = data.member ?? []
|
||||
totalItems.value = data.totalItems ?? 0
|
||||
|
||||
const tp = totalItems.value > 0
|
||||
? Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
|
||||
: 1
|
||||
|
||||
// Si on est hors borne ET qu'il y a au moins une page valide
|
||||
// a viser, on retombe sur la derniere page (cf. cas limite
|
||||
// « page hors borne apres filtre » de la spec #73). On ne
|
||||
// refetch que si la nouvelle page est differente, sinon
|
||||
// boucle infinie potentielle.
|
||||
if (currentPage.value > tp && tp >= 1 && totalItems.value > 0) {
|
||||
currentPage.value = tp
|
||||
const data2 = await api.get<HydraCollection<T>>(
|
||||
options.url,
|
||||
buildQuery(),
|
||||
{ toast: false, headers: JSONLD_HEADERS },
|
||||
)
|
||||
// Meme garde apres le refetch hors-borne.
|
||||
if (token !== fetchToken) return
|
||||
items.value = data2.member ?? []
|
||||
totalItems.value = data2.totalItems ?? 0
|
||||
}
|
||||
|
||||
hasFetched.value = true
|
||||
} catch (e) {
|
||||
// Reponse periemee : ne pas toucher au state, une requete plus
|
||||
// recente est en cours et fera foi.
|
||||
if (token !== fetchToken) return
|
||||
// Swallow volontaire : on remet la liste a vide pour ne pas
|
||||
// afficher de donnees stale, et on expose l'erreur pour que la
|
||||
// page distingue « vide » d'« echec ». Le composant parent
|
||||
// decide de l'UX (toast / message d'erreur) — pas d'a-priori ici.
|
||||
error.value = e
|
||||
items.value = []
|
||||
totalItems.value = 0
|
||||
hasFetched.value = true
|
||||
} finally {
|
||||
// Seule la requete la plus recente eteint le spinner : une
|
||||
// reponse periemee ne doit pas le couper alors qu'un fetch plus
|
||||
// recent est encore en vol.
|
||||
if (token === fetchToken) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goToPage(page: number): Promise<void> {
|
||||
const tp = totalPages.value
|
||||
const next = Math.max(1, Math.min(page, tp))
|
||||
if (next === currentPage.value) return
|
||||
currentPage.value = next
|
||||
await fetch()
|
||||
}
|
||||
|
||||
async function nextPage(): Promise<void> {
|
||||
if (currentPage.value >= totalPages.value) return
|
||||
await goToPage(currentPage.value + 1)
|
||||
}
|
||||
|
||||
async function prevPage(): Promise<void> {
|
||||
if (currentPage.value <= 1) return
|
||||
await goToPage(currentPage.value - 1)
|
||||
}
|
||||
|
||||
async function setItemsPerPage(value: number): Promise<void> {
|
||||
if (!Number.isFinite(value) || value <= 0) return
|
||||
const rounded = Math.floor(value)
|
||||
if (rounded === itemsPerPage.value) return
|
||||
itemsPerPage.value = rounded
|
||||
currentPage.value = 1
|
||||
await fetch()
|
||||
}
|
||||
|
||||
/**
|
||||
* `replace: false` (defaut) fusionne avec les filtres courants. Une
|
||||
* valeur explicitement `undefined` retire la cle (utile pour effacer
|
||||
* un filtre depuis un champ controle). `replace: true` remplace
|
||||
* integralement l'objet par `next`.
|
||||
*/
|
||||
async function setFilters(next: Partial<F>, opts?: { replace?: boolean }): Promise<void> {
|
||||
if (opts?.replace) {
|
||||
filters.value = { ...(next as F) }
|
||||
} else {
|
||||
const merged = { ...filters.value, ...next } as F
|
||||
// Supprime les cles explicitement passees a undefined : sans ce
|
||||
// nettoyage, l'objet `filters` accumulerait des cles fantomes.
|
||||
for (const key of Object.keys(next)) {
|
||||
if (next[key as keyof F] === undefined) {
|
||||
delete (merged as Record<string, unknown>)[key]
|
||||
}
|
||||
}
|
||||
filters.value = merged
|
||||
}
|
||||
currentPage.value = 1
|
||||
await fetch()
|
||||
}
|
||||
|
||||
async function setSort(next: SortSpec | null): Promise<void> {
|
||||
sort.value = next ? { ...next } : null
|
||||
currentPage.value = 1
|
||||
await fetch()
|
||||
}
|
||||
|
||||
async function reset(): Promise<void> {
|
||||
filters.value = { ...initialFilters }
|
||||
sort.value = initialSort ? { ...initialSort } : null
|
||||
itemsPerPage.value = defaultItemsPerPage
|
||||
currentPage.value = 1
|
||||
await fetch()
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
isEmpty,
|
||||
isSinglePage,
|
||||
filters,
|
||||
sort,
|
||||
fetch,
|
||||
goToPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
setSort,
|
||||
reset,
|
||||
refresh: fetch,
|
||||
}
|
||||
}
|
||||
@@ -43,3 +43,12 @@ export interface EffectivePermission {
|
||||
module: string
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupement de permissions par module pour l'affichage en accordeon.
|
||||
* Construit cote consommateur a partir de la liste plate /api/permissions.
|
||||
*/
|
||||
export interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
|
||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||
return collection.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||
* pointe le champ concerne, `message` est le libelle a afficher.
|
||||
*/
|
||||
export interface ApiViolation {
|
||||
propertyPath: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte
|
||||
* les deux formats de negociation (`violations` ou `hydra:violations`) et
|
||||
* renvoie un tableau vide si le payload n'en contient pas d'exploitables.
|
||||
*
|
||||
* Utilise par useCategoryForm et tout futur composable de formulaire qui
|
||||
* doit mapper les violations serveur sur ses champs.
|
||||
*/
|
||||
export function extractApiViolations(data: unknown): ApiViolation[] {
|
||||
if (!data || typeof data !== 'object') return []
|
||||
const record = data as Record<string, unknown>
|
||||
const raw = record.violations ?? record['hydra:violations']
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: ApiViolation[] = []
|
||||
for (const v of raw) {
|
||||
if (!v || typeof v !== 'object') continue
|
||||
const obj = v as Record<string, unknown>
|
||||
out.push({
|
||||
propertyPath: String(obj.propertyPath ?? ''),
|
||||
message: String(obj.message ?? ''),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||
* `hydra:description` → `detail` → `description` → `message` → `error` →
|
||||
* `title` → `hydra:title`. Renvoie '' si rien d'exploitable.
|
||||
*
|
||||
* Si `data` est une string, la renvoie telle quelle (cas des erreurs
|
||||
* Symfony en text/plain ou des messages bruts).
|
||||
*/
|
||||
export function extractApiErrorMessage(data: unknown): string {
|
||||
if (typeof data === 'string') return data
|
||||
if (!data || typeof data !== 'object') return ''
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string)
|
||||
?? (record.detail as string)
|
||||
?? (record.description as string)
|
||||
?? (record.message as string)
|
||||
?? (record.error as string)
|
||||
?? (record.title as string)
|
||||
?? (record['hydra:title'] as string)
|
||||
?? ''
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log'
|
||||
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'categories' | 'audit-log'
|
||||
|
||||
/**
|
||||
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
|
||||
|
||||
@@ -200,20 +200,28 @@ migration-migrate:
|
||||
# en DB, le purger crash.
|
||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
||||
# donc sync doit passer apres.
|
||||
# 4. recreation index `uq_category_name_type_active` : schema:update drop
|
||||
# les index orphelins du mapping ORM. L'index partiel (LOWER + WHERE) du
|
||||
# M0 Catalog n'est pas exprimable via les attributs Doctrine ORM 3
|
||||
# (fonctionnel + partiel), donc il disparait apres schema:update. On le
|
||||
# recree par dbal:run-sql pour que les tests RG-1.07 (unicite
|
||||
# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les
|
||||
# POST doublons remontent 201 au lieu de 409.
|
||||
# 4. recreation des index partiels uniques : schema:update drop les index
|
||||
# orphelins du mapping ORM. Les index partiels (LOWER + WHERE) ne sont pas
|
||||
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
|
||||
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
|
||||
# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
|
||||
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
|
||||
# parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55.
|
||||
# Sans ces restores, les POST doublons remontent 201 au lieu de 409.
|
||||
# 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT
|
||||
# ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte
|
||||
# pas d'attribut options['comment']). On rejoue le catalogue partage
|
||||
# `ColumnCommentsCatalog` pour conserver la documentation SQL exigee par
|
||||
# le test architecture ColumnsHaveSqlCommentTest (ERP-67).
|
||||
test-db-setup:
|
||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-67 — Retrofit `COMMENT ON COLUMN` / `COMMENT ON TABLE` sur toutes les
|
||||
* tables metier existantes.
|
||||
*
|
||||
* Postgres stocke la description dans `pg_description`. Les outils d'admin
|
||||
* (DBeaver, DataGrip, pgAdmin) l'affichent automatiquement, ce qui evite de
|
||||
* remonter au code Doctrine pour comprendre la semantique d'une colonne.
|
||||
*
|
||||
* Source unique : `ColumnCommentsCatalog::comments()`. Le meme catalogue est
|
||||
* rejoue par `app:apply-column-comments` apres `doctrine:schema:update --force`
|
||||
* en environnement de test (Doctrine ORM ne conservant pas les commentaires
|
||||
* absents du mapping PHP).
|
||||
*
|
||||
* Convention :
|
||||
* - Description en francais, ≤ 200 caracteres.
|
||||
* - Semantique du champ + contraintes / lien RG si pertinent.
|
||||
*
|
||||
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
|
||||
* Starseed n°11) car elle touche plusieurs modules. Les futures migrations
|
||||
* applicatives devront poser leur propre `COMMENT ON COLUMN` au moment de
|
||||
* creer leurs colonnes (cf. regle ABSOLUE n°12 + .claude/rules/backend.md).
|
||||
*/
|
||||
final class Version20260528120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-67 : retrofit COMMENT ON COLUMN/TABLE sur toutes les tables metier existantes.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Ne commente que les tables deja presentes a ce stade de la chaine de
|
||||
// migrations. Les modules crees plus tard (ex: M1 Commercial, 06-01)
|
||||
// figurent desormais dans le catalogue partage mais leurs tables
|
||||
// n'existent pas encore ici : elles posent leurs propres COMMENT dans
|
||||
// leur migration dediee (regle ABSOLUE n°12). Garde-fou indispensable,
|
||||
// sinon l'ajout d'un module au catalogue casse ce retrofit avec un
|
||||
// "relation X does not exist".
|
||||
$existingTables = array_values(array_filter(
|
||||
array_keys(ColumnCommentsCatalog::comments()),
|
||||
static fn (string $table): bool => $schema->hasTable($table),
|
||||
));
|
||||
|
||||
foreach (ColumnCommentsCatalog::toSqlStatements($existingTables) as $sql) {
|
||||
$this->addSql($sql);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
|
||||
// Symetrie avec up() : on n'efface que les commentaires des tables
|
||||
// presentes (les tables des modules ulterieurs sont gerees par leur
|
||||
// propre migration).
|
||||
if (!$schema->hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
foreach ($entries as $column => $_) {
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $quotedTable));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS NULL',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M1 — Repertoire clients (ERP-53) : creation de toute la structure BDD du
|
||||
* module Commercial (clients + sous-collections + referentiels comptables).
|
||||
*
|
||||
* Tables creees :
|
||||
* - Referentiels comptables (statiques, seedes ici) : tva_mode, payment_delay,
|
||||
* payment_type, bank.
|
||||
* - Table principale : client (formulaire + Information + Comptabilite +
|
||||
* archive + soft-delete + Timestampable/Blamable).
|
||||
* - Sous-collections : client_category (M2M), client_contact (1:n),
|
||||
* client_address (1:n), client_rib (1:n).
|
||||
* - Jointures de client_address : client_address_site, client_address_contact,
|
||||
* client_address_category.
|
||||
*
|
||||
* Seed `category_type` (extension M0) : DISTRIBUTEUR / COURTIER / SECTEUR /
|
||||
* AUTRE, en `ON CONFLICT (code) DO NOTHING` (idempotent — la table peut deja
|
||||
* porter des donnees en prod). En dev/test, les fixtures purgent et re-seedent
|
||||
* ces 4 types (cf. CategoryTypeFixtures) ; ce seed migration couvre la prod ou
|
||||
* les fixtures ne tournent pas.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
|
||||
* `App\Module\Commercial\...` : avec plusieurs migrations_paths, Doctrine
|
||||
* Migrations 3.x trie par FQCN alphabetique (AlphabeticalComparator → strcmp).
|
||||
* Un namespace `App\Module\Commercial\...` trierait AVANT `DoctrineMigrations\...`
|
||||
* et la migration s'executerait avant la creation de user/category/site sur
|
||||
* base vide → echec des FK. Le namespace racine garantit l'ordre par timestamp.
|
||||
*
|
||||
* Style DDL aligne sur la migration M0 (Version20260527164000) plutot que sur
|
||||
* le pseudo-SQL de la spec § 3.2 : `INT GENERATED BY DEFAULT AS IDENTITY` (et
|
||||
* non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non TIMESTAMPTZ, car le
|
||||
* `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que
|
||||
* `schema:update` restera un no-op quand les entites arriveront (ticket ERP-54).
|
||||
*
|
||||
* Decision Q4 (29/05/2026) : unicite metier sur le NOM DE SOCIETE uniquement.
|
||||
* Pas d'index unique sur siren ni email (RG-1.15 / RG-1.17 supprimees).
|
||||
*
|
||||
* Chaque colonne porte un `COMMENT ON COLUMN` (regle ABSOLUE n°12, garde-fou
|
||||
* ColumnsHaveSqlCommentTest). Les tables n'etant pas encore mappees par l'ORM,
|
||||
* ces commentaires survivent au `schema:update --force` du setup de test.
|
||||
*/
|
||||
final class Version20260601000000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-53 (M1) : tables client + sous-collections + referentiels comptables + seed category_type.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createAccountingReferentials();
|
||||
$this->createClientTable();
|
||||
$this->createClientCategory();
|
||||
$this->createClientContact();
|
||||
$this->createClientAddress();
|
||||
$this->createClientAddressJoinTables();
|
||||
$this->createClientRib();
|
||||
$this->seedCategoryTypes();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK : on supprime d'abord les jointures
|
||||
// et sous-collections, puis client, puis les referentiels.
|
||||
$this->addSql('DROP TABLE client_address_category');
|
||||
$this->addSql('DROP TABLE client_address_contact');
|
||||
$this->addSql('DROP TABLE client_address_site');
|
||||
$this->addSql('DROP TABLE client_rib');
|
||||
$this->addSql('DROP TABLE client_address');
|
||||
$this->addSql('DROP TABLE client_contact');
|
||||
$this->addSql('DROP TABLE client_category');
|
||||
$this->addSql('DROP TABLE client');
|
||||
$this->addSql('DROP TABLE bank');
|
||||
$this->addSql('DROP TABLE payment_type');
|
||||
$this->addSql('DROP TABLE payment_delay');
|
||||
$this->addSql('DROP TABLE tva_mode');
|
||||
|
||||
// Retire uniquement les 4 types seedes par cette migration ET restes
|
||||
// orphelins (aucune `category` ne les reference). Sans la clause
|
||||
// NOT EXISTS, le DELETE casse sur la FK RESTRICT category.category_type_id
|
||||
// des qu'une categorie pointe sur l'un d'eux. Symetrique du
|
||||
// `ON CONFLICT (code) DO NOTHING` du up() : on ne defait que ce qu'on a
|
||||
// reellement cree et qui n'est pas reutilise.
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM category_type
|
||||
WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Referentiels comptables (4 tables statiques, memes colonnes)
|
||||
// =================================================================
|
||||
|
||||
private function createAccountingReferentials(): void
|
||||
{
|
||||
$referentials = [
|
||||
'tva_mode' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
|
||||
'payment_delay' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
|
||||
'payment_type' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
|
||||
'bank' => 'Referentiel des banques selectionnables pour le reglement par virement.',
|
||||
];
|
||||
|
||||
foreach ($referentials as $table => $tableComment) {
|
||||
$this->addSql(sprintf(<<<'SQL'
|
||||
CREATE TABLE %s (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(30) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL, $table));
|
||||
$this->addSql(sprintf('CREATE UNIQUE INDEX uq_%s_code ON %s (code)', $table, $table));
|
||||
|
||||
$this->comment($table, '_table', $tableComment);
|
||||
$this->comment($table, 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment($table, 'code', 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.');
|
||||
$this->comment($table, 'label', 'Libelle affichable (FR, ≤ 120 caracteres).');
|
||||
$this->comment($table, 'position', 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).');
|
||||
}
|
||||
|
||||
// Seed initial (cf. spec § 3.2). Tables fraichement creees donc vides :
|
||||
// INSERT direct sans ON CONFLICT.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO tva_mode (code, label, position) VALUES
|
||||
('FRANCE_VENTES', 'France (ventes)', 10),
|
||||
('EXPORT_VENTES', 'Export (ventes)', 20),
|
||||
('INTRACOM_VENTES', 'Intracom (ventes)', 30)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO payment_delay (code, label, position) VALUES
|
||||
('J15', '15 jours', 10),
|
||||
('J30', '30 jours', 20),
|
||||
('A_RECEPTION', 'À réception', 30)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO payment_type (code, label, position) VALUES
|
||||
('VIREMENT', 'Virement', 10),
|
||||
('LCR', 'LCR', 20),
|
||||
('NON_SOUMISE', 'Non soumise', 30),
|
||||
('CHEQUE', 'Chèque', 40)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO bank (code, label, position) VALUES
|
||||
('SG', 'Société Générale', 10),
|
||||
('CIC', 'CIC', 20),
|
||||
('CA', 'Crédit Agricole', 30)
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `client`
|
||||
// =================================================================
|
||||
|
||||
private function createClientTable(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
company_name VARCHAR(180) NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) NOT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) NOT NULL,
|
||||
distributor_id INT DEFAULT NULL,
|
||||
broker_id INT DEFAULT NULL,
|
||||
triage_service BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
description TEXT DEFAULT NULL,
|
||||
competitors VARCHAR(255) DEFAULT NULL,
|
||||
founded_at DATE DEFAULT NULL,
|
||||
employees_count INT DEFAULT NULL,
|
||||
revenue_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||
director_name VARCHAR(120) DEFAULT NULL,
|
||||
profit_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||
siren VARCHAR(20) DEFAULT NULL,
|
||||
account_number VARCHAR(40) DEFAULT NULL,
|
||||
tva_mode_id INT DEFAULT NULL,
|
||||
n_tva VARCHAR(40) DEFAULT NULL,
|
||||
payment_delay_id INT DEFAULT NULL,
|
||||
payment_type_id INT DEFAULT NULL,
|
||||
bank_id INT DEFAULT NULL,
|
||||
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_client_distrib_or_broker
|
||||
CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)),
|
||||
CONSTRAINT fk_client_distributor
|
||||
FOREIGN KEY (distributor_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_broker
|
||||
FOREIGN KEY (broker_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_tva_mode
|
||||
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_payment_delay
|
||||
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_payment_type
|
||||
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_bank
|
||||
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_client_is_archived ON client (is_archived)');
|
||||
$this->addSql('CREATE INDEX idx_client_deleted_at ON client (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_client_distributor_id ON client (distributor_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_broker_id ON client (broker_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)');
|
||||
|
||||
// Index sur les FK des referentiels comptables — coherence avec les autres
|
||||
// FK deja indexees ci-dessus (Postgres n'indexe pas automatiquement les
|
||||
// colonnes portant une FOREIGN KEY).
|
||||
$this->addSql('CREATE INDEX idx_client_tva_mode_id ON client (tva_mode_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_payment_delay_id ON client (payment_delay_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_payment_type_id ON client (payment_type_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_bank_id ON client (bank_id)');
|
||||
|
||||
// Unicite metier partielle (Q4) : nom de societe insensible a la casse,
|
||||
// parmi les non-archives ET non soft-deletes uniquement. Pas d'index
|
||||
// unique sur siren ni email.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_client_company_name_active
|
||||
ON client (LOWER(company_name))
|
||||
WHERE is_archived = FALSE AND deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
$this->comment('client', '_table', 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).');
|
||||
$this->comment('client', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client', '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).');
|
||||
$this->comment('client', 'first_name', 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||
$this->comment('client', 'last_name', 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||
$this->comment('client', 'phone_primary', 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.');
|
||||
$this->comment('client', 'phone_secondary', 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).');
|
||||
$this->comment('client', 'email', 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).');
|
||||
$this->comment('client', '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.');
|
||||
$this->comment('client', '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.');
|
||||
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
|
||||
$this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
|
||||
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
|
||||
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
|
||||
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
|
||||
$this->comment('client', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
|
||||
$this->comment('client', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.');
|
||||
$this->comment('client', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).');
|
||||
$this->comment('client', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).');
|
||||
$this->comment('client', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).');
|
||||
$this->comment('client', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).');
|
||||
$this->comment('client', 'deleted_at', 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('client');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// M2M client <-> category
|
||||
// =================================================================
|
||||
|
||||
private function createClientCategory(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_category (
|
||||
client_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (client_id, category_id),
|
||||
CONSTRAINT fk_client_category_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_category_category ON client_category (category_id)');
|
||||
|
||||
$this->comment('client_category', '_table', 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).');
|
||||
$this->comment('client_category', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.');
|
||||
$this->comment('client_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : contacts (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createClientContact(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_contact (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
job_title VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) DEFAULT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_client_contact_name
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL),
|
||||
CONSTRAINT fk_client_contact_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_contact_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_contact_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_contact_client ON client_contact (client_id)');
|
||||
|
||||
$this->comment('client_contact', '_table', 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).');
|
||||
$this->comment('client_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client_contact', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.');
|
||||
$this->comment('client_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||
$this->comment('client_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||
$this->comment('client_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||
$this->comment('client_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (RG-1.20).');
|
||||
$this->comment('client_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).');
|
||||
$this->comment('client_contact', 'email', 'Email du contact (lowercase serveur, RG-1.21).');
|
||||
$this->comment('client_contact', 'position', 'Ordre d affichage du contact dans la liste du client (croissant).');
|
||||
$this->addTimestampableBlamableComments('client_contact');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : adresses (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createClientAddress(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
is_prospect BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
is_delivery BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
is_billing BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
||||
postal_code VARCHAR(20) NOT NULL,
|
||||
city VARCHAR(120) NOT NULL,
|
||||
street VARCHAR(255) NOT NULL,
|
||||
street_complement VARCHAR(255) DEFAULT NULL,
|
||||
billing_email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_client_address_prospect_exclusive
|
||||
CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))),
|
||||
CONSTRAINT chk_client_address_billing_email
|
||||
CHECK ((is_billing = FALSE AND billing_email IS NULL)
|
||||
OR (is_billing = TRUE AND billing_email IS NOT NULL)),
|
||||
CONSTRAINT fk_client_address_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_address_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_address_client ON client_address (client_id)');
|
||||
|
||||
$this->comment('client_address', '_table', 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).');
|
||||
$this->comment('client_address', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client_address', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.');
|
||||
$this->comment('client_address', 'is_prospect', 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.');
|
||||
$this->comment('client_address', 'is_delivery', 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.');
|
||||
$this->comment('client_address', 'is_billing', 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.');
|
||||
$this->comment('client_address', 'country', 'Pays de l adresse — defaut France.');
|
||||
$this->comment('client_address', 'postal_code', 'Code postal (4-5 chiffres attendus, RG-1.09).');
|
||||
$this->comment('client_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).');
|
||||
$this->comment('client_address', 'street', 'Numero et voie de l adresse.');
|
||||
$this->comment('client_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
||||
$this->comment('client_address', 'billing_email', 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).');
|
||||
$this->comment('client_address', 'position', 'Ordre d affichage de l adresse dans la liste du client (croissant).');
|
||||
$this->addTimestampableBlamableComments('client_address');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Jointures de client_address (M2M)
|
||||
// =================================================================
|
||||
|
||||
private function createClientAddressJoinTables(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address_site (
|
||||
client_address_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (client_address_id, site_id),
|
||||
CONSTRAINT fk_client_address_site_address
|
||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('client_address_site', '_table', 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).');
|
||||
$this->comment('client_address_site', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('client_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address_contact (
|
||||
client_address_id INT NOT NULL,
|
||||
client_contact_id INT NOT NULL,
|
||||
PRIMARY KEY (client_address_id, client_contact_id),
|
||||
CONSTRAINT fk_client_address_contact_address
|
||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_contact_contact
|
||||
FOREIGN KEY (client_contact_id) REFERENCES client_contact (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
$this->comment('client_address_contact', '_table', 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.');
|
||||
$this->comment('client_address_contact', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('client_address_contact', 'client_contact_id', 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address_category (
|
||||
client_address_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (client_address_id, category_id),
|
||||
CONSTRAINT fk_client_address_category_address
|
||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('client_address_category', '_table', 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).');
|
||||
$this->comment('client_address_category', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('client_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : RIB (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createClientRib(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_rib (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
bic VARCHAR(20) NOT NULL,
|
||||
iban VARCHAR(34) NOT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_client_rib_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_rib_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_rib_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_rib_client ON client_rib (client_id)');
|
||||
|
||||
$this->comment('client_rib', '_table', 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).');
|
||||
$this->comment('client_rib', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client_rib', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.');
|
||||
$this->comment('client_rib', 'label', 'Libelle du RIB (ex: compte principal).');
|
||||
$this->comment('client_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
|
||||
$this->comment('client_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
|
||||
$this->comment('client_rib', 'position', 'Ordre d affichage du RIB dans la liste du client (croissant).');
|
||||
$this->addTimestampableBlamableComments('client_rib');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Seed extension category_type (M0)
|
||||
// =================================================================
|
||||
|
||||
private function seedCategoryTypes(): void
|
||||
{
|
||||
// Idempotent : la table category_type peut deja porter des donnees en
|
||||
// prod. ON CONFLICT (code) s appuie sur l index unique uq_category_type_code.
|
||||
// NB : la table M0 n a pas de colonne `position` (id/code/label seulement),
|
||||
// contrairement au pseudo-SQL de la spec § 3.3.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_type (code, label) VALUES
|
||||
('DISTRIBUTEUR', 'Distributeur'),
|
||||
('COURTIER', 'Courtier'),
|
||||
('SECTEUR', 'Secteur'),
|
||||
('AUTRE', 'Autre')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, cf. ERP-67).
|
||||
*/
|
||||
private function addTimestampableBlamableComments(string $table): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||
$this->comment($table, $column, $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
|
||||
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
|
||||
* tout echappement d apostrophe.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvide
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
@@ -82,7 +83,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Category implements TimestampableInterface, BlamableInterface
|
||||
class Category implements TimestampableInterface, BlamableInterface, CategoryInterface
|
||||
{
|
||||
// === Timestampable + Blamable ===
|
||||
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
||||
@@ -152,6 +153,16 @@ class Category implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implemente CategoryInterface : code du type rattache (ou null). Permet
|
||||
* aux modules tiers de filtrer/valider par type metier sans dependre de
|
||||
* Catalog.
|
||||
*/
|
||||
public function getCategoryTypeCode(): ?string
|
||||
{
|
||||
return $this->categoryType?->getCode();
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
|
||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
@@ -29,18 +32,32 @@ final class CategoryProvider implements ProviderInterface
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
|
||||
private readonly CategoryRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|Paginator|null
|
||||
{
|
||||
$includeDeleted = $this->readIncludeDeleted($context);
|
||||
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->repository
|
||||
->createListQueryBuilder($includeDeleted)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
$qb = $this->repository->createListQueryBuilder($includeDeleted);
|
||||
|
||||
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
|
||||
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
// Branche paginee standard : on applique offset/limit via Pagination,
|
||||
// puis on enveloppe dans le Paginator ORM (fetchJoinCollection: true
|
||||
// pour que Doctrine compte correctement avec les JOINs futurs).
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||
}
|
||||
|
||||
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures du module Catalog : seed des types de categorie metier (M1).
|
||||
*
|
||||
* La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4
|
||||
* types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3).
|
||||
*
|
||||
* Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) :
|
||||
* `category_type` est une entite managee par l ORM, donc le purger Doctrine la
|
||||
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types
|
||||
* seedes par la migration disparaitraient apres `make db-reset` / setup de test.
|
||||
* Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette
|
||||
* fixture re-aligne dev et test. Les deux chemins produisent un etat identique.
|
||||
*
|
||||
* Idempotence : lookup par `code` parmi les types existants avant insertion,
|
||||
* sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme
|
||||
* si le purger est desactive.
|
||||
*/
|
||||
class CategoryTypeFixtures extends Fixture
|
||||
{
|
||||
/**
|
||||
* Source unique des 4 types metier : code technique => libelle FR.
|
||||
* Doit rester aligne sur le seed de la migration Version20260601000000.
|
||||
*/
|
||||
private const TYPES = [
|
||||
'DISTRIBUTEUR' => 'Distributeur',
|
||||
'COURTIER' => 'Courtier',
|
||||
'SECTEUR' => 'Secteur',
|
||||
'AUTRE' => 'Autre',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CategoryTypeRepositoryInterface $categoryTypeRepository,
|
||||
) {}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Index des types deja presents par code, pour ne pas creer de doublon.
|
||||
$existingByCode = [];
|
||||
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
|
||||
$existingByCode[$type->getCode()] = $type;
|
||||
}
|
||||
|
||||
foreach (self::TYPES as $code => $label) {
|
||||
$type = $existingByCode[$code] ?? new CategoryType();
|
||||
$type->setCode($code);
|
||||
$type->setLabel($label);
|
||||
$manager->persist($type);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Application\Service;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un Client / ClientContact, appliquee
|
||||
* par le ClientProcessor (et plus tard le ClientContactProcessor) AVANT
|
||||
* persistance. Cf. spec-back M1 § 2.9 + RG-1.18 a RG-1.21.
|
||||
*
|
||||
* - companyName : UPPERCASE integral (RG-1.18)
|
||||
* - firstName / lastName (personnes) : Title Case (RG-1.19)
|
||||
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "0612345678" (RG-1.20).
|
||||
* Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front.
|
||||
* - email : lowercase integral (RG-1.21)
|
||||
*
|
||||
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide
|
||||
* apres trim devient null (evite de persister "" dans des colonnes nullable).
|
||||
*/
|
||||
final class ClientFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Nom de societe en majuscules (RG-1.18). Conserve null tel quel ; une
|
||||
* chaine non vide est trim + upper. Une chaine vide reste "" (champ
|
||||
* obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer).
|
||||
*/
|
||||
public function normalizeCompanyName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_strtoupper(trim($value), 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom/prenom de personne en Title Case (RG-1.19) : "JEAN dupont" ->
|
||||
* "Jean Dupont". Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizePersonName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Email en minuscules (RG-1.21). Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizeEmail(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_strtolower($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Telephone reduit aux chiffres (RG-1.20) : "06.12.34.56.78" ->
|
||||
* "0612345678". Une valeur sans aucun chiffre devient null.
|
||||
*/
|
||||
public function normalizePhone(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $value) ?? '';
|
||||
|
||||
return '' === $digits ? null : $digits;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Application\Validator;
|
||||
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Validator metier RG-1.04 : pour un utilisateur portant le role metier
|
||||
* Commerciale, TOUS les champs de l'onglet Information deviennent obligatoires
|
||||
* lors d'un PATCH touchant le groupe `client:write:information`.
|
||||
*
|
||||
* Invoque par le ClientProcessor UNIQUEMENT quand les deux conditions sont
|
||||
* reunies (role Commerciale + payload touchant l'onglet Information). Pour les
|
||||
* autres roles, ces champs restent optionnels — le validator n'est pas appele.
|
||||
*
|
||||
* Tant qu'aucun user ne porte le role `commerciale` (seede par ERP-74,
|
||||
* cf. App\Shared\Domain\Security\BusinessRoles::COMMERCIALE), cette regle reste
|
||||
* DORMANTE : aucun appelant ne la declenche.
|
||||
*
|
||||
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
|
||||
* coherence avec les violations Symfony rendues par API Platform.
|
||||
*/
|
||||
final class ClientInformationCompletenessValidator
|
||||
{
|
||||
public function validate(Client $client): void
|
||||
{
|
||||
// Map champ -> valeur courante de l'onglet Information.
|
||||
$fields = [
|
||||
'description' => $client->getDescription(),
|
||||
'competitors' => $client->getCompetitors(),
|
||||
'foundedAt' => $client->getFoundedAt(),
|
||||
'employeesCount' => $client->getEmployeesCount(),
|
||||
'revenueAmount' => $client->getRevenueAmount(),
|
||||
'directorName' => $client->getDirectorName(),
|
||||
'profitAmount' => $client->getProfitAmount(),
|
||||
];
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
|
||||
foreach ($fields as $property => $value) {
|
||||
if ($this->isMissing($value)) {
|
||||
$violations->add(new ConstraintViolation(
|
||||
sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property),
|
||||
null,
|
||||
[],
|
||||
$client,
|
||||
$property,
|
||||
$value,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (count($violations) > 0) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Une valeur est manquante si null ou, pour une chaine, vide apres trim.
|
||||
* Les zeros numeriques (employeesCount = 0, profitAmount = "0.00") sont des
|
||||
* valeurs valides : on ne les considere pas manquants.
|
||||
*/
|
||||
private function isMissing(mixed $value): bool
|
||||
{
|
||||
if (null === $value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($value) && '' === trim($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineBankRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Banque selectionnable pour le reglement par virement (Societe Generale,
|
||||
* CIC, Credit Agricole) : referentiel statique seede par la migration M1 et
|
||||
* re-seede en dev/test par CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
|
||||
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
|
||||
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineBankRepository::class)]
|
||||
#[ORM\Table(name: 'bank')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_bank_code', columns: ['code'])]
|
||||
class Bank
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['bank:read'])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\ClientProvider;
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Client (M1 Commercial) — entite racine du repertoire clients. Porte le
|
||||
* formulaire principal, l'onglet Information, l'onglet Comptabilite, le
|
||||
* mecanisme d'archivage (is_archived / archived_at) et le soft-delete technique
|
||||
* prepare mais non expose au M1 (deleted_at, HP-M2-1).
|
||||
*
|
||||
* Decisions structurantes :
|
||||
* - Audit complet (#[Auditable]) sur tous les champs (M2M categories audite
|
||||
* automatiquement). Timestampable/Blamable via le trait Shared.
|
||||
* - PAS de #[ORM\UniqueConstraint] : l'unicite du nom de societe (RG-1.16) est
|
||||
* portee par l'index partiel fonctionnel uq_client_company_name_active
|
||||
* (LOWER(company_name) WHERE is_archived = FALSE AND deleted_at IS NULL),
|
||||
* inexprimable en attribut ORM, donc possede par la seule migration. Le SIREN
|
||||
* et l'email NE SONT PAS uniques (RG-1.15/1.17 supprimees, decision Q4).
|
||||
* - distributor / broker : 2 FK auto-referentes mutuellement exclusives
|
||||
* (RG-1.03, CHECK chk_client_distrib_or_broker en base).
|
||||
* - categories : M2M vers Category (module Catalog) via le contrat
|
||||
* CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct).
|
||||
*
|
||||
* Operations API (Provider + Processor) branchees en ERP-55 :
|
||||
* - GetCollection / Get : security commercial.clients.view. La liste expose le
|
||||
* groupe client:read ; le detail embarque en plus contacts/adresses/ribs
|
||||
* (groupe client:item:read). Les champs comptables (client:read:accounting)
|
||||
* sont ajoutes DYNAMIQUEMENT par ClientReadGroupContextBuilder si l'user a
|
||||
* la permission accounting.view (§ 2.7 / § 4.1 / § 4.2).
|
||||
* - Post / Patch : security commercial.clients.manage ; le ClientProcessor
|
||||
* applique normalisation, gating accounting/archive et regles metier.
|
||||
* - Pas de Delete au M1 (HP-M2-1) : l'archivage passe par PATCH isArchived.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
provider: ClientProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('commercial.clients.view')",
|
||||
// Detail : client + sous-collections embarquees. Le groupe
|
||||
// client:read:accounting est ajoute par le context builder selon la
|
||||
// permission, donc absent ici volontairement.
|
||||
normalizationContext: ['groups' => [
|
||||
'client:read',
|
||||
'client:item:read',
|
||||
'client_contact:read',
|
||||
'client_address:read',
|
||||
'client_rib:read',
|
||||
'default:read',
|
||||
]],
|
||||
provider: ClientProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['client:write:main']],
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('commercial.clients.manage')",
|
||||
// Le ClientProcessor inspecte les champs reellement envoyes pour
|
||||
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
|
||||
// champs accounting exigent accounting.manage, isArchived exige
|
||||
// archive.
|
||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => [
|
||||
'client:write:main',
|
||||
'client:write:information',
|
||||
'client:write:accounting',
|
||||
'client:write:archive',
|
||||
]],
|
||||
provider: ClientProvider::class,
|
||||
processor: ClientProcessor::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
|
||||
#[ORM\Table(name: 'client')]
|
||||
// Index nommes pour matcher la migration (Version20260601000000). L'index
|
||||
// unique partiel uq_client_company_name_active reste possede par la migration :
|
||||
// Doctrine ORM ne sait pas exprimer un index fonctionnel (LOWER) + partiel
|
||||
// (WHERE) via attribut. Pas de #[ORM\UniqueConstraint] (decision Q4).
|
||||
#[ORM\Index(name: 'idx_client_is_archived', columns: ['is_archived'])]
|
||||
#[ORM\Index(name: 'idx_client_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_client_distributor_id', columns: ['distributor_id'])]
|
||||
#[ORM\Index(name: 'idx_client_broker_id', columns: ['broker_id'])]
|
||||
#[ORM\Index(name: 'idx_client_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_client_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Client implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// === Formulaire principal ===
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(min: 2, max: 180, normalizer: 'trim')]
|
||||
#[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;
|
||||
|
||||
// RG-1.03 : distributor / broker auto-references mutuellement exclusives
|
||||
// (CHECK chk_client_distrib_or_broker en base).
|
||||
#[ORM\ManyToOne(targetEntity: self::class)]
|
||||
#[ORM\JoinColumn(name: 'distributor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?Client $distributor = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: self::class)]
|
||||
#[ORM\JoinColumn(name: 'broker_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?Client $broker = null;
|
||||
|
||||
#[ORM\Column(name: 'triage_service', options: ['default' => false])]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private bool $triageService = false;
|
||||
|
||||
// RG : au moins une categorie (Count min 1). M2M vers Category via le contrat
|
||||
// CategoryInterface (resolve_target_entities -> Category).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'client_category')]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private Collection $categories;
|
||||
|
||||
// === Onglet Information ===
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $competitors = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?DateTimeImmutable $foundedAt = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
#[Assert\PositiveOrZero]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?int $employeesCount = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $revenueAmount = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $directorName = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $profitAmount = null;
|
||||
|
||||
// === Onglet Comptabilite ===
|
||||
// Lecture conditionnee via le groupe `client:read:accounting` (ajoute par le
|
||||
// futur Provider si l'user a la permission accounting.view). Ecriture via
|
||||
// `client:write:accounting` (le futur Processor exige accounting.manage).
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $siren = null;
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TvaMode::class)]
|
||||
#[ORM\JoinColumn(name: 'tva_mode_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?TvaMode $tvaMode = null;
|
||||
|
||||
#[ORM\Column(length: 40, nullable: true)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $nTva = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PaymentDelay::class)]
|
||||
#[ORM\JoinColumn(name: 'payment_delay_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?PaymentDelay $paymentDelay = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PaymentType::class)]
|
||||
#[ORM\JoinColumn(name: 'payment_type_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?PaymentType $paymentType = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Bank::class)]
|
||||
#[ORM\JoinColumn(name: 'bank_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?Bank $bank = null;
|
||||
|
||||
// === Sous-collections (exposees via sous-ressources API dediees, ulterieur) ===
|
||||
/** @var Collection<int, ClientContact> */
|
||||
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientContact::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contacts;
|
||||
|
||||
/** @var Collection<int, ClientAddress> */
|
||||
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientAddress::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $addresses;
|
||||
|
||||
/** @var Collection<int, ClientRib> */
|
||||
#[ORM\OneToMany(mappedBy: 'client', targetEntity: ClientRib::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $ribs;
|
||||
|
||||
// === Archive / Soft delete ===
|
||||
// Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH
|
||||
// archive). Le groupe de LECTURE est declare sur le getter isArchived()
|
||||
// avec SerializedName('isArchived') : sans cela, Symfony strip le prefixe
|
||||
// "is" et exposerait la cle JSON "archived" (meme pattern que User::isAdmin
|
||||
// et Role::isSystem).
|
||||
#[ORM\Column(name: 'is_archived', options: ['default' => false])]
|
||||
#[Groups(['client:write:archive'])]
|
||||
private bool $isArchived = false;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
#[Groups(['client:read'])]
|
||||
private ?DateTimeImmutable $archivedAt = null;
|
||||
|
||||
// Soft delete technique (HP-M2-1) : non expose en lecture/ecriture au M1.
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->categories = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->addresses = new ArrayCollection();
|
||||
$this->ribs = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCompanyName(): ?string
|
||||
{
|
||||
return $this->companyName;
|
||||
}
|
||||
|
||||
public function setCompanyName(string $companyName): static
|
||||
{
|
||||
$this->companyName = $companyName;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function setDistributor(?Client $distributor): static
|
||||
{
|
||||
$this->distributor = $distributor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBroker(): ?Client
|
||||
{
|
||||
return $this->broker;
|
||||
}
|
||||
|
||||
public function setBroker(?Client $broker): static
|
||||
{
|
||||
$this->broker = $broker;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isTriageService(): bool
|
||||
{
|
||||
return $this->triageService;
|
||||
}
|
||||
|
||||
public function setTriageService(bool $triageService): static
|
||||
{
|
||||
$this->triageService = $triageService;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCompetitors(): ?string
|
||||
{
|
||||
return $this->competitors;
|
||||
}
|
||||
|
||||
public function setCompetitors(?string $competitors): static
|
||||
{
|
||||
$this->competitors = $competitors;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFoundedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->foundedAt;
|
||||
}
|
||||
|
||||
public function setFoundedAt(?DateTimeImmutable $foundedAt): static
|
||||
{
|
||||
$this->foundedAt = $foundedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmployeesCount(): ?int
|
||||
{
|
||||
return $this->employeesCount;
|
||||
}
|
||||
|
||||
public function setEmployeesCount(?int $employeesCount): static
|
||||
{
|
||||
$this->employeesCount = $employeesCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRevenueAmount(): ?string
|
||||
{
|
||||
return $this->revenueAmount;
|
||||
}
|
||||
|
||||
public function setRevenueAmount(?string $revenueAmount): static
|
||||
{
|
||||
$this->revenueAmount = $revenueAmount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDirectorName(): ?string
|
||||
{
|
||||
return $this->directorName;
|
||||
}
|
||||
|
||||
public function setDirectorName(?string $directorName): static
|
||||
{
|
||||
$this->directorName = $directorName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProfitAmount(): ?string
|
||||
{
|
||||
return $this->profitAmount;
|
||||
}
|
||||
|
||||
public function setProfitAmount(?string $profitAmount): static
|
||||
{
|
||||
$this->profitAmount = $profitAmount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSiren(): ?string
|
||||
{
|
||||
return $this->siren;
|
||||
}
|
||||
|
||||
public function setSiren(?string $siren): static
|
||||
{
|
||||
$this->siren = $siren;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAccountNumber(): ?string
|
||||
{
|
||||
return $this->accountNumber;
|
||||
}
|
||||
|
||||
public function setAccountNumber(?string $accountNumber): static
|
||||
{
|
||||
$this->accountNumber = $accountNumber;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTvaMode(): ?TvaMode
|
||||
{
|
||||
return $this->tvaMode;
|
||||
}
|
||||
|
||||
public function setTvaMode(?TvaMode $tvaMode): static
|
||||
{
|
||||
$this->tvaMode = $tvaMode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNTva(): ?string
|
||||
{
|
||||
return $this->nTva;
|
||||
}
|
||||
|
||||
public function setNTva(?string $nTva): static
|
||||
{
|
||||
$this->nTva = $nTva;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPaymentDelay(): ?PaymentDelay
|
||||
{
|
||||
return $this->paymentDelay;
|
||||
}
|
||||
|
||||
public function setPaymentDelay(?PaymentDelay $paymentDelay): static
|
||||
{
|
||||
$this->paymentDelay = $paymentDelay;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPaymentType(): ?PaymentType
|
||||
{
|
||||
return $this->paymentType;
|
||||
}
|
||||
|
||||
public function setPaymentType(?PaymentType $paymentType): static
|
||||
{
|
||||
$this->paymentType = $paymentType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBank(): ?Bank
|
||||
{
|
||||
return $this->bank;
|
||||
}
|
||||
|
||||
public function setBank(?Bank $bank): static
|
||||
{
|
||||
$this->bank = $bank;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientContact> */
|
||||
#[Groups(['client:item:read'])]
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
|
||||
public function addContact(ClientContact $contact): static
|
||||
{
|
||||
if (!$this->contacts->contains($contact)) {
|
||||
$this->contacts->add($contact);
|
||||
$contact->setClient($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeContact(ClientContact $contact): static
|
||||
{
|
||||
if ($this->contacts->removeElement($contact) && $contact->getClient() === $this) {
|
||||
$contact->setClient(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientAddress> */
|
||||
#[Groups(['client:item:read'])]
|
||||
public function getAddresses(): Collection
|
||||
{
|
||||
return $this->addresses;
|
||||
}
|
||||
|
||||
public function addAddress(ClientAddress $address): static
|
||||
{
|
||||
if (!$this->addresses->contains($address)) {
|
||||
$this->addresses->add($address);
|
||||
$address->setClient($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeAddress(ClientAddress $address): static
|
||||
{
|
||||
if ($this->addresses->removeElement($address) && $address->getClient() === $this) {
|
||||
$address->setClient(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientRib> */
|
||||
#[Groups(['client:item:read'])]
|
||||
public function getRibs(): Collection
|
||||
{
|
||||
return $this->ribs;
|
||||
}
|
||||
|
||||
public function addRib(ClientRib $rib): static
|
||||
{
|
||||
if (!$this->ribs->contains($rib)) {
|
||||
$this->ribs->add($rib);
|
||||
$rib->setClient($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeRib(ClientRib $rib): static
|
||||
{
|
||||
if ($this->ribs->removeElement($rib) && $rib->getClient() === $this) {
|
||||
$rib->setClient(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony
|
||||
// exposerait la cle "archived" (strip du prefixe "is" sur les getters).
|
||||
#[Groups(['client:read'])]
|
||||
#[SerializedName('isArchived')]
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->isArchived;
|
||||
}
|
||||
|
||||
public function setIsArchived(bool $isArchived): static
|
||||
{
|
||||
$this->isArchived = $isArchived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getArchivedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->archivedAt;
|
||||
}
|
||||
|
||||
public function setArchivedAt(?DateTimeImmutable $archivedAt): static
|
||||
{
|
||||
$this->archivedAt = $archivedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Adresse d'un client (1:n) — onglet Adresse. Une adresse de prospection
|
||||
* (isProspect) est exclusive d'une adresse de livraison/facturation
|
||||
* (RG-1.06/07/08, CHECK BDD). Un email de facturation est obligatoire ssi
|
||||
* isBilling (RG-1.11, CHECK BDD). Au moins un site doit etre rattache
|
||||
* (RG-1.10, Assert\Count).
|
||||
*
|
||||
* Relations M2M :
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
||||
* - contacts : ClientContact (meme module)
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
||||
* — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, futur Processor)
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable. Aucun ApiResource au M1.1
|
||||
* (sous-ressources branchees a un ticket dedie).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)]
|
||||
#[ORM\Table(name: 'client_address')]
|
||||
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
|
||||
#[Auditable]
|
||||
class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client_address:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Client $client = null;
|
||||
|
||||
#[ORM\Column(name: 'is_prospect', options: ['default' => false])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private bool $isProspect = false;
|
||||
|
||||
#[ORM\Column(name: 'is_delivery', options: ['default' => false])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private bool $isDelivery = false;
|
||||
|
||||
#[ORM\Column(name: 'is_billing', options: ['default' => false])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private bool $isBilling = false;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private string $country = 'France';
|
||||
|
||||
// RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur).
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $postalCode = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
// RG-1.11 : obligatoire ssi isBilling (CHECK BDD + futur Processor).
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
#[Assert\Email]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $billingEmail = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
// RG-1.10 : au moins un site rattache a chaque adresse.
|
||||
/** @var Collection<int, SiteInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: SiteInterface::class)]
|
||||
#[ORM\JoinTable(name: 'client_address_site')]
|
||||
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins un site est obligatoire.')]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private Collection $sites;
|
||||
|
||||
/** @var Collection<int, ClientContact> */
|
||||
#[ORM\ManyToMany(targetEntity: ClientContact::class)]
|
||||
#[ORM\JoinTable(name: 'client_address_contact')]
|
||||
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'client_contact_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private Collection $contacts;
|
||||
|
||||
// RG-1.29 : categories de type SECTEUR/AUTRE uniquement (filtre au Processor).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'client_address_category')]
|
||||
#[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private Collection $categories;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->categories = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getClient(): ?Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?Client $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isProspect(): bool
|
||||
{
|
||||
return $this->isProspect;
|
||||
}
|
||||
|
||||
public function setIsProspect(bool $isProspect): static
|
||||
{
|
||||
$this->isProspect = $isProspect;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDelivery(): bool
|
||||
{
|
||||
return $this->isDelivery;
|
||||
}
|
||||
|
||||
public function setIsDelivery(bool $isDelivery): static
|
||||
{
|
||||
$this->isDelivery = $isDelivery;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isBilling(): bool
|
||||
{
|
||||
return $this->isBilling;
|
||||
}
|
||||
|
||||
public function setIsBilling(bool $isBilling): static
|
||||
{
|
||||
$this->isBilling = $isBilling;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountry(): string
|
||||
{
|
||||
return $this->country;
|
||||
}
|
||||
|
||||
public function setCountry(string $country): static
|
||||
{
|
||||
$this->country = $country;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPostalCode(): ?string
|
||||
{
|
||||
return $this->postalCode;
|
||||
}
|
||||
|
||||
public function setPostalCode(?string $postalCode): static
|
||||
{
|
||||
$this->postalCode = $postalCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): ?string
|
||||
{
|
||||
return $this->street;
|
||||
}
|
||||
|
||||
public function setStreet(?string $street): static
|
||||
{
|
||||
$this->street = $street;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreetComplement(): ?string
|
||||
{
|
||||
return $this->streetComplement;
|
||||
}
|
||||
|
||||
public function setStreetComplement(?string $streetComplement): static
|
||||
{
|
||||
$this->streetComplement = $streetComplement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBillingEmail(): ?string
|
||||
{
|
||||
return $this->billingEmail;
|
||||
}
|
||||
|
||||
public function setBillingEmail(?string $billingEmail): static
|
||||
{
|
||||
$this->billingEmail = $billingEmail;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, SiteInterface> */
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
public function addSite(SiteInterface $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSite(SiteInterface $site): static
|
||||
{
|
||||
$this->sites->removeElement($site);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientContact> */
|
||||
public function getContacts(): Collection
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
|
||||
public function addContact(ClientContact $contact): static
|
||||
{
|
||||
if (!$this->contacts->contains($contact)) {
|
||||
$this->contacts->add($contact);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeContact(ClientContact $contact): static
|
||||
{
|
||||
$this->contacts->removeElement($contact);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Contact d'un client (1:n) — onglet Contact. Au moins firstName OU lastName
|
||||
* doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD
|
||||
* (chk_client_contact_name) et validee dans le futur ClientContactProcessor ;
|
||||
* l'entite reste permissive (les deux champs sont nullable).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard).
|
||||
* Les operations CRUD (sous-ressources POST/PATCH/DELETE) sont branchees au
|
||||
* ticket dedie des sous-ressources — aucun ApiResource au M1.1 (ERP-54).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)]
|
||||
#[ORM\Table(name: 'client_contact')]
|
||||
#[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])]
|
||||
#[Auditable]
|
||||
class ClientContact implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client_contact:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'contacts')]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Client $client = null;
|
||||
|
||||
// RG-1.05 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les
|
||||
// deux restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $phonePrimary = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $phoneSecondary = null;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true)]
|
||||
#[Assert\Email]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['client_contact:read', 'client_contact:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getClient(): ?Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?Client $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
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 getJobTitle(): ?string
|
||||
{
|
||||
return $this->jobTitle;
|
||||
}
|
||||
|
||||
public function setJobTitle(?string $jobTitle): static
|
||||
{
|
||||
$this->jobTitle = $jobTitle;
|
||||
|
||||
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 getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Coordonnees bancaires d'un client (1:n) — onglet Comptabilite. Au moins un
|
||||
* RIB est obligatoire si le type de reglement du client est LCR (RG-1.13,
|
||||
* verifie au futur Processor).
|
||||
*
|
||||
* Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et
|
||||
* `bic` — AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 :
|
||||
* l'audit etant admin-only, la tracabilite RIB est necessaire pour le suivi
|
||||
* comptable et la conformite, cf. spec § 2.5 / § 6.1).
|
||||
*
|
||||
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
|
||||
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
|
||||
* standard. Aucun ApiResource au M1.1 (sous-ressource branchee ulterieurement).
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)]
|
||||
#[ORM\Table(name: 'client_rib')]
|
||||
#[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])]
|
||||
#[Auditable]
|
||||
class ClientRib implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client_rib:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')]
|
||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Client $client = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Bic]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
private ?string $bic = null;
|
||||
|
||||
#[ORM\Column(length: 34)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Iban]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
private ?string $iban = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getClient(): ?Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function setClient(?Client $client): static
|
||||
{
|
||||
$this->client = $client;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBic(): ?string
|
||||
{
|
||||
return $this->bic;
|
||||
}
|
||||
|
||||
public function setBic(string $bic): static
|
||||
{
|
||||
$this->bic = $bic;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIban(): ?string
|
||||
{
|
||||
return $this->iban;
|
||||
}
|
||||
|
||||
public function setIban(string $iban): static
|
||||
{
|
||||
$this->iban = $iban;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentDelayRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Delai de reglement applique a un client (15 jours, 30 jours, a reception) :
|
||||
* referentiel statique seede par la migration M1 et re-seede en dev/test par
|
||||
* CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
|
||||
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
|
||||
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrinePaymentDelayRepository::class)]
|
||||
#[ORM\Table(name: 'payment_delay')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_payment_delay_code', columns: ['code'])]
|
||||
class PaymentDelay
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['payment_delay:read'])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrinePaymentTypeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Type de reglement applique a un client (virement, LCR, cheque, non soumise) :
|
||||
* referentiel statique seede par la migration M1 et re-seede en dev/test par
|
||||
* CommercialReferentialFixtures.
|
||||
*
|
||||
* Le `code` porte une semantique metier : VIREMENT impose une banque (RG-1.12),
|
||||
* LCR impose au moins un RIB (RG-1.13).
|
||||
*
|
||||
* Lecture seule au M1 (HP-M2-2). Pas de Timestampable/Blamable (referentiel
|
||||
* statique whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED). Le
|
||||
* groupe `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrinePaymentTypeRepository::class)]
|
||||
#[ORM\Table(name: 'payment_type')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_payment_type_code', columns: ['code'])]
|
||||
class PaymentType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['payment_type:read'])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineTvaModeRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Mode de TVA applique a un client (France ventes, Export, Intracom) :
|
||||
* referentiel statique seede par la migration M1 (Version20260601000000) et
|
||||
* re-seede en dev/test par CommercialReferentialFixtures.
|
||||
*
|
||||
* Lecture seule au M1 : pas de POST/PATCH/DELETE (HP-M2-2). L'ApiResource
|
||||
* (GetCollection + Get, tri position ASC) est branche au ticket dedie des
|
||||
* referentiels lecture seule.
|
||||
*
|
||||
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
|
||||
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le
|
||||
* groupe `client:read:accounting` permet d'embarquer le mode dans la reponse
|
||||
* d'un Client (onglet Comptabilite) au lieu d'un IRI.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DoctrineTvaModeRepository::class)]
|
||||
#[ORM\Table(name: 'tva_mode')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_tva_mode_code', columns: ['code'])]
|
||||
class TvaMode
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
#[Groups(['tva_mode:read'])]
|
||||
private int $position = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
|
||||
interface BankRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Bank;
|
||||
|
||||
/**
|
||||
* Retourne toutes les banques triees position ASC puis label ASC.
|
||||
*
|
||||
* @return list<Bank>
|
||||
*/
|
||||
public function findAllOrdered(): array;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
|
||||
interface ClientAddressRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?ClientAddress;
|
||||
|
||||
public function save(ClientAddress $address): void;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||
|
||||
interface ClientContactRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?ClientContact;
|
||||
|
||||
public function save(ClientContact $contact): void;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface ClientRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Client;
|
||||
|
||||
public function save(Client $client): void;
|
||||
|
||||
/**
|
||||
* Construit un QueryBuilder de liste pour le repertoire clients.
|
||||
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
||||
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
||||
* - Tri par defaut : companyName ASC (RG-1.26).
|
||||
*/
|
||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
|
||||
interface ClientRibRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?ClientRib;
|
||||
|
||||
public function save(ClientRib $rib): void;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
|
||||
interface PaymentDelayRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?PaymentDelay;
|
||||
|
||||
/**
|
||||
* Retourne tous les delais de reglement tries position ASC puis label ASC.
|
||||
*
|
||||
* @return list<PaymentDelay>
|
||||
*/
|
||||
public function findAllOrdered(): array;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
|
||||
interface PaymentTypeRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?PaymentType;
|
||||
|
||||
/**
|
||||
* Retourne tous les types de reglement tries position ASC puis label ASC.
|
||||
*
|
||||
* @return list<PaymentType>
|
||||
*/
|
||||
public function findAllOrdered(): array;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Domain\Repository;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
|
||||
interface TvaModeRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?TvaMode;
|
||||
|
||||
/**
|
||||
* Retourne tous les modes de TVA tries position ASC puis label ASC
|
||||
* (ordre des selecteurs, reutilise par la fixture de re-seed).
|
||||
*
|
||||
* @return list<TvaMode>
|
||||
*/
|
||||
public function findAllOrdered(): array;
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
|
||||
|
||||
use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
|
||||
/**
|
||||
* Denormalise un IRI (`/api/categories/{id}`) vers la Category concrete quand la
|
||||
* propriete cible est type-hintee par le contrat CategoryInterface (ex:
|
||||
* Client::$categories, ClientAddress::$categories).
|
||||
*
|
||||
* Pourquoi ce denormalizer : API Platform deduit le type de l'element de
|
||||
* collection depuis le phpdoc `@var Collection<int, CategoryInterface>`, donc
|
||||
* l'INTERFACE. Or le serializer ne sait pas denormaliser un IRI vers une
|
||||
* interface (« Could not denormalize object of type CategoryInterface[] ») : il
|
||||
* lui faut une classe-ressource concrete. On resout donc l'IRI via l'IriConverter
|
||||
* (qui retourne la Category mappee a la route) sans importer Category — la regle
|
||||
* ABSOLUE n°1 reste respectee (dependance au seul contrat Shared + API Platform).
|
||||
*
|
||||
* En lecture (normalisation), aucun probleme : l'objet reel EST une Category,
|
||||
* resource a part entiere, serialisee en IRI par le normalizer standard.
|
||||
*/
|
||||
final class CategoryReferenceDenormalizer implements DenormalizerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IriConverterInterface $iriConverter,
|
||||
) {}
|
||||
|
||||
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?CategoryInterface
|
||||
{
|
||||
if (!is_string($data) || '' === $data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// getResourceFromIri leve une exception sur IRI invalide -> 400, ce qui
|
||||
// est le comportement attendu pour une reference cassee.
|
||||
$resource = $this->iriConverter->getResourceFromIri($data);
|
||||
|
||||
// IRI syntaxiquement valide mais pointant sur une autre ressource (ex:
|
||||
// '/api/clients/5' la ou une categorie est attendue) : on refuse
|
||||
// explicitement plutot que de retourner null silencieusement, ce qui
|
||||
// perdrait la reference sans erreur. UnexpectedValueException -> 400.
|
||||
if (!$resource instanceof CategoryInterface) {
|
||||
throw new UnexpectedValueException(sprintf(
|
||||
'L\'IRI "%s" ne référence pas une catégorie.',
|
||||
$data,
|
||||
));
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
|
||||
{
|
||||
// Support base sur le seul type cible : l'ArrayDenormalizer (collection
|
||||
// `CategoryInterface[]`) interroge le support en passant le TABLEAU
|
||||
// complet comme $data avant de deleguer element par element. Tester
|
||||
// is_string($data) ici casserait donc la chaine pour les collections.
|
||||
return CategoryInterface::class === $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<class-string|string, bool>
|
||||
*/
|
||||
public function getSupportedTypes(?string $format): array
|
||||
{
|
||||
return [CategoryInterface::class => true];
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
|
||||
|
||||
use ApiPlatform\State\SerializerContextBuilderInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Decore le context builder de serialisation d'API Platform pour ajouter
|
||||
* DYNAMIQUEMENT le groupe de lecture `client:read:accounting` sur les ressources
|
||||
* Client, uniquement si l'utilisateur courant a la permission
|
||||
* `commercial.clients.accounting.view` (cf. spec-back M1 § 2.7 / § 4.1 / § 4.2).
|
||||
*
|
||||
* Pourquoi un context builder et pas le Provider : un Provider retourne des
|
||||
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
|
||||
* de normalisation est construit ici, en amont du serializer — c'est le point
|
||||
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
|
||||
* l'utilisateur. Realise l'intention « ajout conditionnel du groupe accounting »
|
||||
* de la spec.
|
||||
*
|
||||
* S'applique aux operations de LECTURE (normalization) sur Client : liste ET
|
||||
* detail. Sans la permission, les champs comptables (siren, accountNumber,
|
||||
* tvaMode, nTva, paymentDelay, paymentType, bank) ne sont jamais serialises.
|
||||
*/
|
||||
#[AsDecorator('api_platform.serializer.context_builder')]
|
||||
final readonly class ClientReadGroupContextBuilder implements SerializerContextBuilderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[AutowireDecorated]
|
||||
private SerializerContextBuilderInterface $decorated,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
|
||||
{
|
||||
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
|
||||
|
||||
// Uniquement en lecture, sur la ressource Client, avec la permission.
|
||||
if (!$normalization) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
if (Client::class !== ($context['resource_class'] ?? null)) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('commercial.clients.accounting.view')) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$groups = $context['groups'] ?? [];
|
||||
if (!in_array('client:read:accounting', $groups, true)) {
|
||||
$groups[] = 'client:read:accounting';
|
||||
}
|
||||
$context['groups'] = $groups;
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Security\BusinessRoles;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use JsonException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
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.
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Autorisation additionnelle par groupe d'onglet (le `security` de
|
||||
* l'operation a deja exige commercial.clients.manage) :
|
||||
* - champ comptable dans le payload -> exige accounting.manage (RG-1.28, 403) ;
|
||||
* - 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
|
||||
* exclusifs + type de categorie), RG-1.12 (Virement -> banque),
|
||||
* RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role
|
||||
* Commerciale).
|
||||
* 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null).
|
||||
* 5. Persistance via le persist_processor Doctrine, avec traduction des
|
||||
* collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de
|
||||
* restauration).
|
||||
*
|
||||
* Note : la validation Symfony (Assert\NotBlank, Assert\Email, Assert\Count sur
|
||||
* categories...) est jouee par API Platform AVANT ce processor ; on n'y traite
|
||||
* donc que les regles non exprimables en simples contraintes d'attribut.
|
||||
*
|
||||
* @implements ProcessorInterface<Client, Client>
|
||||
*/
|
||||
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',
|
||||
];
|
||||
|
||||
/** Champs de l'onglet Information (groupe client:write:information). */
|
||||
private const array INFORMATION_FIELDS = [
|
||||
'description', 'competitors', 'foundedAt', 'employeesCount',
|
||||
'revenueAmount', 'directorName', 'profitAmount',
|
||||
];
|
||||
|
||||
/** Champs de l'onglet Comptabilite (groupe client:write:accounting). */
|
||||
private const array ACCOUNTING_FIELDS = [
|
||||
'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay',
|
||||
'paymentType', 'bank',
|
||||
];
|
||||
|
||||
/** Champ d'archivage (groupe client:write:archive). */
|
||||
private const string ARCHIVE_FIELD = 'isArchived';
|
||||
|
||||
private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage';
|
||||
private const string PERM_ARCHIVE = 'commercial.clients.archive';
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly ClientFieldNormalizer $normalizer,
|
||||
private readonly ClientInformationCompletenessValidator $informationValidator,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Client) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
$writableKeys = $this->writablePayloadKeys();
|
||||
|
||||
$isArchiveRequest = $this->guardArchive($data, $writableKeys);
|
||||
$this->guardAccounting($data);
|
||||
|
||||
$this->normalize($data);
|
||||
|
||||
$this->validateMainContact($data);
|
||||
$this->validateDistributorBroker($data);
|
||||
$this->validateAccountingConsistency($data);
|
||||
$this->validateInformationCompleteness($data, $writableKeys);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Le seul index unique partiel est uq_client_company_name_active
|
||||
// (LOWER(company_name) parmi non-archives/non-deletes — decision Q4).
|
||||
if ($isArchiveRequest && false === $data->isArchived()) {
|
||||
// RG-1.23 : restauration en conflit avec un homonyme actif.
|
||||
throw new ConflictHttpException(
|
||||
'Restauration impossible : un autre client a pris le nom entre-temps.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
// RG-1.16 : doublon de nom de societe.
|
||||
throw new ConflictHttpException(
|
||||
sprintf('Un client nommé "%s" existe déjà.', (string) $data->getCompanyName()),
|
||||
$e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.22 / RG-1.23 : si le payload bascule reellement isArchived, exige la
|
||||
* permission archive (403), interdit toute autre modification (422) et
|
||||
* pose/retire archivedAt. Retourne true si la requete est une requete
|
||||
* d'archivage.
|
||||
*
|
||||
* Le gating est restreint a la mise a jour d'un client existant ET au seul
|
||||
* cas ou isArchived change vraiment : un POST (entite non encore geree par
|
||||
* l'ORM) ou un PATCH « representation complete » renvoyant isArchived
|
||||
* inchange ne doit declencher ni 403 ni 422 parasite.
|
||||
*
|
||||
* @param list<string> $writableKeys cles ecrivables du payload (hors @* et champs inconnus)
|
||||
*/
|
||||
private function guardArchive(Client $data, array $writableKeys): bool
|
||||
{
|
||||
// POST / entite non geree : l'archivage est une action de mise a jour.
|
||||
if (!$this->em->contains($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// isArchived inchange par rapport a l'etat persiste : pas une requete
|
||||
// d'archivage (cas du PATCH representation complete).
|
||||
if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_ARCHIVE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
self::ARCHIVE_FIELD,
|
||||
self::PERM_ARCHIVE,
|
||||
));
|
||||
}
|
||||
|
||||
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ ecrivable.
|
||||
if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) {
|
||||
throw new UnprocessableEntityHttpException(
|
||||
'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".',
|
||||
);
|
||||
}
|
||||
|
||||
// RG-1.22 (true -> now) / RG-1.23 (false -> null).
|
||||
$data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.28 : la modification effective d'un champ comptable exige
|
||||
* accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas
|
||||
* de filtrage silencieux). On ne gate que si un champ change reellement par
|
||||
* rapport a l'etat persiste : un POST/PATCH renvoyant des champs comptables
|
||||
* inchanges (ou null en creation) ne declenche pas de 403 parasite. Le
|
||||
* message precise le premier champ fautif.
|
||||
*/
|
||||
private function guardAccounting(Client $data): void
|
||||
{
|
||||
$changed = $this->changedAccountingFields($data);
|
||||
|
||||
if ([] === $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) {
|
||||
throw new AccessDeniedHttpException(sprintf(
|
||||
'Le champ "%s" requiert la permission "%s".',
|
||||
$changed[0],
|
||||
self::PERM_ACCOUNTING_MANAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs comptables dont la valeur courante differe de l'etat persiste. Les
|
||||
* relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par
|
||||
* identite d'objet : l'identity map Doctrine renvoie la meme instance tant
|
||||
* que la reference est inchangee.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function changedAccountingFields(Client $data): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach (self::ACCOUNTING_FIELDS as $field) {
|
||||
$newValue = match ($field) {
|
||||
'siren' => $data->getSiren(),
|
||||
'accountNumber' => $data->getAccountNumber(),
|
||||
'tvaMode' => $data->getTvaMode(),
|
||||
'nTva' => $data->getNTva(),
|
||||
'paymentDelay' => $data->getPaymentDelay(),
|
||||
'paymentType' => $data->getPaymentType(),
|
||||
'bank' => $data->getBank(),
|
||||
};
|
||||
|
||||
if ($this->fieldChanged($data, $field, $newValue)) {
|
||||
$changed[] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une
|
||||
* entite non geree (creation/POST), l'etat persiste est vide : toute valeur
|
||||
* non-null est alors un changement.
|
||||
*/
|
||||
private function fieldChanged(Client $data, string $field, mixed $newValue): bool
|
||||
{
|
||||
$original = $this->originalData($data);
|
||||
|
||||
return $newValue !== ($original[$field] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot des valeurs persistees de l'entite (telles que chargees, avant
|
||||
* application du payload). Vide pour une entite non geree (POST).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function originalData(Client $data): array
|
||||
{
|
||||
if (!$this->em->contains($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->em->getUnitOfWork()->getOriginalEntityData($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor
|
||||
* doit referencer un client de categorie DISTRIBUTEUR (idem broker ->
|
||||
* COURTIER).
|
||||
*/
|
||||
private function validateDistributorBroker(Client $data): void
|
||||
{
|
||||
$distributor = $data->getDistributor();
|
||||
$broker = $data->getBroker();
|
||||
|
||||
if (null !== $distributor && null !== $broker) {
|
||||
$this->throwViolation(
|
||||
'distributor',
|
||||
'Un client ne peut pas être rattaché à la fois à un distributeur et à un courtier.',
|
||||
$data,
|
||||
);
|
||||
}
|
||||
|
||||
if (null !== $distributor && !$this->hasCategoryType($distributor, 'DISTRIBUTEUR')) {
|
||||
$this->throwViolation(
|
||||
'distributor',
|
||||
'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.',
|
||||
$data,
|
||||
);
|
||||
}
|
||||
|
||||
if (null !== $broker && !$this->hasCategoryType($broker, 'COURTIER')) {
|
||||
$this->throwViolation(
|
||||
'broker',
|
||||
'Le courtier référencé doit être un client de catégorie COURTIER.',
|
||||
$data,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.12 : Virement -> banque obligatoire. RG-1.13 : LCR -> au moins un RIB.
|
||||
*/
|
||||
private function validateAccountingConsistency(Client $data): void
|
||||
{
|
||||
$paymentCode = $data->getPaymentType()?->getCode();
|
||||
|
||||
if ('VIREMENT' === $paymentCode && null === $data->getBank()) {
|
||||
$this->throwViolation(
|
||||
'bank',
|
||||
'La banque est obligatoire pour le type de règlement Virement.',
|
||||
$data,
|
||||
);
|
||||
}
|
||||
|
||||
if ('LCR' === $paymentCode && $data->getRibs()->isEmpty()) {
|
||||
$this->throwViolation(
|
||||
'paymentType',
|
||||
'Au moins un RIB est obligatoire pour le type de règlement LCR.',
|
||||
$data,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.04 : si l'utilisateur porte le role metier Commerciale ET que le
|
||||
* payload touche l'onglet Information, tous les champs Information sont
|
||||
* obligatoires. Dormant tant qu'aucun user ne porte le role `commerciale`.
|
||||
*
|
||||
* @param list<string> $payloadKeys
|
||||
*/
|
||||
private function validateInformationCompleteness(Client $data, array $payloadKeys): void
|
||||
{
|
||||
$touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS);
|
||||
|
||||
if ($touchesInformation && $this->currentUserIsCommerciale()) {
|
||||
$this->informationValidator->validate($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si au moins une categorie du client porte le type donne. S'appuie
|
||||
* sur CategoryInterface::getCategoryTypeCode() (pas d'import de Category).
|
||||
*/
|
||||
private function hasCategoryType(Client $client, string $typeCode): bool
|
||||
{
|
||||
foreach ($client->getCategories() as $category) {
|
||||
if ($category instanceof CategoryInterface && $category->getCategoryTypeCode() === $typeCode) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function currentUserIsCommerciale(): bool
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
return $user instanceof BusinessRoleAwareInterface
|
||||
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles ecrivables effectivement presentes dans le payload : on retire les
|
||||
* cles JSON-LD (@id, @context, @var...) et tout champ non rattache a un
|
||||
* groupe d'ecriture connu. C'est la base du 422 d'archivage (RG-1.22) et du
|
||||
* declenchement conditionnel de RG-1.04 — sans elles, un PATCH
|
||||
* « representation complete » porteur de @id ferait croire a une
|
||||
* modification multi-onglets.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function writablePayloadKeys(): array
|
||||
{
|
||||
$writable = array_merge(
|
||||
self::MAIN_FIELDS,
|
||||
self::INFORMATION_FIELDS,
|
||||
self::ACCOUNTING_FIELDS,
|
||||
[self::ARCHIVE_FIELD],
|
||||
);
|
||||
|
||||
return array_values(array_intersect($this->payloadKeys(), $writable));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cles de premier niveau effectivement envoyees par le client (payload JSON
|
||||
* brut), filtrage compris. Pour un PATCH merge-patch+json, ce sont les seuls
|
||||
* champs modifies.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function payloadKeys(): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (null === $request) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = $request->getContent();
|
||||
if ('' === $content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve une ValidationException (HTTP 422) portant une violation unique sur
|
||||
* la propriete visee — meme rendu Hydra que les contraintes Symfony.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function throwViolation(string $property, string $message, Client $root): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation($message, null, [], $root, $property, null));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider du repertoire clients (M1). Cf. spec-back M1 § 4.1 / § 4.2.
|
||||
*
|
||||
* Collection (GET /api/clients) :
|
||||
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
|
||||
* (deleted_at IS NOT NULL) — RG-1.24 ;
|
||||
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
|
||||
* exclus au M1) — RG-1.25 ;
|
||||
* - tri par defaut companyName ASC — RG-1.26 ;
|
||||
* - filtres ?search=... (fuzzy companyName + lastName + email) et
|
||||
* ?categoryType=<code> (clients ayant >= 1 categorie de ce type) ;
|
||||
* - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ;
|
||||
* echappatoire ?pagination=false pour alimenter un <select> sans pagination.
|
||||
*
|
||||
* Item (GET /api/clients/{id} + provider de PATCH) :
|
||||
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
|
||||
* M1) ; les archives restent consultables/restaurables en detail.
|
||||
*
|
||||
* Le filtrage des champs comptables en lecture (groupe client:read:accounting)
|
||||
* n'est PAS fait ici mais dans ClientReadGroupContextBuilder (le provider ne
|
||||
* peut pas influencer les groupes de serialisation).
|
||||
*
|
||||
* @implements ProviderInterface<Client>
|
||||
*/
|
||||
final class ClientProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
||||
private readonly ClientRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<Client>|Paginator<Client>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder($includeArchived);
|
||||
$this->applySearch($qb, $filters['search'] ?? null);
|
||||
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
// @var list<Client> $result
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// fetchJoinCollection: true pour un COUNT correct des que des JOINs
|
||||
// to-many seront ajoutes (sous-collections embarquees en detail).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?Client
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$client = $this->repository->findById((int) $id);
|
||||
if (null === $client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose au M1 (HP-M2-1) — 404 via retour null.
|
||||
// Les archives restent visibles en detail (consultation + restauration).
|
||||
if (null !== $client->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||
* litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, mixed $search): void
|
||||
{
|
||||
if (!is_string($search) || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere(
|
||||
'LOWER(c.companyName) LIKE :search '
|
||||
.'OR LOWER(c.lastName) LIKE :search '
|
||||
.'OR LOWER(c.email) LIKE :search',
|
||||
)->setParameter('search', $pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint aux clients possedant au moins une categorie du type donne.
|
||||
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
||||
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
|
||||
*/
|
||||
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
|
||||
{
|
||||
if (!is_string($categoryType) || '' === trim($categoryType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sous-requete construite via l'EntityManager (et non
|
||||
// $repository->createQueryBuilder()) : createQueryBuilder() n'est pas
|
||||
// declaree sur ClientRepositoryInterface, l'appeler exposerait un detail
|
||||
// d'implementation Doctrine hors du contrat (fuite d'abstraction).
|
||||
$sub = $this->em->createQueryBuilder()
|
||||
->select('c2.id')
|
||||
->from(Client::class, 'c2')
|
||||
->join('c2.categories', 'cat2')
|
||||
->join('cat2.categoryType', 'ct2')
|
||||
->where('ct2.code = :categoryType')
|
||||
;
|
||||
|
||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||
->setParameter('categoryType', trim($categoryType))
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||
*/
|
||||
private function readBool(mixed $raw): bool
|
||||
{
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures du module Commercial : re-seed des 4 referentiels comptables
|
||||
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
|
||||
* (Version20260601000000).
|
||||
*
|
||||
* Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces
|
||||
* 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les
|
||||
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
|
||||
* referentiels seedes par la migration disparaitraient apres `make db-reset`
|
||||
* (0 ligne en dev/test) — cassant les FK Client -> referentiels et les tests
|
||||
* RG-1.12/1.13. Le seed migration couvre la prod (ou les fixtures ne tournent
|
||||
* pas) ; cette fixture re-aligne dev et test. Memes valeurs des deux cotes.
|
||||
*
|
||||
* Idempotence : lookup par `code` avant insertion (sur le modele de
|
||||
* CategoryTypeFixtures). Rejouable sans doublon meme si le purger est desactive.
|
||||
*/
|
||||
class CommercialReferentialFixtures extends Fixture
|
||||
{
|
||||
/**
|
||||
* Source unique des referentiels : classe d'entite => [code => [label, position]].
|
||||
* Doit rester aligne sur le seed de la migration Version20260601000000.
|
||||
*
|
||||
* @var array<class-string, array<string, array{string, int}>>
|
||||
*/
|
||||
private const REFERENTIALS = [
|
||||
TvaMode::class => [
|
||||
'FRANCE_VENTES' => ['France (ventes)', 10],
|
||||
'EXPORT_VENTES' => ['Export (ventes)', 20],
|
||||
'INTRACOM_VENTES' => ['Intracom (ventes)', 30],
|
||||
],
|
||||
PaymentDelay::class => [
|
||||
'J15' => ['15 jours', 10],
|
||||
'J30' => ['30 jours', 20],
|
||||
'A_RECEPTION' => ['À réception', 30],
|
||||
],
|
||||
PaymentType::class => [
|
||||
'VIREMENT' => ['Virement', 10],
|
||||
'LCR' => ['LCR', 20],
|
||||
'NON_SOUMISE' => ['Non soumise', 30],
|
||||
'CHEQUE' => ['Chèque', 40],
|
||||
],
|
||||
Bank::class => [
|
||||
'SG' => ['Société Générale', 10],
|
||||
'CIC' => ['CIC', 20],
|
||||
'CA' => ['Crédit Agricole', 30],
|
||||
],
|
||||
];
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
foreach (self::REFERENTIALS as $entityClass => $rows) {
|
||||
$this->seedReferential($manager, $entityClass, $rows);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert idempotent d'un referentiel : indexe l'existant par code puis
|
||||
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
|
||||
* setCode/setLabel/setPosition.
|
||||
*
|
||||
* @param class-string $entityClass
|
||||
* @param array<string, array{string, int}> $rows
|
||||
*/
|
||||
private function seedReferential(ObjectManager $manager, string $entityClass, array $rows): void
|
||||
{
|
||||
$existingByCode = [];
|
||||
foreach ($manager->getRepository($entityClass)->findAll() as $entity) {
|
||||
$existingByCode[$entity->getCode()] = $entity;
|
||||
}
|
||||
|
||||
foreach ($rows as $code => [$label, $position]) {
|
||||
$entity = $existingByCode[$code] ?? new $entityClass();
|
||||
$entity->setCode($code);
|
||||
$entity->setLabel($label);
|
||||
$entity->setPosition($position);
|
||||
$manager->persist($entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Repository\BankRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Bank>
|
||||
*/
|
||||
class DoctrineBankRepository extends ServiceEntityRepository implements BankRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Bank::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Bank
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findAllOrdered(): array
|
||||
{
|
||||
return $this->createQueryBuilder('b')
|
||||
->orderBy('b.position', 'ASC')
|
||||
->addOrderBy('b.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||
use App\Module\Commercial\Domain\Repository\ClientAddressRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClientAddress>
|
||||
*/
|
||||
class DoctrineClientAddressRepository extends ServiceEntityRepository implements ClientAddressRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClientAddress::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?ClientAddress
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(ClientAddress $address): void
|
||||
{
|
||||
$this->getEntityManager()->persist($address);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||
use App\Module\Commercial\Domain\Repository\ClientContactRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClientContact>
|
||||
*/
|
||||
class DoctrineClientContactRepository extends ServiceEntityRepository implements ClientContactRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClientContact::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?ClientContact
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(ClientContact $contact): void
|
||||
{
|
||||
$this->getEntityManager()->persist($contact);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Client>
|
||||
*/
|
||||
class DoctrineClientRepository extends ServiceEntityRepository implements ClientRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Client::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Client
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(Client $client): void
|
||||
{
|
||||
$this->getEntityManager()->persist($client);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder
|
||||
{
|
||||
$qb = $this->createQueryBuilder('c')
|
||||
->andWhere('c.deletedAt IS NULL')
|
||||
->orderBy('c.companyName', 'ASC')
|
||||
;
|
||||
|
||||
if (!$includeArchived) {
|
||||
$qb->andWhere('c.isArchived = false');
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
use App\Module\Commercial\Domain\Repository\ClientRibRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClientRib>
|
||||
*/
|
||||
class DoctrineClientRibRepository extends ServiceEntityRepository implements ClientRibRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClientRib::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?ClientRib
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(ClientRib $rib): void
|
||||
{
|
||||
$this->getEntityManager()->persist($rib);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Repository\PaymentDelayRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<PaymentDelay>
|
||||
*/
|
||||
class DoctrinePaymentDelayRepository extends ServiceEntityRepository implements PaymentDelayRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PaymentDelay::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentDelay
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findAllOrdered(): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->orderBy('p.position', 'ASC')
|
||||
->addOrderBy('p.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Repository\PaymentTypeRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<PaymentType>
|
||||
*/
|
||||
class DoctrinePaymentTypeRepository extends ServiceEntityRepository implements PaymentTypeRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PaymentType::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?PaymentType
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findAllOrdered(): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->orderBy('p.position', 'ASC')
|
||||
->addOrderBy('p.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use App\Module\Commercial\Domain\Repository\TvaModeRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TvaMode>
|
||||
*/
|
||||
class DoctrineTvaModeRepository extends ServiceEntityRepository implements TvaModeRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TvaMode::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?TvaMode
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findAllOrdered(): array
|
||||
{
|
||||
return $this->createQueryBuilder('t')
|
||||
->orderBy('t.position', 'ASC')
|
||||
->addOrderBy('t.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
// C'est le pattern officiel Doctrine pour les bounded contexts DDD.
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Attribute\AuditIgnore;
|
||||
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
|
||||
use DateTimeImmutable;
|
||||
@@ -75,7 +76,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
#[Auditable]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface, BusinessRoleAwareInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
@@ -337,6 +338,23 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC
|
||||
* rattaches porte le code donne. Permet aux modules tiers de detecter un
|
||||
* role metier (ex: `commerciale` pour RG-1.04 du M1 Clients) sans importer
|
||||
* cette classe. Comparaison stricte sur Role::code.
|
||||
*/
|
||||
public function hasBusinessRole(string $roleCode): bool
|
||||
{
|
||||
foreach ($this->rbacRoles as $role) {
|
||||
if ($role->getCode() === $roleCode) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getPassword(): ?string
|
||||
{
|
||||
return $this->password;
|
||||
|
||||
@@ -29,18 +29,16 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
|
||||
* ?performed_at[after]=2026-04-01T00:00:00Z
|
||||
* ?performed_at[before]=2026-04-30T23:59:59Z
|
||||
*
|
||||
* La pagination est assuree par le provider via DbalPaginator (implementant
|
||||
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
|
||||
* automatiquement hydra:view — aucune construction manuelle.
|
||||
* La pagination herite du standard global (10 items / page, max 50, cf.
|
||||
* `config/packages/api_platform.yaml`). Elle est materialisee par le
|
||||
* DbalPaginator du provider qui implemente PaginatorInterface — API Platform
|
||||
* genere automatiquement hydra:view sans construction manuelle.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'AuditLog',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/audit-logs',
|
||||
paginationItemsPerPage: 30,
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 50,
|
||||
security: "is_granted('core.audit_log.view')",
|
||||
provider: AuditLogProvider::class,
|
||||
),
|
||||
|
||||
@@ -68,6 +68,13 @@ final readonly class AuditLogProvider implements ProviderInterface
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): DbalPaginator
|
||||
{
|
||||
// Contrairement aux ressources ORM (cf. CategoryProvider), ce provider
|
||||
// ne gere PAS l'echappatoire `?pagination=false` : la pagination y est
|
||||
// toujours forcee. `audit_log` est une table append-only a croissance
|
||||
// infinie — la dumper entierement saturerait memoire/reseau et n'a aucun
|
||||
// usage front (pas de <select> alimente par l'audit). Le flag global
|
||||
// `pagination_client_enabled: true` reste donc volontairement inerte ici.
|
||||
//
|
||||
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
|
||||
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
|
||||
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Console;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:apply-column-comments',
|
||||
description: 'Reapplique les COMMENT ON TABLE/COLUMN du catalogue (workaround schema:update).',
|
||||
)]
|
||||
final class ApplyColumnCommentsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$statements = ColumnCommentsCatalog::toSqlStatements();
|
||||
|
||||
foreach ($statements as $sql) {
|
||||
$this->connection->executeStatement($sql);
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d COMMENT ON statements appliques.', count($statements)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Expose, sans coupler a la classe concrete User (module Core), le moyen de
|
||||
* savoir si un utilisateur porte un role METIER donne (par son code, cf.
|
||||
* App\Shared\Domain\Security\BusinessRoles).
|
||||
*
|
||||
* Implementee par App\Module\Core\Domain\Entity\User. Permet a un module tiers
|
||||
* (ex: Commercial — RG-1.04, completude Information pour le role Commerciale)
|
||||
* de raisonner sur les roles metier via Security::getUser() sans importer User
|
||||
* (regle ABSOLUE n°1 : pas d'import inter-modules).
|
||||
*
|
||||
* Distinct de UserInterface::getRoles() (roles SYSTEME Symfony ROLE_*, derives
|
||||
* de is_admin) : ici on parle des roles RBAC metier rattaches a l'utilisateur.
|
||||
*/
|
||||
interface BusinessRoleAwareInterface
|
||||
{
|
||||
/**
|
||||
* Vrai si l'utilisateur porte le role RBAC metier identifie par $roleCode
|
||||
* (compare au champ Role::code).
|
||||
*/
|
||||
public function hasBusinessRole(string $roleCode): bool;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Interface minimale exposant ce qu'un module tiers (Commercial...) doit
|
||||
* connaitre d'une Category, sans creer de couplage direct vers le module
|
||||
* Catalog (regle ABSOLUE n°1 : pas d'import inter-modules).
|
||||
*
|
||||
* Implementee par App\Module\Catalog\Domain\Entity\Category.
|
||||
* Utilisee comme cible des ManyToMany Client.categories et
|
||||
* ClientAddress.categories via resolve_target_entities (cf. doctrine.yaml),
|
||||
* sur le meme modele que SiteInterface / UserInterface.
|
||||
*/
|
||||
interface CategoryInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getName(): ?string;
|
||||
|
||||
/**
|
||||
* Code du type de categorie rattache (CategoryType::code), ou null si la
|
||||
* categorie n'a pas de type. Expose pour permettre a un module tiers de
|
||||
* raisonner sur le type metier (ex: M1 Commercial — RG-1.03 : un distributor
|
||||
* doit referencer un client categorise DISTRIBUTEUR ; RG-1.29 : categorie
|
||||
* d'adresse limitee a SECTEUR/AUTRE) sans importer la classe concrete
|
||||
* Category (regle ABSOLUE n°1).
|
||||
*/
|
||||
public function getCategoryTypeCode(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Security;
|
||||
|
||||
/**
|
||||
* Codes des roles METIER MALIO partages entre modules.
|
||||
*
|
||||
* Distincts des roles SYSTEME (cf. App\Module\Core\Domain\Security\SystemRoles :
|
||||
* `admin` / `user`). Un role metier porte une intention fonctionnelle (poste de
|
||||
* travail) et conditionne certaines regles de gestion au-dela des permissions
|
||||
* RBAC pures — ex : RG-1.04 du M1 Clients rend l'onglet Information obligatoire
|
||||
* pour le seul role Commerciale, alors que Commerciale et Bureau partagent les
|
||||
* memes permissions (commercial.clients.view + manage, cf. spec-back M1 § 5.2).
|
||||
*
|
||||
* Ces constantes vivent dans Shared (et non dans un module) pour que :
|
||||
* - le seed des roles cote Core (ERP-74) reference le meme code sans importer
|
||||
* une constante du module Commercial (regle ABSOLUE n°1 : pas d'import
|
||||
* inter-modules) ;
|
||||
* - le ClientProcessor (module Commercial) detecte le role Commerciale via ce
|
||||
* meme code, sans dependre de Core.
|
||||
*
|
||||
* Coordination stack M1 :
|
||||
* - ERP-74 seede le role `commerciale` avec ce code exact.
|
||||
* - ERP-59 / ERP-60 (declaration RBAC + tests personas) le reutilisent.
|
||||
* - ERP-55 (ici) ne fait que le REFERENCER : tant qu'aucun user ne porte le
|
||||
* role `commerciale`, la validation de completude Information reste dormante.
|
||||
*/
|
||||
final class BusinessRoles
|
||||
{
|
||||
/**
|
||||
* Role metier « Commerciale » — code de Role RBAC (champ Role::code,
|
||||
* snake_case impose par la regex Role). Conditionne RG-1.04.
|
||||
*/
|
||||
public const string COMMERCIALE = 'commerciale';
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
// Classe de constantes : non instanciable.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Database;
|
||||
|
||||
/**
|
||||
* Catalogue centralise des descriptions SQL (`COMMENT ON TABLE` /
|
||||
* `COMMENT ON COLUMN`) appliquees aux tables metier de Starseed.
|
||||
*
|
||||
* Source unique de verite, utilisee par :
|
||||
* - `migrations/Version20260528120000.php` : retrofit initial des tables
|
||||
* pre-existantes (ERP-67).
|
||||
* - `App\Module\Core\Infrastructure\Console\ApplyColumnCommentsCommand` :
|
||||
* reapplique les commentaires apres `doctrine:schema:update --force` en
|
||||
* environnement de test (cf. commentaire de `test-db-setup` dans le
|
||||
* `makefile`). Doctrine ORM ne conservant pas les commentaires absents
|
||||
* du mapping PHP, on les rejoue depuis ce catalogue.
|
||||
*
|
||||
* Pour ajouter ou modifier un commentaire :
|
||||
* - Mettre a jour `comments()` ci-dessous.
|
||||
* - La migration retrofit pose la valeur initiale, la commande la rejoue
|
||||
* en boucle. Toute future colonne doit etre documentee dans sa propre
|
||||
* migration (cf. CLAUDE.md regle ABSOLUE n°12) — ce catalogue ne sert
|
||||
* qu'au retrofit + au workaround schema:update.
|
||||
*
|
||||
* Convention : description en francais, ≤ 200 caracteres, semantique du
|
||||
* champ + contraintes / lien RG si pertinent. La cle speciale `_table` est
|
||||
* appliquee a la table elle-meme (`COMMENT ON TABLE`).
|
||||
*/
|
||||
final class ColumnCommentsCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
public static function comments(): array
|
||||
{
|
||||
return [
|
||||
'audit_log' => [
|
||||
'_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.",
|
||||
'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).",
|
||||
'entity_type' => "Type d'entite auditee au format module.Entity (ex: core.User, commercial.Client) — evite les collisions inter-modules.",
|
||||
'entity_id' => "Identifiant de l'entite auditee (supporte INT et UUID — stocke en varchar pour rester generique).",
|
||||
'action' => "Type d'operation auditee : 'create', 'update' ou 'delete'.",
|
||||
'changes' => 'Snapshot complet pour create/delete, diff {champ: {old, new}} pour update. Cles sensibles filtrees (password, token, secret).',
|
||||
'performed_by' => "Username de l'auteur de l'action (denormalise, survit a la suppression du user) — vaut 'system' en CLI.",
|
||||
'performed_at' => "Horodatage UTC de l'action auditee.",
|
||||
'ip_address' => "Adresse IP de l'auteur (IPv4/IPv6) — null hors contexte HTTP.",
|
||||
'request_id' => "UUID v4 de la requete HTTP — regroupe les changements d'un meme flush, facilite la correlation logs.",
|
||||
],
|
||||
|
||||
'category' => [
|
||||
'_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).',
|
||||
'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).',
|
||||
'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'category_type' => [
|
||||
'_table' => 'Referentiel statique des types de categories — code technique stable + libelle FR.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable du type (snake_case, ≤ 40 caracteres) — unique, utilise dans le code et les configurations.',
|
||||
'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).',
|
||||
],
|
||||
|
||||
'permission' => [
|
||||
'_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code RBAC au format module.resource[.subresource].action — unique, synchronise par app:sync-permissions.',
|
||||
'label' => 'Libelle affichable de la permission (FR).',
|
||||
'module' => 'Identifiant du module proprietaire de la permission (snake_case, ex: core, commercial).',
|
||||
'orphan' => "Drapeau permission orpheline — vrai quand son module declarant a ete supprime, masquee de l'interface RBAC.",
|
||||
],
|
||||
|
||||
'role' => [
|
||||
'_table' => 'Referentiel des roles RBAC — agregent un ensemble de permissions, attribues aux utilisateurs.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable du role (snake_case) — utilise dans le code (ex: admin, user). Unique.',
|
||||
'label' => 'Libelle affichable du role (FR).',
|
||||
'description' => 'Description longue du role (optionnelle).',
|
||||
'is_system' => "Drapeau role systeme — bloque la suppression et la modification du code via l'interface.",
|
||||
],
|
||||
|
||||
'role_permission' => [
|
||||
'_table' => 'Table de jointure roles <-> permissions (ManyToMany).',
|
||||
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role qui porte la permission.',
|
||||
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission attribuee au role.',
|
||||
],
|
||||
|
||||
'site' => [
|
||||
'_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Nom du site (≤ 100 caracteres).',
|
||||
'city' => 'Ville du site (≤ 100 caracteres).',
|
||||
'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.',
|
||||
'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.",
|
||||
'street' => "Numero et voie de l'adresse (≤ 200 caracteres).",
|
||||
'complement' => "Complement d'adresse (etage, batiment...) — optionnel.",
|
||||
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
||||
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
||||
],
|
||||
|
||||
'user' => [
|
||||
'_table' => 'Comptes utilisateurs Starseed — authentification JWT, RBAC via roles et permissions directes.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'username' => 'Identifiant de connexion (≤ 100 caracteres) — unique.',
|
||||
'password' => 'Hash du mot de passe (algorithme courant Symfony) — exclu de l audit via #[AuditIgnore].',
|
||||
'created_at' => 'Horodatage UTC de creation du compte — rempli manuellement dans le constructeur (pas via TimestampableBlamableSubscriber).',
|
||||
'is_admin' => 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.',
|
||||
'current_site_id' => "Site actuellement selectionne par l'utilisateur (contexte de session) — FK -> site.id, ON DELETE SET NULL.",
|
||||
],
|
||||
|
||||
'user_permission' => [
|
||||
'_table' => 'Table de jointure utilisateurs <-> permissions directes (hors role).',
|
||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur destinataire de la permission directe.',
|
||||
'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission accordee individuellement.',
|
||||
],
|
||||
|
||||
'user_role' => [
|
||||
'_table' => 'Table de jointure utilisateurs <-> roles (ManyToMany).',
|
||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur portant le role.',
|
||||
'role_id' => 'FK -> role.id, ON DELETE CASCADE — role attribue a l utilisateur.',
|
||||
],
|
||||
|
||||
'user_site' => [
|
||||
'_table' => 'Table de jointure utilisateurs <-> sites accessibles — gere le scoping multi-site (un user ne voit que les donnees de ses sites).',
|
||||
'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.',
|
||||
],
|
||||
|
||||
// === M1 Commercial (ERP-53/54) — miroir des COMMENT de la migration
|
||||
// Version20260601000000 pour le chemin schema:update (dev/test). ===
|
||||
|
||||
'tva_mode' => [
|
||||
'_table' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
||||
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
||||
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
||||
],
|
||||
|
||||
'payment_delay' => [
|
||||
'_table' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
||||
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
||||
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
||||
],
|
||||
|
||||
'payment_type' => [
|
||||
'_table' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
||||
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
||||
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
||||
],
|
||||
|
||||
'bank' => [
|
||||
'_table' => 'Referentiel des banques selectionnables pour le reglement par virement.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.',
|
||||
'label' => 'Libelle affichable (FR, ≤ 120 caracteres).',
|
||||
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
|
||||
],
|
||||
|
||||
'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).',
|
||||
'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.',
|
||||
'description' => 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.',
|
||||
'competitors' => 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).',
|
||||
'founded_at' => 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).',
|
||||
'employees_count' => 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).',
|
||||
'revenue_amount' => 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
|
||||
'director_name' => 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).',
|
||||
'profit_amount' => 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).',
|
||||
'siren' => 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).',
|
||||
'account_number' => 'Onglet Comptabilite : numero de compte comptable du client.',
|
||||
'tva_mode_id' => 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.',
|
||||
'n_tva' => 'Onglet Comptabilite : numero de TVA intracommunautaire.',
|
||||
'payment_delay_id' => 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.',
|
||||
'payment_type_id' => 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).',
|
||||
'bank_id' => 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).',
|
||||
'is_archived' => 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).',
|
||||
'archived_at' => 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'client_category' => [
|
||||
'_table' => 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).',
|
||||
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.',
|
||||
],
|
||||
|
||||
'client_contact' => [
|
||||
'_table' => 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).',
|
||||
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
|
||||
'phone_primary' => 'Telephone principal du contact — chiffres uniquement (RG-1.20).',
|
||||
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).',
|
||||
'email' => 'Email du contact (lowercase serveur, RG-1.21).',
|
||||
'position' => 'Ordre d affichage du contact dans la liste du client (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'client_address' => [
|
||||
'_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.',
|
||||
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
|
||||
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
|
||||
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
|
||||
'country' => 'Pays de l adresse — defaut France.',
|
||||
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
|
||||
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
|
||||
'street' => 'Numero et voie de l adresse.',
|
||||
'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.',
|
||||
'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).',
|
||||
'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'client_address_site' => [
|
||||
'_table' => 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).',
|
||||
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.',
|
||||
],
|
||||
|
||||
'client_address_contact' => [
|
||||
'_table' => 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.',
|
||||
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'client_contact_id' => 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
|
||||
],
|
||||
|
||||
'client_address_category' => [
|
||||
'_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).',
|
||||
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).',
|
||||
],
|
||||
|
||||
'client_rib' => [
|
||||
'_table' => 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.',
|
||||
'label' => 'Libelle du RIB (ex: compte principal).',
|
||||
'bic' => 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).',
|
||||
'iban' => 'IBAN du compte (≤ 34 caracteres).',
|
||||
'position' => 'Ordre d affichage du RIB dans la liste du client (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptions standardisees pour les 4 colonnes du pattern
|
||||
* Timestampable/Blamable (`TimestampableBlamableTrait`).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function timestampableBlamableComments(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.',
|
||||
'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.',
|
||||
'created_by' => "ID de l'utilisateur ayant cree la ligne — null hors HTTP (CLI, migration, fixture). FK -> \"user\".id, ON DELETE SET NULL.",
|
||||
'updated_by' => "ID de l'utilisateur ayant modifie la ligne en dernier — null hors HTTP. FK -> \"user\".id, ON DELETE SET NULL.",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en
|
||||
* dollar-quoting Postgres `$_$`) a partir du catalogue.
|
||||
*
|
||||
* @param null|list<string> $onlyTables Restreint la generation a ces tables
|
||||
* (utile pour la migration retrofit qui
|
||||
* ne doit commenter que les tables deja
|
||||
* presentes a son instant T — les tables
|
||||
* des modules crees plus tard posent
|
||||
* leurs propres COMMENT). null = tout.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function toSqlStatements(?array $onlyTables = null): array
|
||||
{
|
||||
$allowed = null === $onlyTables ? null : array_fill_keys($onlyTables, true);
|
||||
|
||||
$statements = [];
|
||||
foreach (self::comments() as $table => $entries) {
|
||||
if (null !== $allowed && !isset($allowed[$table])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$quotedTable = self::quoteIdent($table);
|
||||
foreach ($entries as $column => $description) {
|
||||
if ('_table' === $column) {
|
||||
$statements[] = sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$statements[] = sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
self::quoteIdent($column),
|
||||
$description,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote un identifiant SQL avec des guillemets doubles. Necessaire pour
|
||||
* la table `user` (mot reserve PG) ; applique a tous par coherence.
|
||||
*/
|
||||
private static function quoteIdent(string $name): string
|
||||
{
|
||||
return '"'.str_replace('"', '""', $name).'"';
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"api-platform/symfony": {
|
||||
"version": "4.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "4.0",
|
||||
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/api_platform.yaml",
|
||||
"config/routes/api_platform.yaml",
|
||||
"src/ApiResource/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/deprecations": {
|
||||
"version": "1.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
|
||||
}
|
||||
},
|
||||
"doctrine/doctrine-bundle": {
|
||||
"version": "3.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine.yaml",
|
||||
"src/Entity/.gitignore",
|
||||
"src/Repository/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-fixtures-bundle": {
|
||||
"version": "4.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
|
||||
},
|
||||
"files": [
|
||||
"src/DataFixtures/AppFixtures.php"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-migrations-bundle": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.1",
|
||||
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine_migrations.yaml",
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"friendsofphp/php-cs-fixer": {
|
||||
"version": "3.94",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.39",
|
||||
"ref": "97aaf9026490db73b86c23d49e5774bc89d2b232"
|
||||
},
|
||||
"files": [
|
||||
".php-cs-fixer.dist.php"
|
||||
]
|
||||
},
|
||||
"lexik/jwt-authentication-bundle": {
|
||||
"version": "3.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.5",
|
||||
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/lexik_jwt_authentication.yaml"
|
||||
]
|
||||
},
|
||||
"nelmio/cors-bundle": {
|
||||
"version": "2.6",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.5",
|
||||
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/nelmio_cors.yaml"
|
||||
]
|
||||
},
|
||||
"nyholm/psr7": {
|
||||
"version": "1.8",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "4a8c0345442dcca1d8a2c65633dcf0285dd5a5a2"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/nyholm_psr7.yaml"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "13.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "11.1",
|
||||
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
|
||||
},
|
||||
"files": [
|
||||
".env.test",
|
||||
"phpunit.dist.xml",
|
||||
"tests/bootstrap.php",
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||
},
|
||||
"files": [
|
||||
"bin/console"
|
||||
]
|
||||
},
|
||||
"symfony/flex": {
|
||||
"version": "2.10",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.4",
|
||||
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||
},
|
||||
"files": [
|
||||
".env",
|
||||
".env.dev"
|
||||
]
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "d5dcd308c8becd725c9d8b91e31aab1ff0bbc30b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/cache.yaml",
|
||||
"config/packages/framework.yaml",
|
||||
"config/preload.php",
|
||||
"config/routes/framework.yaml",
|
||||
"config/services.yaml",
|
||||
"public/index.php",
|
||||
"src/Controller/.gitignore",
|
||||
"src/Kernel.php",
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/monolog-bundle": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.7",
|
||||
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/monolog.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/property_info.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/routing.yaml",
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/security-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/security.yaml",
|
||||
"config/routes/security.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/twig.yaml",
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||
}
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/validator.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Garde-fou architecture : toute operation `GetCollection` exposee via API Platform
|
||||
* doit avoir la pagination activee (ou laisser la valeur par defaut, qui est
|
||||
* activee globalement dans `config/packages/api_platform.yaml`).
|
||||
*
|
||||
* Interdit : `new GetCollection(paginationEnabled: false)` sans exception documentee.
|
||||
*
|
||||
* Raison : une collection non paginee peut retourner des milliers de lignes et
|
||||
* saturer la memoire du serveur, le reseau et le navigateur. La pagination est la
|
||||
* seule protection fiable contre ce risque sur un CRM a donnees croissantes.
|
||||
*
|
||||
* Quand ajouter une entree dans `EXCLUDED` :
|
||||
* - La collection est structurellement bornee (referentiel statique, < 100 items,
|
||||
* jamais alimente par des utilisateurs) ET la suppression de la pagination est
|
||||
* documentee avec une justification metier explicite.
|
||||
* - Format obligatoire : `FQCN => 'justification + reference ticket/spec'`
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CollectionsArePaginatedTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Resources API Platform dont un `GetCollection` peut desactiver la pagination.
|
||||
*
|
||||
* Laisser vide au demarrage. Pour ajouter une exception :
|
||||
* 'App\Module\Foo\Infrastructure\ApiPlatform\Resource\BarResource'
|
||||
* => 'Referentiel statique < 50 items (types de contrat). Cf. ERP-XX.',
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
private const EXCLUDED = [];
|
||||
|
||||
public function testAllGetCollectionOperationsHavePaginationEnabled(): void
|
||||
{
|
||||
$finder = new Finder()
|
||||
->files()
|
||||
->in(__DIR__.'/../../src')
|
||||
->name('*.php')
|
||||
->contains('#[ApiResource')
|
||||
;
|
||||
|
||||
// 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),
|
||||
'Aucun fichier #[ApiResource] trouve sous src/ : chemin invalide ou codebase vide.',
|
||||
);
|
||||
|
||||
foreach ($finder as $file) {
|
||||
$fqcn = $this->extractFqcn($file->getRealPath());
|
||||
if (null === $fqcn || !class_exists($fqcn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($fqcn);
|
||||
$apiResourceAttributes = $reflection->getAttributes(ApiResource::class);
|
||||
|
||||
if ([] === $apiResourceAttributes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($apiResourceAttributes as $attribute) {
|
||||
/** @var ApiResource $apiResource */
|
||||
$apiResource = $attribute->newInstance();
|
||||
$operations = $apiResource->getOperations()?->getIterator() ?? [];
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
if (!$operation instanceof GetCollection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false !== $operation->getPaginationEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// La pagination est explicitement desactivee : verifier
|
||||
// que la resource est dans la whitelist EXCLUDED.
|
||||
self::assertArrayHasKey(
|
||||
$fqcn,
|
||||
self::EXCLUDED,
|
||||
sprintf(
|
||||
"La resource %s desactive la pagination sur une operation GetCollection.\n"
|
||||
."Regle : toute collection API Platform doit etre paginee (cf. .claude/rules/backend.md).\n"
|
||||
."Si cette collection est structurellement bornee et que la desactivation est justifiee,\n"
|
||||
.'ajouter une entree dans CollectionsArePaginatedTest::EXCLUDED avec une justification.',
|
||||
$fqcn,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Garde-fou architecture : toute colonne d'une table metier doit porter une
|
||||
* description SQL (`COMMENT ON COLUMN`).
|
||||
*
|
||||
* Postgres stocke la description dans `pg_description`, recuperable via
|
||||
* `col_description(table_oid, column_position)`. Une colonne sans description
|
||||
* remonte `NULL`. Le test parcourt `information_schema.columns` filtre sur le
|
||||
* schema `public` et echoue si une seule colonne metier n'a pas de description.
|
||||
*
|
||||
* Tables ignorees :
|
||||
* - `doctrine_migration_versions` : table system Doctrine, schema fige par la
|
||||
* librairie.
|
||||
* - Whitelist `EXCLUDED_TABLES` : doit rester vide ou justifiee — toute entree
|
||||
* doit avoir un ticket Lesstime ouvert pour le retrofit.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ColumnsHaveSqlCommentTest extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* Tables system, gerees par Doctrine — leur schema n'est pas notre.
|
||||
*/
|
||||
private const EXCLUDED_BUILTINS = [
|
||||
'doctrine_migration_versions',
|
||||
];
|
||||
|
||||
/**
|
||||
* Entites mappees uniquement en `when@test` (fixtures techniques pour les
|
||||
* tests d'integration, jamais en prod). Pas de migration, donc pas de
|
||||
* lieu naturel pour poser un COMMENT ON COLUMN.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EXCLUDED_TEST_FIXTURES = [
|
||||
// tests/Fixtures/SiteAware/FakeSiteAwareEntity.php — fixture du module
|
||||
// Sites pour couvrir le SiteScopedQueryExtension. Cree via schema:update
|
||||
// sur la DB de test uniquement.
|
||||
'fake_site_aware_entity',
|
||||
];
|
||||
|
||||
/**
|
||||
* Whitelist metier — DOIT rester vide ou justifiee.
|
||||
*
|
||||
* Chaque entree doit comporter (1) un commentaire expliquant pourquoi la
|
||||
* table n'est pas encore documentee et (2) la reference d'un ticket
|
||||
* Lesstime ouvert pour le retrofit.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const EXCLUDED_TABLES = [];
|
||||
|
||||
public function testAllPublicColumnsHaveASqlComment(): void
|
||||
{
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.default_connection');
|
||||
|
||||
$excluded = [...self::EXCLUDED_BUILTINS, ...self::EXCLUDED_TEST_FIXTURES, ...self::EXCLUDED_TABLES];
|
||||
|
||||
$rows = $conn->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT c.table_name, c.column_name
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = 'public'
|
||||
AND c.table_name NOT IN (:excluded)
|
||||
AND col_description(
|
||||
(c.table_schema || '.' || c.table_name)::regclass,
|
||||
c.ordinal_position
|
||||
) IS NULL
|
||||
ORDER BY c.table_name, c.ordinal_position
|
||||
SQL,
|
||||
['excluded' => $excluded],
|
||||
['excluded' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
if ([] !== $rows) {
|
||||
$missing = array_map(
|
||||
static fn (array $row): string => sprintf('%s.%s', $row['table_name'], $row['column_name']),
|
||||
$rows,
|
||||
);
|
||||
|
||||
self::fail(sprintf(
|
||||
"%d colonne(s) sans COMMENT ON COLUMN — ajouter une description SQL dans la migration qui les cree (cf. .claude/rules/backend.md § Migrations Doctrine) :\n - %s",
|
||||
count($missing),
|
||||
implode("\n - ", $missing),
|
||||
));
|
||||
}
|
||||
|
||||
// Garde : si la requete ne renvoie rien et qu'aucune table publique
|
||||
// n'existe (sauf doctrine_migration_versions), le test deviendrait un
|
||||
// faux positif vert. On verifie qu'il y a bien des tables a auditer.
|
||||
$tableCount = (int) $conn->fetchOne(
|
||||
<<<'SQL'
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name NOT IN (:excluded)
|
||||
SQL,
|
||||
['excluded' => $excluded],
|
||||
['excluded' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
self::assertGreaterThan(0, $tableCount, 'Aucune table publique a auditer : schema vide ou whitelist trop large.');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use App\Module\Core\Domain\Entity\Permission;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
@@ -49,6 +53,11 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
* - CategoryType : referentiel statique (codes de typage des categories),
|
||||
* pas de besoin de tracabilite user-driven (cree par migration/seed,
|
||||
* pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17.
|
||||
* - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels
|
||||
* comptables statiques (id/code/label/position), seedes par migration +
|
||||
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
|
||||
* tracabilite user-driven, meme justification que CategoryType. Cf.
|
||||
* spec-back M1 § 2.6 + § 3.5.
|
||||
*
|
||||
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
||||
*/
|
||||
@@ -58,6 +67,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
Permission::class,
|
||||
Site::class,
|
||||
CategoryType::class,
|
||||
TvaMode::class,
|
||||
PaymentDelay::class,
|
||||
PaymentType::class,
|
||||
Bank::class,
|
||||
];
|
||||
|
||||
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||
|
||||
@@ -29,7 +29,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
|
||||
);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
$response = $client->request('GET', '/api/categories?pagination=false');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
@@ -62,7 +62,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
|
||||
);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?includeDeleted=true');
|
||||
$response = $client->request('GET', '/api/categories?includeDeleted=true&pagination=false');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$names = array_values(array_filter(
|
||||
@@ -87,7 +87,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'mid', $type);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
$response = $client->request('GET', '/api/categories?pagination=false');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$names = array_values(array_filter(
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests du contrat de pagination sur GET /api/categories (ERP-72).
|
||||
*
|
||||
* Invariants testes :
|
||||
* - la collection expose les metadonnees Hydra (totalItems, view, member) ;
|
||||
* - itemsPerPage est plafonne au maximum global (50) ;
|
||||
* - une page hors limites retourne une collection vide, pas une 500 ;
|
||||
* - ?pagination=false retourne tous les items sans troncature (select-box) ;
|
||||
* - la pagination est compatible avec le flag ?includeDeleted=true.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryPaginationTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
/**
|
||||
* La collection expose les metadonnees de pagination JSON-LD sans prefixe :
|
||||
* `totalItems`, `view`, `member` (convention API Platform 4, pas hydra:*).
|
||||
*
|
||||
* On cree 12 categories pour depasser la limite par page (10) : la cle
|
||||
* `view` n'est presente que lorsqu'il y a plus d'items que la taille de page.
|
||||
*/
|
||||
public function testCollectionExposesHydraPaginationMetadata(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'meta_'.$i, $type);
|
||||
}
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertArrayHasKey('totalItems', $data, 'La collection doit exposer totalItems.');
|
||||
self::assertArrayHasKey('view', $data, 'La collection doit exposer view (pagination) quand totalItems > itemsPerPage.');
|
||||
self::assertIsArray($data['member'], 'member doit etre un tableau.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Un itemsPerPage arbitrairement grand (99999) doit etre plafonne au
|
||||
* maximum global configure (50). On cree 12 categories pour etre certain
|
||||
* de disposer de donnees ; le cap doit s'appliquer quelle que soit la taille
|
||||
* reelle de la collection.
|
||||
*/
|
||||
public function testItemsPerPageIsCappedAtMaximum(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'cap_'.$i, $type);
|
||||
}
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?itemsPerPage=99999');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
// Le cap global est 50 : jamais plus d'items par page que le maximum.
|
||||
self::assertLessThanOrEqual(
|
||||
50,
|
||||
count($response->toArray()['member']),
|
||||
'itemsPerPage doit etre plafonne au maximum global (50).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Une page tres elevee (99999) sur une petite collection ne doit pas
|
||||
* produire une 500 PG (OFFSET negatif ou depassement de capacite) mais
|
||||
* retourner 200 avec un tableau member vide.
|
||||
*/
|
||||
public function testOutOfBoundPageReturnsEmptyCollectionNot500(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'oob', $type);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?page=99999');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
// La page 99999 est forcement vide (on a bien moins que 99999*10 items).
|
||||
self::assertSame(
|
||||
[],
|
||||
$response->toArray()['member'],
|
||||
'Une page hors limites doit retourner un member vide, jamais une 500.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ?pagination=false permet au frontend de desactiver la pagination pour
|
||||
* alimenter un select-box. On cree exactement 12 categories dont les noms
|
||||
* commencent par `test_cat_select_` : le filtre sur ce prefixe isole nos
|
||||
* entrees des donnees concurrentes et prouve que les 12 items sont tous
|
||||
* retournes (et pas seulement les 10 premiers de la page 1).
|
||||
*/
|
||||
public function testClientCanDisablePaginationToFeedASelect(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'select_'.$i, $type);
|
||||
}
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?pagination=false');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$members = $response->toArray()['member'];
|
||||
|
||||
// Filtre sur le sous-prefixe pour ne pas comptabiliser les categories
|
||||
// d'autres tests qui partagent la meme base de donnees.
|
||||
$selectItems = array_values(array_filter(
|
||||
$members,
|
||||
fn (array $m): bool => str_starts_with($m['name'], self::TEST_CATEGORY_PREFIX.'select_'),
|
||||
));
|
||||
|
||||
self::assertCount(
|
||||
12,
|
||||
$selectItems,
|
||||
'?pagination=false doit retourner toutes les categories (pas seulement la page 1).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* La pagination doit fonctionner conjointement avec le flag ?includeDeleted=true.
|
||||
* On seed 3 categories actives + 2 soft-deleted, on demande itemsPerPage=5 :
|
||||
* la page 1 doit contenir exactement 5 items et totalItems doit valoir >= 5.
|
||||
*/
|
||||
public function testPaginationCombinedWithIncludeDeletedFlag(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
|
||||
// 3 categories actives.
|
||||
for ($i = 1; $i <= 3; ++$i) {
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'pag_active_'.$i, $type);
|
||||
}
|
||||
|
||||
// 2 categories soft-deleted.
|
||||
for ($i = 1; $i <= 2; ++$i) {
|
||||
$this->createCategory(
|
||||
self::TEST_CATEGORY_PREFIX.'pag_deleted_'.$i,
|
||||
$type,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?includeDeleted=true&itemsPerPage=5');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
// La page retournee ne doit pas exceder itemsPerPage=5.
|
||||
self::assertCount(
|
||||
5,
|
||||
$data['member'],
|
||||
'La page 1 doit contenir exactement 5 items (itemsPerPage=5 avec >= 5 categories disponibles).',
|
||||
);
|
||||
self::assertGreaterThanOrEqual(
|
||||
5,
|
||||
$data['totalItems'],
|
||||
'totalItems doit refleter au moins les 5 categories seedees (actives + soft-deleted).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du module Commercial (M1 — repertoire clients).
|
||||
*
|
||||
* Etend la base Core : ajoute des factories pour seeder vite des categories
|
||||
* typees (DISTRIBUTEUR / COURTIER / SECTEUR) et des clients, plus un helper
|
||||
* d'authentification admin.
|
||||
*
|
||||
* Cleanup : tearDown purge clients, categories `test_cli_cat_*` et users/roles
|
||||
* `test_*`. Les category_types business sont fetch-or-create (idempotents) et
|
||||
* laisses en place (pas de DELETE pour ne pas entrer en conflit avec d'autres
|
||||
* suites). Pas de DAMA en local -> purge manuelle obligatoire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanupCommercialTestData();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function createAdminClient(): Client
|
||||
{
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere (ou cree) un CategoryType par son code metier. Idempotent : la
|
||||
* contrainte d'unicite sur category_type.code interdit les doublons.
|
||||
*/
|
||||
protected function createCategoryType(string $code): CategoryType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => $code]);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$type = new CategoryType();
|
||||
$type->setCode($code);
|
||||
$type->setLabel(ucfirst(strtolower($code)));
|
||||
$em->persist($type);
|
||||
$em->flush();
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une Category de test rattachee a un type metier donne (code).
|
||||
*/
|
||||
protected function createCategory(string $typeCode = 'SECTEUR'): Category
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$category = new Category();
|
||||
$category->setName(self::TEST_CATEGORY_PREFIX.$suffix);
|
||||
$category->setCategoryType($this->createCategoryType($typeCode));
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede directement un Client en base (sans passer par l'API), pour les
|
||||
* tests de liste / archivage. Le client porte une categorie SECTEUR.
|
||||
*/
|
||||
protected function seedClient(string $companyName, bool $isArchived = false, string $categoryTypeCode = 'SECTEUR'): ClientEntity
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$client = new ClientEntity();
|
||||
// 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($categoryTypeCode));
|
||||
$client->setIsArchived($isArchived);
|
||||
if ($isArchived) {
|
||||
$client->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
$em->persist($client);
|
||||
$em->flush();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function cleanupCommercialTestData(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Clients d'abord (la jointure client_category est purgee par
|
||||
// ON DELETE CASCADE ; les auto-references distributor/broker sont
|
||||
// ON DELETE SET NULL).
|
||||
$em->createQuery('DELETE FROM '.ClientEntity::class)->execute();
|
||||
|
||||
// Categories de test ensuite (FK client_category deja purgee).
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix',
|
||||
)->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute();
|
||||
|
||||
// Users / roles jetables.
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix',
|
||||
)->setParameter('prefix', 'test_%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix',
|
||||
)->setParameter('prefix', 'test_%')->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'API /api/clients (M1) — branche ERP-55.
|
||||
*
|
||||
* Authentifies en ADMIN (bypass RBAC via isAdmin) : on valide ici les regles
|
||||
* METIER (normalisation, unicite, distributor/broker, archivage, liste). Le
|
||||
* gating par permission (accounting.manage / archive / RG-1.28 strict, RG-1.04
|
||||
* Commerciale) est couvert par les tests unitaires du ClientProcessor : il
|
||||
* exige des users non-admin portant des permissions `commercial.clients.*` qui
|
||||
* ne sont declarees qu'en ERP-59 (tests RBAC complets en ERP-60).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
public function testPostNormalizesTextFields(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$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()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
// RG-1.18 / 1.19 / 1.20 / 1.21
|
||||
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::assertFalse($data['isArchived']);
|
||||
}
|
||||
|
||||
public function testPostDuplicateCompanyNameReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$iri = '/api/categories/'.$cat->getId();
|
||||
|
||||
$payload = [
|
||||
'companyName' => 'Doublon SARL',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'dup@test.fr',
|
||||
'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();
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'No Category',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'nocat@test.fr',
|
||||
'categories' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert\Count(min: 1)
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostWithDistributorAndBrokerReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$distributor = $this->seedClient('Distrib Mutex', false, 'DISTRIBUTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'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(),
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.03 (exclusivite)
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostDistributorReferencingNonDistributorReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$notDistro = $this->seedClient('Pas Un Distrib', false, 'SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'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(),
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.03 (le distributor doit etre categorise DISTRIBUTEUR)
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostValidDistributorReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$distributor = $this->seedClient('Vrai Distrib', false, 'DISTRIBUTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'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(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testListSortedByCompanyNameAscAndExcludesArchived(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Zebra Co');
|
||||
$this->seedClient('Alpha Co');
|
||||
$this->seedClient('Archivé Co', true);
|
||||
|
||||
$names = $client->request('GET', '/api/clients?pagination=false', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray()['member'];
|
||||
$companyNames = array_map(static fn (array $c): string => $c['companyName'], $names);
|
||||
|
||||
// RG-1.24 : l'archive est exclue par defaut.
|
||||
self::assertNotContains('ARCHIVÉ CO', $companyNames);
|
||||
// RG-1.26 : tri companyName ASC (Alpha avant Zebra).
|
||||
$alpha = array_search('ALPHA CO', $companyNames, true);
|
||||
$zebra = array_search('ZEBRA CO', $companyNames, true);
|
||||
self::assertNotFalse($alpha);
|
||||
self::assertNotFalse($zebra);
|
||||
self::assertLessThan($zebra, $alpha);
|
||||
}
|
||||
|
||||
public function testListIncludeArchivedReturnsArchived(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Hidden Archived', true);
|
||||
|
||||
$members = $client->request('GET', '/api/clients?includeArchived=true&pagination=false', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray()['member'];
|
||||
$names = array_map(static fn (array $c): string => $c['companyName'], $members);
|
||||
|
||||
// RG-1.25
|
||||
self::assertContains('HIDDEN ARCHIVED', $names);
|
||||
}
|
||||
|
||||
public function testCollectionIsPaginated(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Paginated One');
|
||||
|
||||
// Collection Hydra avec total (la cle `view` n'apparait qu'a partir de
|
||||
// 2 pages cote API Platform 4, donc non assertable sur page unique).
|
||||
$page1 = $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('totalItems', $page1);
|
||||
self::assertNotEmpty($page1['member']);
|
||||
|
||||
// Preuve que la pagination serveur est bien engagee : la page 2 d'un jeu
|
||||
// tenant sur une page est vide (un provider non pagine ignorerait `page`
|
||||
// et renverrait quand meme les items).
|
||||
$page2 = $client->request('GET', '/api/clients?page=2', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertSame([], $page2['member']);
|
||||
}
|
||||
|
||||
public function testPatchArchiveSetsArchivedAtThenRestore(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('To Archive');
|
||||
$iri = '/api/clients/'.$seed->getId();
|
||||
|
||||
// Archive (RG-1.22) : admin a la permission archive via bypass isAdmin.
|
||||
$archived = $client->request('PATCH', $iri, [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isArchived' => true],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertTrue($archived['isArchived']);
|
||||
self::assertNotNull($archived['archivedAt']);
|
||||
|
||||
// Restauration (RG-1.23) : archivedAt repasse a null.
|
||||
$restored = $client->request('PATCH', $iri, [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isArchived' => false],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertFalse($restored['isArchived']);
|
||||
self::assertNull($restored['archivedAt']);
|
||||
}
|
||||
|
||||
public function testPatchArchiveWithOtherFieldReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Archive Plus Field');
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isArchived' => true, 'companyName' => 'Renamed'],
|
||||
]);
|
||||
|
||||
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testGetDetailEmbedsSubCollections(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Detail Embed');
|
||||
|
||||
$data = $client->request('GET', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray();
|
||||
|
||||
// § 4.2 : le detail embarque contacts / adresses / ribs.
|
||||
self::assertArrayHasKey('contacts', $data);
|
||||
self::assertArrayHasKey('addresses', $data);
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\Serializer\CategoryReferenceDenormalizer;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du CategoryReferenceDenormalizer : resolution d'un IRI vers
|
||||
* une Category concrete, et rejet explicite d'un IRI pointant sur une autre
|
||||
* ressource (au lieu d'un null silencieux qui perdrait la reference).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryReferenceDenormalizerTest extends TestCase
|
||||
{
|
||||
public function testResolvesCategoryIri(): void
|
||||
{
|
||||
$category = $this->createStub(CategoryInterface::class);
|
||||
$iriConverter = $this->createMock(IriConverterInterface::class);
|
||||
$iriConverter->method('getResourceFromIri')->willReturn($category);
|
||||
|
||||
$denormalizer = new CategoryReferenceDenormalizer($iriConverter);
|
||||
|
||||
self::assertSame(
|
||||
$category,
|
||||
$denormalizer->denormalize('/api/categories/1', CategoryInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function testRejectsIriOfWrongType(): void
|
||||
{
|
||||
// Bug review ERP-55 : un IRI syntaxiquement valide mais pointant sur une
|
||||
// autre ressource (ex: /api/clients/5) doit lever une exception au lieu
|
||||
// d'etre silencieusement ignore.
|
||||
$iriConverter = $this->createMock(IriConverterInterface::class);
|
||||
$iriConverter->method('getResourceFromIri')->willReturn(new stdClass());
|
||||
|
||||
$denormalizer = new CategoryReferenceDenormalizer($iriConverter);
|
||||
|
||||
$this->expectException(UnexpectedValueException::class);
|
||||
$denormalizer->denormalize('/api/clients/5', CategoryInterface::class);
|
||||
}
|
||||
|
||||
public function testReturnsNullForEmptyData(): void
|
||||
{
|
||||
// Valeur vide deleguee par l'ArrayDenormalizer : aucun appel a
|
||||
// l'IriConverter, retour null.
|
||||
$iriConverter = $this->createMock(IriConverterInterface::class);
|
||||
$iriConverter->expects(self::never())->method('getResourceFromIri');
|
||||
|
||||
$denormalizer = new CategoryReferenceDenormalizer($iriConverter);
|
||||
|
||||
self::assertNull($denormalizer->denormalize('', CategoryInterface::class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires de la normalisation serveur (RG-1.18 a RG-1.21).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientFieldNormalizerTest extends TestCase
|
||||
{
|
||||
private ClientFieldNormalizer $normalizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->normalizer = new ClientFieldNormalizer();
|
||||
}
|
||||
|
||||
public function testCompanyNameIsUppercased(): void
|
||||
{
|
||||
// RG-1.18
|
||||
self::assertSame('ACME SAS', $this->normalizer->normalizeCompanyName(' acme sas '));
|
||||
self::assertNull($this->normalizer->normalizeCompanyName(null));
|
||||
}
|
||||
|
||||
public function testPersonNameIsTitleCased(): void
|
||||
{
|
||||
// RG-1.19
|
||||
self::assertSame('Jean', $this->normalizer->normalizePersonName('JEAN'));
|
||||
self::assertSame('Dupont', $this->normalizer->normalizePersonName('dupont'));
|
||||
self::assertNull($this->normalizer->normalizePersonName(' '));
|
||||
self::assertNull($this->normalizer->normalizePersonName(null));
|
||||
}
|
||||
|
||||
public function testEmailIsLowercased(): void
|
||||
{
|
||||
// RG-1.21
|
||||
self::assertSame('jean.dupont@acme.fr', $this->normalizer->normalizeEmail(' Jean.DUPONT@ACME.FR '));
|
||||
self::assertNull($this->normalizer->normalizeEmail(null));
|
||||
self::assertNull($this->normalizer->normalizeEmail(' '));
|
||||
}
|
||||
|
||||
public function testPhoneKeepsOnlyDigits(): void
|
||||
{
|
||||
// RG-1.20
|
||||
self::assertSame('0612345678', $this->normalizer->normalizePhone('06.12.34.56.78'));
|
||||
self::assertSame('0612345678', $this->normalizer->normalizePhone('06 12 34 56 78'));
|
||||
self::assertNull($this->normalizer->normalizePhone('----'));
|
||||
self::assertNull($this->normalizer->normalizePhone(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
|
||||
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
||||
use App\Shared\Domain\Security\BusinessRoles;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Tests unitaires du ClientProcessor : gating par permission (accounting.manage
|
||||
* / archive / RG-1.28 strict) et regles metier non testables en HTTP admin
|
||||
* (RG-1.04 Commerciale, RG-1.12 Virement, RG-1.13 LCR), grace a un Security et
|
||||
* un RequestStack stubbes.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientProcessorTest extends TestCase
|
||||
{
|
||||
public function testAccountingFieldWithoutPermissionIsForbidden(): void
|
||||
{
|
||||
// RG-1.28 : la modification effective d'un champ comptable sans
|
||||
// accounting.manage -> 403. En creation (POST), positionner siren est un
|
||||
// changement vs l'etat persiste vide.
|
||||
$client = $this->minimalClient();
|
||||
$client->setSiren('123456789');
|
||||
|
||||
$processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testStrictMixWithAccountingFieldIsForbidden(): void
|
||||
{
|
||||
// RG-1.28 : payload mixant main + accounting sans la permission -> 403
|
||||
// sur l'ensemble (pas de filtrage silencieux).
|
||||
$client = $this->minimalClient();
|
||||
$client->setCompanyName('X');
|
||||
$client->setSiren('123456789');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['companyName' => 'X', 'siren' => '123456789'],
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testArchiveWithoutPermissionIsForbidden(): void
|
||||
{
|
||||
// RG-1.22 : basculer isArchived sans la permission archive -> 403.
|
||||
$client = $this->minimalClient();
|
||||
$client->setIsArchived(true);
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['isArchived' => true],
|
||||
managed: true,
|
||||
originalData: ['isArchived' => false],
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testArchiveWithOtherFieldIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
|
||||
$client = $this->minimalClient();
|
||||
$client->setIsArchived(true);
|
||||
$client->setCompanyName('X');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.archive'],
|
||||
payload: ['isArchived' => true, 'companyName' => 'X'],
|
||||
managed: true,
|
||||
originalData: ['isArchived' => false],
|
||||
);
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testPostWithIsArchivedFalseIsNotGated(): void
|
||||
{
|
||||
// Bug review ERP-55 : un POST renvoyant isArchived:false (valeur par
|
||||
// defaut) ne doit declencher ni 403 (archive) ni 422, meme sans
|
||||
// permission. L'entite n'est pas encore geree par l'ORM.
|
||||
$client = $this->minimalClient(); // isArchived = false par defaut
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['companyName' => 'Test Co', 'isArchived' => false],
|
||||
managed: false,
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
public function testFullRepresentationPatchWithUnchangedArchiveIsNotGated(): void
|
||||
{
|
||||
// Bug review ERP-55 : un PATCH « representation complete » renvoyant
|
||||
// isArchived inchange + des cles JSON-LD (@id, @context) ne doit pas etre
|
||||
// gate (ni 403 archive ni 422), meme sans permission.
|
||||
$client = $this->minimalClient(); // isArchived = false (inchange)
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: [
|
||||
'@id' => '/api/clients/1',
|
||||
'@context' => '/api/contexts/Client',
|
||||
'companyName' => 'Test Co',
|
||||
'isArchived' => false,
|
||||
],
|
||||
managed: true,
|
||||
originalData: ['isArchived' => false],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
public function testUnchangedAccountingFieldOnPatchIsNotGated(): void
|
||||
{
|
||||
// Bug review ERP-55 : renvoyer un champ comptable a sa valeur persistee
|
||||
// (PATCH representation complete) ne change rien -> pas d'exigence
|
||||
// accounting.manage.
|
||||
$client = $this->minimalClient();
|
||||
$client->setSiren('123456789'); // identique a l'etat persiste
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['companyName' => 'Test Co', 'siren' => '123456789'],
|
||||
managed: true,
|
||||
// getOriginalEntityData renvoie tous les champs mappes d'une entite
|
||||
// geree : isArchived (non-null) y figure toujours.
|
||||
originalData: ['siren' => '123456789', 'isArchived' => false],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
public function testVirementWithoutBankIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.12
|
||||
$client = $this->minimalClient();
|
||||
$client->setPaymentType($this->paymentType('VIREMENT'));
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['paymentType' => '/api/payment_types/1'],
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testVirementWithBankPasses(): void
|
||||
{
|
||||
// RG-1.12 satisfait : Virement + banque.
|
||||
$client = $this->minimalClient();
|
||||
$client->setPaymentType($this->paymentType('VIREMENT'));
|
||||
$client->setBank(new Bank());
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['paymentType' => '/api/payment_types/1', 'bank' => '/api/banks/1'],
|
||||
);
|
||||
|
||||
$result = $processor->process($client, $this->operation());
|
||||
self::assertInstanceOf(Client::class, $result);
|
||||
}
|
||||
|
||||
public function testLcrWithoutRibIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.13
|
||||
$client = $this->minimalClient();
|
||||
$client->setPaymentType($this->paymentType('LCR'));
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['paymentType' => '/api/payment_types/2'],
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testLcrWithRibPasses(): void
|
||||
{
|
||||
// RG-1.13 satisfait : LCR + au moins un RIB.
|
||||
$client = $this->minimalClient();
|
||||
$client->setPaymentType($this->paymentType('LCR'));
|
||||
$client->addRib(new ClientRib());
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['paymentType' => '/api/payment_types/2'],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
public function testCommercialeIncompleteInformationIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.04 : role Commerciale + onglet Information incomplet -> 422.
|
||||
$client = $this->minimalClient();
|
||||
$client->setDescription('Une description'); // les autres champs Information restent null
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['description' => 'Une description'],
|
||||
user: $this->commercialeUser(),
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testNonCommercialeSkipsInformationCompleteness(): void
|
||||
{
|
||||
// Meme payload incomplet, mais user non-Commerciale -> aucun blocage.
|
||||
$client = $this->minimalClient();
|
||||
$client->setDescription('Une description');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['description' => 'Une description'],
|
||||
user: null,
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $granted Permissions accordees a l'utilisateur courant
|
||||
* @param array<string, mixed> $payload Corps JSON simule de la requete
|
||||
* @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
|
||||
* @param array<string, mixed> $originalData Etat persiste simule (getOriginalEntityData) pour la detection de changement
|
||||
*/
|
||||
private function makeProcessor(
|
||||
array $granted,
|
||||
array $payload,
|
||||
?UserInterface $user = null,
|
||||
bool $managed = false,
|
||||
array $originalData = [],
|
||||
): ClientProcessor {
|
||||
$persist = new class implements ProcessorInterface {
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
};
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('isGranted')->willReturnCallback(
|
||||
static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
|
||||
);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
$requestStack = new RequestStack();
|
||||
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
|
||||
|
||||
// EntityManager stub : contains() distingue creation (POST) et mise a
|
||||
// jour (PATCH) ; getOriginalEntityData() fournit l'etat persiste compare
|
||||
// par le gating (RG-1.22 / RG-1.28).
|
||||
$uow = $this->createMock(UnitOfWork::class);
|
||||
$uow->method('getOriginalEntityData')->willReturn($originalData);
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('contains')->willReturn($managed);
|
||||
$em->method('getUnitOfWork')->willReturn($uow);
|
||||
|
||||
return new ClientProcessor(
|
||||
$persist,
|
||||
new ClientFieldNormalizer(),
|
||||
new ClientInformationCompletenessValidator(),
|
||||
$security,
|
||||
$requestStack,
|
||||
$em,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) — suffisant
|
||||
* pour atteindre les validations testees.
|
||||
*/
|
||||
private function minimalClient(): Client
|
||||
{
|
||||
$client = new Client();
|
||||
$client->setCompanyName('Test Co');
|
||||
$client->setLastName('Dupont');
|
||||
$client->setPhonePrimary('0102030405');
|
||||
$client->setEmail('t@test.fr');
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function paymentType(string $code): PaymentType
|
||||
{
|
||||
$type = new PaymentType();
|
||||
$type->setCode($code);
|
||||
$type->setLabel($code);
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
private function operation(): Operation
|
||||
{
|
||||
return $this->createStub(Operation::class);
|
||||
}
|
||||
|
||||
private function commercialeUser(): UserInterface
|
||||
{
|
||||
return new class implements UserInterface, BusinessRoleAwareInterface {
|
||||
public function hasBusinessRole(string $roleCode): bool
|
||||
{
|
||||
return BusinessRoles::COMMERCIALE === $roleCode;
|
||||
}
|
||||
|
||||
public function getRoles(): array
|
||||
{
|
||||
return ['ROLE_USER'];
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void {}
|
||||
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return 'commerciale-test';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use ApiPlatform\State\SerializerContextBuilderInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\Serializer\ClientReadGroupContextBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Tests unitaires du context builder qui ajoute conditionnellement le groupe
|
||||
* de lecture `client:read:accounting` selon la permission accounting.view
|
||||
* (§ 2.7 / § 4.1 / § 4.2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientReadGroupContextBuilderTest extends TestCase
|
||||
{
|
||||
public function testAddsAccountingGroupForClientReadWhenGranted(): void
|
||||
{
|
||||
$builder = $this->builder(
|
||||
baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']],
|
||||
granted: true,
|
||||
);
|
||||
|
||||
$context = $builder->createFromRequest(new Request(), true);
|
||||
|
||||
self::assertContains('client:read:accounting', $context['groups']);
|
||||
}
|
||||
|
||||
public function testDoesNotAddAccountingGroupWhenNotGranted(): void
|
||||
{
|
||||
$builder = $this->builder(
|
||||
baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']],
|
||||
granted: false,
|
||||
);
|
||||
|
||||
$context = $builder->createFromRequest(new Request(), true);
|
||||
|
||||
self::assertNotContains('client:read:accounting', $context['groups']);
|
||||
}
|
||||
|
||||
public function testDoesNotAddAccountingGroupOnWrite(): void
|
||||
{
|
||||
$builder = $this->builder(
|
||||
baseContext: ['resource_class' => Client::class, 'groups' => ['client:write:main']],
|
||||
granted: true,
|
||||
);
|
||||
|
||||
// normalization = false -> ecriture : pas de groupe de lecture ajoute.
|
||||
$context = $builder->createFromRequest(new Request(), false);
|
||||
|
||||
self::assertNotContains('client:read:accounting', $context['groups']);
|
||||
}
|
||||
|
||||
public function testIgnoresOtherResources(): void
|
||||
{
|
||||
$builder = $this->builder(
|
||||
baseContext: ['resource_class' => 'App\Other\Resource', 'groups' => ['other:read']],
|
||||
granted: true,
|
||||
);
|
||||
|
||||
$context = $builder->createFromRequest(new Request(), true);
|
||||
|
||||
self::assertSame(['other:read'], $context['groups']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baseContext
|
||||
*/
|
||||
private function builder(array $baseContext, bool $granted): ClientReadGroupContextBuilder
|
||||
{
|
||||
$decorated = $this->createStub(SerializerContextBuilderInterface::class);
|
||||
$decorated->method('createFromRequest')->willReturn($baseContext);
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('isGranted')->willReturn($granted);
|
||||
|
||||
return new ClientReadGroupContextBuilder($decorated, $security);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
/**
|
||||
* Regression test de pagination sur GET /api/audit-logs (ERP-72).
|
||||
*
|
||||
* Avant ce ticket, `paginationItemsPerPage` etait fixe a 30 dans
|
||||
* AuditLogResource. Apres migration vers les defaults globaux (10/50),
|
||||
* ce fichier verrouille le nouveau contrat :
|
||||
* - la reponse est paginee (max 10 items par page par defaut) ;
|
||||
* - un itemsPerPage excessif est plafonne a 50.
|
||||
*
|
||||
* Pas de seed : la table audit_log contient deja des lignes issues des
|
||||
* fixtures / autres tests. Les assertions utilisent des inegalites pour
|
||||
* rester robustes quelle que soit la quantite exacte de donnees presentes.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class AuditLogPaginationRegressionTest extends AbstractApiTestCase
|
||||
{
|
||||
/**
|
||||
* La collection /api/audit-logs doit etre paginee avec les defaults globaux :
|
||||
* - `member`, `totalItems`, `view` presentes dans la reponse JSON-LD ;
|
||||
* - au plus 10 items par page (nouveau defaut, etait 30 avant ce ticket).
|
||||
*/
|
||||
public function testAuditLogCollectionStillPaginated(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/audit-logs');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertArrayHasKey('totalItems', $data, 'La collection audit-logs doit exposer totalItems.');
|
||||
self::assertArrayHasKey('view', $data, 'La collection audit-logs doit exposer view (pagination active).');
|
||||
self::assertIsArray($data['member'], 'member doit etre un tableau.');
|
||||
|
||||
// Le nouveau defaut global est 10 (etait 30 dans AuditLogResource avant ERP-72).
|
||||
self::assertLessThanOrEqual(
|
||||
10,
|
||||
count($data['member']),
|
||||
'La page par defaut ne doit pas depasser 10 items (default global ERP-72).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un itemsPerPage excessif (99999) doit etre plafonne au maximum global (50).
|
||||
* Teste la regression specifique du paginator DBAL custom (DbalPaginator) qui
|
||||
* pourrait ignorer la limite si la logique de cap n'est pas appliquee cote provider.
|
||||
*/
|
||||
public function testAuditLogItemsPerPageCappedAt50(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/audit-logs?itemsPerPage=99999');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertIsArray($data['member'], 'member doit etre un tableau.');
|
||||
|
||||
// Le cap global est 50 : jamais plus d'items par page que le maximum.
|
||||
self::assertLessThanOrEqual(
|
||||
50,
|
||||
count($data['member']),
|
||||
'itemsPerPage=99999 doit etre plafonne a 50 (maximum global ERP-72).',
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user