Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94955905cd | |||
| f5040a20fa | |||
| 13eb0722dc | |||
| d9a6d0fa5a | |||
| 78cb5bfa23 | |||
| 3f21248e73 | |||
| 9f96d1c40d | |||
| 836f177ff9 |
@@ -13,64 +13,6 @@
|
|||||||
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
|
- 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}`
|
- **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
|
## Repositories
|
||||||
|
|
||||||
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
|
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
|
||||||
|
|||||||
@@ -53,53 +53,6 @@ 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.
|
**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
|
## 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.
|
**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,8 +25,6 @@ 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.
|
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.
|
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.
|
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
|
## Conventions
|
||||||
@.claude/rules/architecture.md
|
@.claude/rules/architecture.md
|
||||||
@@ -55,7 +53,6 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
|
|||||||
## A NE PAS faire
|
## A NE PAS faire
|
||||||
|
|
||||||
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
|
- 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 `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 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.
|
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"nyholm/psr7": "^1.8",
|
"nyholm/psr7": "^1.8",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
||||||
"phpoffice/phpspreadsheet": "^5.7",
|
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/console": "8.0.*",
|
"symfony/console": "8.0.*",
|
||||||
@@ -24,7 +23,6 @@
|
|||||||
"symfony/expression-language": "8.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/intl": "8.0.*",
|
|
||||||
"symfony/mime": "8.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
|
|||||||
Generated
+80
-514
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "aada2e60fd7563f1498b5505b37e3f4b",
|
"content-hash": "d65a546151abb6b977fbf7f1c86d14fe",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -1160,85 +1160,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-17T15:23:21+00:00"
|
"time": "2026-03-17T15:23:21+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "composer/pcre",
|
|
||||||
"version": "3.3.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/composer/pcre.git",
|
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
|
||||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.4 || ^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"phpstan/phpstan": "<1.11.10"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpstan/phpstan": "^1.12 || ^2",
|
|
||||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
|
||||||
"phpunit/phpunit": "^8 || ^9"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"phpstan": {
|
|
||||||
"includes": [
|
|
||||||
"extension.neon"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-main": "3.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Composer\\Pcre\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Jordi Boggiano",
|
|
||||||
"email": "j.boggiano@seld.be",
|
|
||||||
"homepage": "http://seld.be"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
|
||||||
"keywords": [
|
|
||||||
"PCRE",
|
|
||||||
"preg",
|
|
||||||
"regex",
|
|
||||||
"regular expression"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/composer/pcre/issues",
|
|
||||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://packagist.com",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/composer",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2024-11-12T16:29:46+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "composer/semver",
|
"name": "composer/semver",
|
||||||
"version": "3.4.4",
|
"version": "3.4.4",
|
||||||
@@ -2709,191 +2630,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-12-20T17:47:00+00:00"
|
"time": "2025-12-20T17:47:00+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "maennchen/zipstream-php",
|
|
||||||
"version": "3.2.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
|
||||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
|
||||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-mbstring": "*",
|
|
||||||
"ext-zlib": "*",
|
|
||||||
"php-64bit": "^8.3"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"brianium/paratest": "^7.7",
|
|
||||||
"ext-zip": "*",
|
|
||||||
"friendsofphp/php-cs-fixer": "^3.86",
|
|
||||||
"guzzlehttp/guzzle": "^7.5",
|
|
||||||
"mikey179/vfsstream": "^1.6",
|
|
||||||
"php-coveralls/php-coveralls": "^2.5",
|
|
||||||
"phpunit/phpunit": "^12.0",
|
|
||||||
"vimeo/psalm": "^6.0"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"guzzlehttp/psr7": "^2.4",
|
|
||||||
"psr/http-message": "^2.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"ZipStream\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Paul Duncan",
|
|
||||||
"email": "pabs@pablotron.org"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jonatan Männchen",
|
|
||||||
"email": "jonatan@maennchen.ch"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jesse Donat",
|
|
||||||
"email": "donatj@gmail.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "András Kolesár",
|
|
||||||
"email": "kolesar@kolesar.hu"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
|
||||||
"keywords": [
|
|
||||||
"stream",
|
|
||||||
"zip"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
|
||||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/maennchen",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-04-11T18:38:28+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "markbaker/complex",
|
|
||||||
"version": "3.0.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
|
||||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
|
||||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.2 || ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
|
||||||
"phpcompatibility/php-compatibility": "^9.3",
|
|
||||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
|
||||||
"squizlabs/php_codesniffer": "^3.7"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Complex\\": "classes/src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Mark Baker",
|
|
||||||
"email": "mark@lange.demon.co.uk"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PHP Class for working with complex numbers",
|
|
||||||
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
|
||||||
"keywords": [
|
|
||||||
"complex",
|
|
||||||
"mathematics"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
|
||||||
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
|
||||||
},
|
|
||||||
"time": "2022-12-06T16:21:08+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "markbaker/matrix",
|
|
||||||
"version": "3.0.1",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
|
||||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
|
||||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.1 || ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
|
||||||
"phpcompatibility/php-compatibility": "^9.3",
|
|
||||||
"phpdocumentor/phpdocumentor": "2.*",
|
|
||||||
"phploc/phploc": "^4.0",
|
|
||||||
"phpmd/phpmd": "2.*",
|
|
||||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
|
||||||
"sebastian/phpcpd": "^4.0",
|
|
||||||
"squizlabs/php_codesniffer": "^3.7"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Matrix\\": "classes/src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Mark Baker",
|
|
||||||
"email": "mark@demon-angel.eu"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PHP Class for working with matrices",
|
|
||||||
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
|
||||||
"keywords": [
|
|
||||||
"mathematics",
|
|
||||||
"matrix",
|
|
||||||
"vector"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
|
||||||
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
|
||||||
},
|
|
||||||
"time": "2022-12-02T22:17:43+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@@ -3316,115 +3052,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-01-06T21:53:42+00:00"
|
"time": "2026-01-06T21:53:42+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "phpoffice/phpspreadsheet",
|
|
||||||
"version": "5.7.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
|
||||||
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
|
||||||
"reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"composer/pcre": "^1||^2||^3",
|
|
||||||
"ext-ctype": "*",
|
|
||||||
"ext-dom": "*",
|
|
||||||
"ext-fileinfo": "*",
|
|
||||||
"ext-filter": "*",
|
|
||||||
"ext-gd": "*",
|
|
||||||
"ext-iconv": "*",
|
|
||||||
"ext-libxml": "*",
|
|
||||||
"ext-mbstring": "*",
|
|
||||||
"ext-simplexml": "*",
|
|
||||||
"ext-xml": "*",
|
|
||||||
"ext-xmlreader": "*",
|
|
||||||
"ext-xmlwriter": "*",
|
|
||||||
"ext-zip": "*",
|
|
||||||
"ext-zlib": "*",
|
|
||||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
|
||||||
"markbaker/complex": "^3.0",
|
|
||||||
"markbaker/matrix": "^3.0",
|
|
||||||
"php": "^8.1",
|
|
||||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
|
||||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
|
||||||
"ext-intl": "*",
|
|
||||||
"friendsofphp/php-cs-fixer": "^3.2",
|
|
||||||
"mitoteam/jpgraph": "^10.5",
|
|
||||||
"mpdf/mpdf": "^8.1.1",
|
|
||||||
"phpcompatibility/php-compatibility": "^9.3",
|
|
||||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
|
||||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
|
||||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
|
||||||
"phpunit/phpunit": "^10.5",
|
|
||||||
"squizlabs/php_codesniffer": "^3.7",
|
|
||||||
"tecnickcom/tcpdf": "^6.5"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
|
||||||
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
|
||||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
|
||||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
|
||||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Maarten Balliauw",
|
|
||||||
"homepage": "https://blog.maartenballiauw.be"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Mark Baker",
|
|
||||||
"homepage": "https://markbakeruk.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Franck Lefevre",
|
|
||||||
"homepage": "https://rootslabs.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Erik Tilt"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Adrien Crivelli"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Owen Leibman"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
|
||||||
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
|
||||||
"keywords": [
|
|
||||||
"OpenXML",
|
|
||||||
"excel",
|
|
||||||
"gnumeric",
|
|
||||||
"ods",
|
|
||||||
"php",
|
|
||||||
"spreadsheet",
|
|
||||||
"xls",
|
|
||||||
"xlsx"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
|
||||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
|
|
||||||
},
|
|
||||||
"time": "2026-04-20T02:42:17+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpdoc-parser",
|
"name": "phpstan/phpdoc-parser",
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
@@ -3886,57 +3513,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"time": "2024-09-11T13:17:53+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "psr/simple-cache",
|
|
||||||
"version": "3.0.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/php-fig/simple-cache.git",
|
|
||||||
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
|
||||||
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.0.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "3.0.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Psr\\SimpleCache\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "PHP-FIG",
|
|
||||||
"homepage": "https://www.php-fig.org/"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Common interfaces for simple caching",
|
|
||||||
"keywords": [
|
|
||||||
"cache",
|
|
||||||
"caching",
|
|
||||||
"psr",
|
|
||||||
"psr-16",
|
|
||||||
"simple-cache"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
|
|
||||||
},
|
|
||||||
"time": "2021-10-29T13:26:27+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/asset",
|
"name": "symfony/asset",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -5596,95 +5172,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-31T21:14:05+00:00"
|
"time": "2026-03-31T21:14:05+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/intl",
|
|
||||||
"version": "v8.0.8",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/intl.git",
|
|
||||||
"reference": "604a1dbbd67471e885e93274379cadd80dc33535"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/intl/zipball/604a1dbbd67471e885e93274379cadd80dc33535",
|
|
||||||
"reference": "604a1dbbd67471e885e93274379cadd80dc33535",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.4"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"symfony/string": "<7.4"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"symfony/filesystem": "^7.4|^8.0",
|
|
||||||
"symfony/var-exporter": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Component\\Intl\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/",
|
|
||||||
"/Resources/data/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Bernhard Schussek",
|
|
||||||
"email": "bschussek@gmail.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Eriksen Costa",
|
|
||||||
"email": "eriksen.costa@infranology.com.br"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Igor Wiedler",
|
|
||||||
"email": "igor@wiedler.ch"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Provides access to the localization data of the ICU library",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"keywords": [
|
|
||||||
"i18n",
|
|
||||||
"icu",
|
|
||||||
"internationalization",
|
|
||||||
"intl",
|
|
||||||
"l10n",
|
|
||||||
"localization"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/intl/tree/v8.0.8"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-03-30T15:14:47+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/mime",
|
"name": "symfony/mime",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -8776,6 +8263,85 @@
|
|||||||
],
|
],
|
||||||
"time": "2022-12-23T10:58:28+00:00"
|
"time": "2022-12-23T10:58:28+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/pcre",
|
||||||
|
"version": "3.3.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/pcre.git",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan": "<1.11.10"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.12 || ^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||||
|
"phpunit/phpunit": "^8 || ^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||||
|
"keywords": [
|
||||||
|
"PCRE",
|
||||||
|
"preg",
|
||||||
|
"regex",
|
||||||
|
"regular expression"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
|
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-11-12T16:29:46+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "composer/xdebug-handler",
|
"name": "composer/xdebug-handler",
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
|
|||||||
@@ -21,18 +21,3 @@ api_platform:
|
|||||||
stateless: true
|
stateless: true
|
||||||
cache_headers:
|
cache_headers:
|
||||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
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
@@ -105,7 +105,7 @@ return [
|
|||||||
'items' => [
|
'items' => [
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.commercial.clients',
|
'label' => 'sidebar.commercial.clients',
|
||||||
'to' => '/clients',
|
'to' => '/commercial/clients',
|
||||||
'icon' => 'mdi:account-group-outline',
|
'icon' => 'mdi:account-group-outline',
|
||||||
'module' => 'commercial',
|
'module' => 'commercial',
|
||||||
'permission' => 'commercial.clients.view',
|
'permission' => 'commercial.clients.view',
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.60'
|
app.version: '0.1.54'
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# Cahier de test back — M1 Répertoire clients (ticket ERP-60 / #478)
|
|
||||||
|
|
||||||
Mapping **toutes les RG (§ 7) → test(s) PHPUnit**, à jour après ERP-60.
|
|
||||||
|
|
||||||
Légende source : `ERP-55` `ERP-56` `ERP-57` `ERP-58` = tests écrits par les wagons
|
|
||||||
précédents ; **`ERP-60`** = tests ajoutés par ce ticket (stratégie « combler les
|
|
||||||
trous, zéro duplication »).
|
|
||||||
|
|
||||||
## Stratégie
|
|
||||||
|
|
||||||
ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici
|
|
||||||
l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants
|
|
||||||
des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04
|
|
||||||
fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le
|
|
||||||
merge de la stack.
|
|
||||||
|
|
||||||
## Mapping RG → test
|
|
||||||
|
|
||||||
| RG | Intitulé | Test(s) | Source |
|
|
||||||
|----|----------|---------|--------|
|
|
||||||
| RG-1.01 | Prénom OU nom obligatoire → 422 | `ClientApiTest::testPostWithoutFirstOrLastNameReturns422` ; `ClientProcessorTest` (unit) | ERP-55 |
|
|
||||||
| RG-1.02 | phoneSecondary persisté ; max 2 téléphones | `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` ; `::testThirdPhoneFieldIsIgnored` | **ERP-60** |
|
|
||||||
| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 |
|
|
||||||
| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** |
|
|
||||||
| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 |
|
|
||||||
| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation (CHECK) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | **ERP-60** |
|
|
||||||
| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 |
|
|
||||||
| RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 |
|
|
||||||
| RG-1.11 | billingEmail obligatoire ssi isBilling (CHECK) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | **ERP-60** |
|
|
||||||
| RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 |
|
|
||||||
| RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 |
|
|
||||||
| RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 |
|
|
||||||
| RG-1.15 | ~~Unicité SIREN~~ supprimée (Q4) — SIREN partageable | `ClientUniquenessTest::testDuplicateSirenIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
|
|
||||||
| RG-1.16 | companyName unique (case-insensitive) parmi actifs → 409 | `ClientApiTest::testPostDuplicateCompanyNameReturns409` ; `ClientMigrationTest::testCompanyNameActivePartialIndexExistsExactlyOnce` | ERP-55 / **ERP-60** |
|
|
||||||
| RG-1.17 | ~~Unicité email~~ supprimée (Q4) — email partageable | `ClientUniquenessTest::testDuplicateEmailIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** |
|
|
||||||
| RG-1.18 | companyName upper-cased serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testCompanyNameIsUppercased` (unit) | ERP-55 |
|
|
||||||
| RG-1.19 | firstName/lastName capitalize serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPersonNameIsTitleCased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` | ERP-55 / ERP-57 |
|
|
||||||
| RG-1.20 | Téléphones chiffres-seuls serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPhoneKeepsOnlyDigits` (unit) ; `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` (secondary) | ERP-55 / **ERP-60** |
|
|
||||||
| RG-1.21 | email lowercase serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testEmailIsLowercased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` / `::testPostAddressNormalizesBillingEmail` | ERP-55 / ERP-57 |
|
|
||||||
| RG-1.22 | Archive : permission `archive` + archivedAt + aucun autre champ | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` ; `::testPatchArchiveWithOtherFieldReturns422` ; `ClientProcessorTest` (unit, gating archive) | ERP-55 |
|
|
||||||
| RG-1.23 | Restauration : archivedAt=null ; **409 si conflit d'unicité** | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` (cas nominal) ; **`ClientArchiveTest::testRestoreConflictReturns409`** (409 restauration, gap P1) | ERP-55 / **ERP-60** |
|
|
||||||
| RG-1.24 | Liste exclut les archivés par défaut | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
|
|
||||||
| RG-1.25 | `?includeArchived=true` inclut les archivés | `ClientApiTest::testListIncludeArchivedReturnsArchived` | ERP-55 |
|
|
||||||
| RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 |
|
|
||||||
| RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** |
|
|
||||||
| RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** |
|
|
||||||
| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE (POST/PATPH catégorie DISTRIBUTEUR/COURTIER → 422) NON IMPLÉMENTÉE côté back au M1** (absente du `ClientAddressProcessor` et de la liste § 8.1). → voir « Gaps & suivi » | — (gap) |
|
|
||||||
|
|
||||||
## Couvertures transverses
|
|
||||||
|
|
||||||
| Sujet | Test(s) | Source |
|
|
||||||
|-------|---------|--------|
|
|
||||||
| Audit iban/bic présents dans le diff (pas d'`#[AuditIgnore]`) | `ClientAuditTest::testRibCreateAuditIncludesIbanAndBic` | **ERP-60** |
|
|
||||||
| Sécurité générique : 401 anonyme + 403 sans `commercial.clients.view` | `ClientSecurityTest` (collection + détail) ; `ClientExportControllerTest::testForbiddenWithoutClientsViewPermission` / `::testUnauthorizedWhenAnonymous` | **ERP-60** / ERP-58 |
|
|
||||||
| Migration : index partiel unique présent (1 seul), pas de siren/email unique | `ClientMigrationTest` | **ERP-60** |
|
|
||||||
| Référentiels comptables read-only (405 écriture, 401/403) | `ReferentialApiTest` | ERP-56 |
|
|
||||||
| Export XLSX (colonnes accounting selon permission, 401/403) | `ClientExportControllerTest` | ERP-58 |
|
|
||||||
|
|
||||||
## Délégué à ERP-74 (#493) — NE PAS faire dans ERP-60
|
|
||||||
|
|
||||||
- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale /
|
|
||||||
Usine) : 200/403 par verbe et par onglet selon le rôle.
|
|
||||||
- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec
|
|
||||||
champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec).
|
|
||||||
- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1.
|
|
||||||
|
|
||||||
## Gaps & suivi
|
|
||||||
|
|
||||||
- **RG-1.29 (validation écriture)** : refuser une catégorie de type
|
|
||||||
`DISTRIBUTEUR`/`COURTIER` sur une `ClientAddress` (→ 422, violation
|
|
||||||
`categories`) n'est pas implémenté au M1. La spec § 8.1 ne le liste pas comme
|
|
||||||
cas de test back ; le filtrage de lecture est front-driven. **Suggestion** :
|
|
||||||
ouvrir un follow-up (durcissement `ClientAddressProcessor`) ou l'intégrer à
|
|
||||||
ERP-74. Aucune invention de feature dans ERP-60 (ticket test-only).
|
|
||||||
- **Violations CHECK → statut HTTP** : les CHECK d'adresse (RG-1.06/07/08/11)
|
|
||||||
sont aujourd'hui rejetées par la base (statut ≥ 400) mais sans mapping fin
|
|
||||||
vers 422 (pas d'`exception_to_status` ni de listener DBAL→HTTP). Les tests
|
|
||||||
ERP-60 assertent donc le **rejet** (≥ 400). Un mapping explicite vers 422
|
|
||||||
serait une amélioration UX d'API (follow-up possible).
|
|
||||||
@@ -24,10 +24,10 @@
|
|||||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
<SiteSelector v-if="showSiteSelector"/>
|
<SiteSelector v-if="showSiteSelector"/>
|
||||||
<main
|
<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-11">
|
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]">
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none sticky top-0 z-30 h-11 flex-shrink-0 bg-white"/>
|
class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||||
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
||||||
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
@@ -52,21 +52,21 @@
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
icon-name="mdi:delete-outline"
|
icon-name="mdi:delete-outline"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
@click="emit('delete')"
|
@click="emit('delete')"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-else
|
v-else
|
||||||
:label="t('common.cancel')"
|
:label="t('common.cancel')"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="canShowSave"
|
v-if="canShowSave"
|
||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
:disabled="form.submitting.value || loadingTypes"
|
:disabled="form.submitting.value || loadingTypes"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import type { CategoryType } from '~/modules/catalog/types/category'
|
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||||
import type { HydraCollection } from '~/shared/utils/api'
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
|
||||||
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
|
||||||
@@ -28,6 +28,27 @@ const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
|
|||||||
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
|
||||||
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
|
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> {
|
function makeHydra<T>(items: T[]): HydraCollection<T> {
|
||||||
return {
|
return {
|
||||||
totalItems: items.length,
|
totalItems: items.length,
|
||||||
@@ -35,32 +56,113 @@ 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', () => {
|
describe('useCategoriesAdmin', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGet.mockReset()
|
mockGet.mockReset()
|
||||||
// Reset systematique du state singleton entre tests : sans ca,
|
// Reset systematique du state singleton entre tests : sans ca,
|
||||||
// les types charges dans un test fuiteraient dans le suivant.
|
// les categories chargees dans un test fuiteraient dans le suivant.
|
||||||
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||||
resetCategoriesAdmin()
|
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', () => {
|
describe('fetchTypes', () => {
|
||||||
it('appelle GET /category_types avec ?pagination=false (echappatoire selects)', async () => {
|
it('appelle GET /category_types avec itemsPerPage=999', async () => {
|
||||||
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
|
||||||
const { fetchTypes } = useCategoriesAdmin()
|
const { fetchTypes } = useCategoriesAdmin()
|
||||||
|
|
||||||
await fetchTypes()
|
await fetchTypes()
|
||||||
|
|
||||||
expect(mockGet).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockGet).toHaveBeenCalledWith(
|
expect(mockGet).toHaveBeenCalledWith(
|
||||||
'/category_types',
|
'/category_types',
|
||||||
{ pagination: 'false' },
|
{ itemsPerPage: 999 },
|
||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -101,55 +203,48 @@ describe('useCategoriesAdmin', () => {
|
|||||||
|
|
||||||
expect(loadingTypes.value).toBe(false)
|
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', () => {
|
describe('resetCategoriesAdmin', () => {
|
||||||
it('vide types, loadingTypes et error', () => {
|
it('vide categories, types, loading, loadingTypes et error', () => {
|
||||||
const { resetCategoriesAdmin, types, loadingTypes, error }
|
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
|
||||||
= useCategoriesAdmin()
|
= useCategoriesAdmin()
|
||||||
// Pre-peuple le state pour verifier la purge effective.
|
// Pre-peuple le state pour verifier la purge effective.
|
||||||
|
categories.value = [CAT_A]
|
||||||
types.value = [TYPE_VENTE]
|
types.value = [TYPE_VENTE]
|
||||||
|
loading.value = true
|
||||||
loadingTypes.value = true
|
loadingTypes.value = true
|
||||||
error.value = 'oops'
|
error.value = 'oops'
|
||||||
|
|
||||||
resetCategoriesAdmin()
|
resetCategoriesAdmin()
|
||||||
|
|
||||||
|
expect(categories.value).toEqual([])
|
||||||
expect(types.value).toEqual([])
|
expect(types.value).toEqual([])
|
||||||
|
expect(loading.value).toBe(false)
|
||||||
expect(loadingTypes.value).toBe(false)
|
expect(loadingTypes.value).toBe(false)
|
||||||
expect(error.value).toBeNull()
|
expect(error.value).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('singleton', () => {
|
describe('singleton', () => {
|
||||||
it('deux appels a useCategoriesAdmin() partagent la meme ref types', () => {
|
it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
|
||||||
const a = useCategoriesAdmin()
|
const a = useCategoriesAdmin()
|
||||||
const b = useCategoriesAdmin()
|
const b = useCategoriesAdmin()
|
||||||
|
|
||||||
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
// Les fonctions sont reinstanciees a chaque appel mais les refs
|
||||||
// doivent etre rigoureusement les memes (state au niveau module).
|
// doivent etre rigoureusement les memes (state au niveau module).
|
||||||
|
expect(a.categories).toBe(b.categories)
|
||||||
expect(a.types).toBe(b.types)
|
expect(a.types).toBe(b.types)
|
||||||
expect(a.loadingTypes).toBe(b.loadingTypes)
|
expect(a.loading).toBe(b.loading)
|
||||||
expect(a.error).toBe(b.error)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('une mutation via une instance est visible depuis une autre instance', () => {
|
it('une mutation via une instance est visible depuis une autre instance', () => {
|
||||||
const a = useCategoriesAdmin()
|
const a = useCategoriesAdmin()
|
||||||
const b = useCategoriesAdmin()
|
const b = useCategoriesAdmin()
|
||||||
|
|
||||||
a.types.value = [TYPE_VENTE]
|
a.categories.value = [CAT_A]
|
||||||
|
|
||||||
expect(b.types.value).toEqual([TYPE_VENTE])
|
expect(b.categories.value).toEqual([CAT_A])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,56 +1,96 @@
|
|||||||
/**
|
/**
|
||||||
* Composable de chargement du referentiel CategoryType (M0 — Gestion des
|
* Composable d'administration des categories (M0 — Gestion des categories).
|
||||||
* categories).
|
|
||||||
*
|
*
|
||||||
* Apres ERP-73 (composable de liste paginee), la liste des categories
|
* Centralise le chargement et le state des deux ressources lues par la page
|
||||||
* elle-meme passe par `usePaginatedList<Category>` directement dans
|
* `/admin/categories` : la liste des categories et le referentiel
|
||||||
* `admin/categories.vue` — c'est un etat propre a la page (pagination,
|
* CategoryType (utilise dans le select du drawer).
|
||||||
* filtres, tri locaux). Ce composable se concentre donc sur le
|
|
||||||
* referentiel CategoryType : petite collection lue une fois et reutilisee
|
|
||||||
* dans le drawer (select de type) → singleton volontaire pour eviter de
|
|
||||||
* la recharger a chaque ouverture du drawer.
|
|
||||||
*
|
*
|
||||||
* State singleton au niveau module : reset automatique au logout via
|
* State singleton au niveau module (meme convention que `useSidebar` /
|
||||||
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md), et reset
|
* `useModules` / `useAuditLog`) : reset automatique au logout via
|
||||||
* explicite via `resetCategoriesAdmin()` appele depuis logout.vue.
|
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
|
||||||
|
* avec state singleton doivent etre reinitialises au logout »), et reset
|
||||||
|
* explicite expose via `resetCategoriesAdmin()` appele depuis
|
||||||
|
* `modules/core/pages/logout.vue`.
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { CategoryType } from '~/modules/catalog/types/category'
|
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||||
import type { HydraCollection } from '~/shared/utils/api'
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CategoryType est un referentiel lecture-seule (RG-1.06) avec une
|
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
|
||||||
* cardinalite minuscule (≤ 5 entrees connues). On force `pagination=false`
|
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
|
||||||
* pour recuperer toutes les entrees en un appel et alimenter le select du
|
* toute la liste en un coup. A basculer en pagination serveur quand la
|
||||||
* drawer sans pagination — echappatoire prevue par
|
* volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
|
||||||
* `pagination_client_enabled: true` cote API Platform.
|
|
||||||
*/
|
*/
|
||||||
const NO_PAGINATION_QUERY = { pagination: 'false' } as const
|
const HYDRA_NO_PAGINATION = 999
|
||||||
|
|
||||||
|
// State singleton — partage entre tous les composants qui appellent le
|
||||||
|
// composable dans la meme session. Les refs sont declarees au niveau module
|
||||||
|
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
|
||||||
|
// instance soit creee a chaque appel.
|
||||||
|
const categories = ref<Category[]>([])
|
||||||
const types = ref<CategoryType[]>([])
|
const types = ref<CategoryType[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
const loadingTypes = ref(false)
|
const loadingTypes = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
function resetCategoriesAdminState(): void {
|
function resetCategoriesAdminState(): void {
|
||||||
|
categories.value = []
|
||||||
types.value = []
|
types.value = []
|
||||||
|
loading.value = false
|
||||||
loadingTypes.value = false
|
loadingTypes.value = false
|
||||||
error.value = null
|
error.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-enregistrement singleton : purge le state sur 401/clearSession
|
// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
|
||||||
// pour eviter qu'un user suivant (connecte sur le meme onglet) voie le
|
// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||||
// referentiel de l'ancien tenant. Le logout volontaire (page logout.vue)
|
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
|
||||||
// appelle directement `resetCategoriesAdmin()` ci-dessous.
|
// `resetCategoriesAdmin()` ci-dessous.
|
||||||
onAuthSessionCleared(resetCategoriesAdminState)
|
onAuthSessionCleared(resetCategoriesAdminState)
|
||||||
|
|
||||||
export function useCategoriesAdmin() {
|
export function useCategoriesAdmin() {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge le referentiel CategoryType. Appele a l'ouverture de la page
|
* Charge la liste des categories. Le serveur exclut les soft-deleted par
|
||||||
* admin pour que le select du drawer ait deja les options pretes au
|
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
|
||||||
* moment de la creation/edition.
|
* serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
|
||||||
|
*
|
||||||
|
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
|
||||||
|
* de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
|
||||||
|
* option mais on l'expose pour la suite (corbeille future).
|
||||||
|
*
|
||||||
|
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
|
||||||
|
* toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
|
||||||
|
* la page seulement par URL directe et on affiche un tableau vide propre.
|
||||||
|
*/
|
||||||
|
async function fetchAll(includeDeleted = false): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
|
||||||
|
if (includeDeleted) {
|
||||||
|
query.includeDeleted = 'true'
|
||||||
|
}
|
||||||
|
const data = await api.get<HydraCollection<Category>>(
|
||||||
|
'/categories',
|
||||||
|
query,
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
categories.value = data.member ?? []
|
||||||
|
} catch (e) {
|
||||||
|
categories.value = []
|
||||||
|
error.value = (e as Error)?.message ?? 'Erreur de chargement'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
|
||||||
|
* l'ouverture de la page admin pour que le select du drawer ait deja les
|
||||||
|
* options pretes au moment de la creation/edition.
|
||||||
*
|
*
|
||||||
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
* 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.
|
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||||
@@ -60,7 +100,7 @@ export function useCategoriesAdmin() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.get<HydraCollection<CategoryType>>(
|
const data = await api.get<HydraCollection<CategoryType>>(
|
||||||
'/category_types',
|
'/category_types',
|
||||||
NO_PAGINATION_QUERY,
|
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
||||||
{ toast: false },
|
{ toast: false },
|
||||||
)
|
)
|
||||||
types.value = data.member ?? []
|
types.value = data.member ?? []
|
||||||
@@ -73,18 +113,21 @@ export function useCategoriesAdmin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()`
|
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
|
||||||
* pour garantir que la prochaine session reparte sur un state propre
|
* garantir que la prochaine session reparte sur un state propre meme si
|
||||||
* meme si `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
* `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||||
*/
|
*/
|
||||||
function resetCategoriesAdmin(): void {
|
function resetCategoriesAdmin(): void {
|
||||||
resetCategoriesAdminState()
|
resetCategoriesAdminState()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
categories,
|
||||||
types,
|
types,
|
||||||
|
loading,
|
||||||
loadingTypes,
|
loadingTypes,
|
||||||
error,
|
error,
|
||||||
|
fetchAll,
|
||||||
fetchTypes,
|
fetchTypes,
|
||||||
resetCategoriesAdmin,
|
resetCategoriesAdmin,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,23 +13,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Table des categories. Tri serveur (name ASC, RG-1.10) +
|
<!-- Table des categories. Affichage exhaustif (volumetrie cible
|
||||||
pagination serveur via usePaginatedList (#73). Le composable
|
<= 300, cf. spec § 4.1) — tri 100% serveur via CategoryProvider
|
||||||
remplace l'ancien chargement « tout en un coup » a volumetrie
|
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
|
||||||
cible ≤ 300 — la pagination est desormais alignee sur la regle
|
reste cosmetique tant qu'aucun slice client n'est cable : a
|
||||||
projet (toute collection paginee, regle ABSOLUE n°13). -->
|
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="categoryItems"
|
:items="categoryItems"
|
||||||
:total-items="totalItems"
|
:total-items="categories.length"
|
||||||
:page="currentPage"
|
|
||||||
:per-page="itemsPerPage"
|
|
||||||
:per-page-options="itemsPerPageOptions"
|
|
||||||
:row-clickable="true"
|
:row-clickable="true"
|
||||||
:empty-message="t('admin.categories.noCategories')"
|
:empty-message="t('admin.categories.noCategories')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
@update:page="goToPage"
|
|
||||||
@update:per-page="setItemsPerPage"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Drawer creation / consultation / edition. -->
|
<!-- Drawer creation / consultation / edition. -->
|
||||||
@@ -55,27 +50,13 @@ import type { Category } from '~/modules/catalog/types/category'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const { fetchTypes } = useCategoriesAdmin()
|
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
|
||||||
const { submitDelete } = useCategoryForm()
|
const { submitDelete } = useCategoryForm()
|
||||||
|
|
||||||
useHead({ title: t('admin.categories.title') })
|
useHead({ title: t('admin.categories.title') })
|
||||||
|
|
||||||
const canManage = computed(() => can('catalog.categories.manage'))
|
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 drawerOpen = ref(false)
|
||||||
const selectedCategory = ref<Category | null>(null)
|
const selectedCategory = ref<Category | null>(null)
|
||||||
const deleteModalOpen = ref(false)
|
const deleteModalOpen = ref(false)
|
||||||
@@ -137,7 +118,7 @@ async function handleDelete(): Promise<void> {
|
|||||||
deleteModalOpen.value = false
|
deleteModalOpen.value = false
|
||||||
categoryToDelete.value = null
|
categoryToDelete.value = null
|
||||||
drawerOpen.value = false
|
drawerOpen.value = false
|
||||||
await fetchCategories()
|
await fetchAll()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
@@ -145,14 +126,14 @@ async function handleDelete(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onCategorySaved() {
|
function onCategorySaved() {
|
||||||
fetchCategories()
|
fetchAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chargement initial des deux ressources (liste + referentiel des types).
|
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||||
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
// 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 ».
|
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchCategories()
|
fetchAll()
|
||||||
fetchTypes()
|
fetchTypes()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
|
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
|
||||||
header-class="capitalize"
|
header-class="capitalize"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col gap-3">
|
||||||
<!-- Tout selectionner pour ce module -->
|
<!-- Tout selectionner pour ce module -->
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
:id="`${idPrefix}-group-${group.module}`"
|
:id="`${idPrefix}-group-${group.module}`"
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
label-class="font-semibold text-sm text-neutral-700"
|
label-class="font-semibold text-sm text-neutral-700"
|
||||||
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
|
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col gap-2">
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
v-for="perm in group.permissions"
|
v-for="perm in group.permissions"
|
||||||
:id="`${idPrefix}-perm-${perm.id}`"
|
:id="`${idPrefix}-perm-${perm.id}`"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
|
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
|
||||||
</h2>
|
</h2>
|
||||||
</template>
|
</template>
|
||||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||||
<!-- Champs du role -->
|
<!-- Champs du role -->
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
icon-name="mdi:delete-outline"
|
icon-name="mdi:delete-outline"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
:disabled="role?.isSystem"
|
:disabled="role?.isSystem"
|
||||||
@click="emit('delete')"
|
@click="emit('delete')"
|
||||||
/>
|
/>
|
||||||
@@ -79,13 +79,13 @@
|
|||||||
v-else
|
v-else
|
||||||
:label="t('common.cancel')"
|
:label="t('common.cancel')"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
:disabled="saving || permissionsLoadFailed"
|
:disabled="saving || permissionsLoadFailed"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
|
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
|
||||||
</h2>
|
</h2>
|
||||||
</template>
|
</template>
|
||||||
<div class="flex flex-col space-y-4 py-4">
|
<div class="flex flex-col gap-4 py-4">
|
||||||
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
||||||
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
||||||
<div
|
<div
|
||||||
@@ -41,13 +41,11 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Section Roles -->
|
<!-- Section Roles -->
|
||||||
<!-- !mt-0 : la MalioCheckbox au-dessus expose son slot message (16px),
|
<div>
|
||||||
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">
|
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
|
||||||
{{ t('admin.users.drawer.rolesSection') }}
|
{{ t('admin.users.drawer.rolesSection') }}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col gap-2">
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
v-for="role in allRoles"
|
v-for="role in allRoles"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
@@ -86,7 +84,7 @@
|
|||||||
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
|
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
|
||||||
{{ t('admin.sites.noSites') }}
|
{{ t('admin.sites.noSites') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col gap-2">
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
v-for="site in allSites"
|
v-for="site in allSites"
|
||||||
:id="`site-${site.id}`"
|
:id="`site-${site.id}`"
|
||||||
@@ -115,13 +113,13 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.cancel')"
|
:label="t('common.cancel')"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
:disabled="saving || loadFailed"
|
:disabled="saving || loadFailed"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -66,18 +66,15 @@
|
|||||||
<MalioAccordion>
|
<MalioAccordion>
|
||||||
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
|
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
|
||||||
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
|
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
|
||||||
<!-- pb-4 sur les labels Du/Au : simule le slot message
|
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
|
||||||
du MalioDateTime voisin pour qu'items-center recentre
|
<span>{{ t('audit.filters.date_from') }}</span>
|
||||||
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
|
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
|
||||||
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
|
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
|
||||||
<MalioDateTime
|
<MalioDateTime
|
||||||
v-model="draftDateFrom"
|
v-model="draftDateFrom"
|
||||||
:max="draftDateTo ?? undefined"
|
:max="draftDateTo ?? undefined"
|
||||||
/>
|
/>
|
||||||
<span class="pb-4">{{ t('audit.filters.date_to') }}</span>
|
<span>{{ t('audit.filters.date_to') }}</span>
|
||||||
<MalioDateTime
|
<MalioDateTime
|
||||||
v-model="draftDateTo"
|
v-model="draftDateTo"
|
||||||
:min="draftDateFrom ?? undefined"
|
:min="draftDateFrom ?? undefined"
|
||||||
@@ -87,7 +84,7 @@
|
|||||||
|
|
||||||
<!-- Type d'entite : cases a cocher (multi-selection) -->
|
<!-- Type d'entite : cases a cocher (multi-selection) -->
|
||||||
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
|
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col gap-4">
|
||||||
<MalioCheckbox
|
<MalioCheckbox
|
||||||
v-for="opt in entityTypeOptions"
|
v-for="opt in entityTypeOptions"
|
||||||
:id="`filter-entity-${opt.value}`"
|
:id="`filter-entity-${opt.value}`"
|
||||||
@@ -108,7 +105,6 @@
|
|||||||
name="audit-action"
|
name="audit-action"
|
||||||
:value="opt.value"
|
:value="opt.value"
|
||||||
:label="opt.label"
|
:label="opt.label"
|
||||||
group-class="mt-0"
|
|
||||||
/>
|
/>
|
||||||
</MalioAccordionItem>
|
</MalioAccordionItem>
|
||||||
|
|
||||||
@@ -125,7 +121,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
:label="t('audit.filters.reset')"
|
:label="t('audit.filters.reset')"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
@click="resetFilters"
|
@click="resetFilters"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
|
|||||||
@@ -13,19 +13,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Table des roles — pagination serveur via usePaginatedList (#73). -->
|
<!-- Table des roles -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="roleItems"
|
:items="roleItems"
|
||||||
:total-items="totalItems"
|
:total-items="roles.length"
|
||||||
:page="currentPage"
|
|
||||||
:per-page="itemsPerPage"
|
|
||||||
:per-page-options="itemsPerPageOptions"
|
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.roles.noRoles')"
|
:empty-message="t('admin.roles.noRoles')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
@update:page="goToPage"
|
|
||||||
@update:per-page="setItemsPerPage"
|
|
||||||
>
|
>
|
||||||
<template #cell-code="{ item }">
|
<template #cell-code="{ item }">
|
||||||
<span class="font-mono text-xs">{{ item.code }}</span>
|
<span class="font-mono text-xs">{{ item.code }}</span>
|
||||||
@@ -71,17 +66,8 @@ const canManage = computed(() => can('core.roles.manage'))
|
|||||||
|
|
||||||
useHead({ title: t('admin.roles.title') })
|
useHead({ title: t('admin.roles.title') })
|
||||||
|
|
||||||
// Pagination serveur via le composable partage (#73).
|
const roles = ref<Role[]>([])
|
||||||
const {
|
const loading = ref(false)
|
||||||
items: roles,
|
|
||||||
totalItems,
|
|
||||||
currentPage,
|
|
||||||
itemsPerPage,
|
|
||||||
itemsPerPageOptions,
|
|
||||||
fetch: loadRoles,
|
|
||||||
goToPage,
|
|
||||||
setItemsPerPage,
|
|
||||||
} = usePaginatedList<Role>({ url: '/roles' })
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'label', label: t('admin.roles.table.label') },
|
{ key: 'label', label: t('admin.roles.table.label') },
|
||||||
@@ -116,6 +102,25 @@ const deleteModalOpen = ref(false)
|
|||||||
const roleToDelete = ref<Role | null>(null)
|
const roleToDelete = ref<Role | null>(null)
|
||||||
const deleting = ref(false)
|
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() {
|
function openCreateDrawer() {
|
||||||
selectedRole.value = null
|
selectedRole.value = null
|
||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
|
|||||||
@@ -2,19 +2,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||||
|
|
||||||
<!-- Table des utilisateurs — pagination serveur via usePaginatedList (#73). -->
|
<!-- Table des utilisateurs -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="userItems"
|
:items="userItems"
|
||||||
:total-items="totalItems"
|
:total-items="users.length"
|
||||||
:page="currentPage"
|
|
||||||
:per-page="itemsPerPage"
|
|
||||||
:per-page-options="itemsPerPageOptions"
|
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.users.noUsers')"
|
:empty-message="t('admin.users.noUsers')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
@update:page="goToPage"
|
|
||||||
@update:per-page="setItemsPerPage"
|
|
||||||
>
|
>
|
||||||
<template #cell-admin="{ item }">
|
<template #cell-admin="{ item }">
|
||||||
<span
|
<span
|
||||||
@@ -39,26 +34,15 @@
|
|||||||
import type { UserListItem } from '~/shared/types/rbac'
|
import type { UserListItem } from '~/shared/types/rbac'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
|
|
||||||
useHead({ title: t('admin.users.title') })
|
useHead({ title: t('admin.users.title') })
|
||||||
|
|
||||||
const canManage = computed(() => can('core.users.manage'))
|
const canManage = computed(() => can('core.users.manage'))
|
||||||
|
|
||||||
// Pagination serveur via le composable partage (#73). Le payload `users`
|
const users = ref<UserListItem[]>([])
|
||||||
// reste leger (pas de detail RBAC dans la liste — cf. commentaire colonne
|
const loading = ref(false)
|
||||||
// "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 drawerOpen = ref(false)
|
||||||
const selectedUser = ref<UserListItem | null>(null)
|
const selectedUser = ref<UserListItem | null>(null)
|
||||||
|
|
||||||
@@ -83,6 +67,21 @@ 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 {
|
function getUserById(id: number): UserListItem | undefined {
|
||||||
return users.value.find(u => u.id === id)
|
return users.value.find(u => u.id === id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
|
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
|
||||||
</span>
|
</span>
|
||||||
<form
|
<form
|
||||||
class="mt-8 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
|
||||||
@submit.prevent="handleSubmit"
|
@submit.prevent="handleSubmit"
|
||||||
>
|
>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
/>
|
/>
|
||||||
<p class="mt-6 font-bold">v{{ version }}</p>
|
<p class="font-bold">v{{ version }}</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
|
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
|
||||||
</h2>
|
</h2>
|
||||||
</template>
|
</template>
|
||||||
<form class="flex flex-col py-4 gap-2" @submit.prevent="handleSave">
|
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
:label="t('admin.sites.form.name')"
|
:label="t('admin.sites.form.name')"
|
||||||
@@ -65,16 +65,11 @@
|
|||||||
input-class="w-full font-mono"
|
input-class="w-full font-mono"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<!-- pb-4 sur le wrapper : simule le slot message du
|
<span
|
||||||
MalioInputText voisin pour qu'items-center recentre
|
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
|
||||||
la puce sur le centre visible de l'input. -->
|
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
|
||||||
<div class="shrink-0 pb-4">
|
:class="{ 'border-dashed': !isValidHex }"
|
||||||
<span
|
/>
|
||||||
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
|
|
||||||
class="inline-block size-10 rounded-lg border border-neutral-200"
|
|
||||||
:class="{ 'border-dashed': !isValidHex }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
|
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
|
||||||
{{ t('admin.sites.form.colorInvalid') }}
|
{{ t('admin.sites.form.colorInvalid') }}
|
||||||
@@ -92,20 +87,20 @@
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
icon-name="mdi:delete-outline"
|
icon-name="mdi:delete-outline"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
@click="emit('delete')"
|
@click="emit('delete')"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-else
|
v-else
|
||||||
:label="t('common.cancel')"
|
:label="t('common.cancel')"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
:label="t('common.save')"
|
:label="t('common.save')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
button-class="w-m-btn-action"
|
button-class="w-[150px]"
|
||||||
:disabled="saving || !isValidHex"
|
:disabled="saving || !isValidHex"
|
||||||
@click="handleSave"
|
@click="handleSave"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,19 +13,14 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Table des sites — pagination serveur via usePaginatedList (#73). -->
|
<!-- Table des sites -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="siteItems"
|
:items="siteItems"
|
||||||
:total-items="totalItems"
|
:total-items="sites.length"
|
||||||
:page="currentPage"
|
|
||||||
:per-page="itemsPerPage"
|
|
||||||
:per-page-options="itemsPerPageOptions"
|
|
||||||
:row-clickable="canManage"
|
:row-clickable="canManage"
|
||||||
:empty-message="t('admin.sites.noSites')"
|
:empty-message="t('admin.sites.noSites')"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
@update:page="goToPage"
|
|
||||||
@update:per-page="setItemsPerPage"
|
|
||||||
>
|
>
|
||||||
<template #cell-color="{ item }">
|
<template #cell-color="{ item }">
|
||||||
<span class="inline-flex items-center gap-2">
|
<span class="inline-flex items-center gap-2">
|
||||||
@@ -72,20 +67,8 @@ const canManage = computed(() => can('sites.manage'))
|
|||||||
|
|
||||||
useHead({ title: t('admin.sites.title') })
|
useHead({ title: t('admin.sites.title') })
|
||||||
|
|
||||||
// Pagination serveur via le composable partage (#73). Aucun OrderFilter
|
const sites = ref<Site[]>([])
|
||||||
// declare cote API Platform sur Site, donc on s'appuie sur le tri par
|
const loading = ref(false)
|
||||||
// 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 = [
|
const columns = [
|
||||||
{ key: 'name', label: t('admin.sites.table.name') },
|
{ key: 'name', label: t('admin.sites.table.name') },
|
||||||
@@ -124,6 +107,24 @@ const deleteModalOpen = ref(false)
|
|||||||
const siteToDelete = ref<Site | null>(null)
|
const siteToDelete = ref<Site | null>(null)
|
||||||
const deleting = ref(false)
|
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() {
|
function openCreateDrawer() {
|
||||||
selectedSite.value = null
|
selectedSite.value = null
|
||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
|
|||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.3",
|
"@malio/layer-ui": "^1.7.1",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -1866,9 +1866,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.1",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.3/layer-ui-1.7.3.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
|
||||||
"integrity": "sha512-jw3ka0Az6Jf0F9ifsooknkwXph8TNgoe6H3CjF8tbBxl8oND8HLHjlZ04ooUCoOUEIlsQ1Mm2hFFlQRCB04qdA==",
|
"integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.3",
|
"@malio/layer-ui": "^1.7.1",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,412 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { usePaginatedList } from '../usePaginatedList'
|
|
||||||
|
|
||||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
|
||||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests du composable `usePaginatedList`.
|
|
||||||
*
|
|
||||||
* Couvre les invariants critiques :
|
|
||||||
* - parse Hydra (member / totalItems)
|
|
||||||
* - navigation page (goToPage / next / prev / bornes)
|
|
||||||
* - changement items/page → retour page 1
|
|
||||||
* - mutation filtres / tri → retour page 1
|
|
||||||
* - cas limite : page courante hors borne apres filtre → derniere page valide
|
|
||||||
* - liste vide / page unique
|
|
||||||
* - reset → defaults
|
|
||||||
* - swallow d'erreur reseau (la promesse `fetch` ne reject jamais)
|
|
||||||
* - header `Accept: application/ld+json` toujours envoye (besoin du
|
|
||||||
* paginator Hydra cote API Platform 4).
|
|
||||||
*/
|
|
||||||
describe('usePaginatedList', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockApiGet.mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
function mockResponse(member: unknown[], totalItems: number): void {
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member, totalItems })
|
|
||||||
}
|
|
||||||
|
|
||||||
it('fetch initial : page=1, itemsPerPage par defaut, parse Hydra', async () => {
|
|
||||||
mockResponse([{ id: 1 }, { id: 2 }], 42)
|
|
||||||
const list = usePaginatedList<{ id: number }>({ url: '/sites' })
|
|
||||||
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
|
||||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
|
||||||
expect(url).toBe('/sites')
|
|
||||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
|
||||||
expect(opts).toMatchObject({
|
|
||||||
toast: false,
|
|
||||||
headers: { Accept: 'application/ld+json' },
|
|
||||||
})
|
|
||||||
expect(list.items.value).toEqual([{ id: 1 }, { id: 2 }])
|
|
||||||
expect(list.totalItems.value).toBe(42)
|
|
||||||
expect(list.totalPages.value).toBe(5)
|
|
||||||
expect(list.isEmpty.value).toBe(false)
|
|
||||||
expect(list.isSinglePage.value).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('itemsPerPage personnalise est respecte au premier fetch', async () => {
|
|
||||||
mockResponse([], 0)
|
|
||||||
const list = usePaginatedList({ url: '/users', defaultItemsPerPage: 25 })
|
|
||||||
await list.fetch()
|
|
||||||
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ itemsPerPage: 25 })
|
|
||||||
expect(list.itemsPerPage.value).toBe(25)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('goToPage(N) declenche un nouvel appel avec page=N', async () => {
|
|
||||||
mockResponse([{ id: 1 }], 30) // page 1
|
|
||||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
mockResponse([{ id: 2 }], 30) // page 2
|
|
||||||
await list.goToPage(2)
|
|
||||||
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
|
||||||
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2, itemsPerPage: 10 })
|
|
||||||
expect(list.currentPage.value).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('goToPage hors borne sup. est clampe a totalPages', async () => {
|
|
||||||
mockResponse([], 30) // totalPages = 3
|
|
||||||
const list = usePaginatedList({ url: '/roles' })
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
mockResponse([], 30)
|
|
||||||
await list.goToPage(999)
|
|
||||||
|
|
||||||
expect(list.currentPage.value).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('goToPage hors borne inf. est clampe a 1 (no-op si deja en 1)', async () => {
|
|
||||||
mockResponse([], 30)
|
|
||||||
const list = usePaginatedList({ url: '/roles' })
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
mockApiGet.mockClear()
|
|
||||||
await list.goToPage(-5)
|
|
||||||
|
|
||||||
// Deja en page 1 -> aucun nouvel appel.
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
|
||||||
expect(list.currentPage.value).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('nextPage / prevPage avancent et reculent dans les bornes', async () => {
|
|
||||||
mockResponse([], 30) // page 1, totalPages 3
|
|
||||||
const list = usePaginatedList({ url: '/roles' })
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
mockResponse([], 30)
|
|
||||||
await list.nextPage()
|
|
||||||
expect(list.currentPage.value).toBe(2)
|
|
||||||
|
|
||||||
mockResponse([], 30)
|
|
||||||
await list.nextPage()
|
|
||||||
expect(list.currentPage.value).toBe(3)
|
|
||||||
|
|
||||||
// En derniere page -> no-op
|
|
||||||
mockApiGet.mockClear()
|
|
||||||
await list.nextPage()
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
|
||||||
expect(list.currentPage.value).toBe(3)
|
|
||||||
|
|
||||||
mockResponse([], 30)
|
|
||||||
await list.prevPage()
|
|
||||||
expect(list.currentPage.value).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setItemsPerPage revient en page 1 et refetch', async () => {
|
|
||||||
mockResponse([], 100)
|
|
||||||
const list = usePaginatedList({ url: '/users' })
|
|
||||||
await list.fetch()
|
|
||||||
// place-toi page 3
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.goToPage(3)
|
|
||||||
expect(list.currentPage.value).toBe(3)
|
|
||||||
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.setItemsPerPage(25)
|
|
||||||
|
|
||||||
expect(list.currentPage.value).toBe(1)
|
|
||||||
expect(list.itemsPerPage.value).toBe(25)
|
|
||||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1, itemsPerPage: 25 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setItemsPerPage no-op si meme valeur', async () => {
|
|
||||||
mockResponse([], 10)
|
|
||||||
const list = usePaginatedList({ url: '/users' })
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
mockApiGet.mockClear()
|
|
||||||
await list.setItemsPerPage(10)
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setFilters fusionne et retombe en page 1', async () => {
|
|
||||||
mockResponse([], 100)
|
|
||||||
const list = usePaginatedList<unknown, { name?: string; active?: boolean }>({
|
|
||||||
url: '/users',
|
|
||||||
defaultFilters: { active: true },
|
|
||||||
})
|
|
||||||
await list.fetch()
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.goToPage(2)
|
|
||||||
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.setFilters({ name: 'alice' })
|
|
||||||
|
|
||||||
expect(list.currentPage.value).toBe(1)
|
|
||||||
expect(list.filters.value).toEqual({ active: true, name: 'alice' })
|
|
||||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({
|
|
||||||
page: 1,
|
|
||||||
active: true,
|
|
||||||
name: 'alice',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setFilters({ key: undefined }) supprime la cle', async () => {
|
|
||||||
mockResponse([], 100)
|
|
||||||
const list = usePaginatedList<unknown, { name?: string }>({
|
|
||||||
url: '/users',
|
|
||||||
defaultFilters: { name: 'alice' },
|
|
||||||
})
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.setFilters({ name: undefined })
|
|
||||||
|
|
||||||
expect(list.filters.value).toEqual({})
|
|
||||||
// Le query envoye ne contient plus `name` (compactQuery elimine
|
|
||||||
// aussi les valeurs vides).
|
|
||||||
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
|
||||||
expect(q.name).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setFilters({ replace: true }) remplace integralement', async () => {
|
|
||||||
mockResponse([], 100)
|
|
||||||
const list = usePaginatedList<unknown, { a?: string; b?: string }>({
|
|
||||||
url: '/users',
|
|
||||||
defaultFilters: { a: 'x' },
|
|
||||||
})
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.setFilters({ b: 'y' }, { replace: true })
|
|
||||||
|
|
||||||
expect(list.filters.value).toEqual({ b: 'y' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setSort envoie order[field]=direction et reset page', async () => {
|
|
||||||
mockResponse([], 100)
|
|
||||||
const list = usePaginatedList({ url: '/users' })
|
|
||||||
await list.fetch()
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.goToPage(2)
|
|
||||||
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.setSort({ field: 'username', direction: 'desc' })
|
|
||||||
|
|
||||||
expect(list.currentPage.value).toBe(1)
|
|
||||||
const q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
|
||||||
expect(q['order[username]']).toBe('desc')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setSort(null) retire le tri', async () => {
|
|
||||||
mockResponse([], 100)
|
|
||||||
const list = usePaginatedList({
|
|
||||||
url: '/users',
|
|
||||||
defaultSort: { field: 'name', direction: 'asc' },
|
|
||||||
})
|
|
||||||
await list.fetch()
|
|
||||||
// Le tri initial est applique
|
|
||||||
let q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
|
||||||
expect(q['order[name]']).toBe('asc')
|
|
||||||
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.setSort(null)
|
|
||||||
q = mockApiGet.mock.calls.at(-1)?.[1] as Record<string, unknown>
|
|
||||||
expect(q['order[name]']).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setFilters retombe en page 1 (cas standard, pas le hors-borne)', async () => {
|
|
||||||
// setFilters remet toujours page=1 avant de refetcher : ce n'est
|
|
||||||
// donc PAS le chemin de retry hors-borne (couvert par le test
|
|
||||||
// suivant via un refetch a page constante). On verifie juste le
|
|
||||||
// reset de page ici.
|
|
||||||
mockResponse([], 50) // 5 pages
|
|
||||||
const list = usePaginatedList({ url: '/users' })
|
|
||||||
await list.fetch()
|
|
||||||
mockResponse([], 50)
|
|
||||||
await list.goToPage(5)
|
|
||||||
expect(list.currentPage.value).toBe(5)
|
|
||||||
|
|
||||||
mockResponse([{ id: 1 }, { id: 2 }], 12)
|
|
||||||
await list.setFilters({ active: true } as never)
|
|
||||||
|
|
||||||
expect(list.currentPage.value).toBe(1)
|
|
||||||
expect(mockApiGet.mock.calls.at(-1)?.[1]).toMatchObject({ page: 1 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('declenche le retry sur derniere page si currentPage > totalPages apres fetch', async () => {
|
|
||||||
// Scenario : on a fait un fetch (5 pages, page=1). Sans toucher aux
|
|
||||||
// filtres mais entre deux fetchs la donnee a change cote serveur,
|
|
||||||
// la page courante peut devenir hors borne. On force le scenario
|
|
||||||
// en montant manuellement currentPage via goToPage borne, puis en
|
|
||||||
// simulant une reponse plus petite.
|
|
||||||
mockResponse([], 50) // 5 pages
|
|
||||||
const list = usePaginatedList({ url: '/users' })
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
mockResponse([], 50)
|
|
||||||
await list.goToPage(5)
|
|
||||||
expect(list.currentPage.value).toBe(5)
|
|
||||||
|
|
||||||
// Maintenant simule : refetch -> totalItems chute a 12 (2 pages),
|
|
||||||
// le composable doit refetcher sur page=2.
|
|
||||||
mockApiGet.mockReset()
|
|
||||||
mockApiGet
|
|
||||||
.mockResolvedValueOnce({ member: [], totalItems: 12 }) // page=5 vide
|
|
||||||
.mockResolvedValueOnce({ member: [{ id: 11 }, { id: 12 }], totalItems: 12 }) // page=2
|
|
||||||
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
expect(mockApiGet).toHaveBeenCalledTimes(2)
|
|
||||||
expect(mockApiGet.mock.calls[1][1]).toMatchObject({ page: 2 })
|
|
||||||
expect(list.currentPage.value).toBe(2)
|
|
||||||
expect(list.items.value).toEqual([{ id: 11 }, { id: 12 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('liste vide : isEmpty true, isSinglePage true', async () => {
|
|
||||||
mockResponse([], 0)
|
|
||||||
const list = usePaginatedList({ url: '/users' })
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
expect(list.totalItems.value).toBe(0)
|
|
||||||
expect(list.isEmpty.value).toBe(true)
|
|
||||||
expect(list.isSinglePage.value).toBe(true)
|
|
||||||
expect(list.totalPages.value).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('isEmpty est faux avant le premier fetch (etat indetermine)', () => {
|
|
||||||
const list = usePaginatedList({ url: '/users' })
|
|
||||||
expect(list.isEmpty.value).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reset revient aux defaults', async () => {
|
|
||||||
mockResponse([], 100)
|
|
||||||
const list = usePaginatedList<unknown, { a?: string }>({
|
|
||||||
url: '/users',
|
|
||||||
defaultItemsPerPage: 10,
|
|
||||||
defaultFilters: { a: 'x' },
|
|
||||||
defaultSort: { field: 'name', direction: 'asc' },
|
|
||||||
})
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.setItemsPerPage(50)
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.setFilters({ a: 'y' })
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.setSort({ field: 'id', direction: 'desc' })
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.goToPage(2)
|
|
||||||
expect(list.currentPage.value).toBe(2)
|
|
||||||
|
|
||||||
mockResponse([], 100)
|
|
||||||
await list.reset()
|
|
||||||
|
|
||||||
expect(list.itemsPerPage.value).toBe(10)
|
|
||||||
expect(list.filters.value).toEqual({ a: 'x' })
|
|
||||||
expect(list.sort.value).toEqual({ field: 'name', direction: 'asc' })
|
|
||||||
expect(list.currentPage.value).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('swallow l\'erreur reseau : items vides, loading false, fetch ne reject pas', async () => {
|
|
||||||
const boom = new Error('boom')
|
|
||||||
mockApiGet.mockRejectedValueOnce(boom)
|
|
||||||
const list = usePaginatedList({ url: '/users' })
|
|
||||||
|
|
||||||
await expect(list.fetch()).resolves.toBeUndefined()
|
|
||||||
expect(list.items.value).toEqual([])
|
|
||||||
expect(list.totalItems.value).toBe(0)
|
|
||||||
expect(list.loading.value).toBe(false)
|
|
||||||
// L'erreur est consideree comme un fetch consume -> isEmpty=true.
|
|
||||||
expect(list.isEmpty.value).toBe(true)
|
|
||||||
// ... mais `error` est expose pour distinguer « vide » d'« echec ».
|
|
||||||
expect(list.error.value).toBe(boom)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('error est remis a null des qu\'un fetch ulterieur reussit', async () => {
|
|
||||||
mockApiGet.mockRejectedValueOnce(new Error('boom'))
|
|
||||||
const list = usePaginatedList({ url: '/users' })
|
|
||||||
await list.fetch()
|
|
||||||
expect(list.error.value).toBeInstanceOf(Error)
|
|
||||||
|
|
||||||
mockResponse([{ id: 1 }], 1)
|
|
||||||
await list.fetch()
|
|
||||||
expect(list.error.value).toBeNull()
|
|
||||||
expect(list.items.value).toEqual([{ id: 1 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignore une reponse periemee : la derniere requete *demandee* gagne', async () => {
|
|
||||||
// Deux fetch concurrents : le 1er resout APRES le 2eme. Sans garde
|
|
||||||
// de sequence, la reponse arrivee en dernier (token 1) ecraserait
|
|
||||||
// les donnees plus fraiches du token 2. Avec la garde, token 2 fait
|
|
||||||
// foi quel que soit l'ordre d'arrivee reseau.
|
|
||||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
|
||||||
|
|
||||||
let resolveSlow!: (v: unknown) => void
|
|
||||||
const slow = new Promise((r) => { resolveSlow = r })
|
|
||||||
// 1er appel : reponse lente (en vol).
|
|
||||||
mockApiGet.mockReturnValueOnce(slow)
|
|
||||||
// 2eme appel : reponse immediate avec des donnees plus fraiches.
|
|
||||||
mockApiGet.mockResolvedValueOnce({ member: [{ id: 2 }], totalItems: 30 })
|
|
||||||
|
|
||||||
const p1 = list.fetch() // token 1, en vol
|
|
||||||
const p2 = list.fetch() // token 2, resout tout de suite
|
|
||||||
await p2
|
|
||||||
expect(list.items.value).toEqual([{ id: 2 }])
|
|
||||||
|
|
||||||
// La reponse lente du token 1 arrive enfin : elle doit etre ignoree.
|
|
||||||
resolveSlow({ member: [{ id: 1 }], totalItems: 30 })
|
|
||||||
await p1
|
|
||||||
expect(list.items.value).toEqual([{ id: 2 }])
|
|
||||||
// Le spinner reste eteint (la requete recente l'avait deja coupe).
|
|
||||||
expect(list.loading.value).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('extraQuery est injecte a chaque fetch (ex : includeDeleted)', async () => {
|
|
||||||
mockResponse([], 0)
|
|
||||||
const list = usePaginatedList({
|
|
||||||
url: '/categories',
|
|
||||||
extraQuery: { includeDeleted: 'true' },
|
|
||||||
})
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
expect(mockApiGet.mock.calls[0][1]).toMatchObject({ includeDeleted: 'true' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('valeurs nulles/vides des filtres ne sont pas envoyees', async () => {
|
|
||||||
mockResponse([], 0)
|
|
||||||
const list = usePaginatedList<unknown, { name?: string; q?: string }>({
|
|
||||||
url: '/users',
|
|
||||||
defaultFilters: { name: '', q: undefined } as never,
|
|
||||||
})
|
|
||||||
await list.fetch()
|
|
||||||
|
|
||||||
const q = mockApiGet.mock.calls[0][1] as Record<string, unknown>
|
|
||||||
expect(q.name).toBeUndefined()
|
|
||||||
expect(q.q).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refresh() est un alias de fetch()', async () => {
|
|
||||||
mockResponse([{ id: 1 }], 1)
|
|
||||||
const list = usePaginatedList<{ id: number }>({ url: '/users' })
|
|
||||||
|
|
||||||
await list.refresh()
|
|
||||||
expect(list.items.value).toEqual([{ id: 1 }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
|
||||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\Pagination\Pagination;
|
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Module\Catalog\Domain\Entity\Category;
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,32 +29,18 @@ final class CategoryProvider implements ProviderInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
|
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
|
||||||
private readonly CategoryRepositoryInterface $repository,
|
private readonly CategoryRepositoryInterface $repository,
|
||||||
private readonly Pagination $pagination,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|Paginator|null
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
|
||||||
{
|
{
|
||||||
$includeDeleted = $this->readIncludeDeleted($context);
|
$includeDeleted = $this->readIncludeDeleted($context);
|
||||||
|
|
||||||
if ($operation instanceof CollectionOperationInterface) {
|
if ($operation instanceof CollectionOperationInterface) {
|
||||||
$qb = $this->repository->createListQueryBuilder($includeDeleted);
|
return $this->repository
|
||||||
|
->createListQueryBuilder($includeDeleted)
|
||||||
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
|
->getQuery()
|
||||||
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
|
->getResult()
|
||||||
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.
|
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Commercial\Domain\Entity;
|
namespace App\Module\Commercial\Domain\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\Link;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientAddressProcessor;
|
|
||||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
|
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientAddressRepository;
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
@@ -35,46 +28,11 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
* - sites : SiteInterface (module Sites) via resolve_target_entities
|
||||||
* - contacts : ClientContact (meme module)
|
* - contacts : ClientContact (meme module)
|
||||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
* - categories : CategoryInterface (module Catalog) via resolve_target_entities
|
||||||
* — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, hors ERP-57)
|
* — limitees aux types SECTEUR/AUTRE cote validation (RG-1.29, futur Processor)
|
||||||
*
|
*
|
||||||
* Audite (#[Auditable]) + Timestampable/Blamable.
|
* Audite (#[Auditable]) + Timestampable/Blamable. Aucun ApiResource au M1.1
|
||||||
*
|
* (sous-ressources branchees a un ticket dedie).
|
||||||
* Sous-ressource API (ERP-57, spec § 4.5) :
|
|
||||||
* - POST /api/clients/{clientId}/addresses : creation rattachee au client parent
|
|
||||||
* (Link toProperty 'client'), security commercial.clients.manage.
|
|
||||||
* - PATCH / DELETE /api/client_addresses/{id} : security commercial.clients.manage.
|
|
||||||
* - GET /api/client_addresses/{id} : lecture unitaire (security view) — la
|
|
||||||
* lecture courante reste via le parent. Pas de GET collection autonome.
|
|
||||||
* Tout passe par le ClientAddressProcessor (normalisation RG-1.21 billingEmail).
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
|
||||||
operations: [
|
|
||||||
new Get(
|
|
||||||
security: "is_granted('commercial.clients.view')",
|
|
||||||
normalizationContext: ['groups' => ['client_address:read']],
|
|
||||||
),
|
|
||||||
new Post(
|
|
||||||
uriTemplate: '/clients/{clientId}/addresses',
|
|
||||||
uriVariables: [
|
|
||||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
|
||||||
],
|
|
||||||
security: "is_granted('commercial.clients.manage')",
|
|
||||||
normalizationContext: ['groups' => ['client_address:read']],
|
|
||||||
denormalizationContext: ['groups' => ['client_address:write']],
|
|
||||||
processor: ClientAddressProcessor::class,
|
|
||||||
),
|
|
||||||
new Patch(
|
|
||||||
security: "is_granted('commercial.clients.manage')",
|
|
||||||
normalizationContext: ['groups' => ['client_address:read']],
|
|
||||||
denormalizationContext: ['groups' => ['client_address:write']],
|
|
||||||
processor: ClientAddressProcessor::class,
|
|
||||||
),
|
|
||||||
new Delete(
|
|
||||||
security: "is_granted('commercial.clients.manage')",
|
|
||||||
processor: ClientAddressProcessor::class,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)]
|
|
||||||
#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineClientAddressRepository::class)]
|
||||||
#[ORM\Table(name: 'client_address')]
|
#[ORM\Table(name: 'client_address')]
|
||||||
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
|
#[ORM\Index(name: 'idx_client_address_client', columns: ['client_id'])]
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Commercial\Domain\Entity;
|
namespace App\Module\Commercial\Domain\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\Link;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientContactProcessor;
|
|
||||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository;
|
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientContactRepository;
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
@@ -23,50 +16,13 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
/**
|
/**
|
||||||
* Contact d'un client (1:n) — onglet Contact. Au moins firstName OU lastName
|
* Contact d'un client (1:n) — onglet Contact. Au moins firstName OU lastName
|
||||||
* doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD
|
* doit etre renseigne (RG-1.05) : la contrainte est portee par un CHECK BDD
|
||||||
* (chk_client_contact_name) et validee dans le ClientContactProcessor ;
|
* (chk_client_contact_name) et validee dans le futur ClientContactProcessor ;
|
||||||
* l'entite reste permissive (les deux champs sont nullable).
|
* l'entite reste permissive (les deux champs sont nullable).
|
||||||
*
|
*
|
||||||
* Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard).
|
* Audite (#[Auditable]) + Timestampable/Blamable (pattern Shared standard).
|
||||||
*
|
* Les operations CRUD (sous-ressources POST/PATCH/DELETE) sont branchees au
|
||||||
* Sous-ressource API (ERP-57, spec § 4.5) :
|
* ticket dedie des sous-ressources — aucun ApiResource au M1.1 (ERP-54).
|
||||||
* - POST /api/clients/{clientId}/contacts : creation rattachee au client parent
|
|
||||||
* (Link toProperty 'client'), security commercial.clients.manage.
|
|
||||||
* - PATCH / DELETE /api/client_contacts/{id} : security commercial.clients.manage.
|
|
||||||
* Le DELETE est physique (sous-collection, pas le client) ; le processor
|
|
||||||
* refuse la suppression du dernier contact (RG-1.14, 409).
|
|
||||||
* - GET /api/client_contacts/{id} : lecture unitaire (security view) — la
|
|
||||||
* lecture courante reste via le parent (client embarque ses contacts). Pas de
|
|
||||||
* GET collection autonome : non concernee par la pagination ERP-72.
|
|
||||||
* Tout passe par le ClientContactProcessor (normalisation RG-1.19/1.20/1.21).
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
|
||||||
operations: [
|
|
||||||
new Get(
|
|
||||||
security: "is_granted('commercial.clients.view')",
|
|
||||||
normalizationContext: ['groups' => ['client_contact:read']],
|
|
||||||
),
|
|
||||||
new Post(
|
|
||||||
uriTemplate: '/clients/{clientId}/contacts',
|
|
||||||
uriVariables: [
|
|
||||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
|
||||||
],
|
|
||||||
security: "is_granted('commercial.clients.manage')",
|
|
||||||
normalizationContext: ['groups' => ['client_contact:read']],
|
|
||||||
denormalizationContext: ['groups' => ['client_contact:write']],
|
|
||||||
processor: ClientContactProcessor::class,
|
|
||||||
),
|
|
||||||
new Patch(
|
|
||||||
security: "is_granted('commercial.clients.manage')",
|
|
||||||
normalizationContext: ['groups' => ['client_contact:read']],
|
|
||||||
denormalizationContext: ['groups' => ['client_contact:write']],
|
|
||||||
processor: ClientContactProcessor::class,
|
|
||||||
),
|
|
||||||
new Delete(
|
|
||||||
security: "is_granted('commercial.clients.manage')",
|
|
||||||
processor: ClientContactProcessor::class,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)]
|
|
||||||
#[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineClientContactRepository::class)]
|
||||||
#[ORM\Table(name: 'client_contact')]
|
#[ORM\Table(name: 'client_contact')]
|
||||||
#[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])]
|
#[ORM\Index(name: 'idx_client_contact_client', columns: ['client_id'])]
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Commercial\Domain\Entity;
|
namespace App\Module\Commercial\Domain\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\Link;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientRibProcessor;
|
|
||||||
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository;
|
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRibRepository;
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
@@ -23,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
/**
|
/**
|
||||||
* Coordonnees bancaires d'un client (1:n) — onglet Comptabilite. Au moins un
|
* Coordonnees bancaires d'un client (1:n) — onglet Comptabilite. Au moins un
|
||||||
* RIB est obligatoire si le type de reglement du client est LCR (RG-1.13,
|
* RIB est obligatoire si le type de reglement du client est LCR (RG-1.13,
|
||||||
* verifie au ClientRibProcessor : refus du DELETE du dernier RIB sous LCR).
|
* verifie au futur Processor).
|
||||||
*
|
*
|
||||||
* Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et
|
* Audit (#[Auditable]) : TOUS les champs sont audites, y compris `iban` et
|
||||||
* `bic` — AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 :
|
* `bic` — AUCUN #[AuditIgnore] (decision Matthieu en revue MR 29/05/2026 :
|
||||||
@@ -32,45 +25,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
*
|
*
|
||||||
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
|
* Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony au M1
|
||||||
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
|
* (HP-M2-14 : pas de controle externe banque reelle). Timestampable/Blamable
|
||||||
* standard.
|
* standard. Aucun ApiResource au M1.1 (sous-ressource branchee ulterieurement).
|
||||||
*
|
|
||||||
* Sous-ressource API (ERP-57, spec § 4.5) — gating comptable renforce :
|
|
||||||
* - POST /api/clients/{clientId}/ribs : creation rattachee au client parent
|
|
||||||
* (Link toProperty 'client'), security commercial.clients.accounting.manage.
|
|
||||||
* - PATCH / DELETE /api/client_ribs/{id} : security commercial.clients.accounting.manage.
|
|
||||||
* - GET /api/client_ribs/{id} : lecture unitaire, security
|
|
||||||
* commercial.clients.accounting.view (donnees bancaires sensibles). Pas de
|
|
||||||
* GET collection autonome.
|
|
||||||
* Tout passe par le ClientRibProcessor (RG-1.13 sur DELETE).
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
|
||||||
operations: [
|
|
||||||
new Get(
|
|
||||||
security: "is_granted('commercial.clients.accounting.view')",
|
|
||||||
normalizationContext: ['groups' => ['client_rib:read']],
|
|
||||||
),
|
|
||||||
new Post(
|
|
||||||
uriTemplate: '/clients/{clientId}/ribs',
|
|
||||||
uriVariables: [
|
|
||||||
'clientId' => new Link(fromClass: Client::class, toProperty: 'client'),
|
|
||||||
],
|
|
||||||
security: "is_granted('commercial.clients.accounting.manage')",
|
|
||||||
normalizationContext: ['groups' => ['client_rib:read']],
|
|
||||||
denormalizationContext: ['groups' => ['client_rib:write']],
|
|
||||||
processor: ClientRibProcessor::class,
|
|
||||||
),
|
|
||||||
new Patch(
|
|
||||||
security: "is_granted('commercial.clients.accounting.manage')",
|
|
||||||
normalizationContext: ['groups' => ['client_rib:read']],
|
|
||||||
denormalizationContext: ['groups' => ['client_rib:write']],
|
|
||||||
processor: ClientRibProcessor::class,
|
|
||||||
),
|
|
||||||
new Delete(
|
|
||||||
security: "is_granted('commercial.clients.accounting.manage')",
|
|
||||||
processor: ClientRibProcessor::class,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)]
|
|
||||||
#[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineClientRibRepository::class)]
|
||||||
#[ORM\Table(name: 'client_rib')]
|
#[ORM\Table(name: 'client_rib')]
|
||||||
#[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])]
|
#[ORM\Index(name: 'idx_client_rib_client', columns: ['client_id'])]
|
||||||
|
|||||||
@@ -18,18 +18,6 @@ interface ClientRepositoryInterface
|
|||||||
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
||||||
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
||||||
* - Tri par defaut : companyName ASC (RG-1.26).
|
* - Tri par defaut : companyName ASC (RG-1.26).
|
||||||
* - $search : recherche fuzzy insensible a la casse sur companyName +
|
|
||||||
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
|
|
||||||
* - $categoryType : restreint aux clients possedant au moins une categorie
|
|
||||||
* du type donne (code). Ignore si null/vide.
|
|
||||||
*
|
|
||||||
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
|
|
||||||
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
|
||||||
* partagent strictement la meme logique de selection.
|
|
||||||
*/
|
*/
|
||||||
public function createListQueryBuilder(
|
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder;
|
||||||
bool $includeArchived = false,
|
|
||||||
?string $search = null,
|
|
||||||
?string $categoryType = null,
|
|
||||||
): QueryBuilder;
|
|
||||||
}
|
}
|
||||||
|
|||||||
-71
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\Serializer;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\IriConverterInterface;
|
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
|
||||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
|
||||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Denormalise un IRI (`/api/sites/{id}`) vers le Site concret quand la propriete
|
|
||||||
* cible est type-hintee par le contrat SiteInterface (ClientAddress::$sites).
|
|
||||||
*
|
|
||||||
* Meme mecanisme que CategoryReferenceDenormalizer : API Platform deduit le type
|
|
||||||
* d'element de collection depuis le phpdoc `@var Collection<int, SiteInterface>`,
|
|
||||||
* donc l'INTERFACE. Le serializer ne sait pas denormaliser un IRI vers une
|
|
||||||
* interface (« Could not denormalize object of type SiteInterface[] ») ; on
|
|
||||||
* resout l'IRI via l'IriConverter (qui retourne le Site mappe a la route) sans
|
|
||||||
* importer la classe Site du module Sites — la regle ABSOLUE n°1 (pas d'import
|
|
||||||
* cross-module) reste respectee : dependance au seul contrat Shared + API Platform.
|
|
||||||
*
|
|
||||||
* En lecture (normalisation), aucun probleme : l'objet reel EST un Site,
|
|
||||||
* ressource a part entiere, serialise en IRI par le normalizer standard.
|
|
||||||
*/
|
|
||||||
final class SiteReferenceDenormalizer implements DenormalizerInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly IriConverterInterface $iriConverter,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?SiteInterface
|
|
||||||
{
|
|
||||||
if (!is_string($data) || '' === $data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// getResourceFromIri leve une exception sur IRI invalide -> 400, ce qui
|
|
||||||
// est le comportement attendu pour une reference cassee.
|
|
||||||
$resource = $this->iriConverter->getResourceFromIri($data);
|
|
||||||
|
|
||||||
// IRI syntaxiquement valide mais pointant sur une autre ressource : on
|
|
||||||
// refuse explicitement plutot que de retourner null silencieusement.
|
|
||||||
if (!$resource instanceof SiteInterface) {
|
|
||||||
throw new UnexpectedValueException(sprintf(
|
|
||||||
'L\'IRI "%s" ne référence pas un site.',
|
|
||||||
$data,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resource;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
|
|
||||||
{
|
|
||||||
// Support base sur le seul type cible : l'ArrayDenormalizer (collection
|
|
||||||
// `SiteInterface[]`) interroge le support en passant le TABLEAU complet
|
|
||||||
// comme $data avant de deleguer element par element. Tester
|
|
||||||
// is_string($data) ici casserait la chaine pour les collections.
|
|
||||||
return SiteInterface::class === $type;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<class-string|string, bool>
|
|
||||||
*/
|
|
||||||
public function getSupportedTypes(?string $format): array
|
|
||||||
{
|
|
||||||
return [SiteInterface::class => true];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-92
@@ -1,92 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client;
|
|
||||||
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processor d'ecriture de la sous-ressource Adresse d'un client (M1, § 4.5).
|
|
||||||
*
|
|
||||||
* Sequence :
|
|
||||||
* - POST / PATCH : normalisation serveur du billingEmail en lowercase (RG-1.21)
|
|
||||||
* via le ClientFieldNormalizer partage. Les autres regles de l'onglet Adresse
|
|
||||||
* sont deja garanties en amont : RG-1.09 (code postal) et RG-1.10 (>= 1 site)
|
|
||||||
* par des contraintes Assert sur l'entite, RG-1.06/07/08/11 par des CHECK BDD.
|
|
||||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
|
||||||
*
|
|
||||||
* La security de l'operation (commercial.clients.manage) est deja appliquee par
|
|
||||||
* API Platform, de meme que la validation Symfony des contraintes d'attribut.
|
|
||||||
*
|
|
||||||
* @implements ProcessorInterface<ClientAddress, null|ClientAddress>
|
|
||||||
*/
|
|
||||||
final class ClientAddressProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
private readonly ProcessorInterface $persistProcessor,
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
|
||||||
private readonly ProcessorInterface $removeProcessor,
|
|
||||||
private readonly ClientFieldNormalizer $normalizer,
|
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if (!$data instanceof ClientAddress) {
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operation instanceof DeleteOperationInterface) {
|
|
||||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->linkParent($data, $uriVariables);
|
|
||||||
$this->normalize($data);
|
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rattache l'adresse au client parent de la sous-ressource POST
|
|
||||||
* (/clients/{clientId}/addresses) : la relation n'est pas peuplee
|
|
||||||
* automatiquement par le Link sur une ecriture. Sur PATCH, no-op.
|
|
||||||
*/
|
|
||||||
private function linkParent(ClientAddress $address, array $uriVariables): void
|
|
||||||
{
|
|
||||||
if (null !== $address->getClient()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$clientId = $uriVariables['clientId'] ?? null;
|
|
||||||
if (null === $clientId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client = $clientId instanceof Client
|
|
||||||
? $clientId
|
|
||||||
: $this->em->getRepository(Client::class)->find($clientId);
|
|
||||||
|
|
||||||
if ($client instanceof Client) {
|
|
||||||
$address->setClient($client);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalisation serveur (RG-1.21) : email de facturation en minuscules. La
|
|
||||||
* methode est null-safe — une adresse non facturable (billingEmail null)
|
|
||||||
* reste null.
|
|
||||||
*/
|
|
||||||
private function normalize(ClientAddress $address): void
|
|
||||||
{
|
|
||||||
$address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-151
@@ -1,151 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use ApiPlatform\Validator\Exception\ValidationException;
|
|
||||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client;
|
|
||||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
|
||||||
use Symfony\Component\Validator\ConstraintViolation;
|
|
||||||
use Symfony\Component\Validator\ConstraintViolationList;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processor d'ecriture de la sous-ressource Contact d'un client (M1, § 4.5).
|
|
||||||
*
|
|
||||||
* Sequence :
|
|
||||||
* - POST / PATCH : normalisation serveur (RG-1.19 prenom/nom capitalize,
|
|
||||||
* RG-1.20 telephones reduits aux chiffres, RG-1.21 email lowercase) via le
|
|
||||||
* ClientFieldNormalizer partage (reutilise d'ERP-55), puis validation RG-1.05
|
|
||||||
* (au moins prenom OU nom) avant persistance.
|
|
||||||
* - DELETE : RG-1.14 — la suppression du DERNIER contact d'un client est
|
|
||||||
* refusee (409). Au M1, la completude de l'onglet Contact est purement front
|
|
||||||
* (pas de state machine back) : on garantit seulement qu'un client deja dote
|
|
||||||
* d'un contact n'en soit jamais vide via l'API.
|
|
||||||
*
|
|
||||||
* La security de l'operation (commercial.clients.manage) est deja appliquee par
|
|
||||||
* API Platform en amont. La validation Symfony des contraintes d'attribut
|
|
||||||
* (Assert\Email, Assert\Length...) est jouee avant ce processor.
|
|
||||||
*
|
|
||||||
* @implements ProcessorInterface<ClientContact, null|ClientContact>
|
|
||||||
*/
|
|
||||||
final class ClientContactProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
private readonly ProcessorInterface $persistProcessor,
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
|
||||||
private readonly ProcessorInterface $removeProcessor,
|
|
||||||
private readonly ClientFieldNormalizer $normalizer,
|
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if (!$data instanceof ClientContact) {
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operation instanceof DeleteOperationInterface) {
|
|
||||||
$this->guardLastContactDeletion($data);
|
|
||||||
|
|
||||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->linkParent($data, $uriVariables);
|
|
||||||
$this->normalize($data);
|
|
||||||
$this->validateName($data);
|
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rattache le contact au client parent de la sous-ressource POST
|
|
||||||
* (/clients/{clientId}/contacts). La relation n'est pas peuplee
|
|
||||||
* automatiquement par le Link sur une operation d'ecriture : on resout donc
|
|
||||||
* le parent depuis l'uri variable. Sur PATCH (entite existante), le client
|
|
||||||
* est deja present -> no-op.
|
|
||||||
*/
|
|
||||||
private function linkParent(ClientContact $contact, array $uriVariables): void
|
|
||||||
{
|
|
||||||
if (null !== $contact->getClient()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$clientId = $uriVariables['clientId'] ?? null;
|
|
||||||
if (null === $clientId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client = $clientId instanceof Client
|
|
||||||
? $clientId
|
|
||||||
: $this->em->getRepository(Client::class)->find($clientId);
|
|
||||||
|
|
||||||
if ($client instanceof Client) {
|
|
||||||
$contact->setClient($client);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalisation serveur (RG-1.19 / 1.20 / 1.21). Toutes les methodes du
|
|
||||||
* normalizer sont null-safe : une chaine vide apres trim devient null.
|
|
||||||
*/
|
|
||||||
private function normalize(ClientContact $contact): void
|
|
||||||
{
|
|
||||||
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
|
|
||||||
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
|
|
||||||
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
|
|
||||||
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
|
|
||||||
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.05 : au moins le prenom OU le nom est obligatoire (double garde avec
|
|
||||||
* le CHECK BDD chk_client_contact_name — leve un 422 propre plutot qu'une
|
|
||||||
* erreur SQL). Joue apres normalisation, donc les chaines vides sont deja
|
|
||||||
* ramenees a null.
|
|
||||||
*/
|
|
||||||
private function validateName(ClientContact $contact): void
|
|
||||||
{
|
|
||||||
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
|
||||||
$violations = new ConstraintViolationList();
|
|
||||||
$violations->add(new ConstraintViolation(
|
|
||||||
'Le prénom ou le nom du contact est obligatoire.',
|
|
||||||
null,
|
|
||||||
[],
|
|
||||||
$contact,
|
|
||||||
'firstName',
|
|
||||||
null,
|
|
||||||
));
|
|
||||||
|
|
||||||
throw new ValidationException($violations);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.14 : refuse la suppression du dernier contact d'un client (409). La
|
|
||||||
* collection inclut le contact en cours de suppression : un effectif <= 1
|
|
||||||
* signifie qu'il ne resterait aucun contact. Sans client rattache (cas
|
|
||||||
* theorique), on laisse passer.
|
|
||||||
*/
|
|
||||||
private function guardLastContactDeletion(ClientContact $contact): void
|
|
||||||
{
|
|
||||||
$client = $contact->getClient();
|
|
||||||
if (null === $client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($client->getContacts()->count() <= 1) {
|
|
||||||
throw new ConflictHttpException(
|
|
||||||
'Impossible de supprimer le dernier contact du client : au moins un contact est requis.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\DeleteOperationInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client;
|
|
||||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processor d'ecriture de la sous-ressource RIB d'un client (M1, § 4.5).
|
|
||||||
*
|
|
||||||
* Sequence :
|
|
||||||
* - POST / PATCH : aucune normalisation specifique. La validite de l'IBAN et du
|
|
||||||
* BIC est garantie par Assert\Iban / Assert\Bic sur l'entite (jouees en amont
|
|
||||||
* par API Platform). Aucun #[AuditIgnore] sur iban/bic : la tracabilite
|
|
||||||
* comptable est volontaire (decision Matthieu 29/05, spec § 6.1).
|
|
||||||
* - DELETE : RG-1.13 — si le client est en reglement LCR, la suppression de son
|
|
||||||
* DERNIER RIB est refusee (409), car LCR exige au moins un RIB.
|
|
||||||
*
|
|
||||||
* La security de l'operation (commercial.clients.accounting.manage) est deja
|
|
||||||
* appliquee par API Platform en amont : un utilisateur sans cette permission
|
|
||||||
* recoit 403 sur POST/PATCH/DELETE avant d'atteindre ce processor.
|
|
||||||
*
|
|
||||||
* @implements ProcessorInterface<ClientRib, null|ClientRib>
|
|
||||||
*/
|
|
||||||
final class ClientRibProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
private readonly ProcessorInterface $persistProcessor,
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
|
||||||
private readonly ProcessorInterface $removeProcessor,
|
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if (!$data instanceof ClientRib) {
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($operation instanceof DeleteOperationInterface) {
|
|
||||||
$this->guardLastRibDeletionUnderLcr($data);
|
|
||||||
|
|
||||||
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->linkParent($data, $uriVariables);
|
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rattache le RIB au client parent de la sous-ressource POST
|
|
||||||
* (/clients/{clientId}/ribs) : la relation n'est pas peuplee automatiquement
|
|
||||||
* par le Link sur une ecriture. Sur PATCH, no-op.
|
|
||||||
*/
|
|
||||||
private function linkParent(ClientRib $rib, array $uriVariables): void
|
|
||||||
{
|
|
||||||
if (null !== $rib->getClient()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$clientId = $uriVariables['clientId'] ?? null;
|
|
||||||
if (null === $clientId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$client = $clientId instanceof Client
|
|
||||||
? $clientId
|
|
||||||
: $this->em->getRepository(Client::class)->find($clientId);
|
|
||||||
|
|
||||||
if ($client instanceof Client) {
|
|
||||||
$rib->setClient($client);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.13 : un client dont le type de reglement est LCR doit conserver au
|
|
||||||
* moins un RIB. La collection inclut le RIB en cours de suppression : un
|
|
||||||
* effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre
|
|
||||||
* type de reglement, les RIBs sont optionnels (suppression libre).
|
|
||||||
*/
|
|
||||||
private function guardLastRibDeletionUnderLcr(ClientRib $rib): void
|
|
||||||
{
|
|
||||||
$client = $rib->getClient();
|
|
||||||
if (null === $client) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('LCR' === $client->getPaymentType()?->getCode() && $client->getRibs()->count() <= 1) {
|
|
||||||
throw new ConflictHttpException(
|
|
||||||
'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,8 @@ use ApiPlatform\State\Pagination\Pagination;
|
|||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Module\Commercial\Domain\Entity\Client;
|
use App\Module\Commercial\Domain\Entity\Client;
|
||||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
@@ -44,6 +46,7 @@ final class ClientProvider implements ProviderInterface
|
|||||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
||||||
private readonly ClientRepositoryInterface $repository,
|
private readonly ClientRepositoryInterface $repository,
|
||||||
private readonly Pagination $pagination,
|
private readonly Pagination $pagination,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Client|iterable|Paginator|null
|
||||||
@@ -64,15 +67,10 @@ final class ClientProvider implements ProviderInterface
|
|||||||
{
|
{
|
||||||
$filters = $context['filters'] ?? [];
|
$filters = $context['filters'] ?? [];
|
||||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||||
$search = $filters['search'] ?? null;
|
|
||||||
$categoryType = $filters['categoryType'] ?? null;
|
|
||||||
|
|
||||||
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
$qb = $this->repository->createListQueryBuilder($includeArchived);
|
||||||
$qb = $this->repository->createListQueryBuilder(
|
$this->applySearch($qb, $filters['search'] ?? null);
|
||||||
$includeArchived,
|
$this->applyCategoryType($qb, $filters['categoryType'] ?? null);
|
||||||
is_string($search) ? $search : null,
|
|
||||||
is_string($categoryType) ? $categoryType : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||||
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
// (cf. convention ERP-72 — utile pour un <select> cote front).
|
||||||
@@ -116,6 +114,55 @@ final class ClientProvider implements ProviderInterface
|
|||||||
return $client;
|
return $client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
||||||
|
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
||||||
|
* litteraux.
|
||||||
|
*/
|
||||||
|
private function applySearch(QueryBuilder $qb, mixed $search): void
|
||||||
|
{
|
||||||
|
if (!is_string($search) || '' === trim($search)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||||
|
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||||
|
|
||||||
|
$qb->andWhere(
|
||||||
|
'LOWER(c.companyName) LIKE :search '
|
||||||
|
.'OR LOWER(c.lastName) LIKE :search '
|
||||||
|
.'OR LOWER(c.email) LIKE :search',
|
||||||
|
)->setParameter('search', $pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restreint aux clients possedant au moins une categorie du type donne.
|
||||||
|
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
||||||
|
* perturber le DISTINCT / ORDER BY de la requete paginee principale.
|
||||||
|
*/
|
||||||
|
private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void
|
||||||
|
{
|
||||||
|
if (!is_string($categoryType) || '' === trim($categoryType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sous-requete construite via l'EntityManager (et non
|
||||||
|
// $repository->createQueryBuilder()) : createQueryBuilder() n'est pas
|
||||||
|
// declaree sur ClientRepositoryInterface, l'appeler exposerait un detail
|
||||||
|
// d'implementation Doctrine hors du contrat (fuite d'abstraction).
|
||||||
|
$sub = $this->em->createQueryBuilder()
|
||||||
|
->select('c2.id')
|
||||||
|
->from(Client::class, 'c2')
|
||||||
|
->join('c2.categories', 'cat2')
|
||||||
|
->join('cat2.categoryType', 'ct2')
|
||||||
|
->where('ct2.code = :categoryType')
|
||||||
|
;
|
||||||
|
|
||||||
|
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||||
|
->setParameter('categoryType', trim($categoryType))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,201 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Commercial\Infrastructure\Controller;
|
|
||||||
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client;
|
|
||||||
use App\Module\Commercial\Domain\Repository\ClientRepositoryInterface;
|
|
||||||
use App\Shared\Domain\Contract\CategoryInterface;
|
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
|
||||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export XLSX du repertoire clients (M1, spec-back § 4.6).
|
|
||||||
*
|
|
||||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
|
||||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est
|
|
||||||
* OBLIGATOIRE sur la route : sans cela API Platform capterait
|
|
||||||
* `/api/clients/export.xlsx` comme l'item `GET /api/clients/{id}.{_format}`
|
|
||||||
* (id="export", _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
|
|
||||||
*
|
|
||||||
* Separation des responsabilites :
|
|
||||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
|
||||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
|
||||||
* - le QUOI vit ICI : selection des clients (memes filtres que
|
|
||||||
* `GET /api/clients`, via {@see ClientRepositoryInterface::createListQueryBuilder()})
|
|
||||||
* et mapping metier des colonnes.
|
|
||||||
*
|
|
||||||
* La colonne SIREN n'est ajoutee que si l'utilisateur a la permission
|
|
||||||
* `commercial.clients.accounting.view` (gating identique a la lecture).
|
|
||||||
*/
|
|
||||||
#[AsController]
|
|
||||||
final class ClientExportController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository')]
|
|
||||||
private readonly ClientRepositoryInterface $repository,
|
|
||||||
private readonly SpreadsheetExporterInterface $exporter,
|
|
||||||
private readonly Security $security,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
#[Route('/api/clients/export.xlsx', name: 'commercial_clients_export_xlsx', methods: ['GET'], priority: 1)]
|
|
||||||
#[IsGranted('commercial.clients.view')]
|
|
||||||
public function __invoke(Request $request): Response
|
|
||||||
{
|
|
||||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
|
||||||
$search = $request->query->getString('search') ?: null;
|
|
||||||
$categoryType = $request->query->getString('categoryType') ?: null;
|
|
||||||
|
|
||||||
/** @var list<Client> $clients */
|
|
||||||
$clients = $this->repository
|
|
||||||
->createListQueryBuilder($includeArchived, $search, $categoryType)
|
|
||||||
->getQuery()
|
|
||||||
->getResult()
|
|
||||||
;
|
|
||||||
|
|
||||||
$withSiren = $this->security->isGranted('commercial.clients.accounting.view');
|
|
||||||
|
|
||||||
$binary = $this->exporter->export(
|
|
||||||
'Répertoire clients',
|
|
||||||
$this->buildHeaders($withSiren),
|
|
||||||
$this->buildRows($clients, $withSiren),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->buildResponse($binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Colonnes dans l'ordre impose par la spec § 4.6. SIREN inseree avant la
|
|
||||||
* date de creation, uniquement si l'utilisateur a accounting.view.
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function buildHeaders(bool $withSiren): array
|
|
||||||
{
|
|
||||||
$headers = [
|
|
||||||
'Nom entreprise',
|
|
||||||
'Nom contact principal',
|
|
||||||
'Prénom',
|
|
||||||
'Téléphone principal',
|
|
||||||
'Téléphone secondaire',
|
|
||||||
'Email',
|
|
||||||
'Catégories',
|
|
||||||
'Sites',
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($withSiren) {
|
|
||||||
$headers[] = 'SIREN';
|
|
||||||
}
|
|
||||||
|
|
||||||
$headers[] = 'Date de création';
|
|
||||||
|
|
||||||
return $headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Client> $clients
|
|
||||||
*
|
|
||||||
* @return iterable<list<null|scalar>>
|
|
||||||
*/
|
|
||||||
private function buildRows(array $clients, bool $withSiren): iterable
|
|
||||||
{
|
|
||||||
foreach ($clients as $client) {
|
|
||||||
$row = [
|
|
||||||
$client->getCompanyName(),
|
|
||||||
$client->getLastName(),
|
|
||||||
$client->getFirstName(),
|
|
||||||
$client->getPhonePrimary(),
|
|
||||||
$client->getPhoneSecondary(),
|
|
||||||
$client->getEmail(),
|
|
||||||
$this->formatCategories($client),
|
|
||||||
$this->formatSites($client),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($withSiren) {
|
|
||||||
$row[] = $client->getSiren();
|
|
||||||
}
|
|
||||||
|
|
||||||
$row[] = $client->getCreatedAt()?->format('d/m/Y');
|
|
||||||
|
|
||||||
yield $row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Libelles des categories du client, dedupliques, tries, joints par virgule.
|
|
||||||
*/
|
|
||||||
private function formatCategories(Client $client): string
|
|
||||||
{
|
|
||||||
$names = [];
|
|
||||||
foreach ($client->getCategories() as $category) {
|
|
||||||
// @var CategoryInterface $category
|
|
||||||
$name = $category->getName();
|
|
||||||
if (null !== $name && '' !== $name) {
|
|
||||||
$names[$name] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->joinSorted($names);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Le Client ne porte pas de sites en propre : ils sont rattaches aux
|
|
||||||
* adresses (RG-1.10). La colonne « Sites » agrege donc l'union distincte des
|
|
||||||
* sites de toutes les adresses du client (decision validee 01/06).
|
|
||||||
*/
|
|
||||||
private function formatSites(Client $client): string
|
|
||||||
{
|
|
||||||
$names = [];
|
|
||||||
foreach ($client->getAddresses() as $address) {
|
|
||||||
foreach ($address->getSites() as $site) {
|
|
||||||
// @var SiteInterface $site
|
|
||||||
$name = $site->getName();
|
|
||||||
if (null !== $name && '' !== $name) {
|
|
||||||
$names[$name] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->joinSorted($names);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, true> $names ensemble de libelles (cles)
|
|
||||||
*/
|
|
||||||
private function joinSorted(array $names): string
|
|
||||||
{
|
|
||||||
$list = array_keys($names);
|
|
||||||
sort($list);
|
|
||||||
|
|
||||||
return implode(', ', $list);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildResponse(string $binary): Response
|
|
||||||
{
|
|
||||||
$filename = sprintf('repertoire-clients-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
|
||||||
|
|
||||||
$response = new Response($binary);
|
|
||||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
||||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lit un flag booleen issu des query params. Accepte true / "true" / "1".
|
|
||||||
* Aligne sur ClientProvider pour un comportement identique a la liste.
|
|
||||||
*/
|
|
||||||
private function readBool(mixed $raw): bool
|
|
||||||
{
|
|
||||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,11 +31,8 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
$this->getEntityManager()->flush();
|
$this->getEntityManager()->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createListQueryBuilder(
|
public function createListQueryBuilder(bool $includeArchived = false): QueryBuilder
|
||||||
bool $includeArchived = false,
|
{
|
||||||
?string $search = null,
|
|
||||||
?string $categoryType = null,
|
|
||||||
): QueryBuilder {
|
|
||||||
$qb = $this->createQueryBuilder('c')
|
$qb = $this->createQueryBuilder('c')
|
||||||
->andWhere('c.deletedAt IS NULL')
|
->andWhere('c.deletedAt IS NULL')
|
||||||
->orderBy('c.companyName', 'ASC')
|
->orderBy('c.companyName', 'ASC')
|
||||||
@@ -45,54 +42,6 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
$qb->andWhere('c.isArchived = false');
|
$qb->andWhere('c.isArchived = false');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applySearch($qb, $search);
|
|
||||||
$this->applyCategoryType($qb, $categoryType);
|
|
||||||
|
|
||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Recherche fuzzy insensible a la casse sur companyName + lastName + email.
|
|
||||||
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
|
|
||||||
* litteraux.
|
|
||||||
*/
|
|
||||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
|
||||||
{
|
|
||||||
if (null === $search || '' === trim($search)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
|
||||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
|
||||||
|
|
||||||
$qb->andWhere(
|
|
||||||
'LOWER(c.companyName) LIKE :search '
|
|
||||||
.'OR LOWER(c.lastName) LIKE :search '
|
|
||||||
.'OR LOWER(c.email) LIKE :search',
|
|
||||||
)->setParameter('search', $pattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restreint aux clients possedant au moins une categorie du type donne.
|
|
||||||
* Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas
|
|
||||||
* perturber le DISTINCT / ORDER BY de la requete principale.
|
|
||||||
*/
|
|
||||||
private function applyCategoryType(QueryBuilder $qb, ?string $categoryType): void
|
|
||||||
{
|
|
||||||
if (null === $categoryType || '' === trim($categoryType)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
|
||||||
->select('c2.id')
|
|
||||||
->from(Client::class, 'c2')
|
|
||||||
->join('c2.categories', 'cat2')
|
|
||||||
->join('cat2.categoryType', 'ct2')
|
|
||||||
->where('ct2.code = :categoryType')
|
|
||||||
;
|
|
||||||
|
|
||||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
|
||||||
->setParameter('categoryType', trim($categoryType))
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,16 +29,18 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
|
|||||||
* ?performed_at[after]=2026-04-01T00:00:00Z
|
* ?performed_at[after]=2026-04-01T00:00:00Z
|
||||||
* ?performed_at[before]=2026-04-30T23:59:59Z
|
* ?performed_at[before]=2026-04-30T23:59:59Z
|
||||||
*
|
*
|
||||||
* La pagination herite du standard global (10 items / page, max 50, cf.
|
* La pagination est assuree par le provider via DbalPaginator (implementant
|
||||||
* `config/packages/api_platform.yaml`). Elle est materialisee par le
|
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
|
||||||
* DbalPaginator du provider qui implemente PaginatorInterface — API Platform
|
* automatiquement hydra:view — aucune construction manuelle.
|
||||||
* genere automatiquement hydra:view sans construction manuelle.
|
|
||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
shortName: 'AuditLog',
|
shortName: 'AuditLog',
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(
|
new GetCollection(
|
||||||
uriTemplate: '/audit-logs',
|
uriTemplate: '/audit-logs',
|
||||||
|
paginationItemsPerPage: 30,
|
||||||
|
paginationClientItemsPerPage: true,
|
||||||
|
paginationMaximumItemsPerPage: 50,
|
||||||
security: "is_granted('core.audit_log.view')",
|
security: "is_granted('core.audit_log.view')",
|
||||||
provider: AuditLogProvider::class,
|
provider: AuditLogProvider::class,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -68,13 +68,6 @@ final readonly class AuditLogProvider implements ProviderInterface
|
|||||||
*/
|
*/
|
||||||
private function provideCollection(Operation $operation, array $context): DbalPaginator
|
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
|
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
|
||||||
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
|
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
|
||||||
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
|
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Shared\Domain\Contract;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contrat d'export d'une feuille de calcul tabulaire vers un binaire XLSX.
|
|
||||||
*
|
|
||||||
* Service GENERIQUE et reutilisable : il ne connait aucune entite metier. Le
|
|
||||||
* module appelant decide QUOI exporter (en-tetes + lignes deja mappees) ; cette
|
|
||||||
* interface decrit seulement COMMENT produire le fichier. Aucun module n'est
|
|
||||||
* couple a une implementation concrete : on depend de ce contrat (dans Shared),
|
|
||||||
* jamais l'inverse (regle ABSOLUE n°1).
|
|
||||||
*
|
|
||||||
* Implementee par App\Shared\Infrastructure\Export\PhpSpreadsheetExporter (on
|
|
||||||
* ne la reference pas via @see pour ne pas creer un import Domain -> Infra).
|
|
||||||
*/
|
|
||||||
interface SpreadsheetExporterInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Genere un classeur XLSX a une feuille et retourne son contenu binaire.
|
|
||||||
*
|
|
||||||
* @param string $sheetTitle titre de l'onglet (assaini / tronque par l'implementation si besoin)
|
|
||||||
* @param list<string> $headers libelles de la ligne d'en-tete (ligne 1)
|
|
||||||
* @param iterable<list<null|scalar>> $rows lignes de donnees ; chaque ligne est une liste de cellules alignee sur $headers
|
|
||||||
*
|
|
||||||
* @return string contenu binaire du fichier XLSX
|
|
||||||
*/
|
|
||||||
public function export(string $sheetTitle, array $headers, iterable $rows): string;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Shared\Infrastructure\Export;
|
|
||||||
|
|
||||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Implementation XLSX du contrat d'export via la librairie PhpSpreadsheet.
|
|
||||||
*
|
|
||||||
* Strictement technique : ecrit la ligne d'en-tete puis les lignes de donnees
|
|
||||||
* dans l'unique feuille du classeur, et retourne le binaire. Aucune logique
|
|
||||||
* metier, aucune reference a une entite d'un module — le mapping des colonnes
|
|
||||||
* est de la responsabilite de l'appelant.
|
|
||||||
*/
|
|
||||||
final class PhpSpreadsheetExporter implements SpreadsheetExporterInterface
|
|
||||||
{
|
|
||||||
// Excel limite le titre d'un onglet a 31 caracteres et interdit certains
|
|
||||||
// caracteres ; on assainit pour ne jamais faire echouer setTitle().
|
|
||||||
private const int MAX_SHEET_TITLE_LENGTH = 31;
|
|
||||||
private const string INVALID_TITLE_CHARS = '*:/\?[]';
|
|
||||||
|
|
||||||
public function export(string $sheetTitle, array $headers, iterable $rows): string
|
|
||||||
{
|
|
||||||
$spreadsheet = new Spreadsheet();
|
|
||||||
$sheet = $spreadsheet->getActiveSheet();
|
|
||||||
$sheet->setTitle($this->sanitizeSheetTitle($sheetTitle));
|
|
||||||
|
|
||||||
// Ligne 1 : en-tete.
|
|
||||||
$sheet->fromArray($headers, null, 'A1');
|
|
||||||
|
|
||||||
// Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable
|
|
||||||
// paresseux (generator) sans tout materialiser en memoire.
|
|
||||||
$rowNumber = 2;
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$sheet->fromArray($row, null, 'A'.$rowNumber);
|
|
||||||
++$rowNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->toBinary($spreadsheet);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toBinary(Spreadsheet $spreadsheet): string
|
|
||||||
{
|
|
||||||
$writer = new Xlsx($spreadsheet);
|
|
||||||
|
|
||||||
// Le writer ecrit vers un chemin de fichier : on passe par un fichier
|
|
||||||
// temporaire puis on lit son contenu binaire.
|
|
||||||
$tmpFile = tempnam(sys_get_temp_dir(), 'xlsx_export_');
|
|
||||||
if (false === $tmpFile) {
|
|
||||||
throw new RuntimeException('Impossible de creer un fichier temporaire pour l\'export XLSX.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$writer->save($tmpFile);
|
|
||||||
$binary = file_get_contents($tmpFile);
|
|
||||||
if (false === $binary) {
|
|
||||||
throw new RuntimeException('Lecture du fichier XLSX temporaire impossible.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $binary;
|
|
||||||
} finally {
|
|
||||||
// Libere les references internes de PhpSpreadsheet puis supprime le
|
|
||||||
// fichier temporaire, meme en cas d'exception.
|
|
||||||
$spreadsheet->disconnectWorksheets();
|
|
||||||
@unlink($tmpFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retire les caracteres interdits et tronque a 31 caracteres ; renvoie un
|
|
||||||
* titre par defaut si la chaine resultante est vide.
|
|
||||||
*/
|
|
||||||
private function sanitizeSheetTitle(string $title): string
|
|
||||||
{
|
|
||||||
$clean = str_replace(str_split(self::INVALID_TITLE_CHARS), '', $title);
|
|
||||||
$clean = mb_substr($clean, 0, self::MAX_SHEET_TITLE_LENGTH);
|
|
||||||
|
|
||||||
return '' === $clean ? 'Export' : $clean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-256
@@ -1,256 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<?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();
|
$client = $this->createAdminClient();
|
||||||
$response = $client->request('GET', '/api/categories?pagination=false');
|
$response = $client->request('GET', '/api/categories');
|
||||||
self::assertSame(200, $response->getStatusCode());
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
$data = $response->toArray();
|
$data = $response->toArray();
|
||||||
@@ -62,7 +62,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
|
|||||||
);
|
);
|
||||||
|
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$response = $client->request('GET', '/api/categories?includeDeleted=true&pagination=false');
|
$response = $client->request('GET', '/api/categories?includeDeleted=true');
|
||||||
self::assertSame(200, $response->getStatusCode());
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
$names = array_values(array_filter(
|
$names = array_values(array_filter(
|
||||||
@@ -87,7 +87,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
|
|||||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'mid', $type);
|
$this->createCategory(self::TEST_CATEGORY_PREFIX.'mid', $type);
|
||||||
|
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$response = $client->request('GET', '/api/categories?pagination=false');
|
$response = $client->request('GET', '/api/categories');
|
||||||
self::assertSame(200, $response->getStatusCode());
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
$names = array_values(array_filter(
|
$names = array_values(array_filter(
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
<?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).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests fonctionnels de l'onglet Adresse — combler les trous (ERP-60).
|
|
||||||
*
|
|
||||||
* RG-1.09 (code postal) et RG-1.10 (>= 1 site) sont DEJA couverts par
|
|
||||||
* ClientSubResourceApiTest (ERP-57) et ne sont pas reduplique ici. Ce fichier
|
|
||||||
* cible les contraintes CHECK BDD non encore testees :
|
|
||||||
* - RG-1.06 / RG-1.07 / RG-1.08 : `chk_client_address_prospect_exclusive`
|
|
||||||
* (is_prospect exclusif de is_delivery / is_billing) ;
|
|
||||||
* - RG-1.11 : `chk_client_address_billing_email` (billing_email obligatoire
|
|
||||||
* ssi is_billing).
|
|
||||||
*
|
|
||||||
* Note : ces regles sont portees par des CHECK Postgres (pas d'Assert ni de
|
|
||||||
* regle Processor au M1). On verifie donc que la combinaison invalide est
|
|
||||||
* REJETEE par le serveur (statut >= 400), sans coupler le test au code exact :
|
|
||||||
* une violation CHECK non mappee remonte aujourd'hui en erreur serveur ; un
|
|
||||||
* mapping fin vers 422 serait une amelioration ulterieure (hors perimetre
|
|
||||||
* ERP-60, test-only).
|
|
||||||
*
|
|
||||||
* RG-1.29 (filtrage du type de categorie SECTEUR/AUTRE sur une adresse) n'est
|
|
||||||
* PAS testee : la validation d'ecriture correspondante n'est pas implementee
|
|
||||||
* cote back au M1 (et ne figure pas dans la liste § 8.1). Documentee comme gap
|
|
||||||
* dans le cahier de test #478.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientAddressTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string LD = 'application/ld+json';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.06 / RG-1.07 : une adresse de prospection ne peut pas etre une
|
|
||||||
* adresse de livraison (CHECK chk_client_address_prospect_exclusive).
|
|
||||||
*/
|
|
||||||
public function testProspectAddressCannotBeDelivery(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Prospect Delivery');
|
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'isProspect' => true,
|
|
||||||
'isDelivery' => true,
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$this->firstSiteIri()],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.06 / RG-1.08 : une adresse de prospection ne peut pas etre une
|
|
||||||
* adresse de facturation (meme CHECK). On fournit billingEmail pour que la
|
|
||||||
* seule violation possible soit l'exclusivite prospect/billing.
|
|
||||||
*/
|
|
||||||
public function testProspectAddressCannotBeBilling(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Prospect Billing');
|
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'isProspect' => true,
|
|
||||||
'isBilling' => true,
|
|
||||||
'billingEmail' => 'facturation@test.fr',
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$this->firstSiteIri()],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.11 : une adresse de facturation exige un billingEmail
|
|
||||||
* (CHECK chk_client_address_billing_email).
|
|
||||||
*/
|
|
||||||
public function testBillingAddressRequiresBillingEmail(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Billing No Email');
|
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'isBilling' => true,
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$this->firstSiteIri()],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.11 (sens inverse) : une adresse NON facturable ne peut pas porter un
|
|
||||||
* billingEmail (meme CHECK).
|
|
||||||
*/
|
|
||||||
public function testNonBillingAddressRejectsBillingEmail(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Non Billing With Email');
|
|
||||||
|
|
||||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'isBilling' => false,
|
|
||||||
'billingEmail' => 'parasite@test.fr',
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$this->firstSiteIri()],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retourne l'IRI du premier site seede (fixtures Sites).
|
|
||||||
*/
|
|
||||||
private function firstSiteIri(): string
|
|
||||||
{
|
|
||||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
|
||||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
|
|
||||||
|
|
||||||
return '/api/sites/'.$site->getId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests d'archivage / restauration — combler les trous (ERP-60).
|
|
||||||
*
|
|
||||||
* Le cas nominal RG-1.22 (archive pose archivedAt) + RG-1.23 (restauration
|
|
||||||
* repasse archivedAt a null) ainsi que le 422 « archive + autre champ » sont
|
|
||||||
* DEJA couverts par ClientApiTest (ERP-55). Ce fichier cible le trou identifie
|
|
||||||
* en revue (P1 review ERP-55) : le 409 de RESTAURATION en conflit d'unicite.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientArchiveTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string MERGE = 'application/merge-patch+json';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.23 : restaurer un client archive dont le nom a ete repris par un
|
|
||||||
* client actif entre-temps doit echouer en 409 (l'index partiel
|
|
||||||
* uq_client_company_name_active n'admet qu'un seul actif portant ce nom).
|
|
||||||
*
|
|
||||||
* Scenario :
|
|
||||||
* 1. un client « ACME CONFLICT » est archive (donc hors index partiel) ;
|
|
||||||
* 2. un autre client actif « ACME CONFLICT » est cree (autorise tant que le
|
|
||||||
* premier reste archive) ;
|
|
||||||
* 3. la restauration du premier le rendrait actif -> collision d'unicite
|
|
||||||
* -> ClientProcessor traduit la UniqueConstraintViolationException en 409.
|
|
||||||
*/
|
|
||||||
public function testRestoreConflictReturns409(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
|
|
||||||
$archived = $this->seedClient('Acme Conflict', true);
|
|
||||||
$this->seedClient('Acme Conflict', false);
|
|
||||||
|
|
||||||
$client->request('PATCH', '/api/clients/'.$archived->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['isArchived' => false],
|
|
||||||
]);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(409);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\DBAL\Connection;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests Audit + Timestampable/Blamable du repertoire clients (ERP-60).
|
|
||||||
*
|
|
||||||
* Couvre :
|
|
||||||
* - RG-1.27 : createdAt / createdBy figes au POST, updatedBy reflete bien
|
|
||||||
* l'auteur de la modification (POST admin puis PATCH par un autre user) ;
|
|
||||||
* - Audit (§ 6.1) : le RIB est `#[Auditable]` SANS `#[AuditIgnore]` sur iban /
|
|
||||||
* bic — ces champs sensibles DOIVENT donc apparaitre dans le diff audite
|
|
||||||
* (decision Matthieu, revue MR 29/05/2026).
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientAuditTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string LD = 'application/ld+json';
|
|
||||||
private const string MERGE = 'application/merge-patch+json';
|
|
||||||
private const string RIB_TYPE = 'commercial.ClientRib';
|
|
||||||
private const string VALID_IBAN = 'FR1420041010050500013M02606';
|
|
||||||
private const string VALID_BIC = 'BNPAFRPPXXX';
|
|
||||||
|
|
||||||
private ?Connection $auditConnection = null;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
self::bootKernel();
|
|
||||||
|
|
||||||
/** @var Connection $conn */
|
|
||||||
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
|
||||||
$this->auditConnection = $conn;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
if (null !== $this->auditConnection) {
|
|
||||||
$this->auditConnection->close();
|
|
||||||
}
|
|
||||||
parent::tearDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.27 : createdAt / createdBy sont poses au POST puis figes ; updatedBy
|
|
||||||
* suit l'auteur de la derniere modification. On cree en admin puis on
|
|
||||||
* modifie avec un user `commercial.clients.manage` distinct : createdBy reste
|
|
||||||
* l'admin, updatedBy devient le manager, createdAt ne bouge pas.
|
|
||||||
*/
|
|
||||||
public function testCreatedFrozenAndUpdatedByReflectsModifier(): void
|
|
||||||
{
|
|
||||||
// 1. User modificateur (non-admin) cree AVANT le reboot de kernel induit
|
|
||||||
// par les clients authentifies suivants ; il est persiste en base.
|
|
||||||
$manageCreds = $this->createUserWithPermission('commercial.clients.manage');
|
|
||||||
|
|
||||||
// 2. Creation en admin (createdBy = admin).
|
|
||||||
$admin = $this->createAdminClient();
|
|
||||||
$cat = $this->createCategory('SECTEUR');
|
|
||||||
|
|
||||||
$created = $admin->request('POST', '/api/clients', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'companyName' => 'Blamable Co',
|
|
||||||
'firstName' => 'A',
|
|
||||||
'phonePrimary' => '0102030405',
|
|
||||||
'email' => 'blamable@test.fr',
|
|
||||||
'categories' => ['/api/categories/'.$cat->getId()],
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
$id = (int) $created['id'];
|
|
||||||
$createdAtTs = new DateTimeImmutable((string) $created['createdAt'])->getTimestamp();
|
|
||||||
|
|
||||||
// 3. Modification par le manager (updatedBy = manager).
|
|
||||||
$manage = $this->authenticatedClient($manageCreds['username'], $manageCreds['password']);
|
|
||||||
$manage->request('PATCH', '/api/clients/'.$id, [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['companyName' => 'Blamable Renamed'],
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(200);
|
|
||||||
|
|
||||||
// 4. Verification cote base (etat re-charge depuis la BDD).
|
|
||||||
$em = $this->getEm();
|
|
||||||
$em->clear();
|
|
||||||
$reloaded = $em->getRepository(ClientEntity::class)->find($id);
|
|
||||||
self::assertNotNull($reloaded);
|
|
||||||
|
|
||||||
self::assertSame('admin', $reloaded->getCreatedBy()?->getUserIdentifier(), 'createdBy doit rester l\'admin createur.');
|
|
||||||
self::assertSame(
|
|
||||||
$manageCreds['username'],
|
|
||||||
$reloaded->getUpdatedBy()?->getUserIdentifier(),
|
|
||||||
'updatedBy doit refleter le dernier modificateur.',
|
|
||||||
);
|
|
||||||
self::assertSame($createdAtTs, $reloaded->getCreatedAt()?->getTimestamp(), 'createdAt doit etre fige au POST.');
|
|
||||||
self::assertNotNull($reloaded->getUpdatedAt());
|
|
||||||
self::assertGreaterThanOrEqual($createdAtTs, $reloaded->getUpdatedAt()->getTimestamp());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audit § 6.1 : la creation d'un RIB produit une ligne audit_log
|
|
||||||
* `commercial.ClientRib` / `create` dont le snapshot contient iban et bic
|
|
||||||
* (champs volontairement NON ignores).
|
|
||||||
*/
|
|
||||||
public function testRibCreateAuditIncludesIbanAndBic(): void
|
|
||||||
{
|
|
||||||
$admin = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Rib Audit Host');
|
|
||||||
|
|
||||||
$rib = $admin->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'label' => 'Compte audite',
|
|
||||||
'bic' => self::VALID_BIC,
|
|
||||||
'iban' => self::VALID_IBAN,
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
|
|
||||||
$rows = $this->auditConnection->fetchAllAssociative(
|
|
||||||
'SELECT changes FROM audit_log '
|
|
||||||
.'WHERE entity_type = :type AND entity_id = :id AND action = :action '
|
|
||||||
.'ORDER BY performed_at DESC',
|
|
||||||
['type' => self::RIB_TYPE, 'id' => (string) $rib['id'], 'action' => 'create'],
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "create" doit etre genere pour le RIB.');
|
|
||||||
|
|
||||||
/** @var array<string, mixed> $changes */
|
|
||||||
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
|
|
||||||
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
|
|
||||||
self::assertSame(self::VALID_IBAN, $changes['iban']);
|
|
||||||
self::assertSame(self::VALID_BIC, $changes['bic']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests fonctionnels de l'export XLSX du repertoire clients (M1, § 4.6).
|
|
||||||
*
|
|
||||||
* Couvre : reponse 200 (Content-Type + Content-Disposition), exclusion des
|
|
||||||
* archives par defaut, respect des filtres ?search / ?categoryType, gating de
|
|
||||||
* la colonne SIREN selon commercial.clients.accounting.view, 403 sans
|
|
||||||
* commercial.clients.view, 401 anonyme.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientExportControllerTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
||||||
private const string EXPORT_URL = '/api/clients/export.xlsx';
|
|
||||||
|
|
||||||
public function testExportReturnsXlsxResponseWithAttachmentFilename(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$this->seedClient('Export Alpha');
|
|
||||||
|
|
||||||
$response = $client->request('GET', self::EXPORT_URL);
|
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
|
||||||
$headers = $response->getHeaders(false);
|
|
||||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
|
||||||
|
|
||||||
$disposition = $headers['content-disposition'][0] ?? '';
|
|
||||||
self::assertStringContainsString('attachment; filename="repertoire-clients-', $disposition);
|
|
||||||
self::assertMatchesRegularExpression(
|
|
||||||
'/filename="repertoire-clients-\d{8}\.xlsx"/',
|
|
||||||
$disposition,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
|
||||||
$grid = $this->gridFromResponse($response->getContent());
|
|
||||||
$headers = $grid[0];
|
|
||||||
self::assertSame('Nom entreprise', $headers[0]);
|
|
||||||
self::assertContains('Catégories', $headers);
|
|
||||||
self::assertContains('Sites', $headers);
|
|
||||||
self::assertContains('Date de création', $headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testExportExcludesArchivedByDefault(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$this->seedClient('Active One');
|
|
||||||
$this->seedClient('Archived One', true);
|
|
||||||
|
|
||||||
$names = $this->companyNames($client->request('GET', self::EXPORT_URL)->getContent());
|
|
||||||
|
|
||||||
self::assertContains('ACTIVE ONE', $names);
|
|
||||||
self::assertNotContains('ARCHIVED ONE', $names);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testExportRespectsSearchFilter(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$this->seedClient('Searchable Alpha');
|
|
||||||
$this->seedClient('Other Beta');
|
|
||||||
|
|
||||||
$names = $this->companyNames(
|
|
||||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertContains('SEARCHABLE ALPHA', $names);
|
|
||||||
self::assertNotContains('OTHER BETA', $names);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testExportRespectsCategoryTypeFilter(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$this->seedClient('Distrib Co', false, 'DISTRIBUTEUR');
|
|
||||||
$this->seedClient('Secteur Co', false, 'SECTEUR');
|
|
||||||
|
|
||||||
$names = $this->companyNames(
|
|
||||||
$client->request('GET', self::EXPORT_URL.'?categoryType=DISTRIBUTEUR')->getContent(),
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertContains('DISTRIB CO', $names);
|
|
||||||
self::assertNotContains('SECTEUR CO', $names);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSirenColumnPresentWithAccountingView(): void
|
|
||||||
{
|
|
||||||
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Siren Co');
|
|
||||||
$em = $this->getEm();
|
|
||||||
$seed->setSiren('123456789');
|
|
||||||
$em->flush();
|
|
||||||
|
|
||||||
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
|
|
||||||
|
|
||||||
self::assertContains('SIREN', $grid[0]);
|
|
||||||
self::assertStringContainsString('123456789', $this->flatten($grid));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testSirenColumnAbsentWithoutAccountingView(): void
|
|
||||||
{
|
|
||||||
// Seed via admin, puis relecture par un user qui n'a QUE clients.view.
|
|
||||||
$admin = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('No Siren Co');
|
|
||||||
$em = $this->getEm();
|
|
||||||
$seed->setSiren('987654321');
|
|
||||||
$em->flush();
|
|
||||||
|
|
||||||
$creds = $this->createUserWithPermission('commercial.clients.view');
|
|
||||||
$viewer = $this->authenticatedClient($creds['username'], $creds['password']);
|
|
||||||
|
|
||||||
$grid = $this->gridFromResponse($viewer->request('GET', self::EXPORT_URL)->getContent());
|
|
||||||
|
|
||||||
self::assertNotContains('SIREN', $grid[0]);
|
|
||||||
self::assertStringNotContainsString('987654321', $this->flatten($grid));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testForbiddenWithoutClientsViewPermission(): void
|
|
||||||
{
|
|
||||||
$creds = $this->createUserWithPermission('core.users.view');
|
|
||||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
|
||||||
|
|
||||||
$client->request('GET', self::EXPORT_URL);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUnauthorizedWhenAnonymous(): void
|
|
||||||
{
|
|
||||||
$client = self::createClient();
|
|
||||||
$client->request('GET', self::EXPORT_URL);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
|
||||||
*
|
|
||||||
* @return array<int, array<int, mixed>>
|
|
||||||
*/
|
|
||||||
private function gridFromResponse(string $binary): array
|
|
||||||
{
|
|
||||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
|
|
||||||
self::assertIsString($tmp);
|
|
||||||
file_put_contents($tmp, $binary);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
|
||||||
} finally {
|
|
||||||
@unlink($tmp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extrait la colonne « Nom entreprise » (1re colonne) des lignes de donnees.
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function companyNames(string $binary): array
|
|
||||||
{
|
|
||||||
$grid = $this->gridFromResponse($binary);
|
|
||||||
$rows = array_slice($grid, 1); // saute l'en-tete
|
|
||||||
|
|
||||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aplatit toute la grille en une chaine, pour les assertions de presence.
|
|
||||||
*
|
|
||||||
* @param array<int, array<int, mixed>> $grid
|
|
||||||
*/
|
|
||||||
private function flatten(array $grid): string
|
|
||||||
{
|
|
||||||
return implode('|', array_map(
|
|
||||||
static fn (array $row): string => implode('|', array_map(static fn ($cell): string => (string) $cell, $row)),
|
|
||||||
$grid,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests fonctionnels du formulaire principal — combler les trous (ERP-60).
|
|
||||||
*
|
|
||||||
* RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs
|
|
||||||
* + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les
|
|
||||||
* reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire),
|
|
||||||
* non encore testee.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string LD = 'application/ld+json';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes
|
|
||||||
* distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur
|
|
||||||
* la colonne secondaire.
|
|
||||||
*/
|
|
||||||
public function testPostPersistsSecondaryPhoneNormalized(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$cat = $this->createCategory('SECTEUR');
|
|
||||||
|
|
||||||
$data = $client->request('POST', '/api/clients', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'companyName' => 'Two Phones SARL',
|
|
||||||
'firstName' => 'A',
|
|
||||||
'phonePrimary' => '06.12.34.56.78',
|
|
||||||
'phoneSecondary' => '05 49 00 11 22',
|
|
||||||
'email' => 'twophones@test.fr',
|
|
||||||
'categories' => ['/api/categories/'.$cat->getId()],
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
self::assertSame('0612345678', $data['phonePrimary']);
|
|
||||||
self::assertSame('0549001122', $data['phoneSecondary']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.02 : maximum 2 telephones — le modele n'expose que phonePrimary et
|
|
||||||
* phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est
|
|
||||||
* ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero.
|
|
||||||
*/
|
|
||||||
public function testThirdPhoneFieldIsIgnored(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$cat = $this->createCategory('SECTEUR');
|
|
||||||
|
|
||||||
$data = $client->request('POST', '/api/clients', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'companyName' => 'Third Phone SARL',
|
|
||||||
'firstName' => 'A',
|
|
||||||
'phonePrimary' => '0612345678',
|
|
||||||
'phoneSecondary' => '0549001122',
|
|
||||||
'phoneTertiary' => '0700000000',
|
|
||||||
'email' => 'thirdphone@test.fr',
|
|
||||||
'categories' => ['/api/categories/'.$cat->getId()],
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
// Le champ inconnu est ignore par le denormaliseur : il n'apparait pas
|
|
||||||
// dans la representation et n'a pas ete persiste.
|
|
||||||
self::assertArrayNotHasKey('phoneTertiary', $data);
|
|
||||||
|
|
||||||
// Confirmation cote base : seules les 2 colonnes telephone existent.
|
|
||||||
$persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']);
|
|
||||||
self::assertNotNull($persisted);
|
|
||||||
self::assertSame('0612345678', $persisted->getPhonePrimary());
|
|
||||||
self::assertSame('0549001122', $persisted->getPhoneSecondary());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests de structure / migration M1 (ERP-60).
|
|
||||||
*
|
|
||||||
* Verifie la decision Q4 (29/05/2026) au niveau du schema Postgres :
|
|
||||||
* - l'unique index partiel fonctionnel uq_client_company_name_active existe
|
|
||||||
* (un seul, sur LOWER(company_name), partiel sur les actifs non archives /
|
|
||||||
* non supprimes) — seule unicite metier conservee (RG-1.16) ;
|
|
||||||
* - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active
|
|
||||||
* (RG-1.17) ont ete supprimes / ne sont jamais crees.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientMigrationTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
|
|
||||||
{
|
|
||||||
$rows = $this->clientIndexes();
|
|
||||||
|
|
||||||
$companyNameIndexes = array_filter(
|
|
||||||
$rows,
|
|
||||||
static fn (array $r): bool => 'uq_client_company_name_active' === $r['indexname'],
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertCount(
|
|
||||||
1,
|
|
||||||
$companyNameIndexes,
|
|
||||||
'Il doit exister exactement UN index uq_client_company_name_active.',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Confirme la nature fonctionnelle (LOWER) + partielle (WHERE) de l'index.
|
|
||||||
// Postgres serialise l'expression sous la forme `lower((company_name)::text)`,
|
|
||||||
// d'ou des verifications de sous-chaines distinctes.
|
|
||||||
$def = strtolower((string) array_values($companyNameIndexes)[0]['indexdef']);
|
|
||||||
self::assertStringContainsString('unique', $def);
|
|
||||||
self::assertStringContainsString('lower', $def);
|
|
||||||
self::assertStringContainsString('company_name', $def);
|
|
||||||
self::assertStringContainsString('where', $def, 'L\'index doit etre partiel (clause WHERE sur les actifs).');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testNoSirenOrEmailUniqueIndex(): void
|
|
||||||
{
|
|
||||||
$names = array_map(static fn (array $r): string => $r['indexname'], $this->clientIndexes());
|
|
||||||
|
|
||||||
// RG-1.15 / RG-1.17 supprimees (Q4) : aucun index unique siren / email.
|
|
||||||
self::assertNotContains('uq_client_siren_active', $names);
|
|
||||||
self::assertNotContains('uq_client_email_active', $names);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{indexname: string, indexdef: string}>
|
|
||||||
*/
|
|
||||||
private function clientIndexes(): array
|
|
||||||
{
|
|
||||||
self::bootKernel();
|
|
||||||
|
|
||||||
/** @var list<array{indexname: string, indexdef: string}> $rows */
|
|
||||||
return $this->getEm()->getConnection()->fetchAllAssociative(
|
|
||||||
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'client'",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test fonctionnel du mode strict PATCH multi-groupes (RG-1.28) — ERP-60.
|
|
||||||
*
|
|
||||||
* Le cas est deja couvert en unitaire (ClientProcessorTest) ; on en ajoute la
|
|
||||||
* preuve fonctionnelle HTTP, SANS dependre d'un role metier : un utilisateur
|
|
||||||
* portant `commercial.clients.manage` mais PAS `commercial.clients.accounting.manage`
|
|
||||||
* qui envoie un PATCH melant un champ principal (companyName) et un champ
|
|
||||||
* comptable (siren) recoit un 403 sur l'ENSEMBLE du payload — aucun champ n'est
|
|
||||||
* applique (pas de filtrage silencieux).
|
|
||||||
*
|
|
||||||
* ⚠ La matrice differenciee par role metier (Bureau / Compta / Commerciale) est
|
|
||||||
* DELEGUEE a ERP-74 (#493). Ici on n'utilise qu'un user mono-permission.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientPatchStrictTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string MERGE = 'application/merge-patch+json';
|
|
||||||
|
|
||||||
public function testMixedGroupsPatchWithoutAccountingPermissionIsForbidden(): void
|
|
||||||
{
|
|
||||||
$seed = $this->seedClient('Strict Mix');
|
|
||||||
$credentials = $this->createUserWithPermission('commercial.clients.manage');
|
|
||||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
|
||||||
|
|
||||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => [
|
|
||||||
'companyName' => 'Renamed Strict',
|
|
||||||
'siren' => '123456789',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// RG-1.28 : 403 strict (le champ comptable siren exige accounting.manage).
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
|
|
||||||
// Aucun champ applique : le companyName d'origine est intact.
|
|
||||||
$em = $this->getEm();
|
|
||||||
$em->clear();
|
|
||||||
$reloaded = $em->getRepository(ClientEntity::class)->find($seed->getId());
|
|
||||||
self::assertNotNull($reloaded);
|
|
||||||
self::assertSame('STRICT MIX', $reloaded->getCompanyName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests de securite GENERIQUE de /api/clients (ERP-60).
|
|
||||||
*
|
|
||||||
* Couvre les garde-fous non dependants des roles metier :
|
|
||||||
* - 401 si requete anonyme (firewall JWT) ;
|
|
||||||
* - 403 si l'utilisateur authentifie ne porte pas `commercial.clients.view`.
|
|
||||||
*
|
|
||||||
* ⚠ La matrice RBAC differenciee par role metier (bureau / compta / commerciale
|
|
||||||
* / usine) et le test fonctionnel RG-1.04 sont DELEGUES a ERP-74 (#493) : ils
|
|
||||||
* exigent les roles seedes apres le merge de la stack. NE PAS les ajouter ici.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientSecurityTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string LD = 'application/ld+json';
|
|
||||||
|
|
||||||
public function testAnonymousGetCollectionReturns401(): void
|
|
||||||
{
|
|
||||||
$client = self::createClient();
|
|
||||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAnonymousGetItemReturns401(): void
|
|
||||||
{
|
|
||||||
$seed = $this->seedClient('Anon Item');
|
|
||||||
$client = self::createClient();
|
|
||||||
|
|
||||||
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testForbiddenWithoutClientsViewPermission(): void
|
|
||||||
{
|
|
||||||
// User authentifie portant une permission SANS rapport avec les clients.
|
|
||||||
$seed = $this->seedClient('Forbidden Target');
|
|
||||||
$credentials = $this->createUserWithPermission('core.users.view');
|
|
||||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
|
||||||
|
|
||||||
// Collection.
|
|
||||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
|
|
||||||
// Detail.
|
|
||||||
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
|
||||||
use App\Module\Commercial\Domain\Entity\ClientContact;
|
|
||||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
|
||||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB (ERP-57,
|
|
||||||
* spec § 4.5). Couvrent : CRUD via admin, normalisation serveur
|
|
||||||
* (RG-1.19/1.20/1.21), validations (Assert\Count sites RG-1.10, Assert\Iban/Bic),
|
|
||||||
* regles metier RG-1.13 (DELETE dernier RIB sous LCR -> 409) et RG-1.14 (DELETE
|
|
||||||
* dernier contact -> 409), plus le gating comptable (POST/PATCH/DELETE de
|
|
||||||
* client_ribs sans accounting.manage -> 403).
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string LD = 'application/ld+json';
|
|
||||||
private const string MERGE = 'application/merge-patch+json';
|
|
||||||
private const string VALID_IBAN = 'FR1420041010050500013M02606';
|
|
||||||
private const string VALID_BIC = 'BNPAFRPPXXX';
|
|
||||||
|
|
||||||
// === Contacts ===
|
|
||||||
|
|
||||||
public function testPostContactNormalizesFields(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Contact Host');
|
|
||||||
|
|
||||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'firstName' => 'JEAN',
|
|
||||||
'lastName' => 'dupont',
|
|
||||||
'phonePrimary' => '06.12.34.56.78',
|
|
||||||
'email' => 'Jean.DUPONT@ACME.FR',
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
// RG-1.19 / 1.20 / 1.21
|
|
||||||
self::assertSame('Jean', $data['firstName']);
|
|
||||||
self::assertSame('Dupont', $data['lastName']);
|
|
||||||
self::assertSame('0612345678', $data['phonePrimary']);
|
|
||||||
self::assertSame('jean.dupont@acme.fr', $data['email']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostContactWithoutNameReturns422(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Contact No Name');
|
|
||||||
|
|
||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => ['jobTitle' => 'Directeur'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// RG-1.05
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPatchContactNormalizes(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Contact Patch');
|
|
||||||
$contact = $this->seedContact($seed, 'Paul');
|
|
||||||
|
|
||||||
$data = $client->request('PATCH', '/api/client_contacts/'.$contact->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['lastName' => 'martin'],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(200);
|
|
||||||
self::assertSame('Martin', $data['lastName']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteContactWhenSeveralReturns204(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Contact Multi');
|
|
||||||
$this->seedContact($seed, 'Premier');
|
|
||||||
$second = $this->seedContact($seed, 'Second');
|
|
||||||
|
|
||||||
$client->request('DELETE', '/api/client_contacts/'.$second->getId());
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(204);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteLastContactReturns409(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Contact Solo');
|
|
||||||
$only = $this->seedContact($seed, 'Unique');
|
|
||||||
|
|
||||||
$client->request('DELETE', '/api/client_contacts/'.$only->getId());
|
|
||||||
|
|
||||||
// RG-1.14
|
|
||||||
self::assertResponseStatusCodeSame(409);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Adresses ===
|
|
||||||
|
|
||||||
public function testPostAddressNormalizesBillingEmail(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Address Host');
|
|
||||||
$siteIri = $this->firstSiteIri();
|
|
||||||
|
|
||||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'isBilling' => true,
|
|
||||||
'billingEmail' => 'Facturation@ACME.FR',
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$siteIri],
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
// RG-1.21
|
|
||||||
self::assertSame('facturation@acme.fr', $data['billingEmail']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostAddressWithoutSiteReturns422(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Address No Site');
|
|
||||||
|
|
||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'postalCode' => '86100',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// RG-1.10 (Assert\Count min 1)
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostAddressWithInvalidPostalCodeReturns422(): void
|
|
||||||
{
|
|
||||||
$this->skipIfSitesModuleDisabled();
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Address Bad CP');
|
|
||||||
$siteIri = $this->firstSiteIri();
|
|
||||||
|
|
||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'postalCode' => '123',
|
|
||||||
'city' => 'Châtellerault',
|
|
||||||
'street' => '1 rue du Test',
|
|
||||||
'sites' => [$siteIri],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// RG-1.09 (Assert\Regex ^[0-9]{4,5}$)
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === RIBs ===
|
|
||||||
|
|
||||||
public function testPostRibByAdminReturns201(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Rib Host');
|
|
||||||
|
|
||||||
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'label' => 'Compte principal',
|
|
||||||
'bic' => self::VALID_BIC,
|
|
||||||
'iban' => self::VALID_IBAN,
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
self::assertSame('Compte principal', $data['label']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testPostRibWithInvalidIbanReturns422(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Rib Bad Iban');
|
|
||||||
|
|
||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => [
|
|
||||||
'label' => 'Compte invalide',
|
|
||||||
'bic' => self::VALID_BIC,
|
|
||||||
'iban' => 'INVALID-IBAN',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Assert\Iban
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteRibNonLcrReturns204(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Rib Non LCR');
|
|
||||||
$rib = $this->seedRib($seed);
|
|
||||||
|
|
||||||
$client->request('DELETE', '/api/client_ribs/'.$rib->getId());
|
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(204);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteLastRibUnderLcrReturns409(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$seed = $this->seedClient('Rib LCR Solo');
|
|
||||||
$this->setPaymentType($seed, 'LCR');
|
|
||||||
$rib = $this->seedRib($seed);
|
|
||||||
|
|
||||||
$client->request('DELETE', '/api/client_ribs/'.$rib->getId());
|
|
||||||
|
|
||||||
// RG-1.13
|
|
||||||
self::assertResponseStatusCodeSame(409);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRibWriteWithoutAccountingManageReturns403(): void
|
|
||||||
{
|
|
||||||
// Un utilisateur portant seulement commercial.clients.manage (sans
|
|
||||||
// accounting.manage) ne peut ni creer, ni modifier, ni supprimer un RIB.
|
|
||||||
$seed = $this->seedClient('Rib Forbidden');
|
|
||||||
$rib = $this->seedRib($seed);
|
|
||||||
$credentials = $this->createUserWithPermission('commercial.clients.manage');
|
|
||||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
|
||||||
|
|
||||||
$client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
|
||||||
'headers' => ['Content-Type' => self::LD],
|
|
||||||
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
|
|
||||||
$client->request('PATCH', '/api/client_ribs/'.$rib->getId(), [
|
|
||||||
'headers' => ['Content-Type' => self::MERGE],
|
|
||||||
'json' => ['label' => 'Y'],
|
|
||||||
]);
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
|
|
||||||
$client->request('DELETE', '/api/client_ribs/'.$rib->getId());
|
|
||||||
self::assertResponseStatusCodeSame(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Helpers ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seede un ClientContact rattache a un client (sans passer par l'API).
|
|
||||||
*/
|
|
||||||
private function seedContact(ClientEntity $client, string $firstName): ClientContact
|
|
||||||
{
|
|
||||||
$em = $this->getEm();
|
|
||||||
$contact = new ClientContact();
|
|
||||||
$contact->setFirstName($firstName);
|
|
||||||
$contact->setClient($client);
|
|
||||||
$em->persist($contact);
|
|
||||||
$em->flush();
|
|
||||||
|
|
||||||
return $contact;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seede un ClientRib valide rattache a un client (sans passer par l'API).
|
|
||||||
*/
|
|
||||||
private function seedRib(ClientEntity $client): ClientRib
|
|
||||||
{
|
|
||||||
$em = $this->getEm();
|
|
||||||
$rib = new ClientRib();
|
|
||||||
$rib->setLabel('Seed RIB');
|
|
||||||
$rib->setBic(self::VALID_BIC);
|
|
||||||
$rib->setIban(self::VALID_IBAN);
|
|
||||||
$rib->setClient($client);
|
|
||||||
$em->persist($rib);
|
|
||||||
$em->flush();
|
|
||||||
|
|
||||||
return $rib;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Affecte un type de reglement (par code) au client seede.
|
|
||||||
*/
|
|
||||||
private function setPaymentType(ClientEntity $client, string $code): void
|
|
||||||
{
|
|
||||||
$em = $this->getEm();
|
|
||||||
$type = $em->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
|
|
||||||
self::assertNotNull($type, sprintf('PaymentType "%s" introuvable (fixtures).', $code));
|
|
||||||
|
|
||||||
$managed = $em->getRepository(ClientEntity::class)->find($client->getId());
|
|
||||||
$managed->setPaymentType($type);
|
|
||||||
$em->flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retourne l'IRI du premier site seede (fixtures Sites). Skip en amont si le
|
|
||||||
* module Sites est desactive.
|
|
||||||
*/
|
|
||||||
private function firstSiteIri(): string
|
|
||||||
{
|
|
||||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
|
||||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
|
|
||||||
|
|
||||||
return '/api/sites/'.$site->getId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
|
||||||
|
|
||||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests d'unicite — combler les trous (ERP-60).
|
|
||||||
*
|
|
||||||
* RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par
|
|
||||||
* ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier
|
|
||||||
* verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee)
|
|
||||||
* et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ClientUniquenessTest extends AbstractCommercialApiTestCase
|
|
||||||
{
|
|
||||||
private const string LD = 'application/ld+json';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme
|
|
||||||
* email principal — aucune contrainte d'unicite (un email peut servir
|
|
||||||
* plusieurs clients).
|
|
||||||
*/
|
|
||||||
public function testDuplicateEmailIsAllowed(): void
|
|
||||||
{
|
|
||||||
$client = $this->createAdminClient();
|
|
||||||
$cat = $this->createCategory('SECTEUR');
|
|
||||||
$iri = '/api/categories/'.$cat->getId();
|
|
||||||
|
|
||||||
$payload = static fn (string $name): array => [
|
|
||||||
'companyName' => $name,
|
|
||||||
'firstName' => 'A',
|
|
||||||
'phonePrimary' => '0102030405',
|
|
||||||
'email' => 'partage@test.fr',
|
|
||||||
'categories' => [$iri],
|
|
||||||
];
|
|
||||||
|
|
||||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]);
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
|
|
||||||
// Meme email, nom different -> doit passer (pas d'index unique email).
|
|
||||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]);
|
|
||||||
self::assertResponseStatusCodeSame(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements
|
|
||||||
* multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on
|
|
||||||
* seede donc directement via l'ORM et on prouve que le flush ne leve aucune
|
|
||||||
* violation d'unicite.
|
|
||||||
*/
|
|
||||||
public function testDuplicateSirenIsAllowed(): void
|
|
||||||
{
|
|
||||||
// Boot kernel pour disposer de l'EM (pas d'appel HTTP necessaire ici).
|
|
||||||
self::bootKernel();
|
|
||||||
$em = $this->getEm();
|
|
||||||
|
|
||||||
$one = $this->seedClient('Siren Share One');
|
|
||||||
$two = $this->seedClient('Siren Share Two');
|
|
||||||
|
|
||||||
$one->setSiren('123456789');
|
|
||||||
$two->setSiren('123456789');
|
|
||||||
$em->flush();
|
|
||||||
|
|
||||||
// Aucune exception : preuve qu'il n'existe pas d'index unique sur siren.
|
|
||||||
self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($one->getId())->getSiren());
|
|
||||||
self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($two->getId())->getSiren());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?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,43 +71,11 @@ final class PermissionApiTest extends AbstractApiTestCase
|
|||||||
self::assertGreaterThanOrEqual(3, $data['totalItems']);
|
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
|
public function testCollectionFilterByModule(): void
|
||||||
{
|
{
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
$response = $client->request('GET', '/api/permissions', [
|
$response = $client->request('GET', '/api/permissions', [
|
||||||
'query' => ['module' => 'core', 'pagination' => 'false'],
|
'query' => ['module' => 'core'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
@@ -126,7 +94,7 @@ final class PermissionApiTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
$response = $client->request('GET', '/api/permissions', [
|
$response = $client->request('GET', '/api/permissions', [
|
||||||
'query' => ['orphan' => 'true', 'pagination' => 'false'],
|
'query' => ['orphan' => 'true'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
@@ -146,7 +114,7 @@ final class PermissionApiTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
$response = $client->request('GET', '/api/permissions', [
|
$response = $client->request('GET', '/api/permissions', [
|
||||||
'query' => ['orphan' => 'false', 'pagination' => 'false'],
|
'query' => ['orphan' => 'false'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ final class RoleApiTest extends AbstractApiTestCase
|
|||||||
public function testGetCollectionAsAdminReturnsRoles(): void
|
public function testGetCollectionAsAdminReturnsRoles(): void
|
||||||
{
|
{
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
$response = $client->request('GET', '/api/roles?pagination=false');
|
$response = $client->request('GET', '/api/roles');
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
$data = $response->toArray();
|
$data = $response->toArray();
|
||||||
@@ -157,35 +157,6 @@ final class RoleApiTest extends AbstractApiTestCase
|
|||||||
self::assertContains('test_editor', $codes);
|
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
|
public function testGetCollectionFilterByIsSystemTrue(): void
|
||||||
{
|
{
|
||||||
$client = $this->authenticatedClient('admin', 'admin');
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Shared\Infrastructure\Export;
|
|
||||||
|
|
||||||
use App\Shared\Infrastructure\Export\PhpSpreadsheetExporter;
|
|
||||||
use Generator;
|
|
||||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test unitaire du service Shared d'export XLSX. Verifie que le binaire produit
|
|
||||||
* est un vrai fichier XLSX relisible, que l'en-tete et les lignes sont ecrits
|
|
||||||
* dans le bon ordre, qu'un iterable paresseux (generator) est accepte et que le
|
|
||||||
* titre d'onglet est assaini.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class PhpSpreadsheetExporterTest extends TestCase
|
|
||||||
{
|
|
||||||
public function testExportProducesReadableXlsxWithHeadersAndRows(): void
|
|
||||||
{
|
|
||||||
$binary = new PhpSpreadsheetExporter()->export(
|
|
||||||
'Feuille test',
|
|
||||||
['Nom', 'Email'],
|
|
||||||
[
|
|
||||||
['Alpha', 'alpha@test.fr'],
|
|
||||||
['Beta', null],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertNotSame('', $binary);
|
|
||||||
// Un fichier XLSX (OOXML) est une archive ZIP : signature "PK\x03\x04".
|
|
||||||
self::assertStringStartsWith("PK\x03\x04", $binary);
|
|
||||||
|
|
||||||
$grid = $this->grid($binary);
|
|
||||||
self::assertSame(['Nom', 'Email'], $grid[0]);
|
|
||||||
self::assertSame('Alpha', $grid[1][0]);
|
|
||||||
self::assertSame('alpha@test.fr', $grid[1][1]);
|
|
||||||
self::assertSame('Beta', $grid[2][0]);
|
|
||||||
// Cellule null a l'ecriture -> vide a la relecture.
|
|
||||||
self::assertNull($grid[2][1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testExportAcceptsGeneratorRows(): void
|
|
||||||
{
|
|
||||||
$rows = (static function (): Generator {
|
|
||||||
yield ['L1'];
|
|
||||||
|
|
||||||
yield ['L2'];
|
|
||||||
})();
|
|
||||||
|
|
||||||
$grid = $this->grid(new PhpSpreadsheetExporter()->export('Gen', ['H'], $rows));
|
|
||||||
|
|
||||||
self::assertSame('H', $grid[0][0]);
|
|
||||||
self::assertSame('L1', $grid[1][0]);
|
|
||||||
self::assertSame('L2', $grid[2][0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testLongOrInvalidSheetTitleIsSanitized(): void
|
|
||||||
{
|
|
||||||
// Titre > 31 caracteres + caracteres interdits par Excel ([ ] : * etc.).
|
|
||||||
$binary = new PhpSpreadsheetExporter()->export(
|
|
||||||
str_repeat('A', 50).'[]:*?/\\',
|
|
||||||
['H'],
|
|
||||||
[['x']],
|
|
||||||
);
|
|
||||||
|
|
||||||
$title = $this->load($binary)->getActiveSheet()->getTitle();
|
|
||||||
self::assertLessThanOrEqual(31, mb_strlen($title));
|
|
||||||
self::assertStringNotContainsString('[', $title);
|
|
||||||
self::assertStringNotContainsString(':', $title);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Relit le binaire XLSX et renvoie la grille de cellules (ligne 0 = entete).
|
|
||||||
*
|
|
||||||
* @return array<int, array<int, mixed>>
|
|
||||||
*/
|
|
||||||
private function grid(string $binary): array
|
|
||||||
{
|
|
||||||
return $this->load($binary)->getActiveSheet()->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function load(string $binary): Spreadsheet
|
|
||||||
{
|
|
||||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_test_');
|
|
||||||
self::assertIsString($tmp);
|
|
||||||
file_put_contents($tmp, $binary);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return IOFactory::load($tmp);
|
|
||||||
} finally {
|
|
||||||
@unlink($tmp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user