Compare commits

..

13 Commits

Author SHA1 Message Date
gitea-actions 0c6919201e chore: bump version to v0.1.55
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 17s
2026-05-29 14:16:13 +00:00
tristan 3e46394be1 [ERP-72] Paginer toutes les collections API + regle pagination obligatoire (#28)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte

Ticket Lesstime : [#72](https://lesstime.malio.fr/project/6/task/491) (id 491) — ticket transversal, pas de spec dediee : la description du ticket fait foi.

## Implementation

- **Defaut global de pagination** dans `config/packages/api_platform.yaml` : `items_per_page=10`, `maximum_items_per_page=50`, `client_items_per_page=true`, **`client_enabled=true`** (echappatoire `?pagination=false` pour alimenter les `<select>` cote front).
- **`CategoryProvider` refondu** : retourne maintenant un `ApiPlatform\Doctrine\Orm\Paginator(Doctrine\ORM\Tools\Pagination\Paginator(...))` au lieu d'un array brut. Supporte `?pagination=false`.
- **`AuditLogResource`** : override `paginationItemsPerPage=30 / max=50 / clientItemsPerPage=true` supprime, herite du global (10/50). `AuditLogProvider` (`DbalPaginator`) inchange.
- **Autres ressources** (`Category`, `CategoryType`, `User`, `Role`, `Permission`, `Site`) : aucun changement de code, heritent automatiquement.
- **Regle « pagination obligatoire »** documentee : `CLAUDE.md` (regle ABSOLUE n°13 + section « A NE PAS faire ») + `.claude/rules/backend.md` (nouvelle section dediee avec standard, override, selects, providers customs, garde-fou).
- **Garde-fou CI** : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist `EXCLUDED`.

## Adaptation collaterale (non prevue au plan initial)

7 appels `GET /api/<collection>` dans les tests existants (`CategoryListTest`, `PermissionApiTest`, `RoleApiTest`) ont recu `?pagination=false` parce qu'ils asseyaient sur le contenu complet de l'array. Sans cette adaptation, le commit Task 1 cassait `PermissionApiTest::testCollectionFilterByOrphanFalse`.

## Criteres d'acceptation

- [x] Toutes les collections API existantes paginees (plus aucun retour complet)
- [x] `itemsPerPage` par defaut (10) + max borne (50)
- [x] Tri / filtres / recherche fonctionnent combines a la pagination
- [x] `hydra:totalItems` (cle `totalItems` en JSON-LD API Platform 4) expose pour le front
- [x] Regle documentee (`CLAUDE.md` + `.claude/rules/backend.md`)

## Tests

- `docker exec -t php-starseed-fpm php -d memory_limit=512M vendor/bin/phpunit` → **Tests: 320, 0 failures** (etait 312 avant ce ticket → +8 nouveaux : 5 `CategoryPaginationTest` + 2 `AuditLogPaginationRegressionTest` + 1 `CollectionsArePaginatedTest`)
- `make php-cs-fixer-allow-risky` → 0 fix
- Verifications HTTP manuelles : voir cahier de test dans le ticket Lesstime #72

## Note d'incident

Le tout premier commit (`9060f5d`, pose du standard YAML) a ete cree avec `--no-verify` par un subagent qui n'a pas respecte la consigne explicite « jamais de bypass de hook ». La cause sous-jacente du hook failure etait un drift BDD locale sur `ColumnsHaveSqlCommentTest`, resolu ensuite via `make db-reset`. Les 6 commits suivants ont passe le hook normalement. Le contenu de `9060f5d` est correct (15 lignes YAML ajoutees) — a re-verifier en review.

## Reviewer suggere

A definir (Tristan etant l'auteur).

## Suite

Debloque le volet front **ERP-73** (pagination `MalioDataTable` + composable reutilisable + cablage `?pagination=false` sur les composables de select Role/Permission/Site/CategoryType).

Reviewed-on: #28
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 14:15:41 +00:00
Matthieu 1d91b4dea9 docs(commercial) : fix § 3.5 incohérence AuditIgnore RIB (aligné sur § 2.5)
Auto Tag Develop / tag (push) Failing after 30s
2026-05-29 14:23:51 +02:00
gitea-actions c402418937 chore: bump version to v0.1.54
Auto Tag Develop / tag (push) Successful in 35s
Build & Push Docker Image / build (push) Successful in 56s
2026-05-29 10:01:36 +00:00
matthieu 2866fb8865 [docs] M1 — Répertoire clients : specs front + back (#23)
Auto Tag Develop / tag (push) Successful in 36s
## Contexte

Spécifications front + back du **Module 1 — Répertoire clients** (premier module métier Tiers, extension du module `Commercial` existant).

Origine : V0 client `.docx` du 22/05/2026 (`M1-reportoire-clients.docx`) + maquette Figma `https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898`.

Pattern de rédaction : strictement aligné sur `docs/specs/M0-categories/` (spec-front léger + spec-back très détaillé).

## Contenu

- `docs/specs/M1-clients/spec-front.md` (289 lignes) — V0 client, structure UI, composants Malio, permissions par rôle, règles de formatage
- `docs/specs/M1-clients/spec-back.md` (1056 lignes) — décisions archi, modèle données, migration SQL Postgres, API REST, RBAC matrice complète, 27 RG numérotées, tests à automatiser, 16 HP, liens & dépendances

## Décisions structurantes (validées avec Tristan le 28/05/2026)

- **Module** : extension de `Commercial/` (pas nouveau module)
- **Catégories Client** : M2M `client_category` + seed `CategoryType` (`DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE`)
- **Distributeur / Courtier** : 2 FK auto-référentes nullables sur `client` + contrainte CHECK mutex
- **Workflow création** : sauvegarde incrémentale par onglet (POST formulaire principal → PATCH par onglet)
- **Onglets « À venir »** (Transport / Statistiques / Rapports / Échanges) : placeholders blancs (frames vides, pas de message texte)
- **Archive vs delete** : flag `is_archived` exposé au M1, colonne `deleted_at` préparée mais non exposée (HP M2)
- **API adresse** : api-adresse.data.gouv.fr (BAN), appel direct front via `useAddressAutocomplete()`
- **Unicité métier** : SIREN + `companyName` + email (indexes partiels Postgres, ignorent archivés et soft-deletés)
- **Téléphones** : 2 colonnes plates `phone_primary` + `phone_secondary`
- **Export** : XLSX uniquement (controller custom avec `priority: 1`)
- **Compta = lecture seule** ⚠ s'écarte du tableau du `.docx` (ligne « Compta = Ajout/Modification Comptabilité uniquement » invalidée) — documenté en HP-M2-10

## Seed M1 (référentiels comptables)

| Référentiel | Valeurs |
|---|---|
| `tva_mode` | `FRANCE_VENTES`, `EXPORT_VENTES`, `INTRACOM_VENTES` |
| `payment_delay` | `J15`, `J30`, `A_RECEPTION` |
| `payment_type` | `VIREMENT`, `LCR`, `NON_SOUMISE`, `CHEQUE` |
| `bank` | `SG`, `CIC`, `CA` (Société Générale / CIC / Crédit Agricole) |
| `category_type` (extension M0) | `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` |

## RG ajoutées au-delà du `.docx`

- **RG-1.14** : ≥ 1 bloc Contact valide obligatoire (renforcement Tristan)
- **RG-1.15/16/17** : unicités SIREN / nom / email
- **RG-1.18** : `companyName` UPPERCASE serveur
- **RG-1.19** : `firstName` / `lastName` Capitalize serveur
- **RG-1.20** : téléphones chiffres seuls en BDD, formatage `XX XX XX XX XX` au front
- **RG-1.21** : emails lowercase serveur
- **RG-1.22/23** : archivage / restauration + conflit unicité à la restauration

## Permissions RBAC (à synchroniser dans les 3 miroirs au moment du dev)

| Permission | Admin | Bureau | Compta | Commerciale | Usine |
|---|---|---|---|---|---|
| `commercial.clients.view` |  |  |  |  |  |
| `commercial.clients.manage` |  |  |  |  |  |
| `commercial.clients.accounting.view` |  |  |  |  |  |
| `commercial.clients.accounting.manage` |  |  |  |  |  |
| `commercial.clients.archive` |  |  |  |  |  |

## Prochaines étapes (hors MR)

1. Revue / validation des specs par Matthieu
2. Création du **TaskGroup Lesstime** `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6)
3. Découpage en ~14 tickets (ordre indicatif listé en bas du `spec-back.md`)

## Reviewer suggéré

Matthieu (CP MALIO).

## Cible

`develop`.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #23
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-29 09:58:32 +00:00
gitea-actions 0ed131ce57 chore: bump version to v0.1.53
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 16s
2026-05-29 09:44:34 +00:00
matthieu a948eed9b6 [ERP-67] Documenter toutes les colonnes BDD via COMMENT ON COLUMN + garde-fou (#24)
Auto Tag Develop / tag (push) Successful in 7s
Ticket Lesstime : ERP-67 — `[Convention SQL / Backend / L]`

## Objectif

Documenter toutes les colonnes BDD via `COMMENT ON COLUMN` (visible dans DBeaver / DataGrip / pgAdmin sans lire le code Doctrine) et verrouiller la convention par un garde-fou de test architecture.

## Changements

### Convention (CLAUDE.md + rules)

- `CLAUDE.md` regle ABSOLUE n°12 : toute migration creant ou modifiant une colonne doit poser un `COMMENT ON COLUMN` (FR, ≤ 200 caracteres).
- `.claude/rules/backend.md` § Migrations Doctrine : exemples + helper standardise pour les 4 colonnes du `TimestampableBlamableTrait`.

### Garde-fou architecture

- `tests/Architecture/ColumnsHaveSqlCommentTest` : echoue si une colonne `public` n'a pas de `col_description` (hors `doctrine_migration_versions` et `fake_site_aware_entity` fixture de test).
- Whitelist metier `EXCLUDED_TABLES` volontairement vide.

### Retrofit des tables existantes

- Migration `Version20260528120000` : 64 `COMMENT ON TABLE/COLUMN` sur les 11 tables metier (audit_log, category, category_type, permission, role, role_permission, site, user, user_permission, user_role, user_site).
- Source unique de verite : `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`.
- Commande `app:apply-column-comments` (Module/Core/Infrastructure/Console) : rejoue le catalogue apres `doctrine:schema:update --force` (sinon l'ORM drop les commentaires absents du mapping PHP). Branchee dans `makefile test-db-setup` et `.gitea/workflows/pull-request.yml`.

## Validation

- `make db-reset` puis `make test` : 312 tests verts, 0 regression.
- `make php-cs-fixer-allow-risky` : 0 fix.
- Couverture : 53/53 colonnes documentees sur `starseed` et `starseed_test`.

## Test plan

- [ ] `make db-reset` passe sans erreur.
- [ ] `make test` passe ; `ColumnsHaveSqlCommentTest` vert sur DB de test.
- [ ] Verifier dans DBeaver / pgAdmin que les commentaires apparaissent sur les colonnes de `category`, `user`, `audit_log`.
- [ ] Verifier que le workflow CI Gitea (`pull-request.yml`) passe.

## A noter pour la suite

La convention `options: ['comment' => '...']` sur chaque `#[ORM\Column]` reste recommandee pour les nouvelles entites — Doctrine genere alors automatiquement le `COMMENT ON COLUMN` dans la migration et `schema:update` le preserve sans avoir a rejouer le catalogue. A discuter si on veut en faire une regle forte.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #24
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-29 09:41:29 +00:00
gitea-actions fc78f434d1 chore: bump version to v0.1.52
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 32s
2026-05-29 09:23:47 +00:00
tristan 53e19d61ac [ERP-51] Écrire les tests Vitest des composables Catalog (#26)
Auto Tag Develop / tag (push) Successful in 7s
## Résumé

Couvre les deux composables Catalog extraits du refactor ERP-50 avec **42 tests Vitest unitaires** (happy-dom, sans dépendance backend).

- 14 tests sur \`useCategoriesAdmin\` (fetchAll/fetchTypes, includeDeleted, loading, error, reset, singleton)
- 28 tests sur \`useCategoryForm\` (validation RG-1.02/1.04/1.05 + trim, POST/PATCH/DELETE, mapping 409 RG-1.07 + 422 violations, isDirty, loadFrom, reset, isolation)

Mocks via \`vi.stubGlobal\` (useApi / useI18n / useToast) et \`vi.mock\` (\`~/shared/stores/auth\` pour neutraliser l'auto-enregistrement \`onAuthSessionCleared\`). La suite tourne en **~1.2s**.

Ticket Lesstime : #51

## Tests automatisés

- \`make nuxt-test\` ✓ 85 tests (dont 42 nouveaux), 0 échec, 1.2s

## Reviewer

@matthieu

## À tester en local

- [ ] \`make nuxt-test\` passe
- [ ] Mock \`useApi\` reste stable si le pattern d'auto-import Nuxt évolue
- [ ] Couverture jugée suffisante des cas back miroir

Reviewed-on: #26
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 09:23:41 +00:00
gitea-actions ece8146c03 chore: bump version to v0.1.51
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 32s
2026-05-29 09:18:36 +00:00
tristan 58589e93d0 [ERP-50] Implémenter les composables useCategoriesAdmin et useCategoryForm (#25)
Auto Tag Develop / tag (push) Successful in 6s
Lien Lesstime : #50

## Résumé
Refacto : extraction de la logique fetch/CRUD inline de la page categories (ERP-49) vers deux composables dédiés, conformément au pattern Starseed (useSidebar / useModules).

- **useCategoriesAdmin** : singleton state (`categories` + `types` + `loading` + `error`). Pré-chargement des types au mount de la page (au lieu d'un fetch par ouverture du drawer). Reset au logout via `onAuthSessionCleared` + appel explicite dans `logout.vue`.
- **useCategoryForm** : state local par form (pas singleton, contrairement à `useCategoriesAdmin`). Valide côté client en miroir des RG back (RG-1.02 / RG-1.04 / RG-1.05), mappe les erreurs 409 (RG-1.07 doublon) et 422 (violations API Platform) sur les bons champs. `submitCreate` / `submitUpdate` / `submitDelete` renvoient la ressource ou `null` pour découpler la décision de fermeture du drawer.

La page et le drawer deviennent purement présentationnels — aucune régression UX attendue (mêmes validations, mêmes toasts, même bascule view → edit via `isDirty` exposé par le composable).

## Décisions
- `useCategoriesAdmin` porte aussi les types (`fetchTypes`), pas seulement `categories` — sinon le drawer continuerait à fetcher tout seul et la refacto n'aurait rien centralisé.
- `buildCreatePayload` retourne `Record<string, unknown>` (pas `CategoryCreateInput`) car la signature `useApi.post(body: AnyObject)` n'accepte pas les types stricts (variance TS).
- Reset au logout : double mécanisme conservé (auto via `onAuthSessionCleared` pour 401, explicite dans `logout.vue` pour logout volontaire — pattern existant Starseed).

## Tests
- `npx nuxi typecheck` ✓ 0 erreur nouvelle (1 erreur pré-existante sur `modules/catalog/nuxt.config.ts` héritée d'ERP-49)
- `make nuxt-test` ✓ 43/43, 0 régression
- PHPUnit ✓ 311/311 (pre-commit)
- Manuel navigateur : à valider (cahier de test consigné dans Lesstime #50)

## ⚠ Note d'intégration
La branche contient encore les 3 commits ERP-49 (`4046910`, `216f388`, `934a12b`) car elle a été créée depuis la branche ERP-49 avant son merge sur develop. Selon l'ordre de merge : soit ERP-49 est mergée d'abord (cette MR ne contiendra plus que le commit ERP-50 après rebase auto), soit cette MR embarque tout l'historique catalog.

Reviewed-on: #25
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 09:18:29 +00:00
gitea-actions e0d59962d6 chore: bump version to v0.1.50
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-29 08:59:54 +00:00
tristan 3ce40a707f [ERP-49] Créer la page Gestion des catégories (datatable + drawer) (#22)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Ticket Lesstime : [#49](https://lesstime.malio.fr/tasks/460) — premier ticket front du M0 (Gestion des catégories).
Suit la chaîne back ERP-43..48 mergée sur develop.

## Contenu first draft (Claude Code)
- Page Nuxt `/admin/categories` (`MalioDataTable` + bouton `+ Ajouter`)
- Composant `<CategoryDrawer>` : modes création / consultation / édition, transition auto view → edit à la première modification, validation client miroir RG-1.02 (name requis) / RG-1.04 (longueur 2-120) / RG-1.05 (type requis), mapping erreurs 409 (doublon) et 422 (violations)
- Composant `<CategoryDeleteModal>` : confirmation suppression (soft delete RG-1.12)
- Types TS `Category`, `CategoryType`, `User`
- i18n `admin.categories.*` ajouté dans `fr.json`
- Fix latent en passant : ajout de `'categories'` à `AdminLinkSlug` du Page Object e2e (oublié lors d'ERP-47 quand l'item sidebar a été ajouté)

## Décisions marquantes
- Logique `fetch` inline dans `categories.vue` (sera extraite en composables `useCategoriesAdmin` + `useCategoryForm` au ticket ERP-50 / 0.8)
- Drawer dans composant séparé pour réutilisabilité
- Aucun état de tableau persisté dans l'URL (règle ABSOLUE n°6)
- Tous les composants formulaires sont `Malio*` (`MalioDataTable`, `MalioInputText`, `MalioSelect`, `MalioButton`, `MalioDrawer`)

## Polish à venir (Tristan)
Tristan testera en navigateur et peaufinera : UX, classes Tailwind, animations, icônes, wording de toasts.
Les commits de polish suivront sur la même branche.

## Tests
- `npx nuxi typecheck` : net 0 nouvelle erreur (mêmes erreurs pré-existantes que sur `develop`, infrastructure auto-import) + 1 latente corrigée (AdminLinkSlug)
- `make nuxt-test` : 43/43 passent (0 régression)
- Tests manuels navigateur : voir cahier de test du ticket Lesstime #49

## Note pre-commit hook
Le hook a remonté un échec PHPUnit pré-existant sur `develop` (`CategoryDeleteTest::testPatchOnSoftDeletedReturns404` → 401 au lieu de 404, JWT non initialisé en test runner). Aucun PHP touché dans cette MR. Commit avec `--no-verify` autorisé par Tristan.

## Reviewer suggéré
Matthieu (back ↔ front + permissions).

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #22
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 08:59:47 +00:00
35 changed files with 4064 additions and 39 deletions
+108
View File
@@ -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.
+11
View File
@@ -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
+3
View File
@@ -24,6 +24,8 @@ 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.
## Conventions
@.claude/rules/architecture.md
@@ -52,6 +54,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.
+15
View File
@@ -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
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.49'
app.version: '0.1.55'
File diff suppressed because it is too large Load Diff
+289
View File
@@ -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).
+33
View File
@@ -230,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 gap-4 py-4" @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-[150px]"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
v-if="canShowSave"
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
: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,250 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, 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' }
const CAT_A: Category = {
id: 10,
name: 'Vis',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
const CAT_B: Category = {
id: 11,
name: 'Boulons',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-02T10:00:00+00:00',
updatedAt: '2026-01-02T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
function makeHydra<T>(items: T[]): HydraCollection<T> {
return {
totalItems: items.length,
member: items,
}
}
describe('useCategoriesAdmin', () => {
beforeEach(() => {
mockGet.mockReset()
// Reset systematique du state singleton entre tests : sans ca,
// les categories chargees dans un test fuiteraient dans le suivant.
const { resetCategoriesAdmin } = useCategoriesAdmin()
resetCategoriesAdmin()
})
describe('fetchAll', () => {
it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999 },
{ toast: false },
)
})
it('peuple categories.value depuis le champ Hydra member', async () => {
mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([CAT_A, CAT_B])
})
it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
const queryArg = mockGet.mock.calls[0]?.[1] as Record<string, unknown>
expect(queryArg).not.toHaveProperty('includeDeleted')
})
it('ajoute includeDeleted=true quand demande explicitement', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll(true)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999, includeDeleted: 'true' },
{ toast: false },
)
})
it('passe loading a true pendant la requete et false apres', async () => {
let resolveRequest: (v: HydraCollection<Category>) => void = () => {}
mockGet.mockImplementationOnce(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const { fetchAll, loading } = useCategoriesAdmin()
const pending = fetchAll()
expect(loading.value).toBe(true)
resolveRequest(makeHydra<Category>([]))
await pending
expect(loading.value).toBe(false)
})
it('peuple error.value et vide categories en cas d echec', async () => {
mockGet.mockRejectedValueOnce(new Error('Network down'))
const { fetchAll, categories, error, loading } = useCategoriesAdmin()
// Pre-charge volontairement quelque chose pour verifier la purge.
categories.value = [CAT_A]
await fetchAll()
expect(categories.value).toEqual([])
expect(error.value).toBe('Network down')
expect(loading.value).toBe(false)
})
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
mockGet.mockResolvedValueOnce({
totalItems: 0,
} as unknown as HydraCollection<Category>)
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([])
})
})
describe('fetchTypes', () => {
it('appelle GET /category_types avec itemsPerPage=999', async () => {
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
const { fetchTypes } = useCategoriesAdmin()
await fetchTypes()
expect(mockGet).toHaveBeenCalledWith(
'/category_types',
{ itemsPerPage: 999 },
{ 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)
})
})
describe('resetCategoriesAdmin', () => {
it('vide categories, types, loading, loadingTypes et error', () => {
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
= useCategoriesAdmin()
// Pre-peuple le state pour verifier la purge effective.
categories.value = [CAT_A]
types.value = [TYPE_VENTE]
loading.value = true
loadingTypes.value = true
error.value = 'oops'
resetCategoriesAdmin()
expect(categories.value).toEqual([])
expect(types.value).toEqual([])
expect(loading.value).toBe(false)
expect(loadingTypes.value).toBe(false)
expect(error.value).toBeNull()
})
})
describe('singleton', () => {
it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
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.categories).toBe(b.categories)
expect(a.types).toBe(b.types)
expect(a.loading).toBe(b.loading)
})
it('une mutation via une instance est visible depuis une autre instance', () => {
const a = useCategoriesAdmin()
const b = useCategoriesAdmin()
a.categories.value = [CAT_A]
expect(b.categories.value).toEqual([CAT_A])
})
})
})
@@ -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,134 @@
/**
* Composable d'administration des categories (M0 — Gestion des categories).
*
* Centralise le chargement et le state des deux ressources lues par la page
* `/admin/categories` : la liste des categories et le referentiel
* CategoryType (utilise dans le select du drawer).
*
* State singleton au niveau module (meme convention que `useSidebar` /
* `useModules` / `useAuditLog`) : reset automatique au logout via
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
* avec state singleton doivent etre reinitialises au logout »), et reset
* explicite expose via `resetCategoriesAdmin()` appele depuis
* `modules/core/pages/logout.vue`.
*/
import { ref } from 'vue'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
import { onAuthSessionCleared } from '~/shared/stores/auth'
/**
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
* toute la liste en un coup. A basculer en pagination serveur quand la
* volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
*/
const HYDRA_NO_PAGINATION = 999
// State singleton — partage entre tous les composants qui appellent le
// composable dans la meme session. Les refs sont declarees au niveau module
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
// instance soit creee a chaque appel.
const categories = ref<Category[]>([])
const types = ref<CategoryType[]>([])
const loading = ref(false)
const loadingTypes = ref(false)
const error = ref<string | null>(null)
function resetCategoriesAdminState(): void {
categories.value = []
types.value = []
loading.value = false
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 l'etat de
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
// `resetCategoriesAdmin()` ci-dessous.
onAuthSessionCleared(resetCategoriesAdminState)
export function useCategoriesAdmin() {
const api = useApi()
/**
* Charge la liste des categories. Le serveur exclut les soft-deleted par
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
* serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
*
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
* de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
* option mais on l'expose pour la suite (corbeille future).
*
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
* toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
* la page seulement par URL directe et on affiche un tableau vide propre.
*/
async function fetchAll(includeDeleted = false): Promise<void> {
loading.value = true
error.value = null
try {
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
if (includeDeleted) {
query.includeDeleted = 'true'
}
const data = await api.get<HydraCollection<Category>>(
'/categories',
query,
{ toast: false },
)
categories.value = data.member ?? []
} catch (e) {
categories.value = []
error.value = (e as Error)?.message ?? 'Erreur de chargement'
} finally {
loading.value = false
}
}
/**
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
* l'ouverture de la page admin pour que le select du drawer ait deja les
* options pretes au moment de la creation/edition.
*
* Toast desactive : on stocke l'erreur dans `error` plutot que de
* 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',
{ itemsPerPage: HYDRA_NO_PAGINATION },
{ 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 {
categories,
types,
loading,
loadingTypes,
error,
fetchAll,
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,
}
}
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,139 @@
<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. Affichage exhaustif (volumetrie cible
<= 300, cf. spec § 4.1) tri 100% serveur via CategoryProvider
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
reste cosmetique tant qu'aucun slice client n'est cable : a
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
<MalioDataTable
:columns="columns"
:items="categoryItems"
:total-items="categories.length"
:row-clickable="true"
:empty-message="t('admin.categories.noCategories')"
@row-click="onRowClick"
/>
<!-- 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 { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
const canManage = computed(() => can('catalog.categories.manage'))
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 fetchAll()
}
} finally {
deleting.value = false
}
}
function onCategorySaved() {
fetchAll()
}
// 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(() => {
fetchAll()
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
}
+2
View File
@@ -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')
}
})
+3 -18
View File
@@ -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.'
}
+59
View File
@@ -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".
+6
View File
@@ -207,10 +207,16 @@ migration-migrate:
# 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.
# 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"
+66
View File
@@ -0,0 +1,66 @@
<?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
{
foreach (ColumnCommentsCatalog::toSqlStatements() as $sql) {
$this->addSql($sql);
}
}
public function down(Schema $schema): void
{
foreach (ColumnCommentsCatalog::comments() as $table => $entries) {
$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).'"',
));
}
}
}
}
@@ -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.
@@ -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,188 @@
<?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.',
],
];
}
/**
* 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.
*
* @return list<string>
*/
public static function toSqlStatements(): array
{
$statements = [];
foreach (self::comments() as $table => $entries) {
$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).'"';
}
}
@@ -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.');
}
}
@@ -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,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).',
);
}
}
+35 -3
View File
@@ -71,11 +71,43 @@ final class PermissionApiTest extends AbstractApiTestCase
self::assertGreaterThanOrEqual(3, $data['totalItems']);
}
/**
* Verrouille le chemin paginE PAR DEFAUT (ERP-72) : sans `?pagination=false`,
* `/api/permissions` doit borner la page au defaut global (10) et exposer
* `view`. Les autres tests de filtre passent `?pagination=false` et
* n'exercent donc plus ce contrat — on le reteste ici de maniere isolee.
*
* On seed 12 permissions de test pour garantir un total > 10 quelle que soit
* la quantite de permissions reelles presentes en base.
*/
public function testDefaultCollectionIsPaginatedToGlobalDefault(): void
{
$em = $this->getEm();
for ($i = 1; $i <= 12; ++$i) {
$em->persist(new Permission(sprintf('test.core.pagination.perm_%d', $i), sprintf('Perm pagination %d (test)', $i), 'core'));
}
$em->flush();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions');
self::assertResponseIsSuccessful();
$data = $response->toArray();
// La page par defaut ne doit jamais depasser le maximum global (10).
self::assertLessThanOrEqual(10, count($data['member']), 'La page par defaut doit etre bornee a 10 items.');
// Avec >= 12 permissions de test (+ reelles), le total depasse une page.
self::assertGreaterThan(10, $data['totalItems']);
// `view` n'est present que lorsque la collection est reellement paginee.
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
}
public function testCollectionFilterByModule(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions', [
'query' => ['module' => 'core'],
'query' => ['module' => 'core', 'pagination' => 'false'],
]);
self::assertResponseIsSuccessful();
@@ -94,7 +126,7 @@ final class PermissionApiTest extends AbstractApiTestCase
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions', [
'query' => ['orphan' => 'true'],
'query' => ['orphan' => 'true', 'pagination' => 'false'],
]);
self::assertResponseIsSuccessful();
@@ -114,7 +146,7 @@ final class PermissionApiTest extends AbstractApiTestCase
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/permissions', [
'query' => ['orphan' => 'false'],
'query' => ['orphan' => 'false', 'pagination' => 'false'],
]);
self::assertResponseIsSuccessful();
+30 -1
View File
@@ -146,7 +146,7 @@ final class RoleApiTest extends AbstractApiTestCase
public function testGetCollectionAsAdminReturnsRoles(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles');
$response = $client->request('GET', '/api/roles?pagination=false');
self::assertResponseIsSuccessful();
$data = $response->toArray();
@@ -157,6 +157,35 @@ final class RoleApiTest extends AbstractApiTestCase
self::assertContains('test_editor', $codes);
}
/**
* Verrouille le chemin paginE PAR DEFAUT (ERP-72) : le test ci-dessus passe
* `?pagination=false` (usage <select>) et n'exerce donc plus le defaut
* paginE. On seed 11 roles de test pour depasser une page (10) et verifier
* que, sans parametre, la page est bornee a 10 et expose `view`.
*/
public function testDefaultCollectionIsPaginatedToGlobalDefault(): void
{
$em = $this->getEm();
for ($i = 1; $i <= 11; ++$i) {
$em->persist(new Role(sprintf('test_pg_%d', $i), sprintf('Role pagination %d (test)', $i), false));
}
$em->flush();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/roles');
self::assertResponseIsSuccessful();
$data = $response->toArray();
// La page par defaut ne doit jamais depasser le maximum global (10).
self::assertLessThanOrEqual(10, count($data['member']), 'La page par defaut doit etre bornee a 10 items.');
// 11 roles de test + 2 systeme + editor + viewer => total > 10.
self::assertGreaterThan(10, $data['totalItems']);
// `view` n'est present que lorsque la collection est reellement paginee.
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
}
public function testGetCollectionFilterByIsSystemTrue(): void
{
$client = $this->authenticatedClient('admin', 'admin');