Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c6919201e | |||
| 3e46394be1 |
@@ -13,6 +13,64 @@
|
|||||||
- 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/`
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
@.claude/rules/architecture.md
|
@.claude/rules/architecture.md
|
||||||
@@ -53,6 +54,7 @@ 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.
|
||||||
|
|||||||
@@ -21,3 +21,18 @@ 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
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.54'
|
app.version: '0.1.55'
|
||||||
|
|||||||
@@ -235,9 +235,7 @@ Le **formatage `XX XX XX XX XX`** est fait à l'affichage côté front (filter V
|
|||||||
|
|
||||||
### 3.2 Migration Doctrine — SQL Postgres
|
### 3.2 Migration Doctrine — SQL Postgres
|
||||||
|
|
||||||
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/Version20260601000000.php` (à dater par le dev).
|
Namespace : `App\Module\Commercial\Infrastructure\Doctrine\Migrations` (modulaire, post-init). Fichier : `Version20260601000000.php` (à dater par le dev).
|
||||||
|
|
||||||
> **Décision 29/05/2026 (vérifiée empiriquement en dev)** : cette migration crée un **schéma avec FK cross-module** (`user`, `category`, `site`) → elle a la même dépendance d'ordre que les migrations d'init. Le namespace modulaire `App\Module\Commercial\…` casse `make db-reset` : Doctrine Migrations 3.x trie par **FQCN alphabétique** (`App\…` < `DoctrineMigrations\…`), donc la migration client tournerait AVANT `user`/`category`/`site` et ses FK échoueraient. Elle relève donc de l'**exception racine** de la règle ABSOLUE n°11 (même choix que la migration cross-module ERP-67). Le namespace modulaire reste réservé aux évolutions post-schéma (ajout de colonnes/index). La correction long-terme (MigrationsComparator custom, tri par timestamp) est un ticket archi dédié, hors scope M1.
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
@@ -477,14 +475,7 @@ INSERT INTO category_type (code, label, position) VALUES
|
|||||||
('AUTRE', 'Autre', 99);
|
('AUTRE', 'Autre', 99);
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0).
|
> **Note** : le CRUD admin de `CategoryType` reste HP (cf. M0). Le seed est fait via migration ou fixture déclenchée à chaque `make db-reset`.
|
||||||
>
|
|
||||||
> **Seed en DEUX endroits (décision 29/05, vérifiée empiriquement)** : le `make db-reset` lance les fixtures, dont le purger Doctrine **vide `category_type`** (entité M0 mappée) avant `load()` → un seed posé uniquement en migration disparaît en dev/test. Donc :
|
|
||||||
> 1. **Migration** (`ON CONFLICT (code) DO NOTHING`) → sert en **prod** (pas de fixtures).
|
|
||||||
> 2. **Fixture Commercial idempotente** (ex. `CommercialReferentialFixtures`) re-seedant les 4 types → survit au `db-reset`, satisfait le critère « 4 types présents après db-reset ».
|
|
||||||
>
|
|
||||||
> ⚠ **À venir en ERP-54** : `tva_mode` / `payment_delay` / `payment_type` / `bank` ne sont pas encore des entités mappées au M1.0 → le purger ne les touche pas, leur seed migration survit. **Dès qu'ERP-54 crée leurs entités, ils seront purgés au db-reset** → il faudra les ajouter à la même fixture référentielle.
|
|
||||||
> 🔗 **Coordination ERP-68** : ERP-53 pose la fixture référentielle minimale (4 category_types). ERP-68 l'**étend** (clients de démo, ~12-15) sans la dupliquer.
|
|
||||||
|
|
||||||
### 3.4 Entité `Client` — squelette
|
### 3.4 Entité `Client` — squelette
|
||||||
|
|
||||||
@@ -980,7 +971,7 @@ Cf. § 2.6. Pattern Shared standard.
|
|||||||
- [ ] **Compta POST création** : Compta → 403 (pas de `manage` global)
|
- [ ] **Compta POST création** : Compta → 403 (pas de `manage` global)
|
||||||
- [ ] **PATCH mix groupes** : Bureau envoie payload avec `companyName` (write:main) + `siren` (write:accounting) → **403 sur tout le payload** (strict, RG-1.28)
|
- [ ] **PATCH mix groupes** : Bureau envoie payload avec `companyName` (write:main) + `siren` (write:accounting) → **403 sur tout le payload** (strict, RG-1.28)
|
||||||
- [ ] **Audit** : POST + PATCH + archive → audit_log avec entity_type='Client', `changes` correct ; **iban/bic présents dans le diff** (pas d'AuditIgnore, cf. § 6.1)
|
- [ ] **Audit** : POST + PATCH + archive → audit_log avec entity_type='Client', `changes` correct ; **iban/bic présents dans le diff** (pas d'AuditIgnore, cf. § 6.1)
|
||||||
- [ ] **Migration** : `make db-reset` → schéma OK ; migration en racine `migrations/` (namespace `DoctrineMigrations`, ordre garanti) ; 4 référentiels comptables seedés ; **4 CategoryType présents APRÈS db-reset** (via fixture idempotente, car le purger vide category_type) ; index partiel unique `uq_client_company_name_active` présent (un seul — cf. Q4)
|
- [ ] **Migration** : `make db-reset` → schéma OK, seed des 4 référentiels + CategoryType (DISTRIBUTEUR/COURTIER/SECTEUR/AUTRE) présent ; index partiel unique `uq_client_company_name_active` présent (un seul — cf. Q4)
|
||||||
|
|
||||||
### 8.2 Cas à couvrir (front — Vitest)
|
### 8.2 Cas à couvrir (front — Vitest)
|
||||||
|
|
||||||
|
|||||||
@@ -1,538 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* M1 — Repertoire clients (ERP-53) : creation de toute la structure BDD du
|
|
||||||
* module Commercial (clients + sous-collections + referentiels comptables).
|
|
||||||
*
|
|
||||||
* Tables creees :
|
|
||||||
* - Referentiels comptables (statiques, seedes ici) : tva_mode, payment_delay,
|
|
||||||
* payment_type, bank.
|
|
||||||
* - Table principale : client (formulaire + Information + Comptabilite +
|
|
||||||
* archive + soft-delete + Timestampable/Blamable).
|
|
||||||
* - Sous-collections : client_category (M2M), client_contact (1:n),
|
|
||||||
* client_address (1:n), client_rib (1:n).
|
|
||||||
* - Jointures de client_address : client_address_site, client_address_contact,
|
|
||||||
* client_address_category.
|
|
||||||
*
|
|
||||||
* Seed `category_type` (extension M0) : DISTRIBUTEUR / COURTIER / SECTEUR /
|
|
||||||
* AUTRE, en `ON CONFLICT (code) DO NOTHING` (idempotent — la table peut deja
|
|
||||||
* porter des donnees en prod). En dev/test, les fixtures purgent et re-seedent
|
|
||||||
* ces 4 types (cf. CategoryTypeFixtures) ; ce seed migration couvre la prod ou
|
|
||||||
* les fixtures ne tournent pas.
|
|
||||||
*
|
|
||||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) et NON
|
|
||||||
* `App\Module\Commercial\...` : avec plusieurs migrations_paths, Doctrine
|
|
||||||
* Migrations 3.x trie par FQCN alphabetique (AlphabeticalComparator → strcmp).
|
|
||||||
* Un namespace `App\Module\Commercial\...` trierait AVANT `DoctrineMigrations\...`
|
|
||||||
* et la migration s'executerait avant la creation de user/category/site sur
|
|
||||||
* base vide → echec des FK. Le namespace racine garantit l'ordre par timestamp.
|
|
||||||
*
|
|
||||||
* Style DDL aligne sur la migration M0 (Version20260527164000) plutot que sur
|
|
||||||
* le pseudo-SQL de la spec § 3.2 : `INT GENERATED BY DEFAULT AS IDENTITY` (et
|
|
||||||
* non SERIAL), `TIMESTAMP(0) WITHOUT TIME ZONE` (et non TIMESTAMPTZ, car le
|
|
||||||
* `TimestampableBlamableTrait` mappe `datetime_immutable`). Garantit que
|
|
||||||
* `schema:update` restera un no-op quand les entites arriveront (ticket ERP-54).
|
|
||||||
*
|
|
||||||
* Decision Q4 (29/05/2026) : unicite metier sur le NOM DE SOCIETE uniquement.
|
|
||||||
* Pas d'index unique sur siren ni email (RG-1.15 / RG-1.17 supprimees).
|
|
||||||
*
|
|
||||||
* Chaque colonne porte un `COMMENT ON COLUMN` (regle ABSOLUE n°12, garde-fou
|
|
||||||
* ColumnsHaveSqlCommentTest). Les tables n'etant pas encore mappees par l'ORM,
|
|
||||||
* ces commentaires survivent au `schema:update --force` du setup de test.
|
|
||||||
*/
|
|
||||||
final class Version20260601000000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'ERP-53 (M1) : tables client + sous-collections + referentiels comptables + seed category_type.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->createAccountingReferentials();
|
|
||||||
$this->createClientTable();
|
|
||||||
$this->createClientCategory();
|
|
||||||
$this->createClientContact();
|
|
||||||
$this->createClientAddress();
|
|
||||||
$this->createClientAddressJoinTables();
|
|
||||||
$this->createClientRib();
|
|
||||||
$this->seedCategoryTypes();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// Ordre inverse des dependances FK : on supprime d'abord les jointures
|
|
||||||
// et sous-collections, puis client, puis les referentiels.
|
|
||||||
$this->addSql('DROP TABLE client_address_category');
|
|
||||||
$this->addSql('DROP TABLE client_address_contact');
|
|
||||||
$this->addSql('DROP TABLE client_address_site');
|
|
||||||
$this->addSql('DROP TABLE client_rib');
|
|
||||||
$this->addSql('DROP TABLE client_address');
|
|
||||||
$this->addSql('DROP TABLE client_contact');
|
|
||||||
$this->addSql('DROP TABLE client_category');
|
|
||||||
$this->addSql('DROP TABLE client');
|
|
||||||
$this->addSql('DROP TABLE bank');
|
|
||||||
$this->addSql('DROP TABLE payment_type');
|
|
||||||
$this->addSql('DROP TABLE payment_delay');
|
|
||||||
$this->addSql('DROP TABLE tva_mode');
|
|
||||||
|
|
||||||
// Retire uniquement les 4 types seedes par cette migration. Les autres
|
|
||||||
// types eventuels (CRUD futur) sont preserves.
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
DELETE FROM category_type WHERE code IN ('DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE')
|
|
||||||
SQL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Referentiels comptables (4 tables statiques, memes colonnes)
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
private function createAccountingReferentials(): void
|
|
||||||
{
|
|
||||||
$referentials = [
|
|
||||||
'tva_mode' => 'Referentiel des modes de TVA appliques a un client (France, Export, Intracom).',
|
|
||||||
'payment_delay' => 'Referentiel des delais de reglement (15 jours, 30 jours, a reception).',
|
|
||||||
'payment_type' => 'Referentiel des types de reglement (virement, LCR, cheque, non soumise).',
|
|
||||||
'bank' => 'Referentiel des banques selectionnables pour le reglement par virement.',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($referentials as $table => $tableComment) {
|
|
||||||
$this->addSql(sprintf(<<<'SQL'
|
|
||||||
CREATE TABLE %s (
|
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
||||||
code VARCHAR(30) NOT NULL,
|
|
||||||
label VARCHAR(120) NOT NULL,
|
|
||||||
position INT DEFAULT 0 NOT NULL,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
)
|
|
||||||
SQL, $table));
|
|
||||||
$this->addSql(sprintf('CREATE UNIQUE INDEX uq_%s_code ON %s (code)', $table, $table));
|
|
||||||
|
|
||||||
$this->comment($table, '_table', $tableComment);
|
|
||||||
$this->comment($table, 'id', 'Identifiant interne auto-incremente.');
|
|
||||||
$this->comment($table, 'code', 'Code technique stable (UPPER_SNAKE, ≤ 30 caracteres) — unique, utilise par le code metier.');
|
|
||||||
$this->comment($table, 'label', 'Libelle affichable (FR, ≤ 120 caracteres).');
|
|
||||||
$this->comment($table, 'position', 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed initial (cf. spec § 3.2). Tables fraichement creees donc vides :
|
|
||||||
// INSERT direct sans ON CONFLICT.
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
INSERT INTO tva_mode (code, label, position) VALUES
|
|
||||||
('FRANCE_VENTES', 'France (ventes)', 10),
|
|
||||||
('EXPORT_VENTES', 'Export (ventes)', 20),
|
|
||||||
('INTRACOM_VENTES', 'Intracom (ventes)', 30)
|
|
||||||
SQL);
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
INSERT INTO payment_delay (code, label, position) VALUES
|
|
||||||
('J15', '15 jours', 10),
|
|
||||||
('J30', '30 jours', 20),
|
|
||||||
('A_RECEPTION', 'À réception', 30)
|
|
||||||
SQL);
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
INSERT INTO payment_type (code, label, position) VALUES
|
|
||||||
('VIREMENT', 'Virement', 10),
|
|
||||||
('LCR', 'LCR', 20),
|
|
||||||
('NON_SOUMISE', 'Non soumise', 30),
|
|
||||||
('CHEQUE', 'Chèque', 40)
|
|
||||||
SQL);
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
INSERT INTO bank (code, label, position) VALUES
|
|
||||||
('SG', 'Société Générale', 10),
|
|
||||||
('CIC', 'CIC', 20),
|
|
||||||
('CA', 'Crédit Agricole', 30)
|
|
||||||
SQL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Table principale `client`
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
private function createClientTable(): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE client (
|
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
||||||
company_name VARCHAR(180) NOT NULL,
|
|
||||||
first_name VARCHAR(120) DEFAULT NULL,
|
|
||||||
last_name VARCHAR(120) DEFAULT NULL,
|
|
||||||
phone_primary VARCHAR(20) NOT NULL,
|
|
||||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
|
||||||
email VARCHAR(180) NOT NULL,
|
|
||||||
distributor_id INT DEFAULT NULL,
|
|
||||||
broker_id INT DEFAULT NULL,
|
|
||||||
triage_service BOOLEAN DEFAULT FALSE NOT NULL,
|
|
||||||
description TEXT DEFAULT NULL,
|
|
||||||
competitors VARCHAR(255) DEFAULT NULL,
|
|
||||||
founded_at DATE DEFAULT NULL,
|
|
||||||
employees_count INT DEFAULT NULL,
|
|
||||||
revenue_amount NUMERIC(15, 2) DEFAULT NULL,
|
|
||||||
director_name VARCHAR(120) DEFAULT NULL,
|
|
||||||
profit_amount NUMERIC(15, 2) DEFAULT NULL,
|
|
||||||
siren VARCHAR(20) DEFAULT NULL,
|
|
||||||
account_number VARCHAR(40) DEFAULT NULL,
|
|
||||||
tva_mode_id INT DEFAULT NULL,
|
|
||||||
n_tva VARCHAR(40) DEFAULT NULL,
|
|
||||||
payment_delay_id INT DEFAULT NULL,
|
|
||||||
payment_type_id INT DEFAULT NULL,
|
|
||||||
bank_id INT DEFAULT NULL,
|
|
||||||
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
|
|
||||||
archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
|
||||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
|
||||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
created_by INT DEFAULT NULL,
|
|
||||||
updated_by INT DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT chk_client_distrib_or_broker
|
|
||||||
CHECK (NOT (distributor_id IS NOT NULL AND broker_id IS NOT NULL)),
|
|
||||||
CONSTRAINT fk_client_distributor
|
|
||||||
FOREIGN KEY (distributor_id) REFERENCES client (id) ON DELETE SET NULL,
|
|
||||||
CONSTRAINT fk_client_broker
|
|
||||||
FOREIGN KEY (broker_id) REFERENCES client (id) ON DELETE SET NULL,
|
|
||||||
CONSTRAINT fk_client_tva_mode
|
|
||||||
FOREIGN KEY (tva_mode_id) REFERENCES tva_mode (id) ON DELETE RESTRICT,
|
|
||||||
CONSTRAINT fk_client_payment_delay
|
|
||||||
FOREIGN KEY (payment_delay_id) REFERENCES payment_delay (id) ON DELETE RESTRICT,
|
|
||||||
CONSTRAINT fk_client_payment_type
|
|
||||||
FOREIGN KEY (payment_type_id) REFERENCES payment_type (id) ON DELETE RESTRICT,
|
|
||||||
CONSTRAINT fk_client_bank
|
|
||||||
FOREIGN KEY (bank_id) REFERENCES bank (id) ON DELETE RESTRICT,
|
|
||||||
CONSTRAINT fk_client_created_by
|
|
||||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
|
||||||
CONSTRAINT fk_client_updated_by
|
|
||||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
|
|
||||||
$this->addSql('CREATE INDEX idx_client_is_archived ON client (is_archived)');
|
|
||||||
$this->addSql('CREATE INDEX idx_client_deleted_at ON client (deleted_at)');
|
|
||||||
$this->addSql('CREATE INDEX idx_client_distributor_id ON client (distributor_id)');
|
|
||||||
$this->addSql('CREATE INDEX idx_client_broker_id ON client (broker_id)');
|
|
||||||
$this->addSql('CREATE INDEX idx_client_created_by ON client (created_by)');
|
|
||||||
$this->addSql('CREATE INDEX idx_client_updated_by ON client (updated_by)');
|
|
||||||
|
|
||||||
// Unicite metier partielle (Q4) : nom de societe insensible a la casse,
|
|
||||||
// parmi les non-archives ET non soft-deletes uniquement. Pas d'index
|
|
||||||
// unique sur siren ni email.
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE UNIQUE INDEX uq_client_company_name_active
|
|
||||||
ON client (LOWER(company_name))
|
|
||||||
WHERE is_archived = FALSE AND deleted_at IS NULL
|
|
||||||
SQL);
|
|
||||||
|
|
||||||
$this->comment('client', '_table', 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).');
|
|
||||||
$this->comment('client', 'id', 'Identifiant interne auto-incremente.');
|
|
||||||
$this->comment('client', 'company_name', 'Raison sociale (stockee en MAJUSCULES, RG-1.18). Unique case-insensitive parmi les actifs non archives/non supprimes (RG-1.16, uq_client_company_name_active).');
|
|
||||||
$this->comment('client', 'first_name', 'Prenom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
|
||||||
$this->comment('client', 'last_name', 'Nom du contact principal (capitalise serveur, RG-1.19). first_name OU last_name obligatoire (RG-1.01).');
|
|
||||||
$this->comment('client', 'phone_primary', 'Telephone principal — stocke en chiffres uniquement (RG-1.20). Obligatoire.');
|
|
||||||
$this->comment('client', 'phone_secondary', 'Telephone secondaire optionnel — chiffres uniquement (RG-1.20).');
|
|
||||||
$this->comment('client', 'email', 'Email principal (lowercase serveur, RG-1.21). NON unique (RG-1.17 supprimee, Q4).');
|
|
||||||
$this->comment('client', 'distributor_id', 'FK auto-referente vers un client porteur de la categorie DISTRIBUTEUR — exclusive avec broker_id (RG-1.03, chk_client_distrib_or_broker). FK -> client.id, ON DELETE SET NULL.');
|
|
||||||
$this->comment('client', 'broker_id', 'FK auto-referente vers un client porteur de la categorie COURTIER — exclusive avec distributor_id (RG-1.03). FK -> client.id, ON DELETE SET NULL.');
|
|
||||||
$this->comment('client', 'triage_service', 'Drapeau service triage active pour le client. Faux par defaut.');
|
|
||||||
$this->comment('client', 'description', 'Onglet Information : description libre. Obligatoire pour le role Commerciale (RG-1.04), optionnel sinon.');
|
|
||||||
$this->comment('client', 'competitors', 'Onglet Information : concurrents identifies (texte libre ≤ 255). Obligatoire role Commerciale (RG-1.04).');
|
|
||||||
$this->comment('client', 'founded_at', 'Onglet Information : date de creation de l entreprise. Obligatoire role Commerciale (RG-1.04).');
|
|
||||||
$this->comment('client', 'employees_count', 'Onglet Information : effectif (entier >= 0). Obligatoire role Commerciale (RG-1.04).');
|
|
||||||
$this->comment('client', 'revenue_amount', 'Onglet Information : chiffre d affaires (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
|
||||||
$this->comment('client', 'director_name', 'Onglet Information : nom du dirigeant. Obligatoire role Commerciale (RG-1.04).');
|
|
||||||
$this->comment('client', 'profit_amount', 'Onglet Information : resultat / benefice (NUMERIC 15,2). Obligatoire role Commerciale (RG-1.04).');
|
|
||||||
$this->comment('client', 'siren', 'Onglet Comptabilite : SIREN (9 chiffres attendus). NON unique — peut etre partage entre etablissements (RG-1.15 supprimee, Q4).');
|
|
||||||
$this->comment('client', 'account_number', 'Onglet Comptabilite : numero de compte comptable du client.');
|
|
||||||
$this->comment('client', 'tva_mode_id', 'Onglet Comptabilite : mode de TVA applique — FK -> tva_mode.id, ON DELETE RESTRICT.');
|
|
||||||
$this->comment('client', 'n_tva', 'Onglet Comptabilite : numero de TVA intracommunautaire.');
|
|
||||||
$this->comment('client', 'payment_delay_id', 'Onglet Comptabilite : delai de reglement — FK -> payment_delay.id, ON DELETE RESTRICT.');
|
|
||||||
$this->comment('client', 'payment_type_id', 'Onglet Comptabilite : type de reglement — FK -> payment_type.id, ON DELETE RESTRICT. Code LCR impose >= 1 RIB (RG-1.13), VIREMENT impose une banque (RG-1.12).');
|
|
||||||
$this->comment('client', 'bank_id', 'Onglet Comptabilite : banque — FK -> bank.id, ON DELETE RESTRICT. Obligatoire si payment_type = VIREMENT (RG-1.12).');
|
|
||||||
$this->comment('client', 'is_archived', 'Drapeau fonctionnel d archivage — masque par defaut dans la liste. Bascule via permission commercial.clients.archive (RG-1.22/23).');
|
|
||||||
$this->comment('client', 'archived_at', 'Horodatage de l archivage — pose quand is_archived passe a vrai, remis a null a la restauration (RG-1.22/23).');
|
|
||||||
$this->comment('client', 'deleted_at', 'Horodatage du soft-delete technique (HP M2) — non expose par l API au M1. Null = ligne active.');
|
|
||||||
$this->addTimestampableBlamableComments('client');
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// M2M client <-> category
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
private function createClientCategory(): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE client_category (
|
|
||||||
client_id INT NOT NULL,
|
|
||||||
category_id INT NOT NULL,
|
|
||||||
PRIMARY KEY (client_id, category_id),
|
|
||||||
CONSTRAINT fk_client_category_client
|
|
||||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_client_category_category
|
|
||||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE INDEX idx_client_category_category ON client_category (category_id)');
|
|
||||||
|
|
||||||
$this->comment('client_category', '_table', 'Jointure M2M client <-> category (Catalog) — categories metier du client (au moins une obligatoire).');
|
|
||||||
$this->comment('client_category', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client porteur de la categorie.');
|
|
||||||
$this->comment('client_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie rattachee au client.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Sous-collection : contacts (1:n)
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
private function createClientContact(): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE client_contact (
|
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
||||||
client_id INT NOT NULL,
|
|
||||||
first_name VARCHAR(120) DEFAULT NULL,
|
|
||||||
last_name VARCHAR(120) DEFAULT NULL,
|
|
||||||
job_title VARCHAR(120) DEFAULT NULL,
|
|
||||||
phone_primary VARCHAR(20) DEFAULT NULL,
|
|
||||||
phone_secondary VARCHAR(20) DEFAULT NULL,
|
|
||||||
email VARCHAR(180) DEFAULT NULL,
|
|
||||||
position INT DEFAULT 0 NOT NULL,
|
|
||||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
created_by INT DEFAULT NULL,
|
|
||||||
updated_by INT DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT chk_client_contact_name
|
|
||||||
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL),
|
|
||||||
CONSTRAINT fk_client_contact_client
|
|
||||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_client_contact_created_by
|
|
||||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
|
||||||
CONSTRAINT fk_client_contact_updated_by
|
|
||||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE INDEX idx_client_contact_client ON client_contact (client_id)');
|
|
||||||
|
|
||||||
$this->comment('client_contact', '_table', 'Contacts d un client (1:n) — au moins firstName OU lastName par contact (RG-1.05).');
|
|
||||||
$this->comment('client_contact', 'id', 'Identifiant interne auto-incremente.');
|
|
||||||
$this->comment('client_contact', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du contact.');
|
|
||||||
$this->comment('client_contact', 'first_name', 'Prenom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
|
||||||
$this->comment('client_contact', 'last_name', 'Nom du contact (capitalise serveur). first_name OU last_name obligatoire (RG-1.05, chk_client_contact_name).');
|
|
||||||
$this->comment('client_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).');
|
|
||||||
$this->comment('client_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (RG-1.20).');
|
|
||||||
$this->comment('client_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (RG-1.20).');
|
|
||||||
$this->comment('client_contact', 'email', 'Email du contact (lowercase serveur, RG-1.21).');
|
|
||||||
$this->comment('client_contact', 'position', 'Ordre d affichage du contact dans la liste du client (croissant).');
|
|
||||||
$this->addTimestampableBlamableComments('client_contact');
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Sous-collection : adresses (1:n)
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
private function createClientAddress(): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE client_address (
|
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
||||||
client_id INT NOT NULL,
|
|
||||||
is_prospect BOOLEAN DEFAULT FALSE NOT NULL,
|
|
||||||
is_delivery BOOLEAN DEFAULT FALSE NOT NULL,
|
|
||||||
is_billing BOOLEAN DEFAULT FALSE NOT NULL,
|
|
||||||
country VARCHAR(80) DEFAULT 'France' NOT NULL,
|
|
||||||
postal_code VARCHAR(20) NOT NULL,
|
|
||||||
city VARCHAR(120) NOT NULL,
|
|
||||||
street VARCHAR(255) NOT NULL,
|
|
||||||
street_complement VARCHAR(255) DEFAULT NULL,
|
|
||||||
billing_email VARCHAR(180) DEFAULT NULL,
|
|
||||||
position INT DEFAULT 0 NOT NULL,
|
|
||||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
created_by INT DEFAULT NULL,
|
|
||||||
updated_by INT DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT chk_client_address_prospect_exclusive
|
|
||||||
CHECK (NOT (is_prospect = TRUE AND (is_delivery = TRUE OR is_billing = TRUE))),
|
|
||||||
CONSTRAINT chk_client_address_billing_email
|
|
||||||
CHECK ((is_billing = FALSE AND billing_email IS NULL)
|
|
||||||
OR (is_billing = TRUE AND billing_email IS NOT NULL)),
|
|
||||||
CONSTRAINT fk_client_address_client
|
|
||||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_client_address_created_by
|
|
||||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
|
||||||
CONSTRAINT fk_client_address_updated_by
|
|
||||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE INDEX idx_client_address_client ON client_address (client_id)');
|
|
||||||
|
|
||||||
$this->comment('client_address', '_table', 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).');
|
|
||||||
$this->comment('client_address', 'id', 'Identifiant interne auto-incremente.');
|
|
||||||
$this->comment('client_address', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.');
|
|
||||||
$this->comment('client_address', 'is_prospect', 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.');
|
|
||||||
$this->comment('client_address', 'is_delivery', 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.');
|
|
||||||
$this->comment('client_address', 'is_billing', 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.');
|
|
||||||
$this->comment('client_address', 'country', 'Pays de l adresse — defaut France.');
|
|
||||||
$this->comment('client_address', 'postal_code', 'Code postal (4-5 chiffres attendus, RG-1.09).');
|
|
||||||
$this->comment('client_address', 'city', 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).');
|
|
||||||
$this->comment('client_address', 'street', 'Numero et voie de l adresse.');
|
|
||||||
$this->comment('client_address', 'street_complement', 'Complement d adresse (etage, batiment...) — optionnel.');
|
|
||||||
$this->comment('client_address', 'billing_email', 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).');
|
|
||||||
$this->comment('client_address', 'position', 'Ordre d affichage de l adresse dans la liste du client (croissant).');
|
|
||||||
$this->addTimestampableBlamableComments('client_address');
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Jointures de client_address (M2M)
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
private function createClientAddressJoinTables(): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE client_address_site (
|
|
||||||
client_address_id INT NOT NULL,
|
|
||||||
site_id INT NOT NULL,
|
|
||||||
PRIMARY KEY (client_address_id, site_id),
|
|
||||||
CONSTRAINT fk_client_address_site_address
|
|
||||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_client_address_site_site
|
|
||||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->comment('client_address_site', '_table', 'Jointure M2M client_address <-> site (Sites) — sites desservis par l adresse (>= 1 obligatoire, RG-1.10).');
|
|
||||||
$this->comment('client_address_site', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
|
||||||
$this->comment('client_address_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site rattache a l adresse.');
|
|
||||||
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE client_address_contact (
|
|
||||||
client_address_id INT NOT NULL,
|
|
||||||
client_contact_id INT NOT NULL,
|
|
||||||
PRIMARY KEY (client_address_id, client_contact_id),
|
|
||||||
CONSTRAINT fk_client_address_contact_address
|
|
||||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_client_address_contact_contact
|
|
||||||
FOREIGN KEY (client_contact_id) REFERENCES client_contact (id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->comment('client_address_contact', '_table', 'Jointure M2M client_address <-> client_contact — contacts associes a une adresse.');
|
|
||||||
$this->comment('client_address_contact', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
|
||||||
$this->comment('client_address_contact', 'client_contact_id', 'FK -> client_contact.id, ON DELETE CASCADE — contact associe a l adresse.');
|
|
||||||
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE client_address_category (
|
|
||||||
client_address_id INT NOT NULL,
|
|
||||||
category_id INT NOT NULL,
|
|
||||||
PRIMARY KEY (client_address_id, category_id),
|
|
||||||
CONSTRAINT fk_client_address_category_address
|
|
||||||
FOREIGN KEY (client_address_id) REFERENCES client_address (id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_client_address_category_category
|
|
||||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->comment('client_address_category', '_table', 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).');
|
|
||||||
$this->comment('client_address_category', 'client_address_id', 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.');
|
|
||||||
$this->comment('client_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).');
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Sous-collection : RIB (1:n)
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
private function createClientRib(): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE client_rib (
|
|
||||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
|
||||||
client_id INT NOT NULL,
|
|
||||||
label VARCHAR(120) NOT NULL,
|
|
||||||
bic VARCHAR(20) NOT NULL,
|
|
||||||
iban VARCHAR(34) NOT NULL,
|
|
||||||
position INT DEFAULT 0 NOT NULL,
|
|
||||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
|
||||||
created_by INT DEFAULT NULL,
|
|
||||||
updated_by INT DEFAULT NULL,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_client_rib_client
|
|
||||||
FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_client_rib_created_by
|
|
||||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
|
||||||
CONSTRAINT fk_client_rib_updated_by
|
|
||||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE INDEX idx_client_rib_client ON client_rib (client_id)');
|
|
||||||
|
|
||||||
$this->comment('client_rib', '_table', 'Coordonnees bancaires d un client (1:n) — >= 1 RIB obligatoire si payment_type = LCR (RG-1.13). Tous les champs audites (pas d AuditIgnore).');
|
|
||||||
$this->comment('client_rib', 'id', 'Identifiant interne auto-incremente.');
|
|
||||||
$this->comment('client_rib', 'client_id', 'FK -> client.id, ON DELETE CASCADE — client proprietaire du RIB.');
|
|
||||||
$this->comment('client_rib', 'label', 'Libelle du RIB (ex: compte principal).');
|
|
||||||
$this->comment('client_rib', 'bic', 'Code BIC/SWIFT de la banque (8 ou 11 caracteres).');
|
|
||||||
$this->comment('client_rib', 'iban', 'IBAN du compte (≤ 34 caracteres).');
|
|
||||||
$this->comment('client_rib', 'position', 'Ordre d affichage du RIB dans la liste du client (croissant).');
|
|
||||||
$this->addTimestampableBlamableComments('client_rib');
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Seed extension category_type (M0)
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
private function seedCategoryTypes(): void
|
|
||||||
{
|
|
||||||
// Idempotent : la table category_type peut deja porter des donnees en
|
|
||||||
// prod. ON CONFLICT (code) s appuie sur l index unique uq_category_type_code.
|
|
||||||
// NB : la table M0 n a pas de colonne `position` (id/code/label seulement),
|
|
||||||
// contrairement au pseudo-SQL de la spec § 3.3.
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
INSERT INTO category_type (code, label) VALUES
|
|
||||||
('DISTRIBUTEUR', 'Distributeur'),
|
|
||||||
('COURTIER', 'Courtier'),
|
|
||||||
('SECTEUR', 'Secteur'),
|
|
||||||
('AUTRE', 'Autre')
|
|
||||||
ON CONFLICT (code) DO NOTHING
|
|
||||||
SQL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Helpers
|
|
||||||
// =================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
|
||||||
* en reutilisant le catalogue partage (source unique, cf. ERP-67).
|
|
||||||
*/
|
|
||||||
private function addTimestampableBlamableComments(string $table): void
|
|
||||||
{
|
|
||||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
|
||||||
$this->comment($table, $column, $description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou
|
|
||||||
* `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour eviter
|
|
||||||
* tout echappement d apostrophe.
|
|
||||||
*/
|
|
||||||
private function comment(string $table, string $column, string $description): void
|
|
||||||
{
|
|
||||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
|
||||||
|
|
||||||
if ('_table' === $column) {
|
|
||||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->addSql(sprintf(
|
|
||||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
|
||||||
$quotedTable,
|
|
||||||
'"'.str_replace('"', '""', $column).'"',
|
|
||||||
$description,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,18 +32,32 @@ 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|null
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|Paginator|null
|
||||||
{
|
{
|
||||||
$includeDeleted = $this->readIncludeDeleted($context);
|
$includeDeleted = $this->readIncludeDeleted($context);
|
||||||
|
|
||||||
if ($operation instanceof CollectionOperationInterface) {
|
if ($operation instanceof CollectionOperationInterface) {
|
||||||
return $this->repository
|
$qb = $this->repository->createListQueryBuilder($includeDeleted);
|
||||||
->createListQueryBuilder($includeDeleted)
|
|
||||||
->getQuery()
|
// Echappatoire ?pagination=false : retourne la collection complete sans Paginator.
|
||||||
->getResult()
|
// 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.
|
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
|
||||||
|
|
||||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
|
||||||
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
|
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
|
||||||
use Doctrine\Persistence\ObjectManager;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixtures du module Catalog : seed des types de categorie metier (M1).
|
|
||||||
*
|
|
||||||
* La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4
|
|
||||||
* types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3).
|
|
||||||
*
|
|
||||||
* Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) :
|
|
||||||
* `category_type` est une entite managee par l ORM, donc le purger Doctrine la
|
|
||||||
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types
|
|
||||||
* seedes par la migration disparaitraient apres `make db-reset` / setup de test.
|
|
||||||
* Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette
|
|
||||||
* fixture re-aligne dev et test. Les deux chemins produisent un etat identique.
|
|
||||||
*
|
|
||||||
* Idempotence : lookup par `code` parmi les types existants avant insertion,
|
|
||||||
* sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme
|
|
||||||
* si le purger est desactive.
|
|
||||||
*/
|
|
||||||
class CategoryTypeFixtures extends Fixture
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Source unique des 4 types metier : code technique => libelle FR.
|
|
||||||
* Doit rester aligne sur le seed de la migration Version20260601000000.
|
|
||||||
*/
|
|
||||||
private const TYPES = [
|
|
||||||
'DISTRIBUTEUR' => 'Distributeur',
|
|
||||||
'COURTIER' => 'Courtier',
|
|
||||||
'SECTEUR' => 'Secteur',
|
|
||||||
'AUTRE' => 'Autre',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly CategoryTypeRepositoryInterface $categoryTypeRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function load(ObjectManager $manager): void
|
|
||||||
{
|
|
||||||
// Index des types deja presents par code, pour ne pas creer de doublon.
|
|
||||||
$existingByCode = [];
|
|
||||||
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
|
|
||||||
$existingByCode[$type->getCode()] = $type;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::TYPES as $code => $label) {
|
|
||||||
$type = $existingByCode[$code] ?? new CategoryType();
|
|
||||||
$type->setCode($code);
|
|
||||||
$type->setLabel($label);
|
|
||||||
$manager->persist($type);
|
|
||||||
}
|
|
||||||
|
|
||||||
$manager->flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,18 +29,16 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
|
|||||||
* ?performed_at[after]=2026-04-01T00:00:00Z
|
* ?performed_at[after]=2026-04-01T00:00:00Z
|
||||||
* ?performed_at[before]=2026-04-30T23:59:59Z
|
* ?performed_at[before]=2026-04-30T23:59:59Z
|
||||||
*
|
*
|
||||||
* La pagination est assuree par le provider via DbalPaginator (implementant
|
* La pagination herite du standard global (10 items / page, max 50, cf.
|
||||||
* ApiPlatform\State\Pagination\PaginatorInterface), ce qui genere
|
* `config/packages/api_platform.yaml`). Elle est materialisee par le
|
||||||
* automatiquement hydra:view — aucune construction manuelle.
|
* DbalPaginator du provider qui implemente PaginatorInterface — API Platform
|
||||||
|
* 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,6 +68,13 @@ 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
|
||||||
|
|||||||
@@ -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();
|
$client = $this->createAdminClient();
|
||||||
$response = $client->request('GET', '/api/categories');
|
$response = $client->request('GET', '/api/categories?pagination=false');
|
||||||
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');
|
$response = $client->request('GET', '/api/categories?includeDeleted=true&pagination=false');
|
||||||
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');
|
$response = $client->request('GET', '/api/categories?pagination=false');
|
||||||
self::assertSame(200, $response->getStatusCode());
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
$names = array_values(array_filter(
|
$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']);
|
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'],
|
'query' => ['module' => 'core', 'pagination' => 'false'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
@@ -94,7 +126,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'],
|
'query' => ['orphan' => 'true', 'pagination' => 'false'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
@@ -114,7 +146,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'],
|
'query' => ['orphan' => 'false', 'pagination' => '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');
|
$response = $client->request('GET', '/api/roles?pagination=false');
|
||||||
|
|
||||||
self::assertResponseIsSuccessful();
|
self::assertResponseIsSuccessful();
|
||||||
$data = $response->toArray();
|
$data = $response->toArray();
|
||||||
@@ -157,6 +157,35 @@ 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');
|
||||||
|
|||||||
Reference in New Issue
Block a user