Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56cf492dcc | |||
| d9023ec9b9 | |||
| ddf874a4e1 | |||
| a25ddac466 | |||
| df8e44fcfa | |||
| 5bdd63cc6c | |||
| ad20d1f4c9 | |||
| 0c6919201e | |||
| 3e46394be1 | |||
| 1d91b4dea9 |
@@ -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/`
|
||||
|
||||
@@ -53,6 +53,53 @@ Tout affichage LISTE tabulaire (donnees metier paginees, CRUD admin) doit passer
|
||||
|
||||
**Exception** : tableaux purement presentationnels non paginables (diff field/old/new, grille de comparaison, matrice RBAC d'admin, etc.) peuvent rester en `<table>` HTML brut.
|
||||
|
||||
## Listes paginees (standard) — usePaginatedList obligatoire
|
||||
|
||||
**Toute liste qui consomme une `GetCollection` API doit passer par `usePaginatedList`** (`frontend/shared/composables/usePaginatedList.ts`). Le composable est le pendant front de la regle ABSOLUE n°13 (« toute collection est paginee cote back ») : il consomme l'envelope Hydra (`member` / `totalItems` / `view`) et expose un etat reactif a brancher directement sur `MalioDataTable`.
|
||||
|
||||
Pattern de reference :
|
||||
|
||||
```ts
|
||||
const {
|
||||
items,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadList,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<MyEntity>({ url: '/my-resources' })
|
||||
|
||||
onMounted(loadList)
|
||||
```
|
||||
|
||||
```vue
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:empty-message="t('foo.empty')"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
```
|
||||
|
||||
Garanties offertes par le composable :
|
||||
- Force `Accept: application/ld+json` → API Platform 4 renvoie bien `member` / `totalItems` (sans Accept, retour tableau plat sans pagination).
|
||||
- Defaut 10 items/page, choix client 10 / 25 / 50, aligne sur le defaut serveur.
|
||||
- Mutation `setFilters` / `setSort` / `setItemsPerPage` → retombe systematiquement en page 1.
|
||||
- Cas limite « page hors borne apres filtre » : retombe automatiquement sur la derniere page valide (`tests/usePaginatedList.test.ts`).
|
||||
- Etat 100 % local (refs internes a l'instance) — **jamais reflete dans l'URL**, conformement a la regle « Etat des tableaux — pas de persistance URL » ci-dessous.
|
||||
|
||||
A NE PAS faire :
|
||||
- Charger une collection complete via `?itemsPerPage=999` pour bypasser la pagination. Le seul cas legitime de retour complet est l'alimentation d'un `<select>` sur un referentiel ≤ quelques dizaines d'entrees, et il passe par `?pagination=false` (echappatoire prevue par `pagination_client_enabled: true`).
|
||||
- Reimplementer la pagination prev/next a la main au-dessus de `MalioDataTable` — le composant porte deja le selecteur items/page et les boutons Prev/Next.
|
||||
- Persister `page`/`tri`/`filtre` dans la query string — meme regle que pour `<MalioDataTable>` brut (cf. section suivante).
|
||||
|
||||
## Etat des tableaux — pas de persistance URL
|
||||
|
||||
**Interdit** de persister l'etat d'un tableau (filtres, pagination, tri par colonne, selection, ligne active, scroll) dans la query string ou de le reinjecter depuis `route.query` au montage.
|
||||
|
||||
@@ -25,6 +25,8 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
||||
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
||||
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
||||
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
|
||||
13. **Toujours paginer toute collection exposee par l'API.** Aucun retour de collection complete (pas de provider qui retourne un array brut). Standard pose dans `config/packages/api_platform.yaml` : 10 items par defaut, max 50, le client peut basculer entre 10/25/50 et peut envoyer `?pagination=false` pour alimenter un select. Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist. Details et exemples : @.claude/rules/backend.md § Pagination.
|
||||
14. **`symfony.lock` est versionne** (au meme titre que `composer.lock`) — ne JAMAIS le `.gitignore`. C'est le registre des recipes Flex appliquees : sans lui, chaque `composer require` rejoue toutes les recipes et repollue `.env`, `config/bundles.php`, `docker-compose.yml` et recree du scaffolding parasite (`src/Entity/`, `src/Controller/`...). Le regenerer si besoin via `composer recipes:install --force`.
|
||||
|
||||
## Conventions
|
||||
@.claude/rules/architecture.md
|
||||
@@ -53,6 +55,7 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
|
||||
## A NE PAS faire
|
||||
|
||||
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
|
||||
- Pas de provider API Platform qui retourne un array brut sur une `GetCollection` — court-circuite la pagination Hydra (`totalItems` / `view` absents). Utiliser `ApiPlatform\Doctrine\Orm\Paginator` (ORM) ou un paginator implementant `PaginatorInterface` (DBAL — cf. `DbalPaginator`).
|
||||
- Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur).
|
||||
- Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`.
|
||||
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
|
||||
|
||||
@@ -21,3 +21,18 @@ api_platform:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
# === Pagination Hydra (regle projet : toute collection DOIT etre paginee) ===
|
||||
# Standard datatable : 10 items par defaut, choix client 10 / 25 / 50.
|
||||
# Borne dure cote serveur a 50 pour prevenir tout `?itemsPerPage=999999`
|
||||
# (attaque memoire / deep-fetch). Le client peut neanmoins desactiver la
|
||||
# pagination via `?pagination=false` pour alimenter un <select> ou autre
|
||||
# vue "tout-en-un" — c'est l'echappatoire prevue pour les ressources
|
||||
# servant a la fois de datatable et de source de select (Role,
|
||||
# Permission, Site, CategoryType). Override par ressource possible via
|
||||
# `paginationItemsPerPage` / `paginationMaximumItemsPerPage` /
|
||||
# `paginationEnabled` sur l'attribut #[ApiResource] ou sur une operation.
|
||||
pagination_enabled: true
|
||||
pagination_items_per_page: 10
|
||||
pagination_maximum_items_per_page: 50
|
||||
pagination_client_items_per_page: true
|
||||
pagination_client_enabled: true
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.54'
|
||||
app.version: '0.1.58'
|
||||
|
||||
@@ -235,7 +235,9 @@ Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter V
|
||||
|
||||
### 3.2 Migration Doctrine — SQL Postgres
|
||||
|
||||
Namespace : `App\Module\Commercial\Infrastructure\Doctrine\Migrations` (modulaire, post-init). Fichier : `Version20260601000000.php` (à dater par le dev).
|
||||
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/Version20260601000000.php` (à dater par le dev).
|
||||
|
||||
> **Décision 29/05/2026 (vérifiée empiriquement en dev)** : cette migration crée un **schéma avec FK cross-module** (`user`, `category`, `site`) → elle a la même dépendance d'ordre que les migrations d'init. Le namespace modulaire `App\Module\Commercial\…` casse `make db-reset` : Doctrine Migrations 3.x trie par **FQCN alphabétique** (`App\…` < `DoctrineMigrations\…`), donc la migration client tournerait AVANT `user`/`category`/`site` et ses FK échoueraient. Elle relève donc de l'**exception racine** de la règle ABSOLUE n°11 (même choix que la migration cross-module ERP-67). Le namespace modulaire reste réservé aux évolutions post-schéma (ajout de colonnes/index). La correction long-terme (MigrationsComparator custom, tri par timestamp) est un ticket archi dédié, hors scope M1.
|
||||
|
||||
```sql
|
||||
-- =====================================================================
|
||||
@@ -475,7 +477,14 @@ INSERT INTO category_type (code, label, position) VALUES
|
||||
('AUTRE', 'Autre', 99);
|
||||
```
|
||||
|
||||
> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). Le seed est fait via migration ou fixture déclenchée à chaque `make db-reset`.
|
||||
> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0).
|
||||
>
|
||||
> **Seed en DEUX endroits (décision 29/05, vérifiée empiriquement)** : le `make db-reset` lance les fixtures, dont le purger Doctrine **vide `category_type`** (entité M0 mappée) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc :
|
||||
> 1. **Migration** (`ON CONFLICT (code) DO NOTHING`) → sert en **prod** (pas de fixtures).
|
||||
> 2. **Fixture Commercial idempotente** (ex. `CommercialReferentialFixtures`) re-seedant les 4 types → survit au `db-reset`, satisfait le critère « 4 types présents après db-reset ».
|
||||
>
|
||||
> ⚠ **À venir en ERP-54** : `tva_mode` / `payment_delay` / `payment_type` / `bank` ne sont pas encore des entités mappées au M1.0 → le purger ne les touche pas, leur seed migration survit. **Dès qu'ERP-54 crée leurs entités, ils seront purgés au db-reset** → il faudra les ajouter à la même fixture référentielle.
|
||||
> 🔗 **Coordination ERP-68** : ERP-53 pose la fixture référentielle minimale (4 category_types). ERP-68 l'**étend** (clients de démo, ~12-15) sans la dupliquer.
|
||||
|
||||
### 3.4 Entité `Client` — squelette
|
||||
|
||||
@@ -722,7 +731,7 @@ class Client implements TimestampableInterface, BlamableInterface
|
||||
|
||||
### 3.5 Squelettes des autres entités
|
||||
|
||||
**`ClientContact`**, **`ClientAddress`**, **`ClientRib`** : même pattern (`#[Auditable]`, `TimestampableBlamableTrait`, FK `client_id`). `ClientRib.iban` et `ClientRib.bic` portent `#[AuditIgnore]` (champs sensibles).
|
||||
**`ClientContact`**, **`ClientAddress`**, **`ClientRib`** : même pattern (`#[Auditable]`, `TimestampableBlamableTrait`, FK `client_id`). ⚠ **Aucun `#[AuditIgnore]`** sur `ClientRib.iban`/`bic` — tous les champs RIB sont audités (décision Matthieu en revue MR du 29/05/2026, cf. § 2.5 : audit admin-only → traçabilité comptable nécessaire). Source de vérité : § 2.5.
|
||||
|
||||
**Référentiels (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`)** : entités lecture seule via API Platform `GetCollection` + `Get` uniquement (security `commercial.clients.view`). Pas de POST/PATCH/DELETE au M1 (HP). Pas de Timestampable+Blamable (whitelistés dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`).
|
||||
|
||||
@@ -971,7 +980,7 @@ Cf. § 2.6. Pattern Shared standard.
|
||||
- [ ] **Compta POST création** : Compta → 403 (pas de `manage` global)
|
||||
- [ ] **PATCH mix groupes** : Bureau envoie payload avec `companyName` (write:main) + `siren` (write:accounting) → **403 sur tout le payload** (strict, RG-1.28)
|
||||
- [ ] **Audit** : POST + PATCH + archive → audit_log avec entity_type='Client', `changes` correct ; **iban/bic présents dans le diff** (pas d'AuditIgnore, cf. § 6.1)
|
||||
- [ ] **Migration** : `make db-reset` → schéma OK, seed des 4 référentiels + CategoryType (DISTRIBUTEUR/COURTIER/SECTEUR/AUTRE) présent ; index partiel unique `uq_client_company_name_active` présent (un seul — cf. Q4)
|
||||
- [ ] **Migration** : `make db-reset` → schéma OK ; migration en racine `migrations/` (namespace `DoctrineMigrations`, ordre garanti) ; 4 référentiels comptables seedés ; **4 CategoryType présents APRÈS db-reset** (via fixture idempotente, car le purger vide category_type) ; index partiel unique `uq_client_company_name_active` présent (un seul — cf. Q4)
|
||||
|
||||
### 8.2 Cas à couvrir (front — Vitest)
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<SiteSelector v-if="showSiteSelector"/>
|
||||
<main
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-[170px]">
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-11">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
|
||||
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/>
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
||||
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
||||
<MalioInputText
|
||||
@@ -52,21 +52,21 @@
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canShowSave"
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="form.submitting.value || loadingTypes"
|
||||
@click="handleSave"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
|
||||
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
||||
@@ -28,27 +28,6 @@ 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,
|
||||
@@ -56,113 +35,32 @@ function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apres ERP-73, `useCategoriesAdmin` ne porte plus la liste paginee des
|
||||
* categories (elle est geree par `usePaginatedList<Category>` cote page).
|
||||
* Le composable se concentre sur le referentiel CategoryType (lecture
|
||||
* seule, ≤ 5 entrees connues) charge en une fois via `?pagination=false`.
|
||||
*/
|
||||
describe('useCategoriesAdmin', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// Reset systematique du state singleton entre tests : sans ca,
|
||||
// les categories chargees dans un test fuiteraient dans le suivant.
|
||||
// les types charges 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 () => {
|
||||
it('appelle GET /category_types avec ?pagination=false (echappatoire selects)', async () => {
|
||||
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'/category_types',
|
||||
{ itemsPerPage: 999 },
|
||||
{ pagination: 'false' },
|
||||
{ toast: false },
|
||||
)
|
||||
})
|
||||
@@ -203,48 +101,55 @@ describe('useCategoriesAdmin', () => {
|
||||
|
||||
expect(loadingTypes.value).toBe(false)
|
||||
})
|
||||
|
||||
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
|
||||
mockGet.mockResolvedValueOnce({
|
||||
totalItems: 0,
|
||||
} as unknown as HydraCollection<CategoryType>)
|
||||
const { fetchTypes, types } = useCategoriesAdmin()
|
||||
|
||||
await fetchTypes()
|
||||
|
||||
expect(types.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetCategoriesAdmin', () => {
|
||||
it('vide categories, types, loading, loadingTypes et error', () => {
|
||||
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
|
||||
it('vide types, loadingTypes et error', () => {
|
||||
const { resetCategoriesAdmin, types, 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', () => {
|
||||
it('deux appels a useCategoriesAdmin() partagent la meme ref types', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||
// doivent etre rigoureusement les memes (state au niveau module).
|
||||
expect(a.categories).toBe(b.categories)
|
||||
expect(a.types).toBe(b.types)
|
||||
expect(a.loading).toBe(b.loading)
|
||||
expect(a.loadingTypes).toBe(b.loadingTypes)
|
||||
expect(a.error).toBe(b.error)
|
||||
})
|
||||
|
||||
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||
const a = useCategoriesAdmin()
|
||||
const b = useCategoriesAdmin()
|
||||
|
||||
a.categories.value = [CAT_A]
|
||||
a.types.value = [TYPE_VENTE]
|
||||
|
||||
expect(b.categories.value).toEqual([CAT_A])
|
||||
expect(b.types.value).toEqual([TYPE_VENTE])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,96 +1,56 @@
|
||||
/**
|
||||
* Composable d'administration des categories (M0 — Gestion des categories).
|
||||
* Composable de chargement du referentiel CategoryType (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).
|
||||
* Apres ERP-73 (composable de liste paginee), la liste des categories
|
||||
* elle-meme passe par `usePaginatedList<Category>` directement dans
|
||||
* `admin/categories.vue` — c'est un etat propre a la page (pagination,
|
||||
* filtres, tri locaux). Ce composable se concentre donc sur le
|
||||
* referentiel CategoryType : petite collection lue une fois et reutilisee
|
||||
* dans le drawer (select de type) → singleton volontaire pour eviter de
|
||||
* la recharger a chaque ouverture du drawer.
|
||||
*
|
||||
* State singleton au niveau module (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`.
|
||||
* State singleton au niveau module : reset automatique au logout via
|
||||
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
|
||||
* explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { 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.
|
||||
* CategoryType est un referentiel lecture-seule (RG-1.06) avec une
|
||||
* cardinalite minuscule (≤ 5 entrees connues). On force `pagination=false`
|
||||
* pour recuperer toutes les entrees en un appel et alimenter le select du
|
||||
* drawer sans pagination — echappatoire prevue par
|
||||
* `pagination_client_enabled: true` cote API Platform.
|
||||
*/
|
||||
const HYDRA_NO_PAGINATION = 999
|
||||
const NO_PAGINATION_QUERY = { pagination: 'false' } as const
|
||||
|
||||
// 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.
|
||||
// Auto-enregistrement singleton : purge le state sur 401/clearSession
|
||||
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
|
||||
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
|
||||
// appelle directement `resetCategoriesAdmin()` ci-dessous.
|
||||
onAuthSessionCleared(resetCategoriesAdminState)
|
||||
|
||||
export function useCategoriesAdmin() {
|
||||
const api = useApi()
|
||||
|
||||
/**
|
||||
* Charge 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.
|
||||
* Charge le referentiel CategoryType. Appele a l'ouverture de la page
|
||||
* admin pour que le select du drawer ait deja les options pretes au
|
||||
* moment de la creation/edition.
|
||||
*
|
||||
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
||||
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||
@@ -100,7 +60,7 @@ export function useCategoriesAdmin() {
|
||||
try {
|
||||
const data = await api.get<HydraCollection<CategoryType>>(
|
||||
'/category_types',
|
||||
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
||||
NO_PAGINATION_QUERY,
|
||||
{ toast: false },
|
||||
)
|
||||
types.value = data.member ?? []
|
||||
@@ -113,21 +73,18 @@ export function useCategoriesAdmin() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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,
|
||||
}
|
||||
|
||||
@@ -13,18 +13,23 @@
|
||||
</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. -->
|
||||
<!-- Table des categories. Tri serveur (name ASC, RG-1.10) +
|
||||
pagination serveur via usePaginatedList (#73). Le composable
|
||||
remplace l'ancien chargement « tout en un coup » a volumetrie
|
||||
cible ≤ 300 — la pagination est desormais alignee sur la regle
|
||||
projet (toute collection paginee, regle ABSOLUE n°13). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="categoryItems"
|
||||
:total-items="categories.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="true"
|
||||
:empty-message="t('admin.categories.noCategories')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
|
||||
<!-- Drawer creation / consultation / edition. -->
|
||||
@@ -50,13 +55,27 @@ import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
|
||||
const { fetchTypes } = useCategoriesAdmin()
|
||||
const { submitDelete } = useCategoryForm()
|
||||
|
||||
useHead({ title: t('admin.categories.title') })
|
||||
|
||||
const canManage = computed(() => can('catalog.categories.manage'))
|
||||
|
||||
// Pagination serveur via le composable partage (#73). Le CategoryProvider
|
||||
// applique deja name ASC (RG-1.10) — pas besoin de defaultSort cote front
|
||||
// tant qu'aucun OrderFilter n'est expose.
|
||||
const {
|
||||
items: categories,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: fetchCategories,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<Category>({ url: '/categories' })
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedCategory = ref<Category | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
@@ -118,7 +137,7 @@ async function handleDelete(): Promise<void> {
|
||||
deleteModalOpen.value = false
|
||||
categoryToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await fetchAll()
|
||||
await fetchCategories()
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
@@ -126,14 +145,14 @@ async function handleDelete(): Promise<void> {
|
||||
}
|
||||
|
||||
function onCategorySaved() {
|
||||
fetchAll()
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
||||
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
fetchCategories()
|
||||
fetchTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
|
||||
header-class="capitalize"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col">
|
||||
<!-- Tout selectionner pour ce module -->
|
||||
<MalioCheckbox
|
||||
:id="`${idPrefix}-group-${group.module}`"
|
||||
@@ -20,7 +20,7 @@
|
||||
label-class="font-semibold text-sm text-neutral-700"
|
||||
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="perm in group.permissions"
|
||||
:id="`${idPrefix}-perm-${perm.id}`"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||
<!-- Champs du role -->
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -71,7 +71,7 @@
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="role?.isSystem"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
@@ -79,13 +79,13 @@
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || permissionsLoadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
|
||||
</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 py-4">
|
||||
<div class="flex flex-col space-y-4 py-4">
|
||||
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
||||
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
||||
<div
|
||||
@@ -41,11 +41,13 @@
|
||||
/>
|
||||
|
||||
<!-- Section Roles -->
|
||||
<div>
|
||||
<!-- !mt-0 : la MalioCheckbox au-dessus expose son slot message (16px),
|
||||
qui couvre deja l'ecart attendu — pas besoin du space-y-4 ici. -->
|
||||
<div class="!mt-0">
|
||||
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||
{{ t('admin.users.drawer.rolesSection') }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="role in allRoles"
|
||||
:key="role.id"
|
||||
@@ -84,7 +86,7 @@
|
||||
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.sites.noSites') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="site in allSites"
|
||||
:id="`site-${site.id}`"
|
||||
@@ -113,13 +115,13 @@
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || loadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
|
||||
@@ -66,15 +66,18 @@
|
||||
<MalioAccordion>
|
||||
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
|
||||
<span>{{ t('audit.filters.date_from') }}</span>
|
||||
<!-- pb-4 sur les labels Du/Au : simule le slot message
|
||||
du MalioDateTime voisin pour qu'items-center recentre
|
||||
le label sur le centre visible du champ. -->
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3">
|
||||
<span class="pb-4">{{ t('audit.filters.date_from') }}</span>
|
||||
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
|
||||
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
|
||||
<MalioDateTime
|
||||
v-model="draftDateFrom"
|
||||
:max="draftDateTo ?? undefined"
|
||||
/>
|
||||
<span>{{ t('audit.filters.date_to') }}</span>
|
||||
<span class="pb-4">{{ t('audit.filters.date_to') }}</span>
|
||||
<MalioDateTime
|
||||
v-model="draftDateTo"
|
||||
:min="draftDateFrom ?? undefined"
|
||||
@@ -84,7 +87,7 @@
|
||||
|
||||
<!-- Type d'entite : cases a cocher (multi-selection) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col">
|
||||
<MalioCheckbox
|
||||
v-for="opt in entityTypeOptions"
|
||||
:id="`filter-entity-${opt.value}`"
|
||||
@@ -105,6 +108,7 @@
|
||||
name="audit-action"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
group-class="mt-0"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
@@ -121,7 +125,7 @@
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des roles -->
|
||||
<!-- Table des roles — pagination serveur via usePaginatedList (#73). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="roleItems"
|
||||
:total-items="roles.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.roles.noRoles')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-code="{ item }">
|
||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||
@@ -66,8 +71,17 @@ const canManage = computed(() => can('core.roles.manage'))
|
||||
|
||||
useHead({ title: t('admin.roles.title') })
|
||||
|
||||
const roles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
// Pagination serveur via le composable partage (#73).
|
||||
const {
|
||||
items: roles,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadRoles,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<Role>({ url: '/roles' })
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: t('admin.roles.table.label') },
|
||||
@@ -102,25 +116,6 @@ const deleteModalOpen = ref(false)
|
||||
const roleToDelete = ref<Role | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Charger la liste des roles
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: Role[] }>(
|
||||
'/roles',
|
||||
{},
|
||||
{ toast: false },
|
||||
)
|
||||
roles.value = data.member
|
||||
} catch {
|
||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
||||
// requete reussie avant une perte reseau ou 403).
|
||||
roles.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedRole.value = null
|
||||
drawerOpen.value = true
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
<div>
|
||||
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||
|
||||
<!-- Table des utilisateurs -->
|
||||
<!-- Table des utilisateurs — pagination serveur via usePaginatedList (#73). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="userItems"
|
||||
:total-items="users.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.users.noUsers')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-admin="{ item }">
|
||||
<span
|
||||
@@ -34,15 +39,26 @@
|
||||
import type { UserListItem } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('admin.users.title') })
|
||||
|
||||
const canManage = computed(() => can('core.users.manage'))
|
||||
|
||||
const users = ref<UserListItem[]>([])
|
||||
const loading = ref(false)
|
||||
// Pagination serveur via le composable partage (#73). Le payload `users`
|
||||
// reste leger (pas de detail RBAC dans la liste — cf. commentaire colonne
|
||||
// "Sites" plus bas) ce qui rend la pagination 10/25/50 par page confortable.
|
||||
const {
|
||||
items: users,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadUsers,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<UserListItem>({ url: '/users' })
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedUser = ref<UserListItem | null>(null)
|
||||
|
||||
@@ -67,21 +83,6 @@ const userItems = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const usersData = await api.get<{ member: UserListItem[] }>('/users', {}, { toast: false })
|
||||
users.value = usersData.member
|
||||
} catch {
|
||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
||||
// requete reussie avant une perte reseau ou 403). Pas de toast par
|
||||
// design ici : on laisse la liste vide parler d'elle-meme.
|
||||
users.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getUserById(id: number): UserListItem | undefined {
|
||||
return users.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
|
||||
</span>
|
||||
<form
|
||||
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
class="mt-8 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<MalioInputText
|
||||
@@ -30,7 +30,7 @@
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<p class="font-bold">v{{ version }}</p>
|
||||
<p class="mt-6 font-bold">v{{ version }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="t('admin.sites.form.name')"
|
||||
@@ -65,12 +65,17 @@
|
||||
input-class="w-full font-mono"
|
||||
required
|
||||
/>
|
||||
<!-- pb-4 sur le wrapper : simule le slot message du
|
||||
MalioInputText voisin pour qu'items-center recentre
|
||||
la puce sur le centre visible de l'input. -->
|
||||
<div class="shrink-0 pb-4">
|
||||
<span
|
||||
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
|
||||
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
|
||||
class="inline-block size-10 rounded-lg border border-neutral-200"
|
||||
:class="{ 'border-dashed': !isValidHex }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
|
||||
{{ t('admin.sites.form.colorInvalid') }}
|
||||
</p>
|
||||
@@ -87,20 +92,20 @@
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
button-class="w-m-btn-action"
|
||||
:disabled="saving || !isValidHex"
|
||||
@click="handleSave"
|
||||
/>
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des sites -->
|
||||
<!-- Table des sites — pagination serveur via usePaginatedList (#73). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="siteItems"
|
||||
:total-items="sites.length"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
:row-clickable="canManage"
|
||||
:empty-message="t('admin.sites.noSites')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
@@ -67,8 +72,20 @@ const canManage = computed(() => can('sites.manage'))
|
||||
|
||||
useHead({ title: t('admin.sites.title') })
|
||||
|
||||
const sites = ref<Site[]>([])
|
||||
const loading = ref(false)
|
||||
// Pagination serveur via le composable partage (#73). Aucun OrderFilter
|
||||
// declare cote API Platform sur Site, donc on s'appuie sur le tri par
|
||||
// defaut du repository (id ASC). Le composable est neanmoins pret a
|
||||
// recevoir un `defaultSort` ou des filtres le jour ou l'API les expose.
|
||||
const {
|
||||
items: sites,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadSites,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = usePaginatedList<Site>({ url: '/sites' })
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.sites.table.name') },
|
||||
@@ -107,24 +124,6 @@ const deleteModalOpen = ref(false)
|
||||
const siteToDelete = ref<Site | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function loadSites() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.get<{ member: Site[] }>(
|
||||
'/sites',
|
||||
{ itemsPerPage: 999 },
|
||||
{ toast: false },
|
||||
)
|
||||
sites.value = data.member
|
||||
} catch {
|
||||
// Reset sur echec pour ne pas afficher de donnees stale (ancienne
|
||||
// requete reussie avant une perte reseau ou 403).
|
||||
sites.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedSite.value = null
|
||||
drawerOpen.value = true
|
||||
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.1",
|
||||
"@malio/layer-ui": "^1.7.3",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
|
||||
"integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
|
||||
"integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.1",
|
||||
"@malio/layer-ui": "^1.7.3",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { usePaginatedList } from '../usePaginatedList'
|
||||
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
|
||||
/**
|
||||
* Tests du composable `usePaginatedList`.
|
||||
*
|
||||
* Couvre les invariants critiques :
|
||||
* - parse Hydra (member / totalItems)
|
||||
* - navigation page (goToPage / next / prev / bornes)
|
||||
* - changement items/page → retour page 1
|
||||
* - mutation filtres / tri → retour page 1
|
||||
* - cas limite : page courante hors borne apres filtre → derniere page valide
|
||||
* - liste vide / page unique
|
||||
* - reset → defaults
|
||||
* - swallow d'erreur reseau (la promesse `fetch` ne reject jamais)
|
||||
* - header `Accept: application/ld+json` toujours envoye (besoin du
|
||||
* paginator Hydra cote API Platform 4).
|
||||
*/
|
||||
describe('usePaginatedList', () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset()
|
||||
})
|
||||
|
||||
function mockResponse(member: unknown[], totalItems: number): void {
|
||||
mockApiGet.mockResolvedValueOnce({ member, totalItems })
|
||||
}
|
||||
|
||||
it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => {
|
||||
mockResponse([{ id: 1 }, { id: 2 }], 42)
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/sites' })
|
||||
|
||||
await list.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/sites')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
})
|
||||
expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }])
|
||||
expect(list.totalItems.value).toBe(42)
|
||||
expect(list.totalPages.value).toBe(5)
|
||||
expect(list.isEmpty.value).toBe(false)
|
||||
expect(list.isSinglePage.value).toBe(false)
|
||||
})
|
||||
|
||||
it('itemsPerPage personnalise est respecte au premier fetch', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 })
|
||||
await list.fetch()
|
||||
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 })
|
||||
expect(list.itemsPerPage.value).toBe(25)
|
||||
})
|
||||
|
||||
it('goToPage(N) declenche un nouvel appel avec page=N', async () => {
|
||||
mockResponse([{ id: 1 }], 30) // page 1
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([{ id: 2 }], 30) // page 2
|
||||
await list.goToPage(2)
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 })
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
})
|
||||
|
||||
it('goToPage hors borne sup. est clampe a totalPages', async () => {
|
||||
mockResponse([], 30) // totalPages = 3
|
||||
const list = usePaginatedList({ url: '/roles' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.goToPage(999)
|
||||
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
})
|
||||
|
||||
it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => {
|
||||
mockResponse([], 30)
|
||||
const list = usePaginatedList({ url: '/roles' })
|
||||
await list.fetch()
|
||||
|
||||
mockApiGet.mockClear()
|
||||
await list.goToPage(-5)
|
||||
|
||||
// Deja en page 1 -> aucun nouvel appel.
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
})
|
||||
|
||||
it('nextPage / prevPage avancent et reculent dans les bornes', async () => {
|
||||
mockResponse([], 30) // page 1, totalPages 3
|
||||
const list = usePaginatedList({ url: '/roles' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.nextPage()
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.nextPage()
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
|
||||
// En derniere page -> no-op
|
||||
mockApiGet.mockClear()
|
||||
await list.nextPage()
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
|
||||
mockResponse([], 30)
|
||||
await list.prevPage()
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
})
|
||||
|
||||
it('setItemsPerPage revient en page 1 et refetch', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
// place-toi page 3
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(3)
|
||||
expect(list.currentPage.value).toBe(3)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setItemsPerPage(25)
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
expect(list.itemsPerPage.value).toBe(25)
|
||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||
})
|
||||
|
||||
it('setItemsPerPage no-op si meme valeur', async () => {
|
||||
mockResponse([], 10)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
mockApiGet.mockClear()
|
||||
await list.setItemsPerPage(10)
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it('setFilters fusionne et retombe en page 1', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { name?: string; active?: boolean }>({
|
||||
url: '/users',
|
||||
defaultFilters: { active: true },
|
||||
})
|
||||
await list.fetch()
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(2)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ name: 'alice' })
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
expect(list.filters.value).toEqual({ active: true, name: 'alice' })
|
||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||
page: 1,
|
||||
active: true,
|
||||
name: 'alice',
|
||||
})
|
||||
})
|
||||
|
||||
it('setFilters({ key: undefined }) supprime la cle', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { name?: string }>({
|
||||
url: '/users',
|
||||
defaultFilters: { name: 'alice' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ name: undefined })
|
||||
|
||||
expect(list.filters.value).toEqual({})
|
||||
// Le query envoye ne contient plus `name` (compactQuery elimine
|
||||
// aussi les valeurs vides).
|
||||
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(q.name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setFilters({ replace: true }) remplace integralement', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { a?: string; b?: string }>({
|
||||
url: '/users',
|
||||
defaultFilters: { a: 'x' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ b: 'y' }, { replace: true })
|
||||
|
||||
expect(list.filters.value).toEqual({ b: 'y' })
|
||||
})
|
||||
|
||||
it('setSort envoie order[field]=direction et reset page', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(2)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setSort({ field: 'username', direction: 'desc' })
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(q['order[username]']).toBe('desc')
|
||||
})
|
||||
|
||||
it('setSort(null) retire le tri', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList({
|
||||
url: '/users',
|
||||
defaultSort: { field: 'name', direction: 'asc' },
|
||||
})
|
||||
await list.fetch()
|
||||
// Le tri initial est applique
|
||||
let q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(q['order[name]']).toBe('asc')
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setSort(null)
|
||||
q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
||||
expect(q['order[name]']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setFilters retombe en page 1 (cas standard, pas le hors-borne)', async () => {
|
||||
// setFilters remet toujours page=1 avant de refetcher : ce n'est
|
||||
// donc PAS le chemin de retry hors-borne (couvert par le test
|
||||
// suivant via un refetch a page constante). On verifie juste le
|
||||
// reset de page ici.
|
||||
mockResponse([], 50) // 5 pages
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
mockResponse([], 50)
|
||||
await list.goToPage(5)
|
||||
expect(list.currentPage.value).toBe(5)
|
||||
|
||||
mockResponse([{ id: 1 }, { id: 2 }], 12)
|
||||
await list.setFilters({ active: true } as never)
|
||||
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1 })
|
||||
})
|
||||
|
||||
it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
|
||||
// Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux
|
||||
// filtres mais entre deux fetchs la donnee a change cote serveur,
|
||||
// la page courante peut devenir hors borne. On force le scenario
|
||||
// en montant manuellement currentPage via goToPage borne, puis en
|
||||
// simulant une reponse plus petite.
|
||||
mockResponse([], 50) // 5 pages
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 50)
|
||||
await list.goToPage(5)
|
||||
expect(list.currentPage.value).toBe(5)
|
||||
|
||||
// Maintenant simule : refetch -> totalItems chute a 12 (2 pages),
|
||||
// le composable doit refetcher sur page=2.
|
||||
mockApiGet.mockReset()
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide
|
||||
.mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2
|
||||
|
||||
await list.fetch()
|
||||
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
||||
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 })
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }])
|
||||
})
|
||||
|
||||
it('liste vide : isEmpty true, isSinglePage true', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
|
||||
expect(list.totalItems.value).toBe(0)
|
||||
expect(list.isEmpty.value).toBe(true)
|
||||
expect(list.isSinglePage.value).toBe(true)
|
||||
expect(list.totalPages.value).toBe(1)
|
||||
})
|
||||
|
||||
it('isEmpty est faux avant le premier fetch (etat indetermine)', () => {
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
expect(list.isEmpty.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reset revient aux defaults', async () => {
|
||||
mockResponse([], 100)
|
||||
const list = usePaginatedList<unknown, { a?: string }>({
|
||||
url: '/users',
|
||||
defaultItemsPerPage: 10,
|
||||
defaultFilters: { a: 'x' },
|
||||
defaultSort: { field: 'name', direction: 'asc' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.setItemsPerPage(50)
|
||||
mockResponse([], 100)
|
||||
await list.setFilters({ a: 'y' })
|
||||
mockResponse([], 100)
|
||||
await list.setSort({ field: 'id', direction: 'desc' })
|
||||
mockResponse([], 100)
|
||||
await list.goToPage(2)
|
||||
expect(list.currentPage.value).toBe(2)
|
||||
|
||||
mockResponse([], 100)
|
||||
await list.reset()
|
||||
|
||||
expect(list.itemsPerPage.value).toBe(10)
|
||||
expect(list.filters.value).toEqual({ a: 'x' })
|
||||
expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' })
|
||||
expect(list.currentPage.value).toBe(1)
|
||||
})
|
||||
|
||||
it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
|
||||
const boom = new Error('boom')
|
||||
mockApiGet.mockRejectedValueOnce(boom)
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
|
||||
await expect(list.fetch()).resolves.toBeUndefined()
|
||||
expect(list.items.value).toEqual([])
|
||||
expect(list.totalItems.value).toBe(0)
|
||||
expect(list.loading.value).toBe(false)
|
||||
// L'erreur est consideree comme un fetch consume -> isEmpty=true.
|
||||
expect(list.isEmpty.value).toBe(true)
|
||||
// ... mais `error` est expose pour distinguer « vide » d'« echec ».
|
||||
expect(list.error.value).toBe(boom)
|
||||
})
|
||||
|
||||
it('error est remis a null des qu\'un fetch ulterieur reussit', async () => {
|
||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
||||
const list = usePaginatedList({ url: '/users' })
|
||||
await list.fetch()
|
||||
expect(list.error.value).toBeInstanceOf(Error)
|
||||
|
||||
mockResponse([{ id: 1 }], 1)
|
||||
await list.fetch()
|
||||
expect(list.error.value).toBeNull()
|
||||
expect(list.items.value).toEqual([{ id: 1 }])
|
||||
})
|
||||
|
||||
it('ignore une reponse periemee : la derniere requete *demandee* gagne', async () => {
|
||||
// Deux fetch concurrents : le 1er resout APRES le 2eme. Sans garde
|
||||
// de sequence, la reponse arrivee en dernier (token 1) ecraserait
|
||||
// les donnees plus fraiches du token 2. Avec la garde, token 2 fait
|
||||
// foi quel que soit l'ordre d'arrivee reseau.
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||
|
||||
let resolveSlow!: (v: unknown) => void
|
||||
const slow = new Promise((r) => { resolveSlow = r })
|
||||
// 1er appel : reponse lente (en vol).
|
||||
mockApiGet.mockReturnValueOnce(slow)
|
||||
// 2eme appel : reponse immediate avec des donnees plus fraiches.
|
||||
mockApiGet.mockResolvedValueOnce({ member: [{ id: 2 }], totalItems: 30 })
|
||||
|
||||
const p1 = list.fetch() // token 1, en vol
|
||||
const p2 = list.fetch() // token 2, resout tout de suite
|
||||
await p2
|
||||
expect(list.items.value).toEqual([{ id: 2 }])
|
||||
|
||||
// La reponse lente du token 1 arrive enfin : elle doit etre ignoree.
|
||||
resolveSlow({ member: [{ id: 1 }], totalItems: 30 })
|
||||
await p1
|
||||
expect(list.items.value).toEqual([{ id: 2 }])
|
||||
// Le spinner reste eteint (la requete recente l'avait deja coupe).
|
||||
expect(list.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList({
|
||||
url: '/categories',
|
||||
extraQuery: { includeDeleted: 'true' },
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' })
|
||||
})
|
||||
|
||||
it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => {
|
||||
mockResponse([], 0)
|
||||
const list = usePaginatedList<unknown, { name?: string; q?: string }>({
|
||||
url: '/users',
|
||||
defaultFilters: { name: '', q: undefined } as never,
|
||||
})
|
||||
await list.fetch()
|
||||
|
||||
const q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(q.name).toBeUndefined()
|
||||
expect(q.q).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refresh() est un alias de fetch()', async () => {
|
||||
mockResponse([{ id: 1 }], 1)
|
||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
||||
|
||||
await list.refresh()
|
||||
expect(list.items.value).toEqual([{ id: 1 }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,364 @@
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
|
||||
/**
|
||||
* Composable generique de liste paginee serveur.
|
||||
*
|
||||
* Responsabilites :
|
||||
* - centraliser l'etat tableau (page courante, items/page, tri, filtres,
|
||||
* totalItems, items, loading, error) cote local — JAMAIS dans l'URL,
|
||||
* conformement a la regle ABSOLUE n°6 du CLAUDE.md (« Jamais persister
|
||||
* l'etat de tableau dans l'URL »).
|
||||
* - dialoguer avec une ressource API Platform 4 (Hydra) en passant
|
||||
* `page`, `itemsPerPage` et le tri/filtres en query params.
|
||||
* - exposer une API simple a brancher sur `MalioDataTable`
|
||||
* (props page/perPage/totalItems + events update:page / update:per-page).
|
||||
*
|
||||
* Volontairement **par-instance** (state local a chaque appel) : a la
|
||||
* difference de `useAuditLog` / `useCategoriesAdmin` qui sont des
|
||||
* singletons module-level partages, une liste paginee est propre a son
|
||||
* ecran et ne doit pas etre partagee entre pages (sinon un retour
|
||||
* arriere reprendrait la pagination d'une autre liste).
|
||||
*
|
||||
* Pas de gestion URL : si une page veut un deep link (ex : ouvrir un
|
||||
* detail), elle le fait via sa propre route, pas via la query string
|
||||
* de pagination. Derogation possible uniquement si l'utilisateur le
|
||||
* demande explicitement, cf. CLAUDE.md.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Direction de tri serveur. API Platform 4 attend `asc` ou `desc` via la
|
||||
* syntaxe `?order[field]=asc`.
|
||||
*/
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
/**
|
||||
* Specification de tri : un seul champ trie a la fois cote front (la
|
||||
* majorite des tableaux Malio n'expose pas le multi-tri). Si null, aucun
|
||||
* `order[...]` n'est envoye et l'API applique son tri par defaut.
|
||||
*/
|
||||
export interface SortSpec {
|
||||
field: string
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Type des filtres : un dictionnaire de valeurs serialisables en query
|
||||
* params. Le caller decide du mapping (ex : `{ active: true }`,
|
||||
* `{ 'name[ilike]': 'a' }`). Valeurs `null` / `undefined` / chaines vides
|
||||
* sont automatiquement omises au moment de la requete.
|
||||
*/
|
||||
export type PaginatedListFilters = Record<string, string | number | boolean | string[] | null | undefined>
|
||||
|
||||
export interface UsePaginatedListOptions<F extends PaginatedListFilters = PaginatedListFilters> {
|
||||
/** URL relative au prefix `/api` (ex : `/sites`, `/categories`). */
|
||||
url: string
|
||||
/** Items par page initial. Defaut 10 (aligne avec le defaut serveur). */
|
||||
defaultItemsPerPage?: number
|
||||
/** Options proposees dans le selecteur items/page. Defaut [10, 25, 50]. */
|
||||
itemsPerPageOptions?: number[]
|
||||
/** Filtres initiaux. */
|
||||
defaultFilters?: F
|
||||
/** Tri initial. */
|
||||
defaultSort?: SortSpec | null
|
||||
/**
|
||||
* Query params additionnels propres a la ressource (ex : `includeDeleted=true`,
|
||||
* `groups[]=foo`) injectes a chaque requete. **Snapshot statique** : l'objet
|
||||
* est lu tel quel a chaque `fetch()`, ses valeurs ne sont pas deballees. Ne
|
||||
* pas y passer de `ref` / `computed` (elles seraient serialisees comme objet,
|
||||
* pas comme valeur) — pour un extra reactif, muter les filtres via
|
||||
* `setFilters` ou ouvrir un ticket pour un support `MaybeRefOrGetter`.
|
||||
*/
|
||||
extraQuery?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UsePaginatedListReturn<T, F extends PaginatedListFilters = PaginatedListFilters> {
|
||||
/** Items de la page courante. */
|
||||
items: Ref<T[]>
|
||||
/** Total d'items (toutes pages) renvoye par Hydra. */
|
||||
totalItems: Ref<number>
|
||||
/** Page courante (1-based). */
|
||||
currentPage: Ref<number>
|
||||
/** Taille de page courante. */
|
||||
itemsPerPage: Ref<number>
|
||||
/** Options exposees au selecteur items/page. */
|
||||
itemsPerPageOptions: Ref<number[]>
|
||||
/** Nombre total de pages (≥ 1). */
|
||||
totalPages: Ref<number>
|
||||
/** Indicateur de chargement (vrai pendant `fetch()`). */
|
||||
loading: Ref<boolean>
|
||||
/**
|
||||
* Derniere erreur de `fetch()` (null si le dernier appel a abouti).
|
||||
* Permet a la page de distinguer « liste reellement vide » d'un echec
|
||||
* reseau / 403 : sans ca, `isEmpty` confond les deux cas (la liste
|
||||
* tombe a 0 item dans les deux situations). La page decide de l'UX
|
||||
* (bandeau, bouton reessayer) — le composable ne toaste pas.
|
||||
*/
|
||||
error: Ref<unknown | null>
|
||||
/** Vrai apres au moins un fetch reussi avec 0 item. */
|
||||
isEmpty: Ref<boolean>
|
||||
/** Vrai si la collection tient en une seule page (totalPages <= 1). */
|
||||
isSinglePage: Ref<boolean>
|
||||
/** Filtres courants (mutation via `setFilters`). */
|
||||
filters: Ref<F>
|
||||
/** Tri courant (mutation via `setSort`). */
|
||||
sort: Ref<SortSpec | null>
|
||||
/** Lance un fetch contre l'API et met a jour items/totalItems. */
|
||||
fetch: () => Promise<void>
|
||||
/** Va a la page demandee (bornes appliquees : 1 ≤ p ≤ totalPages). */
|
||||
goToPage: (page: number) => Promise<void>
|
||||
/** Page suivante (no-op si deja en derniere page). */
|
||||
nextPage: () => Promise<void>
|
||||
/** Page precedente (no-op si deja en premiere page). */
|
||||
prevPage: () => Promise<void>
|
||||
/** Change la taille de page et revient en page 1. */
|
||||
setItemsPerPage: (value: number) => Promise<void>
|
||||
/** Applique de nouveaux filtres et revient en page 1. */
|
||||
setFilters: (next: Partial<F>, options?: { replace?: boolean }) => Promise<void>
|
||||
/** Change le tri et revient en page 1. */
|
||||
setSort: (next: SortSpec | null) => Promise<void>
|
||||
/** Reinitialise filtres + tri + page sur les valeurs par defaut. */
|
||||
reset: () => Promise<void>
|
||||
/** Alias de `fetch()` (intention plus claire dans certains contextes). */
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Force `application/ld+json` : sous `application/json`, API Platform 4
|
||||
* renvoie un tableau plat sans envelope de pagination — on ne pourrait pas
|
||||
* lire `totalItems` ni `view`. Voir aussi `useAuditLog.ts`.
|
||||
*/
|
||||
const JSONLD_HEADERS = { Accept: 'application/ld+json' } as const
|
||||
|
||||
/**
|
||||
* Filtre les entrees nulles/undefined/vides d'un objet de query : evite
|
||||
* d'envoyer `?foo=&bar=null` a l'API qui declencherait parfois des erreurs
|
||||
* de filtre cote Symfony (`FilterInterface::apply` strict).
|
||||
*/
|
||||
function compactQuery(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (value === null || value === undefined) continue
|
||||
if (typeof value === 'string' && value === '') continue
|
||||
if (Array.isArray(value) && value.length === 0) continue
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function usePaginatedList<T, F extends PaginatedListFilters = PaginatedListFilters>(
|
||||
options: UsePaginatedListOptions<F>,
|
||||
): UsePaginatedListReturn<T, F> {
|
||||
const api = useApi()
|
||||
|
||||
const defaultItemsPerPage = options.defaultItemsPerPage ?? 10
|
||||
const initialFilters = { ...(options.defaultFilters ?? ({} as F)) } as F
|
||||
const initialSort = options.defaultSort ?? null
|
||||
|
||||
const items = ref<T[]>([]) as Ref<T[]>
|
||||
const totalItems = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(defaultItemsPerPage)
|
||||
const itemsPerPageOptions = ref(options.itemsPerPageOptions ?? [10, 25, 50])
|
||||
const loading = ref(false)
|
||||
const error = ref<unknown | null>(null)
|
||||
// Jeton de sequence : incremente a chaque `fetch()`. Une reponse dont
|
||||
// le jeton n'est plus le dernier est ignoree (protection contre les
|
||||
// reponses periemes quand l'utilisateur enchaine page / tri / filtres
|
||||
// plus vite que le reseau ne repond — sinon la derniere reponse
|
||||
// *arrivee* gagnerait au lieu de la derniere *demandee*).
|
||||
let fetchToken = 0
|
||||
// `hasFetched` evite que `isEmpty` retourne `true` avant le premier
|
||||
// chargement (etat initial = 0 items mais on ne sait pas encore si la
|
||||
// ressource est vide ou en cours de chargement). Un appel reseau au
|
||||
// moins doit avoir abouti pour qu'on annonce une liste « vide ».
|
||||
const hasFetched = ref(false)
|
||||
const filters = ref({ ...initialFilters }) as Ref<F>
|
||||
const sort = ref<SortSpec | null>(initialSort ? { ...initialSort } : null)
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (totalItems.value <= 0 || itemsPerPage.value <= 0) return 1
|
||||
return Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
|
||||
})
|
||||
const isEmpty = computed(() => hasFetched.value && totalItems.value === 0)
|
||||
const isSinglePage = computed(() => totalPages.value <= 1)
|
||||
|
||||
/**
|
||||
* Construit l'objet query envoye a l'API : extras + filtres, puis
|
||||
* pagination + tri. Les cles reservees (`page`, `itemsPerPage`,
|
||||
* `order[...]`) sont assignees **en dernier** pour qu'un filtre ou un
|
||||
* extra portant le meme nom ne puisse pas ecraser silencieusement la
|
||||
* pagination. Les filtres `null`/`undefined`/'' sont elimines pour ne
|
||||
* pas polluer l'URL.
|
||||
*/
|
||||
function buildQuery(): Record<string, unknown> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (options.extraQuery) {
|
||||
Object.assign(query, options.extraQuery)
|
||||
}
|
||||
Object.assign(query, filters.value)
|
||||
// Cles reservees en dernier : priorite a la pagination/au tri.
|
||||
query.page = currentPage.value
|
||||
query.itemsPerPage = itemsPerPage.value
|
||||
if (sort.value) {
|
||||
// Format API Platform : ?order[field]=asc
|
||||
query[`order[${sort.value.field}]`] = sort.value.direction
|
||||
}
|
||||
return compactQuery(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance un fetch et applique la borne haute si necessaire. Si la page
|
||||
* courante depasse `totalPages` apres l'application des filtres (cas
|
||||
* « j'etais en page 5, je filtre, il ne reste qu'une page »), on
|
||||
* rappelle l'API sur la derniere page valide. Un seul niveau de retry
|
||||
* pour eviter une boucle si l'API renvoie des resultats incoherents.
|
||||
*/
|
||||
async function fetch(): Promise<void> {
|
||||
const token = ++fetchToken
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await api.get<HydraCollection<T>>(
|
||||
options.url,
|
||||
buildQuery(),
|
||||
{ toast: false, headers: JSONLD_HEADERS },
|
||||
)
|
||||
// Une requete plus recente a ete lancee entre-temps : on jette
|
||||
// cette reponse pour ne pas ecraser des donnees plus fraiches.
|
||||
if (token !== fetchToken) return
|
||||
items.value = data.member ?? []
|
||||
totalItems.value = data.totalItems ?? 0
|
||||
|
||||
const tp = totalItems.value > 0
|
||||
? Math.max(1, Math.ceil(totalItems.value / itemsPerPage.value))
|
||||
: 1
|
||||
|
||||
// Si on est hors borne ET qu'il y a au moins une page valide
|
||||
// a viser, on retombe sur la derniere page (cf. cas limite
|
||||
// « page hors borne apres filtre » de la spec #73). On ne
|
||||
// refetch que si la nouvelle page est differente, sinon
|
||||
// boucle infinie potentielle.
|
||||
if (currentPage.value > tp && tp >= 1 && totalItems.value > 0) {
|
||||
currentPage.value = tp
|
||||
const data2 = await api.get<HydraCollection<T>>(
|
||||
options.url,
|
||||
buildQuery(),
|
||||
{ toast: false, headers: JSONLD_HEADERS },
|
||||
)
|
||||
// Meme garde apres le refetch hors-borne.
|
||||
if (token !== fetchToken) return
|
||||
items.value = data2.member ?? []
|
||||
totalItems.value = data2.totalItems ?? 0
|
||||
}
|
||||
|
||||
hasFetched.value = true
|
||||
} catch (e) {
|
||||
// Reponse periemee : ne pas toucher au state, une requete plus
|
||||
// recente est en cours et fera foi.
|
||||
if (token !== fetchToken) return
|
||||
// Swallow volontaire : on remet la liste a vide pour ne pas
|
||||
// afficher de donnees stale, et on expose l'erreur pour que la
|
||||
// page distingue « vide » d'« echec ». Le composant parent
|
||||
// decide de l'UX (toast / message d'erreur) — pas d'a-priori ici.
|
||||
error.value = e
|
||||
items.value = []
|
||||
totalItems.value = 0
|
||||
hasFetched.value = true
|
||||
} finally {
|
||||
// Seule la requete la plus recente eteint le spinner : une
|
||||
// reponse periemee ne doit pas le couper alors qu'un fetch plus
|
||||
// recent est encore en vol.
|
||||
if (token === fetchToken) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function goToPage(page: number): Promise<void> {
|
||||
const tp = totalPages.value
|
||||
const next = Math.max(1, Math.min(page, tp))
|
||||
if (next === currentPage.value) return
|
||||
currentPage.value = next
|
||||
await fetch()
|
||||
}
|
||||
|
||||
async function nextPage(): Promise<void> {
|
||||
if (currentPage.value >= totalPages.value) return
|
||||
await goToPage(currentPage.value + 1)
|
||||
}
|
||||
|
||||
async function prevPage(): Promise<void> {
|
||||
if (currentPage.value <= 1) return
|
||||
await goToPage(currentPage.value - 1)
|
||||
}
|
||||
|
||||
async function setItemsPerPage(value: number): Promise<void> {
|
||||
if (!Number.isFinite(value) || value <= 0) return
|
||||
const rounded = Math.floor(value)
|
||||
if (rounded === itemsPerPage.value) return
|
||||
itemsPerPage.value = rounded
|
||||
currentPage.value = 1
|
||||
await fetch()
|
||||
}
|
||||
|
||||
/**
|
||||
* `replace: false` (defaut) fusionne avec les filtres courants. Une
|
||||
* valeur explicitement `undefined` retire la cle (utile pour effacer
|
||||
* un filtre depuis un champ controle). `replace: true` remplace
|
||||
* integralement l'objet par `next`.
|
||||
*/
|
||||
async function setFilters(next: Partial<F>, opts?: { replace?: boolean }): Promise<void> {
|
||||
if (opts?.replace) {
|
||||
filters.value = { ...(next as F) }
|
||||
} else {
|
||||
const merged = { ...filters.value, ...next } as F
|
||||
// Supprime les cles explicitement passees a undefined : sans ce
|
||||
// nettoyage, l'objet `filters` accumulerait des cles fantomes.
|
||||
for (const key of Object.keys(next)) {
|
||||
if (next[key as keyof F] === undefined) {
|
||||
delete (merged as Record<string, unknown>)[key]
|
||||
}
|
||||
}
|
||||
filters.value = merged
|
||||
}
|
||||
currentPage.value = 1
|
||||
await fetch()
|
||||
}
|
||||
|
||||
async function setSort(next: SortSpec | null): Promise<void> {
|
||||
sort.value = next ? { ...next } : null
|
||||
currentPage.value = 1
|
||||
await fetch()
|
||||
}
|
||||
|
||||
async function reset(): Promise<void> {
|
||||
filters.value = { ...initialFilters }
|
||||
sort.value = initialSort ? { ...initialSort } : null
|
||||
itemsPerPage.value = defaultItemsPerPage
|
||||
currentPage.value = 1
|
||||
await fetch()
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
isEmpty,
|
||||
isSinglePage,
|
||||
filters,
|
||||
sort,
|
||||
fetch,
|
||||
goToPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
setItemsPerPage,
|
||||
setFilters,
|
||||
setSort,
|
||||
reset,
|
||||
refresh: fetch,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M1 — Repertoire clients (ERP-53) : creation de toute la structure BDD du
|
||||
* module Commercial (clients + sous-collections + referentiels comptables).
|
||||
*
|
||||
* Tables creees :
|
||||
* - Referentiels comptables (statiques, seedes ici) : tva_mode, payment_delay,
|
||||
* payment_type, bank.
|
||||
* - Table principale : client (formulaire + Information + Comptabilite +
|
||||
* archive + soft-delete + Timestampable/Blamable).
|
||||
* - Sous-collections : client_category (M2M), client_contact (1:n),
|
||||
* client_address (1:n), client_rib (1:n).
|
||||
* - Jointures de client_address : client_address_site, client_address_contact,
|
||||
* client_address_category.
|
||||
*
|
||||
* Seed `category_type` (extension M0) : DISTRIBUTEUR / COURTIER / SECTEUR /
|
||||
* AUTRE, en `ON CONFLICT (code) DO NOTHING` (idempotent — la table peut deja
|
||||
* porter des donnees en prod). En dev/test, les fixtures purgent et re-seedent
|
||||
* ces 4 types (cf. CategoryTypeFixtures) ; ce seed migration couvre la prod ou
|
||||
* les fixtures ne tournent pas.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
|
||||
* `App\Module\Commercial\...` : avec plusieurs migrations_paths, Doctrine
|
||||
* Migrations 3.x trie par FQCN alphabetique (AlphabeticalComparator → strcmp).
|
||||
* Un namespace `App\Module\Commercial\...` trierait AVANT `DoctrineMigrations\...`
|
||||
* et la migration s'executerait avant la creation de user/category/site sur
|
||||
* base vide → echec des FK. Le namespace racine garantit l'ordre par timestamp.
|
||||
*
|
||||
* Style DDL aligne sur la migration M0 (Version20260527164000) plutot que sur
|
||||
* le pseudo-SQL de la spec § 3.2 : `INT GENERATED BY DEFAULT AS IDENTITY` (et
|
||||
* non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non TIMESTAMPTZ, car le
|
||||
* `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que
|
||||
* `schema:update` restera un no-op quand les entites arriveront (ticket ERP-54).
|
||||
*
|
||||
* Decision Q4 (29/05/2026) : unicite metier sur le NOM DE SOCIETE uniquement.
|
||||
* Pas d'index unique sur siren ni email (RG-1.15 / RG-1.17 supprimees).
|
||||
*
|
||||
* Chaque colonne porte un `COMMENT ON COLUMN` (regle ABSOLUE n°12, garde-fou
|
||||
* ColumnsHaveSqlCommentTest). Les tables n'etant pas encore mappees par l'ORM,
|
||||
* ces commentaires survivent au `schema:update --force` du setup de test.
|
||||
*/
|
||||
final class Version20260601000000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-53 (M1) : tables client + sous-collections + referentiels comptables + seed category_type.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createAccountingReferentials();
|
||||
$this->createClientTable();
|
||||
$this->createClientCategory();
|
||||
$this->createClientContact();
|
||||
$this->createClientAddress();
|
||||
$this->createClientAddressJoinTables();
|
||||
$this->createClientRib();
|
||||
$this->seedCategoryTypes();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK : on supprime d'abord les jointures
|
||||
// et sous-collections, puis client, puis les referentiels.
|
||||
$this->addSql('DROP TABLE client_address_category');
|
||||
$this->addSql('DROP TABLE client_address_contact');
|
||||
$this->addSql('DROP TABLE client_address_site');
|
||||
$this->addSql('DROP TABLE client_rib');
|
||||
$this->addSql('DROP TABLE client_address');
|
||||
$this->addSql('DROP TABLE client_contact');
|
||||
$this->addSql('DROP TABLE client_category');
|
||||
$this->addSql('DROP TABLE client');
|
||||
$this->addSql('DROP TABLE bank');
|
||||
$this->addSql('DROP TABLE payment_type');
|
||||
$this->addSql('DROP TABLE payment_delay');
|
||||
$this->addSql('DROP TABLE tva_mode');
|
||||
|
||||
// Retire uniquement les 4 types seedes par cette migration ET restes
|
||||
// orphelins (aucune `category` ne les reference). Sans la clause
|
||||
// NOT EXISTS, le DELETE casse sur la FK RESTRICT category.category_type_id
|
||||
// des qu'une categorie pointe sur l'un d'eux. Symetrique du
|
||||
// `ON CONFLICT (code) DO NOTHING` du up() : on ne defait que ce qu'on a
|
||||
// reellement cree et qui n'est pas reutilise.
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM category_type
|
||||
WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
|
||||
)
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Referentiels comptables (4 tables statiques, memes colonnes)
|
||||
// =================================================================
|
||||
|
||||
private function createAccountingReferentials(): void
|
||||
{
|
||||
$referentials = [
|
||||
'tva_mode' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
|
||||
'payment_delay' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
|
||||
'payment_type' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
|
||||
'bank' => 'Referentiel des banques selectionnables pour le reglement par virement.',
|
||||
];
|
||||
|
||||
foreach ($referentials as $table => $tableComment) {
|
||||
$this->addSql(sprintf(<<<'SQL'
|
||||
CREATE TABLE %s (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(30) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL, $table));
|
||||
$this->addSql(sprintf('CREATE UNIQUE INDEX uq_%s_code ON %s (code)', $table, $table));
|
||||
|
||||
$this->comment($table, '_table', $tableComment);
|
||||
$this->comment($table, 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment($table, 'code', 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.');
|
||||
$this->comment($table, 'label', 'Libelle affichable (FR, ≤ 120 caracteres).');
|
||||
$this->comment($table, 'position', 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).');
|
||||
}
|
||||
|
||||
// Seed initial (cf. spec § 3.2). Tables fraichement creees donc vides :
|
||||
// INSERT direct sans ON CONFLICT.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO tva_mode (code, label, position) VALUES
|
||||
('FRANCE_VENTES', 'France (ventes)', 10),
|
||||
('EXPORT_VENTES', 'Export (ventes)', 20),
|
||||
('INTRACOM_VENTES', 'Intracom (ventes)', 30)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO payment_delay (code, label, position) VALUES
|
||||
('J15', '15 jours', 10),
|
||||
('J30', '30 jours', 20),
|
||||
('A_RECEPTION', 'À réception', 30)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO payment_type (code, label, position) VALUES
|
||||
('VIREMENT', 'Virement', 10),
|
||||
('LCR', 'LCR', 20),
|
||||
('NON_SOUMISE', 'Non soumise', 30),
|
||||
('CHEQUE', 'Chèque', 40)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO bank (code, label, position) VALUES
|
||||
('SG', 'Société Générale', 10),
|
||||
('CIC', 'CIC', 20),
|
||||
('CA', 'Crédit Agricole', 30)
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `client`
|
||||
// =================================================================
|
||||
|
||||
private function createClientTable(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
company_name VARCHAR(180) NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) NOT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) NOT NULL,
|
||||
distributor_id INT DEFAULT NULL,
|
||||
broker_id INT DEFAULT NULL,
|
||||
triage_service BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
description TEXT DEFAULT NULL,
|
||||
competitors VARCHAR(255) DEFAULT NULL,
|
||||
founded_at DATE DEFAULT NULL,
|
||||
employees_count INT DEFAULT NULL,
|
||||
revenue_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||
director_name VARCHAR(120) DEFAULT NULL,
|
||||
profit_amount NUMERIC(15, 2) DEFAULT NULL,
|
||||
siren VARCHAR(20) DEFAULT NULL,
|
||||
account_number VARCHAR(40) DEFAULT NULL,
|
||||
tva_mode_id INT DEFAULT NULL,
|
||||
n_tva VARCHAR(40) DEFAULT NULL,
|
||||
payment_delay_id INT DEFAULT NULL,
|
||||
payment_type_id INT DEFAULT NULL,
|
||||
bank_id INT DEFAULT NULL,
|
||||
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_client_distrib_or_broker
|
||||
CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)),
|
||||
CONSTRAINT fk_client_distributor
|
||||
FOREIGN KEY (distributor_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_broker
|
||||
FOREIGN KEY (broker_id) REFERENCES client (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_tva_mode
|
||||
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_payment_delay
|
||||
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_payment_type
|
||||
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_bank
|
||||
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_client_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_client_is_archived ON client (is_archived)');
|
||||
$this->addSql('CREATE INDEX idx_client_deleted_at ON client (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_client_distributor_id ON client (distributor_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_broker_id ON client (broker_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)');
|
||||
|
||||
// Index sur les FK des referentiels comptables — coherence avec les autres
|
||||
// FK deja indexees ci-dessus (Postgres n'indexe pas automatiquement les
|
||||
// colonnes portant une FOREIGN KEY).
|
||||
$this->addSql('CREATE INDEX idx_client_tva_mode_id ON client (tva_mode_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_payment_delay_id ON client (payment_delay_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_payment_type_id ON client (payment_type_id)');
|
||||
$this->addSql('CREATE INDEX idx_client_bank_id ON client (bank_id)');
|
||||
|
||||
// Unicite metier partielle (Q4) : nom de societe insensible a la casse,
|
||||
// parmi les non-archives ET non soft-deletes uniquement. Pas d'index
|
||||
// unique sur siren ni email.
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE UNIQUE INDEX uq_client_company_name_active
|
||||
ON client (LOWER(company_name))
|
||||
WHERE is_archived = FALSE AND deleted_at IS NULL
|
||||
SQL);
|
||||
|
||||
$this->comment('client', '_table', 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).');
|
||||
$this->comment('client', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client', 'company_name', 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).');
|
||||
$this->comment('client', 'first_name', 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||
$this->comment('client', 'last_name', 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
||||
$this->comment('client', 'phone_primary', 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.');
|
||||
$this->comment('client', 'phone_secondary', 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).');
|
||||
$this->comment('client', 'email', 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).');
|
||||
$this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.');
|
||||
$this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.');
|
||||
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
|
||||
$this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
|
||||
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
||||
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
|
||||
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
|
||||
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
|
||||
$this->comment('client', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
|
||||
$this->comment('client', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.');
|
||||
$this->comment('client', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).');
|
||||
$this->comment('client', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).');
|
||||
$this->comment('client', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).');
|
||||
$this->comment('client', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).');
|
||||
$this->comment('client', 'deleted_at', 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('client');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// M2M client <-> category
|
||||
// =================================================================
|
||||
|
||||
private function createClientCategory(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_category (
|
||||
client_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (client_id, category_id),
|
||||
CONSTRAINT fk_client_category_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_category_category ON client_category (category_id)');
|
||||
|
||||
$this->comment('client_category', '_table', 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).');
|
||||
$this->comment('client_category', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.');
|
||||
$this->comment('client_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : contacts (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createClientContact(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_contact (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
first_name VARCHAR(120) DEFAULT NULL,
|
||||
last_name VARCHAR(120) DEFAULT NULL,
|
||||
job_title VARCHAR(120) DEFAULT NULL,
|
||||
phone_primary VARCHAR(20) DEFAULT NULL,
|
||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
||||
email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_client_contact_name
|
||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL),
|
||||
CONSTRAINT fk_client_contact_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_contact_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_contact_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_contact_client ON client_contact (client_id)');
|
||||
|
||||
$this->comment('client_contact', '_table', 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).');
|
||||
$this->comment('client_contact', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client_contact', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.');
|
||||
$this->comment('client_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||
$this->comment('client_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
||||
$this->comment('client_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
||||
$this->comment('client_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (RG-1.20).');
|
||||
$this->comment('client_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).');
|
||||
$this->comment('client_contact', 'email', 'Email du contact (lowercase serveur, RG-1.21).');
|
||||
$this->comment('client_contact', 'position', 'Ordre d affichage du contact dans la liste du client (croissant).');
|
||||
$this->addTimestampableBlamableComments('client_contact');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : adresses (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createClientAddress(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
is_prospect BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
is_delivery BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
is_billing BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
||||
postal_code VARCHAR(20) NOT NULL,
|
||||
city VARCHAR(120) NOT NULL,
|
||||
street VARCHAR(255) NOT NULL,
|
||||
street_complement VARCHAR(255) DEFAULT NULL,
|
||||
billing_email VARCHAR(180) DEFAULT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_client_address_prospect_exclusive
|
||||
CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))),
|
||||
CONSTRAINT chk_client_address_billing_email
|
||||
CHECK ((is_billing = FALSE AND billing_email IS NULL)
|
||||
OR (is_billing = TRUE AND billing_email IS NOT NULL)),
|
||||
CONSTRAINT fk_client_address_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_address_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_address_client ON client_address (client_id)');
|
||||
|
||||
$this->comment('client_address', '_table', 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).');
|
||||
$this->comment('client_address', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client_address', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.');
|
||||
$this->comment('client_address', 'is_prospect', 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.');
|
||||
$this->comment('client_address', 'is_delivery', 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.');
|
||||
$this->comment('client_address', 'is_billing', 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.');
|
||||
$this->comment('client_address', 'country', 'Pays de l adresse — defaut France.');
|
||||
$this->comment('client_address', 'postal_code', 'Code postal (4-5 chiffres attendus, RG-1.09).');
|
||||
$this->comment('client_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).');
|
||||
$this->comment('client_address', 'street', 'Numero et voie de l adresse.');
|
||||
$this->comment('client_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
||||
$this->comment('client_address', 'billing_email', 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).');
|
||||
$this->comment('client_address', 'position', 'Ordre d affichage de l adresse dans la liste du client (croissant).');
|
||||
$this->addTimestampableBlamableComments('client_address');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Jointures de client_address (M2M)
|
||||
// =================================================================
|
||||
|
||||
private function createClientAddressJoinTables(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address_site (
|
||||
client_address_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (client_address_id, site_id),
|
||||
CONSTRAINT fk_client_address_site_address
|
||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('client_address_site', '_table', 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).');
|
||||
$this->comment('client_address_site', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('client_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address_contact (
|
||||
client_address_id INT NOT NULL,
|
||||
client_contact_id INT NOT NULL,
|
||||
PRIMARY KEY (client_address_id, client_contact_id),
|
||||
CONSTRAINT fk_client_address_contact_address
|
||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_contact_contact
|
||||
FOREIGN KEY (client_contact_id) REFERENCES client_contact (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
$this->comment('client_address_contact', '_table', 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.');
|
||||
$this->comment('client_address_contact', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('client_address_contact', 'client_contact_id', 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_address_category (
|
||||
client_address_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (client_address_id, category_id),
|
||||
CONSTRAINT fk_client_address_category_address
|
||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_address_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('client_address_category', '_table', 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).');
|
||||
$this->comment('client_address_category', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('client_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Sous-collection : RIB (1:n)
|
||||
// =================================================================
|
||||
|
||||
private function createClientRib(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE client_rib (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
bic VARCHAR(20) NOT NULL,
|
||||
iban VARCHAR(34) NOT NULL,
|
||||
position INT DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_client_rib_client
|
||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_client_rib_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_client_rib_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_client_rib_client ON client_rib (client_id)');
|
||||
|
||||
$this->comment('client_rib', '_table', 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).');
|
||||
$this->comment('client_rib', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('client_rib', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.');
|
||||
$this->comment('client_rib', 'label', 'Libelle du RIB (ex: compte principal).');
|
||||
$this->comment('client_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
|
||||
$this->comment('client_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
|
||||
$this->comment('client_rib', 'position', 'Ordre d affichage du RIB dans la liste du client (croissant).');
|
||||
$this->addTimestampableBlamableComments('client_rib');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Seed extension category_type (M0)
|
||||
// =================================================================
|
||||
|
||||
private function seedCategoryTypes(): void
|
||||
{
|
||||
// Idempotent : la table category_type peut deja porter des donnees en
|
||||
// prod. ON CONFLICT (code) s appuie sur l index unique uq_category_type_code.
|
||||
// NB : la table M0 n a pas de colonne `position` (id/code/label seulement),
|
||||
// contrairement au pseudo-SQL de la spec § 3.3.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_type (code, label) VALUES
|
||||
('DISTRIBUTEUR', 'Distributeur'),
|
||||
('COURTIER', 'Courtier'),
|
||||
('SECTEUR', 'Secteur'),
|
||||
('AUTRE', 'Autre')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, cf. ERP-67).
|
||||
*/
|
||||
private function addTimestampableBlamableComments(string $table): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||
$this->comment($table, $column, $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
|
||||
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
|
||||
* tout echappement d apostrophe.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
@@ -29,18 +32,32 @@ final class CategoryProvider implements ProviderInterface
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
|
||||
private readonly CategoryRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|Paginator|null
|
||||
{
|
||||
$includeDeleted = $this->readIncludeDeleted($context);
|
||||
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->repository
|
||||
->createListQueryBuilder($includeDeleted)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
$qb = $this->repository->createListQueryBuilder($includeDeleted);
|
||||
|
||||
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
|
||||
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
// Branche paginee standard : on applique offset/limit via Pagination,
|
||||
// puis on enveloppe dans le Paginator ORM (fetchJoinCollection: true
|
||||
// pour que Doctrine compte correctement avec les JOINs futurs).
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||
}
|
||||
|
||||
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures du module Catalog : seed des types de categorie metier (M1).
|
||||
*
|
||||
* La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4
|
||||
* types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3).
|
||||
*
|
||||
* Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) :
|
||||
* `category_type` est une entite managee par l ORM, donc le purger Doctrine la
|
||||
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types
|
||||
* seedes par la migration disparaitraient apres `make db-reset` / setup de test.
|
||||
* Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette
|
||||
* fixture re-aligne dev et test. Les deux chemins produisent un etat identique.
|
||||
*
|
||||
* Idempotence : lookup par `code` parmi les types existants avant insertion,
|
||||
* sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme
|
||||
* si le purger est desactive.
|
||||
*/
|
||||
class CategoryTypeFixtures extends Fixture
|
||||
{
|
||||
/**
|
||||
* Source unique des 4 types metier : code technique => libelle FR.
|
||||
* Doit rester aligne sur le seed de la migration Version20260601000000.
|
||||
*/
|
||||
private const TYPES = [
|
||||
'DISTRIBUTEUR' => 'Distributeur',
|
||||
'COURTIER' => 'Courtier',
|
||||
'SECTEUR' => 'Secteur',
|
||||
'AUTRE' => 'Autre',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly CategoryTypeRepositoryInterface $categoryTypeRepository,
|
||||
) {}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Index des types deja presents par code, pour ne pas creer de doublon.
|
||||
$existingByCode = [];
|
||||
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
|
||||
$existingByCode[$type->getCode()] = $type;
|
||||
}
|
||||
|
||||
foreach (self::TYPES as $code => $label) {
|
||||
$type = $existingByCode[$code] ?? new CategoryType();
|
||||
$type->setCode($code);
|
||||
$type->setLabel($label);
|
||||
$manager->persist($type);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"api-platform/symfony": {
|
||||
"version": "4.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "4.0",
|
||||
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/api_platform.yaml",
|
||||
"config/routes/api_platform.yaml",
|
||||
"src/ApiResource/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/deprecations": {
|
||||
"version": "1.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
|
||||
}
|
||||
},
|
||||
"doctrine/doctrine-bundle": {
|
||||
"version": "3.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine.yaml",
|
||||
"src/Entity/.gitignore",
|
||||
"src/Repository/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-fixtures-bundle": {
|
||||
"version": "4.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.0",
|
||||
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
|
||||
},
|
||||
"files": [
|
||||
"src/DataFixtures/AppFixtures.php"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-migrations-bundle": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.1",
|
||||
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine_migrations.yaml",
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"friendsofphp/php-cs-fixer": {
|
||||
"version": "3.94",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.39",
|
||||
"ref": "97aaf9026490db73b86c23d49e5774bc89d2b232"
|
||||
},
|
||||
"files": [
|
||||
".php-cs-fixer.dist.php"
|
||||
]
|
||||
},
|
||||
"lexik/jwt-authentication-bundle": {
|
||||
"version": "3.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.5",
|
||||
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/lexik_jwt_authentication.yaml"
|
||||
]
|
||||
},
|
||||
"nelmio/cors-bundle": {
|
||||
"version": "2.6",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.5",
|
||||
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/nelmio_cors.yaml"
|
||||
]
|
||||
},
|
||||
"nyholm/psr7": {
|
||||
"version": "1.8",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "4a8c0345442dcca1d8a2c65633dcf0285dd5a5a2"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/nyholm_psr7.yaml"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "13.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "11.1",
|
||||
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
|
||||
},
|
||||
"files": [
|
||||
".env.test",
|
||||
"phpunit.dist.xml",
|
||||
"tests/bootstrap.php",
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||
},
|
||||
"files": [
|
||||
"bin/console"
|
||||
]
|
||||
},
|
||||
"symfony/flex": {
|
||||
"version": "2.10",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.4",
|
||||
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||
},
|
||||
"files": [
|
||||
".env",
|
||||
".env.dev"
|
||||
]
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "d5dcd308c8becd725c9d8b91e31aab1ff0bbc30b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/cache.yaml",
|
||||
"config/packages/framework.yaml",
|
||||
"config/preload.php",
|
||||
"config/routes/framework.yaml",
|
||||
"config/services.yaml",
|
||||
"public/index.php",
|
||||
"src/Controller/.gitignore",
|
||||
"src/Kernel.php",
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/monolog-bundle": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.7",
|
||||
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/monolog.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/property_info.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/routing.yaml",
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/security-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.4",
|
||||
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/security.yaml",
|
||||
"config/routes/security.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/twig.yaml",
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/uid": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
||||
}
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/validator.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Garde-fou architecture : toute operation `GetCollection` exposee via API Platform
|
||||
* doit avoir la pagination activee (ou laisser la valeur par defaut, qui est
|
||||
* activee globalement dans `config/packages/api_platform.yaml`).
|
||||
*
|
||||
* Interdit : `new GetCollection(paginationEnabled: false)` sans exception documentee.
|
||||
*
|
||||
* Raison : une collection non paginee peut retourner des milliers de lignes et
|
||||
* saturer la memoire du serveur, le reseau et le navigateur. La pagination est la
|
||||
* seule protection fiable contre ce risque sur un CRM a donnees croissantes.
|
||||
*
|
||||
* Quand ajouter une entree dans `EXCLUDED` :
|
||||
* - La collection est structurellement bornee (referentiel statique, < 100 items,
|
||||
* jamais alimente par des utilisateurs) ET la suppression de la pagination est
|
||||
* documentee avec une justification metier explicite.
|
||||
* - Format obligatoire : `FQCN => 'justification + reference ticket/spec'`
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CollectionsArePaginatedTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Resources API Platform dont un `GetCollection` peut desactiver la pagination.
|
||||
*
|
||||
* Laisser vide au demarrage. Pour ajouter une exception :
|
||||
* 'App\Module\Foo\Infrastructure\ApiPlatform\Resource\BarResource'
|
||||
* => 'Referentiel statique < 50 items (types de contrat). Cf. ERP-XX.',
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
private const EXCLUDED = [];
|
||||
|
||||
public function testAllGetCollectionOperationsHavePaginationEnabled(): void
|
||||
{
|
||||
$finder = new Finder()
|
||||
->files()
|
||||
->in(__DIR__.'/../../src')
|
||||
->name('*.php')
|
||||
->contains('#[ApiResource')
|
||||
;
|
||||
|
||||
// Garde : si le scan ne trouve rien, le chemin est casse — le test
|
||||
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
|
||||
self::assertNotEmpty(
|
||||
iterator_to_array($finder),
|
||||
'Aucun fichier #[ApiResource] trouve sous src/ : chemin invalide ou codebase vide.',
|
||||
);
|
||||
|
||||
foreach ($finder as $file) {
|
||||
$fqcn = $this->extractFqcn($file->getRealPath());
|
||||
if (null === $fqcn || !class_exists($fqcn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($fqcn);
|
||||
$apiResourceAttributes = $reflection->getAttributes(ApiResource::class);
|
||||
|
||||
if ([] === $apiResourceAttributes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($apiResourceAttributes as $attribute) {
|
||||
/** @var ApiResource $apiResource */
|
||||
$apiResource = $attribute->newInstance();
|
||||
$operations = $apiResource->getOperations()?->getIterator() ?? [];
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
if (!$operation instanceof GetCollection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false !== $operation->getPaginationEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// La pagination est explicitement desactivee : verifier
|
||||
// que la resource est dans la whitelist EXCLUDED.
|
||||
self::assertArrayHasKey(
|
||||
$fqcn,
|
||||
self::EXCLUDED,
|
||||
sprintf(
|
||||
"La resource %s desactive la pagination sur une operation GetCollection.\n"
|
||||
."Regle : toute collection API Platform doit etre paginee (cf. .claude/rules/backend.md).\n"
|
||||
."Si cette collection est structurellement bornee et que la desactivation est justifiee,\n"
|
||||
.'ajouter une entree dans CollectionsArePaginatedTest::EXCLUDED avec une justification.',
|
||||
$fqcn,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
|
||||
* source, sans charger le fichier.
|
||||
*/
|
||||
private function extractFqcn(string $path): ?string
|
||||
{
|
||||
$source = file_get_contents($path);
|
||||
if (false === $source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|
||||
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($nsMatch[1]).'\\'.$classMatch[1];
|
||||
}
|
||||
}
|
||||
@@ -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).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user