Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c6919201e | |||
| 3e46394be1 | |||
| 1d91b4dea9 | |||
| c402418937 | |||
| 2866fb8865 |
@@ -13,6 +13,64 @@
|
||||
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
|
||||
- **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
||||
|
||||
## Pagination (obligatoire)
|
||||
|
||||
**Regle** : toute collection API DOIT etre paginee. Aucun retour de collection complete cote serveur.
|
||||
|
||||
### Standard global
|
||||
|
||||
Pose dans `config/packages/api_platform.yaml` (section `defaults:`) et heritee par toutes les ressources :
|
||||
|
||||
| Cle | Valeur | Effet |
|
||||
|---|---|---|
|
||||
| `pagination_enabled` | `true` | Pagination Hydra active par defaut. |
|
||||
| `pagination_items_per_page` | `10` | Taille de page par defaut, aligne sur l'UI `MalioDataTable`. |
|
||||
| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramene a 50. Anti deep-fetch. |
|
||||
| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornee par le max). |
|
||||
| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT recuperer (echappatoire selects). |
|
||||
|
||||
### Override par ressource (rare)
|
||||
|
||||
Si une ressource a besoin d'un autre defaut (ex: payload lourd), utiliser les attributs sur l'operation. **JAMAIS `paginationEnabled: false`** sans whitelist explicite dans `tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`.
|
||||
|
||||
```php
|
||||
new GetCollection(
|
||||
paginationItemsPerPage: 5, // override taille par defaut
|
||||
paginationMaximumItemsPerPage: 20, // override borne max
|
||||
)
|
||||
```
|
||||
|
||||
### Selects et autocompletions
|
||||
|
||||
Pour alimenter un `<select>` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe :
|
||||
|
||||
```ts
|
||||
useApi().get('/api/roles?pagination=false')
|
||||
```
|
||||
|
||||
Le serveur retourne toute la collection, sans `view`. C'est l'echappatoire prevue par `pagination_client_enabled: true`. Sur les ressources a forte volumetrie, preferer une saisie assistee (recherche serveur via `?q=`) — a planifier dans un ticket dedie.
|
||||
|
||||
Les tests fonctionnels qui exercent ce comportement doivent egalement passer `?pagination=false` (cf. `CategoryListTest`, `PermissionApiTest`).
|
||||
|
||||
### Providers customs et pagination
|
||||
|
||||
Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface` **court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportes :
|
||||
|
||||
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
|
||||
- **DBAL** : implementer un paginator local conforme a `PaginatorInterface`. Exemple : `DbalPaginator` (Core) + `AuditLogProvider`.
|
||||
|
||||
Gerer l'echappatoire `?pagination=false` :
|
||||
|
||||
```php
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult(); // tout retourner
|
||||
}
|
||||
```
|
||||
|
||||
### Garde-fou architecture
|
||||
|
||||
`tests/Architecture/CollectionsArePaginatedTest` scanne reflexivement toutes les classes `#[ApiResource]` sous `src/` et echoue si une `GetCollection` pose `paginationEnabled: false` hors whitelist `EXCLUDED`. Ajouter une entree a la whitelist requiert une justification courte + un ticket Lesstime ouvert.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
|
||||
|
||||
@@ -25,6 +25,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
||||
10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement.
|
||||
11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison.
|
||||
12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine.
|
||||
13. **Toujours paginer toute collection exposee par l'API.** Aucun retour de collection complete (pas de provider qui retourne un array brut). Standard pose dans `config/packages/api_platform.yaml` : 10 items par defaut, max 50, le client peut basculer entre 10/25/50 et peut envoyer `?pagination=false` pour alimenter un select. Garde-fou : `tests/Architecture/CollectionsArePaginatedTest` echoue si une `GetCollection` desactive la pagination sans whitelist. Details et exemples : @.claude/rules/backend.md § Pagination.
|
||||
|
||||
## Conventions
|
||||
@.claude/rules/architecture.md
|
||||
@@ -53,6 +54,7 @@ Editer uniquement `config/modules.php` (commenter la ligne). Cascade automatique
|
||||
## A NE PAS faire
|
||||
|
||||
- Pas de controller Symfony custom sous `/api/` sans `priority: 1` sur `#[Route]` (conflit API Platform `{id}`).
|
||||
- Pas de provider API Platform qui retourne un array brut sur une `GetCollection` — court-circuite la pagination Hydra (`totalItems` / `view` absents). Utiliser `ApiPlatform\Doctrine\Orm\Paginator` (ORM) ou un paginator implementant `PaginatorInterface` (DBAL — cf. `DbalPaginator`).
|
||||
- Pas de `getClientMimeType()` pour valider un upload — utiliser `$file->getMimeType()` (serveur).
|
||||
- Pas de hardcode de la sidebar cote front, pas de `modules-loader.ts` ni `.module.ts`.
|
||||
- Pas d'edition manuelle de `extends` dans `frontend/nuxt.config.ts` — les layers sont scannes automatiquement.
|
||||
|
||||
@@ -21,3 +21,18 @@ api_platform:
|
||||
stateless: true
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
# === Pagination Hydra (regle projet : toute collection DOIT etre paginee) ===
|
||||
# Standard datatable : 10 items par defaut, choix client 10 / 25 / 50.
|
||||
# Borne dure cote serveur a 50 pour prevenir tout `?itemsPerPage=999999`
|
||||
# (attaque memoire / deep-fetch). Le client peut neanmoins desactiver la
|
||||
# pagination via `?pagination=false` pour alimenter un <select> ou autre
|
||||
# vue "tout-en-un" — c'est l'echappatoire prevue pour les ressources
|
||||
# servant a la fois de datatable et de source de select (Role,
|
||||
# Permission, Site, CategoryType). Override par ressource possible via
|
||||
# `paginationItemsPerPage` / `paginationMaximumItemsPerPage` /
|
||||
# `paginationEnabled` sur l'attribut #[ApiResource] ou sur une operation.
|
||||
pagination_enabled: true
|
||||
pagination_items_per_page: 10
|
||||
pagination_maximum_items_per_page: 50
|
||||
pagination_client_items_per_page: true
|
||||
pagination_client_enabled: true
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.53'
|
||||
app.version: '0.1.55'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M1
|
||||
nom: "Répertoire clients"
|
||||
ecran: repertoire-clients
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0
|
||||
date_redaction: 2026-05-28
|
||||
|
||||
# === LIENS ===
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
|
||||
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21, RG-1.22, RG-1.23, RG-1.24, RG-1.25, RG-1.26, RG-1.27, RG-1.28, RG-1.29]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
lien_spec_back: ./spec-back.md
|
||||
|
||||
# === VALIDATION CLIENT #1 ===
|
||||
client_validation_1:
|
||||
statut: validee
|
||||
date: 2026-05-22
|
||||
canal: ecrit
|
||||
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||
resume: "Module 1 — Répertoire clients. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders blancs)."
|
||||
trace_archivee: "uploads/4a1b026f-M1-reportoire-clients.docx (V0 d'origine .docx)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_taskgroup_id: 23
|
||||
lesstime_project_id: 6
|
||||
statut_global: en_dev
|
||||
---
|
||||
|
||||
# Module 1 — Répertoire clients (V0 front)
|
||||
|
||||
> **Origine** : spec front V0 livrée le 22/05/2026 (`M1-reportoire-clients.docx`). Restitution Markdown pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute précision et toute décision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||
|
||||
## But
|
||||
|
||||
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des clients** de l'organisation : consultation, création, modification, archivage. Cette page est la **porte d'entrée du module Commercial**.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire clients »
|
||||
- **Rôles autorisés** :
|
||||
|
||||
| Rôle | Consultation | Création / Modification | Archivage |
|
||||
|---|---|---|---|
|
||||
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
|
||||
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
|
||||
| **Compta** | ✅ Tout | ✅ Onglet Comptabilité uniquement | ❌ |
|
||||
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
|
||||
| **Usine** | ❌ | ❌ | ❌ |
|
||||
|
||||
> **Note** : aligné sur le docx d'origine — Compta édite uniquement l'onglet Comptabilité (champs SIREN / TVA / Délai de règlement / Type de règlement / Banque / RIBs). Compta ne peut pas **créer** un client (pas de droit `manage` général), mais peut éditer la partie comptable d'un client existant créé par Admin ou Bureau.
|
||||
|
||||
## Navigation
|
||||
|
||||
L'écran est la page d'entrée du module **Commercial**. Titre : « **Répertoire clients** ».
|
||||
|
||||
- Affichage principal : un **datatable** listant tous les clients **actifs** de l'organisation (les clients archivés sont masqués par défaut — filtre UI dédié pour les voir).
|
||||
- **Clic sur une ligne** → bascule sur l'écran **Consultation client** (page dédiée, pas un drawer — cf. maquette Figma).
|
||||
- **Bouton « + Ajouter »** (en haut à droite) → bascule sur l'écran **Ajouter un client**.
|
||||
- **Bouton « Exporter »** (en haut à droite) → télécharge un **fichier XLSX** des clients **affichés** (cf. filtre actif). Format détaillé dans [`spec-back.md` § Export](./spec-back.md).
|
||||
|
||||
## Datatable du Répertoire
|
||||
|
||||
Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maquette) :
|
||||
|
||||
| Colonne | Source | Tri |
|
||||
|---|---|---|
|
||||
| **Nom entreprise** | `client.companyName` | ASC par défaut |
|
||||
| **Contact principal** | `firstName + lastName` | Oui |
|
||||
| **Téléphone principal** | `phonePrimary` (formaté `XX XX XX XX XX`) | Non |
|
||||
| **Email principal** | `email` | Oui |
|
||||
| **Catégories** | liste des codes catégories séparés par `,` | Non |
|
||||
| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
|
||||
|
||||
> **Filtre archivés** : toggle UI en haut du datatable. Désactivé par défaut. État local (pas dans l'URL — cf. règle ABSOLUE Starseed n°6).
|
||||
|
||||
> **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible — quelques centaines). Tri serveur `companyName ASC` par défaut.
|
||||
|
||||
## Écran « Ajouter un client »
|
||||
|
||||
Création par **onglets successifs avec validation incrémentale** : pour pouvoir passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant**, et les champs de l'onglet validé passent en lecture seule + bouton « Valider » désactivé (disabled).
|
||||
|
||||
### Formulaire principal (pré-onglets)
|
||||
|
||||
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
|
||||
|
||||
| Champ | Type composant | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
|
||||
| **Nom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Prénom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
|
||||
| **Téléphone principal** | `<MalioInputText>` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) |
|
||||
| **Téléphone secondaire** | `<MalioInputText>` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. |
|
||||
| **Email** | `<MalioInputText>` type email | Oui | RG-1.21 (lowercase) |
|
||||
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
|
||||
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de type `DISTRIBUTEUR`. RG-1.03. |
|
||||
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de type `COURTIER`. RG-1.03. |
|
||||
| **Prestation de triage** | `<MalioCheckbox>` | Non | — |
|
||||
|
||||
**Action** : « Valider » (`<MalioButton>`) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ».
|
||||
|
||||
### Onglet « Information »
|
||||
|
||||
Saisir les informations de l'entreprise.
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
|
||||
| **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
|
||||
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
|
||||
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
|
||||
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
|
||||
|
||||
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
|
||||
|
||||
### Onglet « Contact »
|
||||
|
||||
Saisir un ou plusieurs contacts associés au client. Le 1er bloc est **pré-rempli** depuis les champs du formulaire principal (Nom, Prénom, Téléphone, Email — édition autorisée).
|
||||
|
||||
**Bloc Contact** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Nom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
|
||||
| **Fonction** | `<MalioInputText>` | Non | — |
|
||||
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-1.20 (format) |
|
||||
| **Email** | `<MalioInputText>` type email | Non | RG-1.21 (lowercase) |
|
||||
|
||||
**RG-1.14 (renforcement validée par Tristan le 28/05)** : **au moins 1 bloc Contact valide** (au moins Nom OU Prénom rempli) est obligatoire pour valider l'onglet. Donc l'onglet Contact ne peut pas être finalisé vide.
|
||||
|
||||
**Actions** :
|
||||
- « + Nouveau contact » : ajoute un bloc. Bouton **désactivé tant que le bloc précédent n'a pas Prénom OU Nom rempli** (RG-1.05).
|
||||
- « Supprimer » (icône) sur un bloc : modal de confirmation (`<MalioButton>` Annuler / Confirmer). Si Oui → suppression du bloc.
|
||||
- « Valider » → PATCH `/api/clients/{id}/contacts` (création/mise à jour de la collection).
|
||||
|
||||
### Onglet « Adresse »
|
||||
|
||||
Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites Starseed (Châtellerault 86 / Saint-Jean 17 / Pommevic 82) et à des contacts.
|
||||
|
||||
**Bloc Adresse** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Prospect** | `<MalioCheckbox>` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché |
|
||||
| **Adresse de livraison** | `<MalioCheckbox>` | Non | RG-1.07 — masque Prospect si coché |
|
||||
| **Facturation** | `<MalioCheckbox>` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) |
|
||||
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de **type SECTEUR + AUTRE** uniquement (cf. décision Q5 — DISTRIBUTEUR et COURTIER qualifient une relation entre clients, pas un lieu) |
|
||||
| **Pays** | `<MalioSelect>` | Oui | Préremplie « France » |
|
||||
| **Code postal** | `<MalioInputText>` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN |
|
||||
| **Ville** | `<MalioSelect>` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP |
|
||||
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-1.09 — autocomplete BAN |
|
||||
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
|
||||
| **Sites Starseed** | `<MalioSelectCheckbox>` (multi-checkbox 86 / 17 / 82) | Oui | RG-1.10 — ≥ 1 site obligatoire |
|
||||
| **Contact(s) rattaché(s)** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
|
||||
| **Email (facturation)** | `<MalioInputText>` type email | Conditionnel | RG-1.11 — visible/obligatoire uniquement si « Facturation » coché |
|
||||
|
||||
**Actions** :
|
||||
- « + Nouvelle Adresse » : ajoute un bloc identique.
|
||||
- « Supprimer » : modal de confirmation puis suppression.
|
||||
- « Valider » → PATCH `/api/clients/{id}/addresses`.
|
||||
|
||||
### Onglet « Transport »
|
||||
|
||||
🚧 **Placeholder blanc au M1.** Frame vide. Aucun champ. Aucun bouton de validation. L'utilisateur passe automatiquement à l'onglet suivant. **Pas de mention « En cours »** — c'est juste blanc (décision Tristan 28/05).
|
||||
|
||||
### Onglet « Comptabilité »
|
||||
|
||||
⚠ **Accessible aux rôles avec `commercial.clients.accounting.manage`** (Admin + Compta au M1). Bureau et Commerciale ne voient pas l'onglet. **Compta peut éditer cet onglet** (champs SIREN / N° compte / TVA / Délai / Type de règlement / Banque / RIBs) — cf. décision Q1, aligné docx. Compta ne peut pas créer un client (pas de `manage` général).
|
||||
|
||||
**Champs comptables** :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | Format 9 chiffres. **Pas d'unicité** (décision Q4) |
|
||||
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
|
||||
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` |
|
||||
| **N° de TVA** | `<MalioInputText>` | Oui | — |
|
||||
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
|
||||
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
|
||||
| **Banque** | `<MalioSelect>` | Conditionnel | RG-1.12 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks`. |
|
||||
|
||||
**Bloc RIB** (0..n blocs, présence obligatoire conditionnée par RG-1.13) :
|
||||
|
||||
| Champ | Type | Obligatoire | Règle |
|
||||
|---|---|---|---|
|
||||
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 |
|
||||
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
|
||||
|
||||
**Actions** :
|
||||
- « + RIB » : ajoute un bloc.
|
||||
- « Supprimer » (icône) : modal de confirmation.
|
||||
- « Valider » → PATCH `/api/clients/{id}/accounting`.
|
||||
|
||||
### Onglets « Statistiques » / « Rapports » / « Échanges »
|
||||
|
||||
🚧 **Placeholders blancs au M1.** Mêmes règles que Transport (frames vides, pas de validation).
|
||||
|
||||
## Écran « Consultation client »
|
||||
|
||||
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans bouton `+` pour ajouter des blocs Contact / Adresse / RIB.
|
||||
|
||||
- **Flèche retour** (à gauche) → revient au Répertoire.
|
||||
- **Bouton « Modifier »** (à droite, visible si l'utilisateur a la permission `commercial.clients.manage`) → bascule sur l'écran Modification.
|
||||
- **Bouton « Archiver »** (à droite, visible **uniquement pour Admin** via permission `commercial.clients.archive`) → ouvre une modal de confirmation, puis PATCH `/api/clients/{id}` `{ "isArchived": true }`. Le client passe en archivé (cf. flag `is_archived`).
|
||||
|
||||
> Le client archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. Décision validée Tristan 28/05.
|
||||
|
||||
### Onglets affichés en consultation
|
||||
|
||||
Mêmes onglets qu'en création, **plus** les 4 placeholders blancs. L'utilisateur navigue librement entre les onglets (pas de séquence forcée en consultation).
|
||||
|
||||
## Écran « Modification client »
|
||||
|
||||
Comportement identique à l'écran Ajouter sauf :
|
||||
- **Pas de formulaire principal** (les champs principaux sont édités via les onglets correspondants).
|
||||
- Les champs sont **pré-remplis** avec les valeurs actuelles.
|
||||
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
|
||||
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` restent en lecture seule (pas de bouton Valider, pas d'icône suppression de bloc).
|
||||
- Les onglets placeholders restent inaccessibles à l'édition (blancs).
|
||||
|
||||
## Composants UI à utiliser (`@malio/layer-ui`)
|
||||
|
||||
- **Datatable** : `<MalioDataTable>` (Répertoire)
|
||||
- **Input texte** : `<MalioInputText>`
|
||||
- **Input numérique** : `<MalioInputNumber>`
|
||||
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
|
||||
- **TextArea** : `<MalioInputTextArea>` (Description)
|
||||
- **Select simple** : `<MalioSelect>` (Pays, Ville, distributeur/courtier, refs comptables)
|
||||
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
|
||||
- **Checkbox** : `<MalioCheckbox>` (Prospect, Adresse livraison, Facturation, Prestation de triage)
|
||||
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
|
||||
- **Toasts** : standards via `useApi()`
|
||||
|
||||
**Exceptions autorisées** (à commenter `// TODO migrer quand Malio couvre`) :
|
||||
- `<input type="date">` pour « Date de création » (composant `MalioDate` non couvert)
|
||||
- Modal de confirmation : composant à confirmer côté équipe front (probablement `<MalioModal>` ou un wrapper à créer dans `frontend/shared/`)
|
||||
|
||||
## Règles de formatage et normalisation
|
||||
|
||||
Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.md`](./spec-back.md)) :
|
||||
|
||||
| Champ | Normalisation serveur | Affichage front |
|
||||
|---|---|---|
|
||||
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
|
||||
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
|
||||
| Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
|
||||
| Email | lowercase intégral | identique |
|
||||
|
||||
> **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`.
|
||||
|
||||
## API adresse postale
|
||||
|
||||
Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.data.gouv.fr** (Base Adresse Nationale, gratuite, française).
|
||||
|
||||
- Composable dédié `useAddressAutocomplete()` (à créer en M1).
|
||||
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back.
|
||||
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse.
|
||||
- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre.
|
||||
|
||||
## Points laissés ouverts par la V0 (résolus côté back)
|
||||
|
||||
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||
|---|---|---|
|
||||
| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. CategoryType seedé avec `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` (HP-3 du M0 levé). |
|
||||
| 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. |
|
||||
| 3 | Onglet « Comptabilité » : qui édite ? | **Admin et Compta** peuvent éditer l'onglet Comptabilité (`commercial.clients.accounting.manage`). Bureau / Commerciale ne voient pas l'onglet. Compta ne peut pas créer un client (pas de `manage` global), mais peut éditer la partie comptable d'un client existant. |
|
||||
| 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». |
|
||||
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
|
||||
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
|
||||
| 7 | Unicité métier | **Nom d'entreprise uniquement** (case-insensitive, parmi non-archivés) — décision Q4. SIREN et email NON uniques. Index partiel Postgres `uq_client_company_name_active`. Doublon de nom → 409 Conflict. |
|
||||
| 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. |
|
||||
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. |
|
||||
| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). |
|
||||
| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
|
||||
|
||||
---
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : à créer — `M1 — Répertoire clients` (projet `ERP / Starseed`, projectId=6).
|
||||
|
||||
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
@@ -29,18 +32,32 @@ final class CategoryProvider implements ProviderInterface
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
|
||||
private readonly CategoryRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|Paginator|null
|
||||
{
|
||||
$includeDeleted = $this->readIncludeDeleted($context);
|
||||
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->repository
|
||||
->createListQueryBuilder($includeDeleted)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
$qb = $this->repository->createListQueryBuilder($includeDeleted);
|
||||
|
||||
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
|
||||
// Utile pour les drawers Role/Permission/Site/CategoryType qui alimentent un <select>.
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
// Branche paginee standard : on applique offset/limit via Pagination,
|
||||
// puis on enveloppe dans le Paginator ORM (fetchJoinCollection: true
|
||||
// pour que Doctrine compte correctement avec les JOINs futurs).
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||
}
|
||||
|
||||
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||
|
||||
@@ -29,18 +29,16 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
|
||||
* ?performed_at[after]=2026-04-01T00:00:00Z
|
||||
* ?performed_at[before]=2026-04-30T23:59:59Z
|
||||
*
|
||||
* La pagination est assuree par le provider via DbalPaginator (implementant
|
||||
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
|
||||
* automatiquement hydra:view — aucune construction manuelle.
|
||||
* La pagination herite du standard global (10 items / page, max 50, cf.
|
||||
* `config/packages/api_platform.yaml`). Elle est materialisee par le
|
||||
* DbalPaginator du provider qui implemente PaginatorInterface — API Platform
|
||||
* genere automatiquement hydra:view sans construction manuelle.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'AuditLog',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/audit-logs',
|
||||
paginationItemsPerPage: 30,
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 50,
|
||||
security: "is_granted('core.audit_log.view')",
|
||||
provider: AuditLogProvider::class,
|
||||
),
|
||||
|
||||
@@ -68,6 +68,13 @@ final readonly class AuditLogProvider implements ProviderInterface
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): DbalPaginator
|
||||
{
|
||||
// Contrairement aux ressources ORM (cf. CategoryProvider), ce provider
|
||||
// ne gere PAS l'echappatoire `?pagination=false` : la pagination y est
|
||||
// toujours forcee. `audit_log` est une table append-only a croissance
|
||||
// infinie — la dumper entierement saturerait memoire/reseau et n'a aucun
|
||||
// usage front (pas de <select> alimente par l'audit). Le flag global
|
||||
// `pagination_client_enabled: true` reste donc volontairement inerte ici.
|
||||
//
|
||||
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
|
||||
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
|
||||
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Garde-fou architecture : toute operation `GetCollection` exposee via API Platform
|
||||
* doit avoir la pagination activee (ou laisser la valeur par defaut, qui est
|
||||
* activee globalement dans `config/packages/api_platform.yaml`).
|
||||
*
|
||||
* Interdit : `new GetCollection(paginationEnabled: false)` sans exception documentee.
|
||||
*
|
||||
* Raison : une collection non paginee peut retourner des milliers de lignes et
|
||||
* saturer la memoire du serveur, le reseau et le navigateur. La pagination est la
|
||||
* seule protection fiable contre ce risque sur un CRM a donnees croissantes.
|
||||
*
|
||||
* Quand ajouter une entree dans `EXCLUDED` :
|
||||
* - La collection est structurellement bornee (referentiel statique, < 100 items,
|
||||
* jamais alimente par des utilisateurs) ET la suppression de la pagination est
|
||||
* documentee avec une justification metier explicite.
|
||||
* - Format obligatoire : `FQCN => 'justification + reference ticket/spec'`
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CollectionsArePaginatedTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Resources API Platform dont un `GetCollection` peut desactiver la pagination.
|
||||
*
|
||||
* Laisser vide au demarrage. Pour ajouter une exception :
|
||||
* 'App\Module\Foo\Infrastructure\ApiPlatform\Resource\BarResource'
|
||||
* => 'Referentiel statique < 50 items (types de contrat). Cf. ERP-XX.',
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
private const EXCLUDED = [];
|
||||
|
||||
public function testAllGetCollectionOperationsHavePaginationEnabled(): void
|
||||
{
|
||||
$finder = new Finder()
|
||||
->files()
|
||||
->in(__DIR__.'/../../src')
|
||||
->name('*.php')
|
||||
->contains('#[ApiResource')
|
||||
;
|
||||
|
||||
// Garde : si le scan ne trouve rien, le chemin est casse — le test
|
||||
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
|
||||
self::assertNotEmpty(
|
||||
iterator_to_array($finder),
|
||||
'Aucun fichier #[ApiResource] trouve sous src/ : chemin invalide ou codebase vide.',
|
||||
);
|
||||
|
||||
foreach ($finder as $file) {
|
||||
$fqcn = $this->extractFqcn($file->getRealPath());
|
||||
if (null === $fqcn || !class_exists($fqcn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($fqcn);
|
||||
$apiResourceAttributes = $reflection->getAttributes(ApiResource::class);
|
||||
|
||||
if ([] === $apiResourceAttributes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($apiResourceAttributes as $attribute) {
|
||||
/** @var ApiResource $apiResource */
|
||||
$apiResource = $attribute->newInstance();
|
||||
$operations = $apiResource->getOperations()?->getIterator() ?? [];
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
if (!$operation instanceof GetCollection) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false !== $operation->getPaginationEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// La pagination est explicitement desactivee : verifier
|
||||
// que la resource est dans la whitelist EXCLUDED.
|
||||
self::assertArrayHasKey(
|
||||
$fqcn,
|
||||
self::EXCLUDED,
|
||||
sprintf(
|
||||
"La resource %s desactive la pagination sur une operation GetCollection.\n"
|
||||
."Regle : toute collection API Platform doit etre paginee (cf. .claude/rules/backend.md).\n"
|
||||
."Si cette collection est structurellement bornee et que la desactivation est justifiee,\n"
|
||||
.'ajouter une entree dans CollectionsArePaginatedTest::EXCLUDED avec une justification.',
|
||||
$fqcn,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
|
||||
* source, sans charger le fichier.
|
||||
*/
|
||||
private function extractFqcn(string $path): ?string
|
||||
{
|
||||
$source = file_get_contents($path);
|
||||
if (false === $source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|
||||
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($nsMatch[1]).'\\'.$classMatch[1];
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
|
||||
);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
$response = $client->request('GET', '/api/categories?pagination=false');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
@@ -62,7 +62,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
|
||||
);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?includeDeleted=true');
|
||||
$response = $client->request('GET', '/api/categories?includeDeleted=true&pagination=false');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$names = array_values(array_filter(
|
||||
@@ -87,7 +87,7 @@ final class CategoryListTest extends AbstractCatalogApiTestCase
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'mid', $type);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
$response = $client->request('GET', '/api/categories?pagination=false');
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$names = array_values(array_filter(
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Tests du contrat de pagination sur GET /api/categories (ERP-72).
|
||||
*
|
||||
* Invariants testes :
|
||||
* - la collection expose les metadonnees Hydra (totalItems, view, member) ;
|
||||
* - itemsPerPage est plafonne au maximum global (50) ;
|
||||
* - une page hors limites retourne une collection vide, pas une 500 ;
|
||||
* - ?pagination=false retourne tous les items sans troncature (select-box) ;
|
||||
* - la pagination est compatible avec le flag ?includeDeleted=true.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryPaginationTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
/**
|
||||
* La collection expose les metadonnees de pagination JSON-LD sans prefixe :
|
||||
* `totalItems`, `view`, `member` (convention API Platform 4, pas hydra:*).
|
||||
*
|
||||
* On cree 12 categories pour depasser la limite par page (10) : la cle
|
||||
* `view` n'est presente que lorsqu'il y a plus d'items que la taille de page.
|
||||
*/
|
||||
public function testCollectionExposesHydraPaginationMetadata(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'meta_'.$i, $type);
|
||||
}
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertArrayHasKey('totalItems', $data, 'La collection doit exposer totalItems.');
|
||||
self::assertArrayHasKey('view', $data, 'La collection doit exposer view (pagination) quand totalItems > itemsPerPage.');
|
||||
self::assertIsArray($data['member'], 'member doit etre un tableau.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Un itemsPerPage arbitrairement grand (99999) doit etre plafonne au
|
||||
* maximum global configure (50). On cree 12 categories pour etre certain
|
||||
* de disposer de donnees ; le cap doit s'appliquer quelle que soit la taille
|
||||
* reelle de la collection.
|
||||
*/
|
||||
public function testItemsPerPageIsCappedAtMaximum(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'cap_'.$i, $type);
|
||||
}
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?itemsPerPage=99999');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
// Le cap global est 50 : jamais plus d'items par page que le maximum.
|
||||
self::assertLessThanOrEqual(
|
||||
50,
|
||||
count($response->toArray()['member']),
|
||||
'itemsPerPage doit etre plafonne au maximum global (50).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Une page tres elevee (99999) sur une petite collection ne doit pas
|
||||
* produire une 500 PG (OFFSET negatif ou depassement de capacite) mais
|
||||
* retourner 200 avec un tableau member vide.
|
||||
*/
|
||||
public function testOutOfBoundPageReturnsEmptyCollectionNot500(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'oob', $type);
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?page=99999');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
// La page 99999 est forcement vide (on a bien moins que 99999*10 items).
|
||||
self::assertSame(
|
||||
[],
|
||||
$response->toArray()['member'],
|
||||
'Une page hors limites doit retourner un member vide, jamais une 500.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ?pagination=false permet au frontend de desactiver la pagination pour
|
||||
* alimenter un select-box. On cree exactement 12 categories dont les noms
|
||||
* commencent par `test_cat_select_` : le filtre sur ce prefixe isole nos
|
||||
* entrees des donnees concurrentes et prouve que les 12 items sont tous
|
||||
* retournes (et pas seulement les 10 premiers de la page 1).
|
||||
*/
|
||||
public function testClientCanDisablePaginationToFeedASelect(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'select_'.$i, $type);
|
||||
}
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?pagination=false');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$members = $response->toArray()['member'];
|
||||
|
||||
// Filtre sur le sous-prefixe pour ne pas comptabiliser les categories
|
||||
// d'autres tests qui partagent la meme base de donnees.
|
||||
$selectItems = array_values(array_filter(
|
||||
$members,
|
||||
fn (array $m): bool => str_starts_with($m['name'], self::TEST_CATEGORY_PREFIX.'select_'),
|
||||
));
|
||||
|
||||
self::assertCount(
|
||||
12,
|
||||
$selectItems,
|
||||
'?pagination=false doit retourner toutes les categories (pas seulement la page 1).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* La pagination doit fonctionner conjointement avec le flag ?includeDeleted=true.
|
||||
* On seed 3 categories actives + 2 soft-deleted, on demande itemsPerPage=5 :
|
||||
* la page 1 doit contenir exactement 5 items et totalItems doit valoir >= 5.
|
||||
*/
|
||||
public function testPaginationCombinedWithIncludeDeletedFlag(): void
|
||||
{
|
||||
$type = $this->createCategoryType();
|
||||
|
||||
// 3 categories actives.
|
||||
for ($i = 1; $i <= 3; ++$i) {
|
||||
$this->createCategory(self::TEST_CATEGORY_PREFIX.'pag_active_'.$i, $type);
|
||||
}
|
||||
|
||||
// 2 categories soft-deleted.
|
||||
for ($i = 1; $i <= 2; ++$i) {
|
||||
$this->createCategory(
|
||||
self::TEST_CATEGORY_PREFIX.'pag_deleted_'.$i,
|
||||
$type,
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
|
||||
$client = $this->createAdminClient();
|
||||
$response = $client->request('GET', '/api/categories?includeDeleted=true&itemsPerPage=5');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
// La page retournee ne doit pas exceder itemsPerPage=5.
|
||||
self::assertCount(
|
||||
5,
|
||||
$data['member'],
|
||||
'La page 1 doit contenir exactement 5 items (itemsPerPage=5 avec >= 5 categories disponibles).',
|
||||
);
|
||||
self::assertGreaterThanOrEqual(
|
||||
5,
|
||||
$data['totalItems'],
|
||||
'totalItems doit refleter au moins les 5 categories seedees (actives + soft-deleted).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Core\Api;
|
||||
|
||||
/**
|
||||
* Regression test de pagination sur GET /api/audit-logs (ERP-72).
|
||||
*
|
||||
* Avant ce ticket, `paginationItemsPerPage` etait fixe a 30 dans
|
||||
* AuditLogResource. Apres migration vers les defaults globaux (10/50),
|
||||
* ce fichier verrouille le nouveau contrat :
|
||||
* - la reponse est paginee (max 10 items par page par defaut) ;
|
||||
* - un itemsPerPage excessif est plafonne a 50.
|
||||
*
|
||||
* Pas de seed : la table audit_log contient deja des lignes issues des
|
||||
* fixtures / autres tests. Les assertions utilisent des inegalites pour
|
||||
* rester robustes quelle que soit la quantite exacte de donnees presentes.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class AuditLogPaginationRegressionTest extends AbstractApiTestCase
|
||||
{
|
||||
/**
|
||||
* La collection /api/audit-logs doit etre paginee avec les defaults globaux :
|
||||
* - `member`, `totalItems`, `view` presentes dans la reponse JSON-LD ;
|
||||
* - au plus 10 items par page (nouveau defaut, etait 30 avant ce ticket).
|
||||
*/
|
||||
public function testAuditLogCollectionStillPaginated(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/audit-logs');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertArrayHasKey('totalItems', $data, 'La collection audit-logs doit exposer totalItems.');
|
||||
self::assertArrayHasKey('view', $data, 'La collection audit-logs doit exposer view (pagination active).');
|
||||
self::assertIsArray($data['member'], 'member doit etre un tableau.');
|
||||
|
||||
// Le nouveau defaut global est 10 (etait 30 dans AuditLogResource avant ERP-72).
|
||||
self::assertLessThanOrEqual(
|
||||
10,
|
||||
count($data['member']),
|
||||
'La page par defaut ne doit pas depasser 10 items (default global ERP-72).',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un itemsPerPage excessif (99999) doit etre plafonne au maximum global (50).
|
||||
* Teste la regression specifique du paginator DBAL custom (DbalPaginator) qui
|
||||
* pourrait ignorer la limite si la logique de cap n'est pas appliquee cote provider.
|
||||
*/
|
||||
public function testAuditLogItemsPerPageCappedAt50(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/audit-logs?itemsPerPage=99999');
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
|
||||
$data = $response->toArray();
|
||||
self::assertIsArray($data['member'], 'member doit etre un tableau.');
|
||||
|
||||
// Le cap global est 50 : jamais plus d'items par page que le maximum.
|
||||
self::assertLessThanOrEqual(
|
||||
50,
|
||||
count($data['member']),
|
||||
'itemsPerPage=99999 doit etre plafonne a 50 (maximum global ERP-72).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,11 +71,43 @@ final class PermissionApiTest extends AbstractApiTestCase
|
||||
self::assertGreaterThanOrEqual(3, $data['totalItems']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verrouille le chemin paginE PAR DEFAUT (ERP-72) : sans `?pagination=false`,
|
||||
* `/api/permissions` doit borner la page au defaut global (10) et exposer
|
||||
* `view`. Les autres tests de filtre passent `?pagination=false` et
|
||||
* n'exercent donc plus ce contrat — on le reteste ici de maniere isolee.
|
||||
*
|
||||
* On seed 12 permissions de test pour garantir un total > 10 quelle que soit
|
||||
* la quantite de permissions reelles presentes en base.
|
||||
*/
|
||||
public function testDefaultCollectionIsPaginatedToGlobalDefault(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
for ($i = 1; $i <= 12; ++$i) {
|
||||
$em->persist(new Permission(sprintf('test.core.pagination.perm_%d', $i), sprintf('Perm pagination %d (test)', $i), 'core'));
|
||||
}
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/permissions');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
// La page par defaut ne doit jamais depasser le maximum global (10).
|
||||
self::assertLessThanOrEqual(10, count($data['member']), 'La page par defaut doit etre bornee a 10 items.');
|
||||
// Avec >= 12 permissions de test (+ reelles), le total depasse une page.
|
||||
self::assertGreaterThan(10, $data['totalItems']);
|
||||
// `view` n'est present que lorsque la collection est reellement paginee.
|
||||
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
|
||||
}
|
||||
|
||||
public function testCollectionFilterByModule(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/permissions', [
|
||||
'query' => ['module' => 'core'],
|
||||
'query' => ['module' => 'core', 'pagination' => 'false'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
@@ -94,7 +126,7 @@ final class PermissionApiTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/permissions', [
|
||||
'query' => ['orphan' => 'true'],
|
||||
'query' => ['orphan' => 'true', 'pagination' => 'false'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
@@ -114,7 +146,7 @@ final class PermissionApiTest extends AbstractApiTestCase
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/permissions', [
|
||||
'query' => ['orphan' => 'false'],
|
||||
'query' => ['orphan' => 'false', 'pagination' => 'false'],
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
@@ -146,7 +146,7 @@ final class RoleApiTest extends AbstractApiTestCase
|
||||
public function testGetCollectionAsAdminReturnsRoles(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/roles');
|
||||
$response = $client->request('GET', '/api/roles?pagination=false');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
@@ -157,6 +157,35 @@ final class RoleApiTest extends AbstractApiTestCase
|
||||
self::assertContains('test_editor', $codes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verrouille le chemin paginE PAR DEFAUT (ERP-72) : le test ci-dessus passe
|
||||
* `?pagination=false` (usage <select>) et n'exerce donc plus le defaut
|
||||
* paginE. On seed 11 roles de test pour depasser une page (10) et verifier
|
||||
* que, sans parametre, la page est bornee a 10 et expose `view`.
|
||||
*/
|
||||
public function testDefaultCollectionIsPaginatedToGlobalDefault(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
for ($i = 1; $i <= 11; ++$i) {
|
||||
$em->persist(new Role(sprintf('test_pg_%d', $i), sprintf('Role pagination %d (test)', $i), false));
|
||||
}
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
$response = $client->request('GET', '/api/roles');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
// La page par defaut ne doit jamais depasser le maximum global (10).
|
||||
self::assertLessThanOrEqual(10, count($data['member']), 'La page par defaut doit etre bornee a 10 items.');
|
||||
// 11 roles de test + 2 systeme + editor + viewer => total > 10.
|
||||
self::assertGreaterThan(10, $data['totalItems']);
|
||||
// `view` n'est present que lorsque la collection est reellement paginee.
|
||||
self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.');
|
||||
}
|
||||
|
||||
public function testGetCollectionFilterByIsSystemTrue(): void
|
||||
{
|
||||
$client = $this->authenticatedClient('admin', 'admin');
|
||||
|
||||
Reference in New Issue
Block a user