Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df8ea4d0f0 | |||
| 9a7ae577f4 | |||
| d62629f754 | |||
| 9613857650 | |||
| 2a0918bbfe | |||
| 8e31e1759c | |||
| 4824690923 | |||
| fceb1e0e83 | |||
| adda62c1e1 | |||
| 80fabcae91 | |||
| ff6086bc4d | |||
| d01bbfbc65 | |||
| 92a6343b66 | |||
| 02df221a0b | |||
| 6efe7aa8ea |
@@ -40,6 +40,27 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
|
|||||||
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
||||||
- Spec complete : @doc/audit-log.md
|
- Spec complete : @doc/audit-log.md
|
||||||
|
|
||||||
|
## Timestampable + Blamable (obligatoire pour entites metier)
|
||||||
|
|
||||||
|
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
class MyEntity implements TimestampableInterface, BlamableInterface
|
||||||
|
{
|
||||||
|
use TimestampableBlamableTrait; // porte les 4 props + getters/setters
|
||||||
|
// ... reste metier
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au `prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null` (libelle « Systeme » cote front).
|
||||||
|
- La migration de l'entite doit creer les 4 colonnes (`created_at` / `updated_at` NOT NULL, `created_by` / `updated_by` nullable `ON DELETE SET NULL`).
|
||||||
|
- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` echoue si une entite oublie le pattern. Un referentiel statique justifie (ex: `CategoryType`) doit etre explicitement whiteliste dans la constante `EXCLUDED` avec un commentaire.
|
||||||
|
- Spec complete : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis
|
||||||
|
|
||||||
## Serialization
|
## Serialization
|
||||||
|
|
||||||
Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible.
|
Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible.
|
||||||
|
|||||||
@@ -73,12 +73,20 @@ jobs:
|
|||||||
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||||
|
|
||||||
- name: Bootstrap test database
|
- name: Bootstrap test database
|
||||||
|
# Aligne sur la cible `test-db-setup` du makefile : apres
|
||||||
|
# `schema:update --force`, on RECREE manuellement l'index unique
|
||||||
|
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
|
||||||
|
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
|
||||||
|
# deleted_at IS NULL) et `schema:update` les considere comme
|
||||||
|
# orphelins et les DROP — collisions non detectees, tests d'unicite
|
||||||
|
# qui attendent 409 recoivent 201.
|
||||||
run: |
|
run: |
|
||||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||||
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||||
php bin/console app:sync-permissions --env=test --no-interaction
|
php bin/console app:sync-permissions --env=test --no-interaction
|
||||||
|
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||||
|
|
||||||
- name: Run PHPUnit
|
- name: Run PHPUnit
|
||||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
use App\Module\Catalog\CatalogModule;
|
||||||
use App\Module\Commercial\CommercialModule;
|
use App\Module\Commercial\CommercialModule;
|
||||||
use App\Module\Core\CoreModule;
|
use App\Module\Core\CoreModule;
|
||||||
use App\Module\Sites\SitesModule;
|
use App\Module\Sites\SitesModule;
|
||||||
@@ -9,4 +10,5 @@ return [
|
|||||||
CoreModule::class,
|
CoreModule::class,
|
||||||
CommercialModule::class,
|
CommercialModule::class,
|
||||||
SitesModule::class,
|
SitesModule::class,
|
||||||
|
CatalogModule::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ doctrine:
|
|||||||
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
|
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
|
||||||
resolve_target_entities:
|
resolve_target_entities:
|
||||||
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
|
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
|
||||||
|
# Cible des ManyToOne created_by / updated_by du TimestampableBlamableTrait.
|
||||||
|
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
|
||||||
|
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
|
||||||
|
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
|
||||||
mappings:
|
mappings:
|
||||||
Core:
|
Core:
|
||||||
type: attribute
|
type: attribute
|
||||||
@@ -50,6 +54,18 @@ doctrine:
|
|||||||
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
|
||||||
prefix: 'App\Module\Sites\Domain\Entity'
|
prefix: 'App\Module\Sites\Domain\Entity'
|
||||||
alias: Sites
|
alias: Sites
|
||||||
|
# Mapping inconditionnel du module Catalog (meme logique que Sites) :
|
||||||
|
# la structure DB (category, category_type) existe meme si
|
||||||
|
# CatalogModule::class n'est pas encore wire dans config/modules.php
|
||||||
|
# (declaration du module = ticket 0.5 / ERP-47). L'ORM doit connaitre
|
||||||
|
# les entites pour que le schema soit en phase ; l'activation
|
||||||
|
# fonctionnelle passe exclusivement par config/modules.php.
|
||||||
|
Catalog:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
|
||||||
|
prefix: 'App\Module\Catalog\Domain\Entity'
|
||||||
|
alias: Catalog
|
||||||
controller_resolver:
|
controller_resolver:
|
||||||
auto_mapping: false
|
auto_mapping: false
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ return [
|
|||||||
'module' => 'sites',
|
'module' => 'sites',
|
||||||
'permission' => 'sites.view',
|
'permission' => 'sites.view',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.catalog.categories',
|
||||||
|
'to' => '/admin/categories',
|
||||||
|
'icon' => 'mdi:tag-multiple-outline',
|
||||||
|
'module' => 'catalog',
|
||||||
|
'permission' => 'catalog.categories.view',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.core.audit_log',
|
'label' => 'sidebar.core.audit_log',
|
||||||
'to' => '/admin/audit-log',
|
'to' => '/admin/audit-log',
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.41'
|
app.version: '0.1.49'
|
||||||
|
|||||||
@@ -441,6 +441,21 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
|||||||
|
|
||||||
Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0.
|
Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0.
|
||||||
|
|
||||||
|
#### Décision M0 sur la whitelist `EXCLUDED` (ERP-52)
|
||||||
|
|
||||||
|
Au moment d'introduire le pattern (ticket 0.0), 4 entités préexistantes vivent déjà sous `src/Module/*/Domain/Entity/` : `User`, `Role`, `Permission` (Core) et `Site` (Sites). Aucune n'implémente le pattern. Le test L3 les détecterait et passerait au rouge.
|
||||||
|
|
||||||
|
**Décision** : on **whiteliste explicitement ces 4 entités** dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` avec une justification par entrée, plutôt que de les rétrofiter dans ERP-52 :
|
||||||
|
|
||||||
|
| Entité | Justification du `EXCLUDED` |
|
||||||
|
|---|---|
|
||||||
|
| `User` | Référentiel d'authentification ; `createdAt` géré manuellement dans le constructeur. Rétrofit non trivial : impose de trancher la récursion Blamable (un `User` créé par un `User`) et casse des tests existants → **HP-9**. |
|
||||||
|
| `Role` | Référentiel RBAC synchronisé via `app:sync-permissions`, pas de traçabilité user-driven nécessaire. |
|
||||||
|
| `Permission` | Idem `Role` (synchronisé, pas piloté utilisateur). |
|
||||||
|
| `Site` | Référentiel admin-managed, rétrofit à intégrer dans un futur module Sites v2 → **HP-10**. |
|
||||||
|
|
||||||
|
**Règle dure pour la suite** : toute **nouvelle** entité métier (`Category` au M0, puis `Client`, `Fournisseur`, `Prestataire`, etc.) **doit** implémenter `TimestampableInterface` + `BlamableInterface` via le Trait. La whitelist `EXCLUDED` est réservée aux référentiels statiques justifiés (ex : `CategoryType` au ticket 0.2) — toute nouvelle entrée doit être documentée.
|
||||||
|
|
||||||
#### Tests Subscriber
|
#### Tests Subscriber
|
||||||
|
|
||||||
Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui `use` le Trait + implements les interfaces, vérifier que `prePersist` + `preUpdate` remplissent les 4 colonnes. À écrire dans le ticket 0.0.
|
Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui `use` le Trait + implements les interfaces, vérifier que `prePersist` + `preUpdate` remplissent les 4 colonnes. À écrire dans le ticket 0.0.
|
||||||
@@ -979,6 +994,8 @@ Les deux mécanismes sont indépendants : on peut désactiver `#[Auditable]` (pa
|
|||||||
- **HP-6** : **Filtres avancés / recherche serveur** dans la liste. Pas pertinent à 300 entrées (pagination front).
|
- **HP-6** : **Filtres avancés / recherche serveur** dans la liste. Pas pertinent à 300 entrées (pagination front).
|
||||||
- **HP-7** : **Catégories hiérarchiques** (parent / enfant). Pas demandé. Si besoin futur → migration ajout colonne `parent_id` + spec dédiée.
|
- **HP-7** : **Catégories hiérarchiques** (parent / enfant). Pas demandé. Si besoin futur → migration ajout colonne `parent_id` + spec dédiée.
|
||||||
- **HP-8** : **Création des rôles métier Bureau / Compta / Commerciale / Usine.** Ces rôles font partie du modèle MALIO mais leur seed initial dans `role` + leur attribution aux users est hors du périmètre M0 (probablement un M-RBAC dédié, ou seedés dans `AppFixtures` / `SeedE2ECommand` au fil des modules).
|
- **HP-8** : **Création des rôles métier Bureau / Compta / Commerciale / Usine.** Ces rôles font partie du modèle MALIO mais leur seed initial dans `role` + leur attribution aux users est hors du périmètre M0 (probablement un M-RBAC dédié, ou seedés dans `AppFixtures` / `SeedE2ECommand` au fil des modules).
|
||||||
|
- **HP-9** : **Rétrofit de `User` vers Timestampable + Blamable.** L'entité `User` est whitelistée dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` au M0 (cf. § 2.8.bis). Son rétrofit nécessite une **décision archi dédiée** : récursion Blamable (un `User` créé/modifié par un `User`, FK auto-référente `created_by` / `updated_by` sur la table `user`), impact sur le `createdAt` déjà géré dans le constructeur, et migration des données existantes. À traiter dans un ticket scopé hors M0.
|
||||||
|
- **HP-10** : **Rétrofit de `Site` vers Timestampable + Blamable.** Même logique que HP-9 pour le référentiel `Site` (whitelisté `EXCLUDED`). À intégrer dans un futur module Sites v2, avec la migration ajoutant les 4 colonnes + FK `user`.
|
||||||
|
|
||||||
## 10. Liens & dépendances
|
## 10. Liens & dépendances
|
||||||
|
|
||||||
|
|||||||
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]
|
||||||
|
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 | ❌ (lecture seule) | ❌ |
|
||||||
|
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
|
||||||
|
| **Usine** | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
> **⚠ Décision validée par Tristan (28/05/2026)** : le rôle **Compta est en lecture seule** sur l'ensemble du module clients, y compris l'onglet Comptabilité. Le tableau d'origine du `.docx` indiquait « Compta = Ajout / Modification : Onglet Comptabilité uniquement » — cette ligne est **invalidée** par cette spec. Si un besoin métier d'édition apparaît plus tard, une décision archi dédiée sera prise (cf. HP-X de [`spec-back.md`](./spec-back.md)).
|
||||||
|
|
||||||
|
## 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` |
|
||||||
|
| **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 uniquement aux rôles avec `commercial.clients.accounting.manage`** (Admin seul au M1). Bureau et Commerciale ne voient pas l'onglet. Compta voit l'onglet **en lecture seule** (cf. décision Compta lecture seule).
|
||||||
|
|
||||||
|
**Champs comptables** :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Règle |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | RG-1.15 (unicité) |
|
||||||
|
| **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 uniquement au M1.** Compta lecture seule (décision validée par Tristan 28/05). Bureau / Commerciale ne voient pas l'onglet. |
|
||||||
|
| 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 | **SIREN, Nom entreprise, Email principal — tous uniques parmi non-archivés.** Index partiels Postgres. Tentative de doublon → 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).
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
<!--
|
||||||
|
Valeurs en dur issues de la maquette Figma (design Starseed) :
|
||||||
|
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
|
||||||
|
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
|
||||||
|
- bande blanche sticky sous la navbar : 47px (h-[47px])
|
||||||
|
A faire evoluer uniquement avec une mise a jour de maquette.
|
||||||
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="h-screen overflow-hidden">
|
<div class="h-screen overflow-hidden">
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<MalioSidebar
|
<MalioSidebar
|
||||||
v-model="ui.sidebarCollapsed"
|
v-model="ui.sidebarCollapsed"
|
||||||
:sections="translatedSections"
|
:sections="translatedSections"
|
||||||
|
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||||
>
|
>
|
||||||
<template #logo>
|
<template #logo>
|
||||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||||
@@ -16,10 +24,10 @@
|
|||||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
<SiteSelector v-if="showSiteSelector"/>
|
<SiteSelector v-if="showSiteSelector"/>
|
||||||
<main
|
<main
|
||||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-[170px]">
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/>
|
class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"admin": "Sites"
|
"admin": "Sites"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"categories": "Gestion des catégories"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -85,12 +88,19 @@
|
|||||||
},
|
},
|
||||||
"empty": "Aucune activité enregistrée",
|
"empty": "Aucune activité enregistrée",
|
||||||
"no_results": "Aucun résultat pour ces filtres",
|
"no_results": "Aucun résultat pour ces filtres",
|
||||||
|
"error": {
|
||||||
|
"title": "Erreur",
|
||||||
|
"message": "Impossible de charger le journal d'audit. Vérifiez les filtres ou réessayez."
|
||||||
|
},
|
||||||
"timeline": {
|
"timeline": {
|
||||||
"empty": "Aucun historique",
|
"empty": "Aucun historique",
|
||||||
"load_more": "Voir plus"
|
"load_more": "Voir plus"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
"reset": "Réinitialiser",
|
"reset": "Réinitialiser",
|
||||||
|
"date_range": "Date à date",
|
||||||
"date_from": "Du",
|
"date_from": "Du",
|
||||||
"date_to": "Au",
|
"date_to": "Au",
|
||||||
"entity_type": "Type d'entité",
|
"entity_type": "Type d'entité",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1>
|
<PageHeader>{{ $t('commercial.title') }}</PageHeader>
|
||||||
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
<p class="text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Accordeon de permissions groupees par module : un panneau par module,
|
||||||
|
avec compteur (selectionnees/total) dans le titre, case "Tout selectionner"
|
||||||
|
et liste des permissions individuelles. Source unique de cette UX, utilisee
|
||||||
|
par RoleDrawer (permissions du role) et UserRbacDrawer (permissions directes). -->
|
||||||
|
<MalioAccordion v-model="openModules">
|
||||||
|
<MalioAccordionItem
|
||||||
|
v-for="group in groupsByModule"
|
||||||
|
:key="group.module"
|
||||||
|
:value="group.module"
|
||||||
|
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
|
||||||
|
header-class="capitalize"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<!-- Tout selectionner pour ce module -->
|
||||||
|
<MalioCheckbox
|
||||||
|
:id="`${idPrefix}-group-${group.module}`"
|
||||||
|
:label="t('admin.roles.permissions.selectAll')"
|
||||||
|
:model-value="allSelectedFor(group)"
|
||||||
|
label-class="font-semibold text-sm text-neutral-700"
|
||||||
|
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="perm in group.permissions"
|
||||||
|
:id="`${idPrefix}-perm-${perm.id}`"
|
||||||
|
:key="perm.id"
|
||||||
|
:label="perm.label"
|
||||||
|
:model-value="selectedIds.has(perm.id)"
|
||||||
|
label-class="text-sm text-neutral-600"
|
||||||
|
@update:model-value="(val: boolean) => emit('toggle', perm.id, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PermissionModule } from '~/shared/types/rbac'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Groupes de permissions a afficher, un par module. */
|
||||||
|
groupsByModule: PermissionModule[]
|
||||||
|
/** Ids des permissions actuellement selectionnees. */
|
||||||
|
selectedIds: Set<number>
|
||||||
|
/** Prefixe pour les ids HTML : evite les collisions si plusieurs accordeons coexistent (ex: "role" vs "direct"). */
|
||||||
|
idPrefix: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggle: [permissionId: number, selected: boolean]
|
||||||
|
'toggle-all': [module: string, selected: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Modules ouverts dans l'accordeon (mode multiple). Etat local : chaque instance
|
||||||
|
// du composant garde sa propre liste, pas de partage entre drawers.
|
||||||
|
const openModules = ref<string[]>([])
|
||||||
|
|
||||||
|
// Nombre de permissions selectionnees pour un module donne.
|
||||||
|
function selectedCountFor(group: PermissionModule): number {
|
||||||
|
return group.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrai si toutes les permissions du module sont selectionnees.
|
||||||
|
function allSelectedFor(group: PermissionModule): boolean {
|
||||||
|
return group.permissions.length > 0 && selectedCountFor(group) === group.permissions.length
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="rounded-lg border border-neutral-200 overflow-hidden">
|
|
||||||
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
|
|
||||||
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
|
|
||||||
<MalioCheckbox
|
|
||||||
:id="`group-${module}`"
|
|
||||||
:label="moduleLabel"
|
|
||||||
:model-value="allSelected"
|
|
||||||
label-class="font-semibold text-sm text-neutral-700 capitalize"
|
|
||||||
@update:model-value="toggleAll"
|
|
||||||
/>
|
|
||||||
<span class="ml-auto text-xs text-neutral-400">
|
|
||||||
{{ selectedCount }}/{{ permissions.length }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Liste des permissions individuelles -->
|
|
||||||
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
|
|
||||||
<MalioCheckbox
|
|
||||||
v-for="perm in permissions"
|
|
||||||
:key="perm.id"
|
|
||||||
:id="`perm-${perm.id}`"
|
|
||||||
:label="perm.label"
|
|
||||||
:model-value="selectedIds.has(perm.id)"
|
|
||||||
label-class="text-sm text-neutral-600"
|
|
||||||
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Permission } from '~/shared/types/rbac'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
module: string
|
|
||||||
moduleLabel: string
|
|
||||||
permissions: Permission[]
|
|
||||||
selectedIds: Set<number>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
toggle: [permissionId: number, selected: boolean]
|
|
||||||
toggleAll: [module: string, selected: boolean]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Nombre de permissions selectionnees dans ce groupe
|
|
||||||
const selectedCount = computed(() =>
|
|
||||||
props.permissions.filter(p => props.selectedIds.has(p.id)).length
|
|
||||||
)
|
|
||||||
|
|
||||||
// Vrai si toutes les permissions du groupe sont selectionnees
|
|
||||||
const allSelected = computed(() =>
|
|
||||||
props.permissions.length > 0 && selectedCount.value === props.permissions.length
|
|
||||||
)
|
|
||||||
|
|
||||||
// Emet l'evenement de bascule pour une permission individuelle
|
|
||||||
function togglePermission(id: number, selected: boolean) {
|
|
||||||
emit('toggle', id, selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emet l'evenement de bascule pour toutes les permissions du groupe
|
|
||||||
function toggleAll(selected: boolean) {
|
|
||||||
emit('toggleAll', props.module, selected)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
|
|
||||||
drawer-class="w-full max-w-lg"
|
drawer-class="w-full max-w-lg"
|
||||||
|
header-class="border-b border-black"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||||
<!-- Champs du role -->
|
<!-- Champs du role -->
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
@@ -44,55 +50,51 @@
|
|||||||
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||||
{{ t('admin.roles.permissions.noPermissions') }}
|
{{ t('admin.roles.permissions.noPermissions') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4">
|
<PermissionAccordion
|
||||||
<PermissionGroup
|
v-else
|
||||||
v-for="group in permissionsByModule"
|
:groups-by-module="permissionsByModule"
|
||||||
:key="group.module"
|
:selected-ids="selectedPermissionIds"
|
||||||
:module="group.module"
|
id-prefix="role"
|
||||||
:module-label="group.module"
|
@toggle="handleTogglePermission"
|
||||||
:permissions="group.permissions"
|
@toggle-all="handleToggleAll"
|
||||||
:selected-ids="selectedPermissionIds"
|
/>
|
||||||
@toggle="handleTogglePermission"
|
|
||||||
@toggle-all="handleToggleAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Boutons -->
|
|
||||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
|
||||||
<MalioButton
|
|
||||||
v-if="isEditMode"
|
|
||||||
:label="t('common.delete')"
|
|
||||||
variant="danger"
|
|
||||||
icon-name="mdi:delete-outline"
|
|
||||||
icon-position="left"
|
|
||||||
:disabled="role?.isSystem"
|
|
||||||
@click="emit('delete')"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
v-else
|
|
||||||
:label="t('common.cancel')"
|
|
||||||
variant="tertiary"
|
|
||||||
@click="emit('update:modelValue', false)"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
:label="t('common.save')"
|
|
||||||
variant="primary"
|
|
||||||
:disabled="saving || permissionsLoadFailed"
|
|
||||||
@click="handleSave"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||||
|
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
v-if="isEditMode"
|
||||||
|
:label="t('common.delete')"
|
||||||
|
variant="danger"
|
||||||
|
icon-name="mdi:delete-outline"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
:disabled="role?.isSystem"
|
||||||
|
@click="emit('delete')"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-else
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('common.save')"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
:disabled="saving || permissionsLoadFailed"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Permission, Role } from '~/shared/types/rbac'
|
import type { Permission, PermissionModule, Role } from '~/shared/types/rbac'
|
||||||
|
|
||||||
interface PermissionModule {
|
|
||||||
module: string
|
|
||||||
permissions: Permission[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
|
drawer-class="w-full max-w-[450px]"
|
||||||
drawer-class="w-full max-w-lg"
|
header-class="border-b border-black"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-6 p-4">
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-4 py-4">
|
||||||
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
||||||
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
||||||
<div
|
<div
|
||||||
@@ -60,18 +66,14 @@
|
|||||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||||
{{ t('admin.roles.permissions.noPermissions') }}
|
{{ t('admin.roles.permissions.noPermissions') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4">
|
<PermissionAccordion
|
||||||
<PermissionGroup
|
v-else
|
||||||
v-for="group in permissionsByModule"
|
:groups-by-module="permissionsByModule"
|
||||||
:key="group.module"
|
:selected-ids="selectedDirectPermissionIds"
|
||||||
:module="group.module"
|
id-prefix="direct"
|
||||||
:module-label="group.module"
|
@toggle="handleTogglePermission"
|
||||||
:permissions="group.permissions"
|
@toggle-all="handleToggleAll"
|
||||||
:selected-ids="selectedDirectPermissionIds"
|
/>
|
||||||
@toggle="handleTogglePermission"
|
|
||||||
@toggle-all="handleToggleAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
||||||
@@ -103,33 +105,32 @@
|
|||||||
<EffectivePermissions :permissions="effectivePermissions" />
|
<EffectivePermissions :permissions="effectivePermissions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Boutons -->
|
|
||||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
|
||||||
<MalioButton
|
|
||||||
:label="t('common.cancel')"
|
|
||||||
variant="tertiary"
|
|
||||||
@click="emit('update:modelValue', false)"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
:label="t('common.save')"
|
|
||||||
variant="primary"
|
|
||||||
:disabled="saving || loadFailed"
|
|
||||||
@click="handleSave"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||||
|
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('common.save')"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
:disabled="saving || loadFailed"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
import type { Permission, PermissionModule, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
||||||
import type { Site } from '~/shared/types/sites'
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
|
||||||
interface PermissionModule {
|
|
||||||
module: string
|
|
||||||
permissions: Permission[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|||||||
@@ -1,95 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
{{ t('admin.auditLog.title') }}
|
||||||
{{ t('admin.auditLog.title') }}
|
<template #actions>
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filtres -->
|
|
||||||
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
|
|
||||||
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
|
|
||||||
leur `label` flottant interne pour ne pas mixer deux patterns de label.
|
|
||||||
A revoir une fois le composant calendar Malio développé -->
|
|
||||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
|
|
||||||
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
|
|
||||||
exposera un datetime picker. Cf. exception documentee dans
|
|
||||||
CLAUDE.md (section "Composants formulaires"). -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.date_from') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="filters.performedAtAfter"
|
|
||||||
type="datetime-local"
|
|
||||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<!-- TODO(malio-ui): idem ci-dessus. -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.date_to') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="filters.performedAtBefore"
|
|
||||||
type="datetime-local"
|
|
||||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.entity_type') }}
|
|
||||||
</label>
|
|
||||||
<div class="[&>div>div]:!mt-0">
|
|
||||||
<MalioSelectCheckbox
|
|
||||||
v-model="selectedEntityTypes"
|
|
||||||
:options="entityTypeOptions"
|
|
||||||
:display-select-all="true"
|
|
||||||
:display-tag="true"
|
|
||||||
min-width="w-full"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.user') }}
|
|
||||||
</label>
|
|
||||||
<MalioInputText
|
|
||||||
v-model="performedByInput"
|
|
||||||
icon-name="mdi:account-search"
|
|
||||||
input-class="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.action') }}
|
|
||||||
</label>
|
|
||||||
<div class="[&>div>div]:!mt-0">
|
|
||||||
<MalioSelect
|
|
||||||
v-model="actionValue"
|
|
||||||
:options="actionOptions"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
:label="t('audit.filters.reset')"
|
:label="t('audit.filters.title')"
|
||||||
button-class="text-xs"
|
icon-name="mdi:tune"
|
||||||
@click="resetFilters"
|
icon-position="left"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="w-[184px] justify-start gap-4 text-black"
|
||||||
|
@click="openFilters"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</section>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Tableau -->
|
<!-- Tableau -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-4"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="rows"
|
:items="rows"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
@@ -123,12 +50,99 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioDataTable>
|
</MalioDataTable>
|
||||||
|
|
||||||
|
<!-- Drawer de filtres : etat brouillon, applique uniquement au clic sur
|
||||||
|
"Voir les resultats". `body-class="p-0"` pour que l'accordeon aille
|
||||||
|
bord a bord (les items portent leur propre px-7). -->
|
||||||
|
<MalioDrawer
|
||||||
|
v-model="filterDrawerOpen"
|
||||||
|
drawer-class="max-w-[450px]"
|
||||||
|
body-class="p-0"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('audit.filters.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
|
||||||
|
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
|
||||||
|
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
|
||||||
|
<span>{{ t('audit.filters.date_from') }}</span>
|
||||||
|
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
|
||||||
|
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="draftDateFrom"
|
||||||
|
:max="draftDateTo ?? undefined"
|
||||||
|
/>
|
||||||
|
<span>{{ t('audit.filters.date_to') }}</span>
|
||||||
|
<MalioDateTime
|
||||||
|
v-model="draftDateTo"
|
||||||
|
:min="draftDateFrom ?? undefined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Type d'entite : cases a cocher (multi-selection) -->
|
||||||
|
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in entityTypeOptions"
|
||||||
|
:id="`filter-entity-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftEntityTypes.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleEntity(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Action : boutons radio (selection unique, '' = toutes) -->
|
||||||
|
<MalioAccordionItem :title="t('audit.filters.action')" value="action">
|
||||||
|
<MalioRadioButton
|
||||||
|
v-for="opt in actionOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
v-model="draftAction"
|
||||||
|
name="audit-action"
|
||||||
|
:value="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Utilisateur : recherche texte (ILIKE partiel cote backend) -->
|
||||||
|
<MalioAccordionItem :title="t('audit.filters.user')" value="user">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="draftPerformedBy"
|
||||||
|
icon-name="mdi:account-search"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="t('audit.filters.reset')"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('audit.filters.apply')"
|
||||||
|
button-class="w-[170px]"
|
||||||
|
@click="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
|
||||||
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
v-model="drawerOpen"
|
v-model="drawerOpen"
|
||||||
:title="drawerTitle"
|
|
||||||
drawer-class="max-w-2xl"
|
drawer-class="max-w-2xl"
|
||||||
>
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ drawerTitle }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
<div v-if="selectedEntry">
|
<div v-if="selectedEntry">
|
||||||
<AuditLogDetail :entry="selectedEntry" />
|
<AuditLogDetail :entry="selectedEntry" />
|
||||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||||
@@ -149,12 +163,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
const { t, te } = useI18n()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
||||||
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
||||||
@@ -173,8 +188,11 @@ if (!can('core.audit_log.view')) {
|
|||||||
|
|
||||||
useHead({ title: t('admin.auditLog.title') })
|
useHead({ title: t('admin.auditLog.title') })
|
||||||
|
|
||||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
// Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
|
||||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
// persiste dans l'URL (cf. regle CLAUDE.md "Tableau : pas de persistance URL").
|
||||||
|
// `performedAtAfter`/`performedAtBefore` stockent une date+heure ISO naive
|
||||||
|
// (`YYYY-MM-DDTHH:MM:00`, fournie par MalioDateTime), convertie en ISO UTC
|
||||||
|
// au moment du fetch.
|
||||||
const filters = reactive<AuditLogFilters>({
|
const filters = reactive<AuditLogFilters>({
|
||||||
performedAtAfter: undefined,
|
performedAtAfter: undefined,
|
||||||
performedAtBefore: undefined,
|
performedAtBefore: undefined,
|
||||||
@@ -185,26 +203,23 @@ const filters = reactive<AuditLogFilters>({
|
|||||||
itemsPerPage: 10,
|
itemsPerPage: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
// Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
|
||||||
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
// uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
|
||||||
const selectedEntityTypes = ref<(string | number)[]>([])
|
// fermant le drawer sans relancer de requete.
|
||||||
|
const filterDrawerOpen = ref(false)
|
||||||
|
const draftDateFrom = ref<string | null>(null)
|
||||||
|
const draftDateTo = ref<string | null>(null)
|
||||||
|
const draftEntityTypes = ref<string[]>([])
|
||||||
|
const draftAction = ref<string>('')
|
||||||
|
const draftPerformedBy = ref<string>('')
|
||||||
|
|
||||||
|
// Liste des entity types (distincts) pour alimenter les cases a cocher.
|
||||||
const entityTypes = ref<string[]>([])
|
const entityTypes = ref<string[]>([])
|
||||||
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
|
|
||||||
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
|
|
||||||
const entityTypeOptions = computed(() =>
|
const entityTypeOptions = computed(() =>
|
||||||
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
// Actions : '' = "toutes". Sert d'options aux boutons radio.
|
||||||
// pas binder directement un `string | undefined` reactive.
|
|
||||||
const performedByInput = ref<string>('')
|
|
||||||
|
|
||||||
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
|
|
||||||
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
|
|
||||||
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
|
|
||||||
// car MalioSelect grise visuellement les options dont la valeur est `null`
|
|
||||||
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
|
|
||||||
const actionValue = ref<string>('')
|
|
||||||
const actionOptions = [
|
const actionOptions = [
|
||||||
{ value: '', label: t('audit.filters.all_actions') },
|
{ value: '', label: t('audit.filters.all_actions') },
|
||||||
{ value: 'create', label: t('audit.action.create') },
|
{ value: 'create', label: t('audit.action.create') },
|
||||||
@@ -259,29 +274,55 @@ const isFiltered = computed(() =>
|
|||||||
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||||
let requestToken = 0
|
let requestToken = 0
|
||||||
|
|
||||||
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
// Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
|
||||||
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
// reouverture reflete les filtres actifs.
|
||||||
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
function openFilters(): void {
|
||||||
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
draftDateFrom.value = filters.performedAtAfter ?? null
|
||||||
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
draftDateTo.value = filters.performedAtBefore ?? null
|
||||||
// explicitement apres la liberation.
|
draftEntityTypes.value = Array.isArray(filters.entityType)
|
||||||
let watchersSuspended = false
|
? [...filters.entityType]
|
||||||
|
: (filters.entityType ? [filters.entityType] : [])
|
||||||
|
draftAction.value = filters.action ?? ''
|
||||||
|
draftPerformedBy.value = filters.performedBy ?? ''
|
||||||
|
filterDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bascule un type d'entite dans le brouillon (multi-selection). Les valeurs
|
||||||
|
// sont uniques par construction (v-for sur entityTypeOptions), pas besoin de Set.
|
||||||
|
function toggleEntity(value: string, selected: boolean): void {
|
||||||
|
draftEntityTypes.value = selected
|
||||||
|
? [...draftEntityTypes.value, value]
|
||||||
|
: draftEntityTypes.value.filter(v => v !== value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Reinitialiser" : vide le brouillon ET les filtres actifs, puis recharge.
|
||||||
|
// La remise a zero s'applique immediatement (la table revient a la liste
|
||||||
|
// complete) ; le drawer reste ouvert pour montrer le formulaire vide.
|
||||||
|
function resetFilters(): void {
|
||||||
|
draftDateFrom.value = null
|
||||||
|
draftDateTo.value = null
|
||||||
|
draftEntityTypes.value = []
|
||||||
|
draftAction.value = ''
|
||||||
|
draftPerformedBy.value = ''
|
||||||
|
|
||||||
async function resetFilters(): Promise<void> {
|
|
||||||
watchersSuspended = true
|
|
||||||
filters.performedAtAfter = undefined
|
filters.performedAtAfter = undefined
|
||||||
filters.performedAtBefore = undefined
|
filters.performedAtBefore = undefined
|
||||||
filters.entityType = undefined
|
filters.entityType = undefined
|
||||||
filters.performedBy = undefined
|
|
||||||
filters.action = undefined
|
filters.action = undefined
|
||||||
|
filters.performedBy = undefined
|
||||||
filters.page = 1
|
filters.page = 1
|
||||||
selectedEntityTypes.value = []
|
loadEntries()
|
||||||
performedByInput.value = ''
|
}
|
||||||
actionValue.value = ''
|
|
||||||
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
// "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
|
||||||
// leur execution avec le flag `true`, puis on libere.
|
function applyFilters(): void {
|
||||||
await nextTick()
|
filters.performedAtAfter = draftDateFrom.value ?? undefined
|
||||||
watchersSuspended = false
|
filters.performedAtBefore = draftDateTo.value ?? undefined
|
||||||
|
filters.entityType = draftEntityTypes.value.length > 0 ? [...draftEntityTypes.value] : undefined
|
||||||
|
filters.action = draftAction.value === '' ? undefined : draftAction.value
|
||||||
|
filters.performedBy = draftPerformedBy.value.trim() === '' ? undefined : draftPerformedBy.value.trim()
|
||||||
|
filters.page = 1
|
||||||
|
filterDrawerOpen.value = false
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +332,8 @@ async function loadEntries(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchLogsCached({
|
const data = await fetchLogsCached({
|
||||||
...filters,
|
...filters,
|
||||||
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
|
// MalioDateTime fournit une date+heure sans fuseau (heure locale) ;
|
||||||
|
// on la convertit en ISO UTC pour l'API (bornes exactes, intervalle inclusif).
|
||||||
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
||||||
})
|
})
|
||||||
@@ -300,13 +342,19 @@ async function loadEntries(): Promise<void> {
|
|||||||
if (token !== requestToken) return
|
if (token !== requestToken) return
|
||||||
entries.value = data.member ?? []
|
entries.value = data.member ?? []
|
||||||
totalItems.value = data.totalItems ?? 0
|
totalItems.value = data.totalItems ?? 0
|
||||||
} catch {
|
} catch (err) {
|
||||||
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
|
// useAuditLog appelle useApi avec { toast: false } pour ne pas multiplier
|
||||||
// laisser l'utilisateur croire que les donnees affichees sont a jour.
|
// les toasts, donc c'est ici qu'on fait remonter l'erreur. Sans ce log+toast,
|
||||||
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
|
// une RangeError de `toIso` (date invalide) ou une 500 API laissait l'utilisateur
|
||||||
|
// devant une table vide indistinguable d'un filtre a zero resultat.
|
||||||
if (token === requestToken) {
|
if (token === requestToken) {
|
||||||
entries.value = []
|
entries.value = []
|
||||||
totalItems.value = 0
|
totalItems.value = 0
|
||||||
|
console.error('[audit-log] loadEntries failed', err)
|
||||||
|
toast.error({
|
||||||
|
title: t('audit.error.title'),
|
||||||
|
message: t('audit.error.message'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (token === requestToken) {
|
if (token === requestToken) {
|
||||||
@@ -315,14 +363,9 @@ async function loadEntries(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
|
|
||||||
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
|
|
||||||
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
|
|
||||||
const debouncedReload = debounce(() => loadEntries(), 300)
|
|
||||||
|
|
||||||
function toIso(localDateTime: string): string {
|
function toIso(localDateTime: string): string {
|
||||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
// MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
|
||||||
// laisse le navigateur generer l'ISO via Date().
|
// on laisse Date() generer l'ISO UTC correspondant pour l'API.
|
||||||
return new Date(localDateTime).toISOString()
|
return new Date(localDateTime).toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,53 +411,16 @@ function onPerPageChange(value: number): void {
|
|||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
|
|
||||||
watch(selectedEntityTypes, values => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync MalioSelect action -> filters.action.
|
|
||||||
watch(actionValue, value => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.action = value === '' ? undefined : value
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
|
|
||||||
// refetch par caractere. Le reset passe par debouncedReload egalement pour
|
|
||||||
// coalescer si plusieurs watchers tirent en meme temps.
|
|
||||||
watch(performedByInput, value => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.performedBy = value === '' ? undefined : value
|
|
||||||
filters.page = 1
|
|
||||||
debouncedReload()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Synchronisation reactive : tout changement de dates declenche un fetch +
|
|
||||||
// reset de la pagination a la page 1.
|
|
||||||
watch(
|
|
||||||
() => [filters.performedAtAfter, filters.performedAtBefore],
|
|
||||||
() => {
|
|
||||||
if (watchersSuspended) return
|
|
||||||
filters.page = 1
|
|
||||||
loadEntries()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Charge les entity types en parallele de la liste principale : un
|
// Charge les entity types ET la liste principale en parallele (TTFD divise
|
||||||
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
// par 2 sur un backend lent). Le `.catch` du premier garantit qu'un echec
|
||||||
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
// de /audit-log-entity-types ne bloque pas l'affichage du tableau —
|
||||||
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
// l'utilisateur perd juste le filtre, pas la page entiere.
|
||||||
try {
|
await Promise.all([
|
||||||
entityTypes.value = await fetchEntityTypes()
|
fetchEntityTypes()
|
||||||
} catch {
|
.then(types => { entityTypes.value = types })
|
||||||
entityTypes.value = []
|
.catch(() => { entityTypes.value = [] }),
|
||||||
}
|
loadEntries(),
|
||||||
await loadEntries()
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- En-tete -->
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
{{ t('admin.roles.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
<template #actions>
|
||||||
{{ t('admin.roles.title') }}
|
<MalioButton
|
||||||
</h1>
|
v-if="can('core.roles.manage')"
|
||||||
<MalioButton
|
:label="t('admin.roles.newRole')"
|
||||||
v-if="can('core.roles.manage')"
|
icon-name="mdi:add-bold"
|
||||||
:label="t('admin.roles.newRole')"
|
icon-position="left"
|
||||||
icon-name="mdi:add-bold"
|
@click="openCreateDrawer"
|
||||||
icon-position="left"
|
/>
|
||||||
@click="openCreateDrawer"
|
</template>
|
||||||
/>
|
</PageHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table des roles -->
|
<!-- Table des roles -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-6"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="roleItems"
|
:items="roleItems"
|
||||||
:total-items="roles.length"
|
:total-items="roles.length"
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- En-tete -->
|
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
|
||||||
{{ t('admin.users.title') }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table des utilisateurs -->
|
<!-- Table des utilisateurs -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-6"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="userItems"
|
:items="userItems"
|
||||||
:total-items="users.length"
|
:total-items="users.length"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
<PageHeader>{{ $t('dashboard.title') }}</PageHeader>
|
||||||
<p class="mt-4 text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
<p class="text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
|
|
||||||
drawer-class="w-full max-w-lg"
|
drawer-class="w-full max-w-lg"
|
||||||
|
header-class="border-b border-black"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
:label="t('admin.sites.form.name')"
|
:label="t('admin.sites.form.name')"
|
||||||
@@ -70,30 +76,35 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Boutons -->
|
|
||||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
|
||||||
<MalioButton
|
|
||||||
v-if="isEditMode"
|
|
||||||
:label="t('common.delete')"
|
|
||||||
variant="danger"
|
|
||||||
icon-name="mdi:delete-outline"
|
|
||||||
icon-position="left"
|
|
||||||
@click="emit('delete')"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
v-else
|
|
||||||
:label="t('common.cancel')"
|
|
||||||
variant="tertiary"
|
|
||||||
@click="emit('update:modelValue', false)"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
:label="t('common.save')"
|
|
||||||
variant="primary"
|
|
||||||
:disabled="saving || !isValidHex"
|
|
||||||
@click="handleSave"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||||
|
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
v-if="isEditMode"
|
||||||
|
:label="t('common.delete')"
|
||||||
|
variant="danger"
|
||||||
|
icon-name="mdi:delete-outline"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
@click="emit('delete')"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-else
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('common.save')"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
:disabled="saving || !isValidHex"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- En-tete -->
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
{{ t('admin.sites.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
<template #actions>
|
||||||
{{ t('admin.sites.title') }}
|
<MalioButton
|
||||||
</h1>
|
v-if="can('sites.manage')"
|
||||||
<MalioButton
|
:label="t('admin.sites.newSite')"
|
||||||
v-if="can('sites.manage')"
|
icon-name="mdi:add-bold"
|
||||||
:label="t('admin.sites.newSite')"
|
icon-position="left"
|
||||||
icon-name="mdi:add-bold"
|
@click="openCreateDrawer"
|
||||||
icon-position="left"
|
/>
|
||||||
@click="openCreateDrawer"
|
</template>
|
||||||
/>
|
</PageHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table des sites -->
|
<!-- Table des sites -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-6"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="siteItems"
|
:items="siteItems"
|
||||||
:total-items="sites.length"
|
:total-items="sites.length"
|
||||||
|
|||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.5.0",
|
"@malio/layer-ui": "^1.7.1",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -1866,9 +1866,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.5.0",
|
"version": "1.7.1",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
|
||||||
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==",
|
"integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.5.0",
|
"@malio/layer-ui": "^1.7.1",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Entete de page standard : source unique du style des titres.
|
||||||
|
Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
|
||||||
|
<div class="mb-[44px] flex items-center justify-between gap-4">
|
||||||
|
<h1 class="text-[32px] font-semibold text-primary-500">
|
||||||
|
<slot/>
|
||||||
|
</h1>
|
||||||
|
<div v-if="$slots.actions" class="shrink-0">
|
||||||
|
<slot name="actions"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -43,3 +43,12 @@ export interface EffectivePermission {
|
|||||||
module: string
|
module: string
|
||||||
sources: string[]
|
sources: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groupement de permissions par module pour l'affichage en accordeon.
|
||||||
|
* Construit cote consommateur a partir de la liste plate /api/permissions.
|
||||||
|
*/
|
||||||
|
export interface PermissionModule {
|
||||||
|
module: string
|
||||||
|
permissions: Permission[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export interface Persona {
|
|||||||
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
||||||
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
||||||
// la copie/i18n change.
|
// la copie/i18n change.
|
||||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log'>
|
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHARED_PASSWORD = 'e2e-secret'
|
const SHARED_PASSWORD = 'e2e-secret'
|
||||||
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
password: SHARED_PASSWORD,
|
password: SHARED_PASSWORD,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||||
},
|
},
|
||||||
'user-full': {
|
'user-full': {
|
||||||
key: 'user-full',
|
key: 'user-full',
|
||||||
@@ -63,8 +63,10 @@ export const personas: Record<PersonaKey, Persona> = {
|
|||||||
'sites.view',
|
'sites.view',
|
||||||
'sites.manage',
|
'sites.manage',
|
||||||
'sites.bypass_scope',
|
'sites.bypass_scope',
|
||||||
|
'catalog.categories.view',
|
||||||
|
'catalog.categories.manage',
|
||||||
],
|
],
|
||||||
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'],
|
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||||
},
|
},
|
||||||
'user-readonly': {
|
'user-readonly': {
|
||||||
key: 'user-readonly',
|
key: 'user-readonly',
|
||||||
@@ -109,4 +111,4 @@ export function getPersona(key: PersonaKey): Persona {
|
|||||||
return personas[key]
|
return personas[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'audit-log'] as const
|
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const
|
||||||
|
|||||||
@@ -200,12 +200,20 @@ migration-migrate:
|
|||||||
# en DB, le purger crash.
|
# en DB, le purger crash.
|
||||||
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
|
||||||
# donc sync doit passer apres.
|
# donc sync doit passer apres.
|
||||||
|
# 4. recreation index `uq_category_name_type_active` : schema:update drop
|
||||||
|
# les index orphelins du mapping ORM. L'index partiel (LOWER + WHERE) du
|
||||||
|
# M0 Catalog n'est pas exprimable via les attributs Doctrine ORM 3
|
||||||
|
# (fonctionnel + partiel), donc il disparait apres schema:update. On le
|
||||||
|
# recree par dbal:run-sql pour que les tests RG-1.07 (unicite
|
||||||
|
# case-insensitive) voient bien la contrainte SQL. Sans ce restore, les
|
||||||
|
# POST doublons remontent 201 au lieu de 409.
|
||||||
test-db-setup:
|
test-db-setup:
|
||||||
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
|
||||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
|
||||||
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force
|
||||||
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load
|
||||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||||
|
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||||
|
|
||||||
fixtures:
|
fixtures:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M0 — Catalog : creation des tables `category_type` (referentiel) et `category`.
|
||||||
|
*
|
||||||
|
* Le referentiel `category_type` est cree vide ; ses valeurs seront seedees
|
||||||
|
* ulterieurement (cf. spec-back M0 § 9 HP-1).
|
||||||
|
*
|
||||||
|
* Index unique partiel sur (LOWER(name), category_type_id) WHERE deleted_at
|
||||||
|
* IS NULL : permet la recreation d'une categorie apres suppression logique
|
||||||
|
* (cf. RG-1.07). Postgres supporte nativement le `CREATE UNIQUE INDEX ... WHERE`.
|
||||||
|
*
|
||||||
|
* Les 4 colonnes Timestampable/Blamable (`created_at`, `updated_at`,
|
||||||
|
* `created_by`, `updated_by`) materialisent le pattern Shared (cf. ERP-52,
|
||||||
|
* spec-back M0 § 2.8) : NOT NULL pour les dates (remplies par le subscriber),
|
||||||
|
* nullable + ON DELETE SET NULL pour les FK user (creation hors contexte HTTP
|
||||||
|
* et suppression d'un user sans bloquer les categories existantes).
|
||||||
|
*
|
||||||
|
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
|
||||||
|
* Starseed n°11) : avec plusieurs migrations_paths, Doctrine Migrations 3.x
|
||||||
|
* trie par FQCN alphabetique et non par version timestamp → l'init des tables
|
||||||
|
* d'un module doit vivre au namespace racine pour garantir l'ordre sur base
|
||||||
|
* vide.
|
||||||
|
*/
|
||||||
|
final class Version20260527164000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'M0 Catalog : tables category_type et category, index unique partiel.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE category_type (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
code VARCHAR(40) NOT NULL,
|
||||||
|
label VARCHAR(120) NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uq_category_type_code ON category_type (code)');
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE category (
|
||||||
|
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
category_type_id INT NOT 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 fk_category_type
|
||||||
|
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_category_created_by
|
||||||
|
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_category_updated_by
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Unicite (name, type) case-insensitive, seulement sur les non-soft-deleted.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE UNIQUE INDEX uq_category_name_type_active
|
||||||
|
ON category (LOWER(name), category_type_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql('CREATE INDEX idx_category_deleted_at ON category (deleted_at)');
|
||||||
|
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
|
||||||
|
$this->addSql('CREATE INDEX idx_category_created_by ON category (created_by)');
|
||||||
|
$this->addSql('CREATE INDEX idx_category_updated_by ON category (updated_by)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Ordre important : `category` porte les FK vers `category_type`.
|
||||||
|
$this->addSql('DROP TABLE category');
|
||||||
|
$this->addSql('DROP TABLE category_type');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog;
|
||||||
|
|
||||||
|
final class CatalogModule
|
||||||
|
{
|
||||||
|
public const string ID = 'catalog';
|
||||||
|
public const string LABEL = 'Catalogue';
|
||||||
|
// REQUIRED = true : Category sera FK NOT NULL cote futurs modules Tiers
|
||||||
|
// (M-Clients, M-Fournisseurs, M-Prestataires). Desactiver Catalog casserait
|
||||||
|
// tout le metier au boot Doctrine. Cf. review Tristan MR #12 + spec M0 § 2.1.
|
||||||
|
public const bool REQUIRED = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste declarative des permissions RBAC exposees par le module Catalog.
|
||||||
|
*
|
||||||
|
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
|
||||||
|
* qui se charge d'upserter ces entrees dans la table `permission`, de
|
||||||
|
* reactiver les codes precedemment marques orphelins et de marquer comme
|
||||||
|
* orphelins ceux qui ont disparu du code source.
|
||||||
|
*
|
||||||
|
* La cle `module` est auto-injectee par le sync command a partir de
|
||||||
|
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
|
||||||
|
*
|
||||||
|
* Convention de nommage des codes : `module.resource[.sub].action` en
|
||||||
|
* snake_case, le prefixe module devant correspondre exactement a
|
||||||
|
* `self::ID` (verifie par la commande de synchronisation).
|
||||||
|
*
|
||||||
|
* Granularite alignee sur Core (view + manage), pas view/create/edit/delete
|
||||||
|
* (cf. spec M0 § 2.7).
|
||||||
|
*
|
||||||
|
* @return array<int, array{code: string, label: string}>
|
||||||
|
*/
|
||||||
|
public static function permissions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
|
||||||
|
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProcessor;
|
||||||
|
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvider;
|
||||||
|
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorie : referentiel metier classifiant les futurs tiers (clients,
|
||||||
|
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
|
||||||
|
* (FK vers le referentiel statique CategoryType).
|
||||||
|
*
|
||||||
|
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
|
||||||
|
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
|
||||||
|
* - Timestampable + Blamable via le Trait Shared : les 4 colonnes
|
||||||
|
* created_at / updated_at / created_by / updated_by sont remplies
|
||||||
|
* automatiquement par le TimestampableBlamableSubscriber.
|
||||||
|
* - `#[Auditable]` : chaque create / update / delete (soft) est trace dans
|
||||||
|
* audit_log par l'AuditListener du module Core.
|
||||||
|
*
|
||||||
|
* Provider (filtre soft-delete + ?includeDeleted + tri name ASC + 404 sur
|
||||||
|
* soft-deleted) et Processor (trim, 409 sur doublon, soft delete) branches
|
||||||
|
* au ticket 0.3 (ERP-45).
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('catalog.categories.view')",
|
||||||
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
|
provider: CategoryProvider::class,
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('catalog.categories.view')",
|
||||||
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
|
provider: CategoryProvider::class,
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('catalog.categories.manage')",
|
||||||
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['category:write']],
|
||||||
|
processor: CategoryProcessor::class,
|
||||||
|
),
|
||||||
|
new Patch(
|
||||||
|
security: "is_granted('catalog.categories.manage')",
|
||||||
|
normalizationContext: ['groups' => ['category:read', 'default:read']],
|
||||||
|
denormalizationContext: ['groups' => ['category:write']],
|
||||||
|
provider: CategoryProvider::class,
|
||||||
|
processor: CategoryProcessor::class,
|
||||||
|
),
|
||||||
|
new Delete(
|
||||||
|
security: "is_granted('catalog.categories.manage')",
|
||||||
|
provider: CategoryProvider::class,
|
||||||
|
processor: CategoryProcessor::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
|
||||||
|
#[ORM\Table(name: 'category')]
|
||||||
|
// Index nommes pour matcher la migration (cf. Role/Permission/Site). L'index
|
||||||
|
// unique partiel `uq_category_name_type_active` (LOWER(name), category_type_id
|
||||||
|
// WHERE deleted_at IS NULL) reste possede par la seule migration : Doctrine ORM
|
||||||
|
// ne sait pas exprimer un index fonctionnel + partiel via attribut.
|
||||||
|
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
|
||||||
|
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
|
||||||
|
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
|
||||||
|
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
|
||||||
|
#[Auditable]
|
||||||
|
class Category implements TimestampableInterface, BlamableInterface
|
||||||
|
{
|
||||||
|
// === Timestampable + Blamable ===
|
||||||
|
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
||||||
|
// getters/setters viennent du Trait Shared. Le TimestampableBlamableSubscriber
|
||||||
|
// les remplit automatiquement au prePersist / preUpdate. Aucune redeclaration
|
||||||
|
// manuelle de ces proprietes ici.
|
||||||
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['category:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
// RG-1.02 + RG-1.03 : un name compose uniquement d'espaces doit declencher
|
||||||
|
// NotBlank. Le normalizer 'trim' fait le menage avant validation, alignant
|
||||||
|
// le comportement sur le trim cote Processor (qui s'applique apres) : ainsi
|
||||||
|
// POST {name: " "} -> 422 et POST {name: " Vis "} -> 201 avec "Vis"
|
||||||
|
// persiste, sans contradiction entre l'ordre Validate / Process.
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
#[Assert\NotBlank(message: 'Le nom est obligatoire.', normalizer: 'trim')]
|
||||||
|
#[Assert\Length(min: 2, max: 120, normalizer: 'trim')]
|
||||||
|
#[Groups(['category:read', 'category:write'])]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||||
|
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
|
||||||
|
#[Groups(['category:read', 'category:write'])]
|
||||||
|
private ?CategoryType $categoryType = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
|
||||||
|
* Pas exposee en ecriture (groupe category:write exclu) : seul le DELETE,
|
||||||
|
* via le CategoryProcessor (ticket 0.3), pose la valeur.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||||
|
#[Groups(['category:read'])]
|
||||||
|
private ?DateTimeImmutable $deletedAt = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategoryType(): ?CategoryType
|
||||||
|
{
|
||||||
|
return $this->categoryType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCategoryType(?CategoryType $categoryType): static
|
||||||
|
{
|
||||||
|
$this->categoryType = $categoryType;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeletedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||||
|
{
|
||||||
|
$this->deletedAt = $deletedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryTypeRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type de categorie : referentiel statique classifiant les Category
|
||||||
|
* (ex: MATIERE, PRODUIT, SERVICE). Cree vide par la migration M0 ; son seed
|
||||||
|
* initial et son CRUD admin sont hors perimetre M0 (cf. spec-back § 9 HP-1).
|
||||||
|
*
|
||||||
|
* Lecture seule au M0 : seules les operations GetCollection et Get sont
|
||||||
|
* exposees, sous la meme permission que Category (catalog.categories.view).
|
||||||
|
*
|
||||||
|
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
|
||||||
|
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe `category:read`
|
||||||
|
* est ajoute sur chaque propriete pour que le type soit embarque dans la
|
||||||
|
* reponse d'une Category (cf. .claude/rules/backend.md § Serialization).
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
security: "is_granted('catalog.categories.view')",
|
||||||
|
normalizationContext: ['groups' => ['category_type:read']],
|
||||||
|
// Tri par defaut requis par la spec M0 § 4.6 : ordre alphabetique
|
||||||
|
// stable pour alimenter le <MalioSelect> du formulaire Category.
|
||||||
|
order: ['label' => 'ASC'],
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
security: "is_granted('catalog.categories.view')",
|
||||||
|
normalizationContext: ['groups' => ['category_type:read']],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity(repositoryClass: DoctrineCategoryTypeRepository::class)]
|
||||||
|
#[ORM\Table(name: 'category_type')]
|
||||||
|
// Contrainte d'unicite nommee pour matcher la migration (cf. Role/Permission).
|
||||||
|
#[ORM\UniqueConstraint(name: 'uq_category_type_code', columns: ['code'])]
|
||||||
|
class CategoryType
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['category_type:read', 'category:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 40)]
|
||||||
|
#[Groups(['category_type:read', 'category:read'])]
|
||||||
|
private ?string $code = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 120)]
|
||||||
|
#[Groups(['category_type:read', 'category:read'])]
|
||||||
|
private ?string $label = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCode(): ?string
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCode(string $code): static
|
||||||
|
{
|
||||||
|
$this->code = $code;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabel(): ?string
|
||||||
|
{
|
||||||
|
return $this->label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLabel(string $label): static
|
||||||
|
{
|
||||||
|
$this->label = $label;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
|
||||||
|
interface CategoryRepositoryInterface
|
||||||
|
{
|
||||||
|
public function findById(int $id): ?Category;
|
||||||
|
|
||||||
|
public function save(Category $category): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
|
||||||
|
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
|
||||||
|
* - Tri : name ASC (RG-1.10).
|
||||||
|
*/
|
||||||
|
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||||
|
|
||||||
|
interface CategoryTypeRepositoryInterface
|
||||||
|
{
|
||||||
|
public function findById(int $id): ?CategoryType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<CategoryType>
|
||||||
|
*/
|
||||||
|
public function findAllOrderedByLabel(): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor Category : applique les regles de gestion en ecriture.
|
||||||
|
*
|
||||||
|
* - POST / PATCH : trim du nom (RG-1.03) puis delegation au persist_processor
|
||||||
|
* Doctrine ORM. Toute UniqueConstraintViolationException remontee par Postgres
|
||||||
|
* (collision sur l'index partiel uq_category_name_type_active) est traduite
|
||||||
|
* en HTTP 409 avec le message attendu par la spec (RG-1.07).
|
||||||
|
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
|
||||||
|
* on pose deletedAt = now() puis on delegue au persist_processor pour que
|
||||||
|
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
|
||||||
|
* a jour updatedAt / updatedBy (RG-1.16) en plus de l'AuditListener.
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<Category, null|Category>
|
||||||
|
*/
|
||||||
|
final class CategoryProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private readonly ProcessorInterface $persistProcessor,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (!$data instanceof Category) {
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-1.12 : soft delete au lieu d'un remove physique. On bascule la DELETE
|
||||||
|
// en UPDATE en posant deletedAt, puis on persiste via le persist_processor.
|
||||||
|
if ($operation instanceof DeleteOperationInterface) {
|
||||||
|
$data->setDeletedAt(new DateTimeImmutable());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
} catch (UniqueConstraintViolationException $e) {
|
||||||
|
// Par construction, le soft delete ne peut pas violer l'index
|
||||||
|
// partiel (il LIBERE le couple (name, type) au lieu de le creer).
|
||||||
|
// On laisse remonter en 500 pour signaler une anomalie reelle.
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST / PATCH : trim du nom avant validation et persistance (RG-1.03).
|
||||||
|
if (null !== $data->getName()) {
|
||||||
|
$data->setName(trim($data->getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
} catch (UniqueConstraintViolationException $e) {
|
||||||
|
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
|
||||||
|
throw new HttpException(
|
||||||
|
409,
|
||||||
|
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
|
||||||
|
$e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider Category : applique le filtre soft-delete par defaut (RG-1.08),
|
||||||
|
* accepte ?includeDeleted=true pour inclure les soft-deleted (RG-1.09),
|
||||||
|
* trie par name ASC (RG-1.10), et renvoie 404 sur Get d'une soft-deleted
|
||||||
|
* sans le flag (RG-1.11, via retour null).
|
||||||
|
*
|
||||||
|
* Choix d'implementation : QueryBuilder via le repository custom plutot
|
||||||
|
* qu'un filtre Doctrine global (cf. spec § 2.3 et arbitrage ticket 0.3).
|
||||||
|
* Avantage : pas de magie globale, lisibilite directe du code, controle
|
||||||
|
* fin du flag includeDeleted par requete.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<Category>
|
||||||
|
*/
|
||||||
|
final class CategoryProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
|
||||||
|
private readonly CategoryRepositoryInterface $repository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
|
||||||
|
{
|
||||||
|
$includeDeleted = $this->readIncludeDeleted($context);
|
||||||
|
|
||||||
|
if ($operation instanceof CollectionOperationInterface) {
|
||||||
|
return $this->repository
|
||||||
|
->createListQueryBuilder($includeDeleted)
|
||||||
|
->getQuery()
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||||
|
$id = $uriVariables['id'] ?? null;
|
||||||
|
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = $this->repository->findById((int) $id);
|
||||||
|
if (null === $category) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-1.11 : 404 si soft-deleted et pas de flag includeDeleted.
|
||||||
|
if (!$includeDeleted && null !== $category->getDeletedAt()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le flag includeDeleted depuis les filtres API Platform.
|
||||||
|
* Accepte "true" / "1" / true (booleen).
|
||||||
|
*/
|
||||||
|
private function readIncludeDeleted(array $context): bool
|
||||||
|
{
|
||||||
|
$raw = $context['filters']['includeDeleted'] ?? false;
|
||||||
|
|
||||||
|
if (is_bool($raw)) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($raw)) {
|
||||||
|
return in_array(strtolower($raw), ['true', '1'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Category>
|
||||||
|
*/
|
||||||
|
class DoctrineCategoryRepository extends ServiceEntityRepository implements CategoryRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?Category
|
||||||
|
{
|
||||||
|
return $this->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Category $category): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($category);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('c')
|
||||||
|
->orderBy('c.name', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
if (!$includeDeleted) {
|
||||||
|
$qb->andWhere('c.deletedAt IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||||
|
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<CategoryType>
|
||||||
|
*/
|
||||||
|
class DoctrineCategoryTypeRepository extends ServiceEntityRepository implements CategoryTypeRepositoryInterface
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, CategoryType::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?CategoryType
|
||||||
|
{
|
||||||
|
return $this->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<CategoryType>
|
||||||
|
*/
|
||||||
|
public function findAllOrderedByLabel(): array
|
||||||
|
{
|
||||||
|
return $this->findBy([], ['label' => 'ASC']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,8 @@ final class SeedE2ECommand extends Command
|
|||||||
'sites.view',
|
'sites.view',
|
||||||
'sites.manage',
|
'sites.manage',
|
||||||
'sites.bypass_scope',
|
'sites.bypass_scope',
|
||||||
|
'catalog.categories.view',
|
||||||
|
'catalog.categories.manage',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Contract;
|
||||||
|
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat lu par le TimestampableBlamableSubscriber.
|
||||||
|
*
|
||||||
|
* Toute entite qui l'implemente voit ses colonnes `created_by` / `updated_by`
|
||||||
|
* remplies automatiquement avec l'utilisateur authentifie (ou laissees a null
|
||||||
|
* hors contexte HTTP : CLI, cron, migration).
|
||||||
|
*
|
||||||
|
* Le type-hint cible `Symfony\Component\Security\Core\User\UserInterface`
|
||||||
|
* (deja implementee par App\Module\Core\Domain\Entity\User) pour eviter de
|
||||||
|
* coupler Shared a Module/Core. La classe concrete est resolue par Doctrine
|
||||||
|
* via `resolve_target_entities` (cf. config/packages/doctrine.yaml).
|
||||||
|
*/
|
||||||
|
interface BlamableInterface
|
||||||
|
{
|
||||||
|
public function getCreatedBy(): ?UserInterface;
|
||||||
|
|
||||||
|
public function setCreatedBy(?UserInterface $user): void;
|
||||||
|
|
||||||
|
public function getUpdatedBy(): ?UserInterface;
|
||||||
|
|
||||||
|
public function setUpdatedBy(?UserInterface $user): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Contract;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat lu par le TimestampableBlamableSubscriber.
|
||||||
|
*
|
||||||
|
* Toute entite qui l'implemente voit ses colonnes `created_at` / `updated_at`
|
||||||
|
* remplies automatiquement au prePersist / preUpdate. Le porteur des colonnes
|
||||||
|
* et des accesseurs est le TimestampableBlamableTrait.
|
||||||
|
*/
|
||||||
|
interface TimestampableInterface
|
||||||
|
{
|
||||||
|
public function getCreatedAt(): ?DateTimeImmutable;
|
||||||
|
|
||||||
|
public function setCreatedAt(DateTimeImmutable $createdAt): void;
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?DateTimeImmutable;
|
||||||
|
|
||||||
|
public function setUpdatedAt(DateTimeImmutable $updatedAt): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Trait;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait Doctrine qui porte les 4 colonnes Timestampable + Blamable.
|
||||||
|
*
|
||||||
|
* Usage : `use TimestampableBlamableTrait;` dans l'entite, +
|
||||||
|
* `implements TimestampableInterface, BlamableInterface`. Le
|
||||||
|
* TimestampableBlamableSubscriber remplit les colonnes automatiquement
|
||||||
|
* au prePersist / preUpdate.
|
||||||
|
*
|
||||||
|
* Les Groups Serializer utilisent une convention `default:read` agregee :
|
||||||
|
* pour exposer les 4 colonnes dans une reponse API d'une entite X, ajouter
|
||||||
|
* `default:read` au normalizationContext aux cotes du groupe `x:read`.
|
||||||
|
*/
|
||||||
|
trait TimestampableBlamableTrait
|
||||||
|
{
|
||||||
|
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
|
||||||
|
#[Groups(['default:read'])]
|
||||||
|
private ?DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(name: 'updated_at', type: 'datetime_immutable')]
|
||||||
|
#[Groups(['default:read'])]
|
||||||
|
private ?DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['default:read'])]
|
||||||
|
private ?UserInterface $createdBy = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||||
|
#[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||||
|
#[Groups(['default:read'])]
|
||||||
|
private ?UserInterface $updatedBy = null;
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(DateTimeImmutable $createdAt): void
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = $updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedBy(): ?UserInterface
|
||||||
|
{
|
||||||
|
return $this->createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedBy(?UserInterface $user): void
|
||||||
|
{
|
||||||
|
$this->createdBy = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedBy(): ?UserInterface
|
||||||
|
{
|
||||||
|
return $this->updatedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedBy(?UserInterface $user): void
|
||||||
|
{
|
||||||
|
$this->updatedBy = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||||
|
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||||
|
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||||
|
use Doctrine\ORM\Events;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listener Doctrine global qui remplit automatiquement les colonnes
|
||||||
|
* Timestampable + Blamable.
|
||||||
|
*
|
||||||
|
* Pattern aligne sur AuditListener (cf.
|
||||||
|
* src/Module/Core/Infrastructure/Doctrine/AuditListener.php) : declare via
|
||||||
|
* #[AsDoctrineListener], auto-wire par le DoctrineBundle.
|
||||||
|
*
|
||||||
|
* Regle Blamable : si aucun utilisateur n'est authentifie (CLI, cron,
|
||||||
|
* migration), les FK `created_by` / `updated_by` restent a null. L'affichage
|
||||||
|
* front gere le libelle « Systeme » pour null.
|
||||||
|
*/
|
||||||
|
#[AsDoctrineListener(event: Events::prePersist)]
|
||||||
|
#[AsDoctrineListener(event: Events::preUpdate)]
|
||||||
|
final class TimestampableBlamableSubscriber
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Security $security) {}
|
||||||
|
|
||||||
|
public function prePersist(PrePersistEventArgs $args): void
|
||||||
|
{
|
||||||
|
$entity = $args->getObject();
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
if ($entity instanceof TimestampableInterface) {
|
||||||
|
$entity->setCreatedAt($now);
|
||||||
|
$entity->setUpdatedAt($now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
|
||||||
|
$entity->setCreatedBy($user);
|
||||||
|
$entity->setUpdatedBy($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preUpdate(PreUpdateEventArgs $args): void
|
||||||
|
{
|
||||||
|
$entity = $args->getObject();
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
if ($entity instanceof TimestampableInterface) {
|
||||||
|
$entity->setUpdatedAt(new DateTimeImmutable());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
|
||||||
|
$entity->setUpdatedBy($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Architecture;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||||
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use Doctrine\ORM\Mapping\Entity;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
use Symfony\Component\Finder\Finder;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde-fou architecture (niveau L3 de la spec § 2.8.bis).
|
||||||
|
*
|
||||||
|
* Scanne toutes les entites Doctrine sous `src/Module/<module>/Domain/Entity/`
|
||||||
|
* et verifie qu'elles implementent TimestampableInterface ET BlamableInterface
|
||||||
|
* (via TimestampableBlamableTrait). Empeche tout oubli du pattern sur une
|
||||||
|
* nouvelle entite metier : la CI passe au rouge.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Entites explicitement exemptees du pattern.
|
||||||
|
*
|
||||||
|
* Au M0, on whiteliste les 4 entites preexistantes du noyau (creees avant
|
||||||
|
* l'introduction du pattern) : leur retrofit est une decision archi a part
|
||||||
|
* entiere, hors scope ERP-52.
|
||||||
|
*
|
||||||
|
* - User : referentiel d'authentification, createdAt gere manuellement dans
|
||||||
|
* le constructeur. Retrofit hors scope M0 (cf. HP-9) : impose de trancher
|
||||||
|
* la recursion Blamable (un User cree par un User) + casse des tests
|
||||||
|
* existants.
|
||||||
|
* - Role : referentiel RBAC synchronise via `app:sync-permissions`, pas de
|
||||||
|
* tracabilite user-driven necessaire.
|
||||||
|
* - Permission : idem Role (synchronise, pas pilote utilisateur).
|
||||||
|
* - Site : referentiel admin-managed, a integrer dans un futur module Sites
|
||||||
|
* v2 (cf. HP-10).
|
||||||
|
* - CategoryType : referentiel statique (codes de typage des categories),
|
||||||
|
* pas de besoin de tracabilite user-driven (cree par migration/seed,
|
||||||
|
* pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17.
|
||||||
|
*
|
||||||
|
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
|
||||||
|
*/
|
||||||
|
private const EXCLUDED = [
|
||||||
|
User::class,
|
||||||
|
Role::class,
|
||||||
|
Permission::class,
|
||||||
|
Site::class,
|
||||||
|
CategoryType::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||||
|
{
|
||||||
|
// Garde : chaque entree de la whitelist doit pointer sur une classe
|
||||||
|
// reelle. Empeche un FQCN errone de masquer silencieusement un oubli.
|
||||||
|
foreach (self::EXCLUDED as $excluded) {
|
||||||
|
self::assertTrue(class_exists($excluded), sprintf('Classe whitelistee inexistante : %s', $excluded));
|
||||||
|
}
|
||||||
|
|
||||||
|
$finder = new Finder()
|
||||||
|
->files()
|
||||||
|
->in(__DIR__.'/../../src/Module')
|
||||||
|
->path('Domain/Entity')
|
||||||
|
->name('*.php')
|
||||||
|
;
|
||||||
|
|
||||||
|
// 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), 'Aucune entite scannee : chemin src/Module invalide ?');
|
||||||
|
|
||||||
|
foreach ($finder as $file) {
|
||||||
|
$fqcn = $this->extractFqcn($file->getRealPath());
|
||||||
|
if (null === $fqcn || in_array($fqcn, self::EXCLUDED, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass($fqcn);
|
||||||
|
// On ignore les classes abstraites et tout ce qui n'est pas une
|
||||||
|
// entite Doctrine (value objects, embeddables non mappes, etc.).
|
||||||
|
if ($reflection->isAbstract() || [] === $reflection->getAttributes(Entity::class)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertTrue(
|
||||||
|
$reflection->implementsInterface(TimestampableInterface::class)
|
||||||
|
&& $reflection->implementsInterface(BlamableInterface::class),
|
||||||
|
sprintf(
|
||||||
|
'L\'entite %s doit implementer TimestampableInterface ET BlamableInterface '
|
||||||
|
.'(utiliser TimestampableBlamableTrait). Si c\'est un referentiel statique '
|
||||||
|
.'justifie, l\'ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.',
|
||||||
|
$fqcn,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
|
||||||
|
* source, sans charger le fichier.
|
||||||
|
*/
|
||||||
|
private function extractFqcn(string $path): ?string
|
||||||
|
{
|
||||||
|
$source = file_get_contents($path);
|
||||||
|
if (false === $source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|
||||||
|
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($nsMatch[1]).'\\'.$classMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||||
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classe de base pour les tests fonctionnels du module Catalog.
|
||||||
|
*
|
||||||
|
* Etend la base Core :
|
||||||
|
* - factories `createCategoryType()` et `createCategory()` pour seeder vite
|
||||||
|
* les referentiels et les entites metier dans les tests ;
|
||||||
|
* - helpers d'authentification specifiques au M0 : `createAdminClient()`,
|
||||||
|
* `createManageClient()`, `createViewClient()` et un helper persona
|
||||||
|
* `createPersonaClient($label)` simulant les 4 roles MALIO sans permission
|
||||||
|
* catalog (Bureau / Compta / Commerciale / Usine).
|
||||||
|
*
|
||||||
|
* Cleanup : les noms de Category sont prefixes `test_cat_` et les codes de
|
||||||
|
* CategoryType sont prefixes `TEST_`. Le tearDown purge ces lignes, ainsi
|
||||||
|
* que les users / roles `test_*` crees par `createUserWithPermission` et
|
||||||
|
* `createPersonaClient`. Pas de DAMA en local, donc purge manuelle obligatoire.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
protected const string TEST_CATEGORY_PREFIX = 'test_cat_';
|
||||||
|
protected const string TEST_CATEGORY_TYPE_PREFIX = 'TEST_';
|
||||||
|
protected const string TEST_USER_PREFIX = 'test_';
|
||||||
|
protected const string TEST_ROLE_PREFIX = 'test_';
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->cleanupCatalogTestData();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cree un CategoryType de test. Le code est prefixe `TEST_` pour le
|
||||||
|
* cleanup, suffixe par un nonce aleatoire pour eviter les collisions
|
||||||
|
* inter-tests.
|
||||||
|
*/
|
||||||
|
protected function createCategoryType(?string $code = null, ?string $label = null): CategoryType
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||||
|
$type = new CategoryType();
|
||||||
|
$type->setCode($code ?? self::TEST_CATEGORY_TYPE_PREFIX.strtoupper($suffix));
|
||||||
|
$type->setLabel($label ?? 'Test Type '.$suffix);
|
||||||
|
|
||||||
|
$em->persist($type);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cree une Category de test. Le nom est prefixe `test_cat_` pour le
|
||||||
|
* cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree.
|
||||||
|
* Le flag $deletedAt permet de seeder directement une categorie
|
||||||
|
* soft-deleted (pour les tests RG-1.08 / RG-1.11).
|
||||||
|
*/
|
||||||
|
protected function createCategory(
|
||||||
|
?string $name = null,
|
||||||
|
?CategoryType $type = null,
|
||||||
|
?DateTimeImmutable $deletedAt = null,
|
||||||
|
): Category {
|
||||||
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
$type ??= $this->createCategoryType();
|
||||||
|
|
||||||
|
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||||
|
$category = new Category();
|
||||||
|
$category->setName($name ?? self::TEST_CATEGORY_PREFIX.$suffix);
|
||||||
|
$category->setCategoryType($type);
|
||||||
|
if (null !== $deletedAt) {
|
||||||
|
$category->setDeletedAt($deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->persist($category);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $category;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client authentifie en tant qu'admin fixture (bypass via isAdmin).
|
||||||
|
*/
|
||||||
|
protected function createAdminClient(): Client
|
||||||
|
{
|
||||||
|
return $this->authenticatedClient('admin', 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client non-admin portant la permission `catalog.categories.manage`.
|
||||||
|
* Utilise pour prouver qu'un non-admin avec la permission obtient 200 /
|
||||||
|
* 201 / 204 sur POST / PATCH / DELETE.
|
||||||
|
*
|
||||||
|
* @return array{client: Client, credentials: array{username: string, password: string}}
|
||||||
|
*/
|
||||||
|
protected function createManageClient(): array
|
||||||
|
{
|
||||||
|
$credentials = $this->createUserWithPermission('catalog.categories.manage');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
|
||||||
|
return ['client' => $client, 'credentials' => $credentials];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client non-admin portant la permission `catalog.categories.view`.
|
||||||
|
*/
|
||||||
|
protected function createViewClient(): Client
|
||||||
|
{
|
||||||
|
$credentials = $this->createUserWithPermission('catalog.categories.view');
|
||||||
|
|
||||||
|
return $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client authentifie en tant qu'un des 4 personas metier MALIO sans
|
||||||
|
* permission catalog. Les 4 roles (Bureau / Compta / Commerciale / Usine)
|
||||||
|
* sont seules creees a la volee dans le test, sans aucune permission
|
||||||
|
* catalog.categories.* attachee. Le user obtient donc systematiquement
|
||||||
|
* 403 sur tous les endpoints `/api/categories*` et `/api/category_types*`.
|
||||||
|
*
|
||||||
|
* Note : ces roles ne sont pas seedes dans AppFixtures (cf. HP-8 de la
|
||||||
|
* spec M0). Les tests les materialisent juste pour prouver que porter
|
||||||
|
* un role metier sans la permission catalog donne bien 403.
|
||||||
|
*/
|
||||||
|
protected function createPersonaClient(string $personaLabel): Client
|
||||||
|
{
|
||||||
|
if (!self::$kernel) {
|
||||||
|
self::bootKernel();
|
||||||
|
}
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||||
|
$username = self::TEST_USER_PREFIX.strtolower($personaLabel).'_'.$suffix;
|
||||||
|
$password = 'testpass';
|
||||||
|
|
||||||
|
/** @var UserPasswordHasherInterface $hasher */
|
||||||
|
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
|
||||||
|
// Role nomme d'apres le persona MALIO, ZERO permission catalog.
|
||||||
|
$role = new Role(
|
||||||
|
self::TEST_ROLE_PREFIX.strtolower($personaLabel).'_'.$suffix,
|
||||||
|
$personaLabel.' (test)',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
$em->persist($role);
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername($username);
|
||||||
|
$user->setIsAdmin(false);
|
||||||
|
$user->setPassword($hasher->hashPassword($user, $password));
|
||||||
|
$user->addRbacRole($role);
|
||||||
|
|
||||||
|
// Rattachement aux sites pour rester aligne sur createUserWithPermission.
|
||||||
|
foreach ($em->getRepository(Site::class)->findAll() as $site) {
|
||||||
|
$user->addSite($site);
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->persist($user);
|
||||||
|
$em->flush();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
return $this->authenticatedClient($username, $password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge des donnees Catalog crees par les tests.
|
||||||
|
*
|
||||||
|
* Strategie : purge complete des tables `category` et `category_type`
|
||||||
|
* (aucune fixture ne les remplit au M0 — la migration cree les tables
|
||||||
|
* vides, cf. spec-back § 1 + HP-1). On evite ainsi les pieges de
|
||||||
|
* cleanup par prefixe quand un test valide le mauvais payload (ex:
|
||||||
|
* name="" persiste sans matcher le LIKE) et laisse des orphelins
|
||||||
|
* bloquant le DELETE category_type par FK violation.
|
||||||
|
*
|
||||||
|
* Ordre :
|
||||||
|
* 1. Categories d'abord (FK ON DELETE RESTRICT vers category_type) ;
|
||||||
|
* 2. CategoryTypes ensuite ;
|
||||||
|
* 3. Users / Roles `test_*` enfin (FK created_by/updated_by sur
|
||||||
|
* category est ON DELETE SET NULL, mais on a deja purge category).
|
||||||
|
*/
|
||||||
|
private function cleanupCatalogTestData(): void
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
$em->createQuery('DELETE FROM '.Category::class)->execute();
|
||||||
|
$em->createQuery('DELETE FROM '.CategoryType::class)->execute();
|
||||||
|
|
||||||
|
$em->createQuery(
|
||||||
|
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
|
||||||
|
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
|
||||||
|
|
||||||
|
$em->createQuery(
|
||||||
|
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
|
||||||
|
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests Audit : l'attribut `#[Auditable]` porte sur Category, donc chaque
|
||||||
|
* POST / PATCH / DELETE doit produire une ligne dans `audit_log` via le
|
||||||
|
* AuditListener + AuditLogWriter (cf. spec audit-log.md).
|
||||||
|
*
|
||||||
|
* Verifications :
|
||||||
|
* - une ligne `entity_type='catalog.Category'` apparait apres chaque
|
||||||
|
* operation HTTP authentifiee comme admin ;
|
||||||
|
* - l'action est `create` / `update` (le soft delete est trace comme
|
||||||
|
* `update` puisque c'est un UPDATE Doctrine, cf. spec § 6.1) ;
|
||||||
|
* - `performed_by` est le username du user authentifie ;
|
||||||
|
* - `changes` est non vide (snapshot complet pour insert, diff pour update).
|
||||||
|
*
|
||||||
|
* Lecture via la connexion DBAL `audit` (pattern de AuditLogApiTest).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryAuditTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
private const string ENTITY_TYPE = 'catalog.Category';
|
||||||
|
|
||||||
|
private ?Connection $auditConnection = null;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
self::bootKernel();
|
||||||
|
|
||||||
|
/** @var Connection $conn */
|
||||||
|
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||||
|
$this->auditConnection = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
if (null !== $this->auditConnection) {
|
||||||
|
$this->auditConnection->close();
|
||||||
|
}
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAuditLogOnCreate(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'audit_create',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $response->getStatusCode());
|
||||||
|
$createdId = (string) $response->toArray()['id'];
|
||||||
|
|
||||||
|
$rows = $this->fetchAuditRows($createdId, 'create');
|
||||||
|
self::assertCount(1, $rows, 'Un audit_log "create" doit etre genere apres POST.');
|
||||||
|
self::assertSame('admin', $rows[0]['performed_by']);
|
||||||
|
|
||||||
|
$changes = $this->decodeChanges($rows[0]['changes']);
|
||||||
|
// Snapshot complet : au moins le name doit etre dedans.
|
||||||
|
self::assertArrayHasKey('name', $changes);
|
||||||
|
self::assertSame(
|
||||||
|
self::TEST_CATEGORY_PREFIX.'audit_create',
|
||||||
|
$changes['name'] ?? null,
|
||||||
|
'Le snapshot create doit porter le name persiste.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAuditLogOnUpdate(): void
|
||||||
|
{
|
||||||
|
$category = $this->createCategory();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$client->request('PATCH', '/api/categories/'.$category->getId(), [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'audit_patched'],
|
||||||
|
]);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$rows = $this->fetchAuditRows((string) $category->getId(), 'update');
|
||||||
|
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "update" doit etre genere apres PATCH.');
|
||||||
|
// On prend la ligne la plus recente.
|
||||||
|
$latest = $rows[0];
|
||||||
|
self::assertSame('admin', $latest['performed_by']);
|
||||||
|
|
||||||
|
$changes = $this->decodeChanges($latest['changes']);
|
||||||
|
// L'update doit contenir la diff sur `name` : {old: ..., new: 'audit_patched'}.
|
||||||
|
self::assertArrayHasKey('name', $changes);
|
||||||
|
self::assertIsArray($changes['name']);
|
||||||
|
self::assertArrayHasKey('new', $changes['name']);
|
||||||
|
self::assertSame(self::TEST_CATEGORY_PREFIX.'audit_patched', $changes['name']['new']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAuditLogOnSoftDelete(): void
|
||||||
|
{
|
||||||
|
$category = $this->createCategory();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
// Le soft delete = UPDATE Doctrine -> action 'update' en audit, avec
|
||||||
|
// la diff sur deletedAt (RG-1.12 + spec § 6.1).
|
||||||
|
$rows = $this->fetchAuditRows((string) $category->getId(), 'update');
|
||||||
|
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log doit tracer le soft delete (en tant qu\'update).');
|
||||||
|
$latest = $rows[0];
|
||||||
|
$changes = $this->decodeChanges($latest['changes']);
|
||||||
|
|
||||||
|
self::assertArrayHasKey('deletedAt', $changes, 'La diff doit contenir deletedAt.');
|
||||||
|
self::assertIsArray($changes['deletedAt']);
|
||||||
|
self::assertArrayHasKey('new', $changes['deletedAt']);
|
||||||
|
self::assertNotNull(
|
||||||
|
$changes['deletedAt']['new'],
|
||||||
|
'deletedAt.new doit etre rempli (timestamp ISO ou tableau Doctrine).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAuditLogPerformerCarriesAuthenticatedUsername(): void
|
||||||
|
{
|
||||||
|
// Manage user (non-admin) : prouve que performed_by suit l'auth, pas
|
||||||
|
// un mock hardcode "admin".
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$manage = $this->createManageClient();
|
||||||
|
$client = $manage['client'];
|
||||||
|
$managerUsername = $manage['credentials']['username'];
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $response->getStatusCode());
|
||||||
|
$createdId = (string) $response->toArray()['id'];
|
||||||
|
|
||||||
|
$rows = $this->fetchAuditRows($createdId, 'create');
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
self::assertSame(
|
||||||
|
$managerUsername,
|
||||||
|
$rows[0]['performed_by'],
|
||||||
|
'performed_by doit refleter le user authentifie (pas l\'admin par defaut).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Category::class lookups via entity_id + action
|
||||||
|
*
|
||||||
|
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string, performed_by: string}>
|
||||||
|
*/
|
||||||
|
private function fetchAuditRows(string $entityId, string $action): array
|
||||||
|
{
|
||||||
|
/** @var list<array<string, string>> $rows */
|
||||||
|
return $this->auditConnection->fetchAllAssociative(
|
||||||
|
'SELECT id, entity_type, entity_id, action, changes, performed_by '
|
||||||
|
.'FROM audit_log '
|
||||||
|
.'WHERE entity_type = :type AND entity_id = :id AND action = :action '
|
||||||
|
.'ORDER BY performed_at DESC',
|
||||||
|
[
|
||||||
|
'type' => self::ENTITY_TYPE,
|
||||||
|
'id' => $entityId,
|
||||||
|
'action' => $action,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function decodeChanges(string $raw): array
|
||||||
|
{
|
||||||
|
/** @var array<string, mixed> $decoded */
|
||||||
|
return json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests RG-1.12 / RG-1.13 : suppression et soft-delete de Category.
|
||||||
|
*
|
||||||
|
* - RG-1.12 : DELETE pose `deletedAt` au lieu d'un hard delete (la ligne
|
||||||
|
* reste en BDD avec `deleted_at IS NOT NULL`) et renvoie 204.
|
||||||
|
* - RG-1.13 : PATCH ne peut pas ecrire `deletedAt` (groupe `category:write`
|
||||||
|
* l'exclut), donc une tentative d'override est silencieusement ignoree.
|
||||||
|
* - Provider sur PATCH/DELETE : 404 si la categorie cible est deja
|
||||||
|
* soft-deleted (cf. CategoryProvider, ticket 0.3).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryDeleteTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
public function testDeleteReturns204AndPersistsSoftDelete(): void
|
||||||
|
{
|
||||||
|
$category = $this->createCategory();
|
||||||
|
$categoryId = $category->getId();
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('DELETE', '/api/categories/'.$categoryId);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
// RG-1.12 : la ligne doit toujours exister en BDD avec deletedAt non null.
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var null|Category $reloaded */
|
||||||
|
$reloaded = $em->getRepository(Category::class)->find($categoryId);
|
||||||
|
self::assertNotNull($reloaded, 'La ligne ne doit PAS etre supprimee physiquement (soft delete).');
|
||||||
|
self::assertNotNull($reloaded->getDeletedAt(), 'deletedAt doit etre rempli apres DELETE.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchCannotSetDeletedAt(): void
|
||||||
|
{
|
||||||
|
// RG-1.13 : le groupe `category:write` ne contient pas `deletedAt`,
|
||||||
|
// donc une tentative d'override doit etre silencieusement ignoree.
|
||||||
|
$category = $this->createCategory();
|
||||||
|
$categoryId = $category->getId();
|
||||||
|
self::assertNull($category->getDeletedAt());
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('PATCH', '/api/categories/'.$categoryId, [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => [
|
||||||
|
'deletedAt' => new DateTimeImmutable()->format(DateTimeImmutable::ATOM),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Le code precis depend d'API Platform : 200 (champ ignore) ou 400.
|
||||||
|
// Quoi qu'il arrive, deletedAt en BDD doit rester null.
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var Category $reloaded */
|
||||||
|
$reloaded = $em->getRepository(Category::class)->find($categoryId);
|
||||||
|
self::assertNull(
|
||||||
|
$reloaded->getDeletedAt(),
|
||||||
|
'PATCH ne doit JAMAIS pouvoir ecrire deletedAt (RG-1.13).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchOnSoftDeletedReturns404(): void
|
||||||
|
{
|
||||||
|
// Le Provider est cable sur PATCH (cf. Category::class § Patch). Une
|
||||||
|
// categorie deja soft-deletee n'est pas visible en lecture, donc le
|
||||||
|
// PATCH doit recevoir 404 (route resolved by API Platform retournee
|
||||||
|
// par le provider) — comme un Get unitaire (RG-1.11 etendue).
|
||||||
|
$category = $this->createCategory(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('PATCH', '/api/categories/'.$category->getId(), [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'try_patch'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteOnSoftDeletedReturns404(): void
|
||||||
|
{
|
||||||
|
// Idem PATCH : un DELETE sur une categorie deja soft-deletee est un
|
||||||
|
// 404 (le Provider la masque), pas une operation idempotente silencieuse.
|
||||||
|
$category = $this->createCategory(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests RG-1.11 : GET /api/categories/{id}.
|
||||||
|
*
|
||||||
|
* - Category soft-deleted sans flag → 404 ;
|
||||||
|
* - Category soft-deleted avec `?includeDeleted=true` → 200 ;
|
||||||
|
* - Category inexistante → 404.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryGetTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
public function testGetActiveCategoryReturns200(): void
|
||||||
|
{
|
||||||
|
$category = $this->createCategory();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('GET', '/api/categories/'.$category->getId());
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertSame($category->getId(), $response->toArray()['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetSoftDeletedReturns404(): void
|
||||||
|
{
|
||||||
|
$category = $this->createCategory(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('GET', '/api/categories/'.$category->getId());
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetSoftDeletedWithFlagReturns200(): void
|
||||||
|
{
|
||||||
|
$category = $this->createCategory(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('GET', '/api/categories/'.$category->getId().'?includeDeleted=true');
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
self::assertSame($category->getId(), $data['id']);
|
||||||
|
self::assertNotNull($data['deletedAt'], 'Le champ deletedAt doit etre expose dans la reponse.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetNonExistentReturns404(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('GET', '/api/categories/9999999');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests RG-1.08 / RG-1.09 / RG-1.10 : comportement de GET /api/categories.
|
||||||
|
*
|
||||||
|
* - RG-1.08 : par defaut, les categories soft-deleted sont exclues ;
|
||||||
|
* - RG-1.09 : `?includeDeleted=true` inclut les soft-deleted ;
|
||||||
|
* - RG-1.10 : tri par defaut `name ASC` cote serveur.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryListTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
public function testListExcludesSoftDeletedByDefault(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha', $type);
|
||||||
|
$this->createCategory(self::TEST_CATEGORY_PREFIX.'beta', $type);
|
||||||
|
$this->createCategory(
|
||||||
|
self::TEST_CATEGORY_PREFIX.'gone',
|
||||||
|
$type,
|
||||||
|
new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('GET', '/api/categories');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$data = $response->toArray();
|
||||||
|
$members = $data['member'];
|
||||||
|
|
||||||
|
// On filtre sur le prefix test_cat_ pour ne pas etre pollue par
|
||||||
|
// d'autres entrees presentes en base (fixtures, autres tests).
|
||||||
|
$names = array_values(array_filter(
|
||||||
|
array_map(fn (array $m): string => $m['name'], $members),
|
||||||
|
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertContains(self::TEST_CATEGORY_PREFIX.'alpha', $names);
|
||||||
|
self::assertContains(self::TEST_CATEGORY_PREFIX.'beta', $names);
|
||||||
|
self::assertNotContains(
|
||||||
|
self::TEST_CATEGORY_PREFIX.'gone',
|
||||||
|
$names,
|
||||||
|
'Les categories soft-deleted doivent etre exclues par defaut (RG-1.08).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIncludeDeletedFlagSurfacesSoftDeleted(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha2', $type);
|
||||||
|
$this->createCategory(
|
||||||
|
self::TEST_CATEGORY_PREFIX.'gone2',
|
||||||
|
$type,
|
||||||
|
new DateTimeImmutable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('GET', '/api/categories?includeDeleted=true');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$names = array_values(array_filter(
|
||||||
|
array_map(fn (array $m): string => $m['name'], $response->toArray()['member']),
|
||||||
|
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertContains(self::TEST_CATEGORY_PREFIX.'alpha2', $names);
|
||||||
|
self::assertContains(
|
||||||
|
self::TEST_CATEGORY_PREFIX.'gone2',
|
||||||
|
$names,
|
||||||
|
'?includeDeleted=true doit faire apparaitre les soft-deleted (RG-1.09).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultSortIsNameAsc(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
// Insertion volontairement dans le desordre pour prouver le tri.
|
||||||
|
$this->createCategory(self::TEST_CATEGORY_PREFIX.'zorro', $type);
|
||||||
|
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha_sort', $type);
|
||||||
|
$this->createCategory(self::TEST_CATEGORY_PREFIX.'mid', $type);
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('GET', '/api/categories');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
|
||||||
|
$names = array_values(array_filter(
|
||||||
|
array_map(fn (array $m): string => $m['name'], $response->toArray()['member']),
|
||||||
|
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Verifie que la sous-liste de nos 3 entrees est triee croissante.
|
||||||
|
$expectedSubset = [
|
||||||
|
self::TEST_CATEGORY_PREFIX.'alpha_sort',
|
||||||
|
self::TEST_CATEGORY_PREFIX.'mid',
|
||||||
|
self::TEST_CATEGORY_PREFIX.'zorro',
|
||||||
|
];
|
||||||
|
|
||||||
|
$filtered = array_values(array_intersect($names, $expectedSubset));
|
||||||
|
self::assertSame(
|
||||||
|
$expectedSubset,
|
||||||
|
$filtered,
|
||||||
|
'Les categories doivent etre retournees triees par name ASC (RG-1.10).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests RG-1.01 : permissions RBAC catalog.categories.view / manage.
|
||||||
|
*
|
||||||
|
* Verifie que :
|
||||||
|
* - les 4 personas metier MALIO (Bureau / Compta / Commerciale / Usine) sans
|
||||||
|
* permission catalog.categories.* obtiennent 403 sur tous les verbes des
|
||||||
|
* endpoints `/api/categories*` et `/api/category_types*` ;
|
||||||
|
* - un utilisateur anonyme (sans JWT) obtient 401 ;
|
||||||
|
* - l'admin (bypass via isAdmin) obtient le code attendu (200 / 201 / 204).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
// ============ /api/categories — collection ============
|
||||||
|
|
||||||
|
#[DataProvider('personaProvider')]
|
||||||
|
public function testPersonaWithoutCatalogPermissionGets403OnGetCollection(string $personaLabel): void
|
||||||
|
{
|
||||||
|
$client = $this->createPersonaClient($personaLabel);
|
||||||
|
$client->request('GET', '/api/categories');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnonymousGets401OnGetCollection(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->request('GET', '/api/categories');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminGets200OnGetCollection(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('GET', '/api/categories');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUserWithViewPermissionGets200OnGetCollection(): void
|
||||||
|
{
|
||||||
|
$client = $this->createViewClient();
|
||||||
|
$client->request('GET', '/api/categories');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ /api/categories — POST ============
|
||||||
|
|
||||||
|
#[DataProvider('personaProvider')]
|
||||||
|
public function testPersonaWithoutManagePermissionGets403OnPost(string $personaLabel): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createPersonaClient($personaLabel);
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'forbidden',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnonymousGets401OnPost(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'anon',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminGets201OnPost(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'admin_create',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUserWithOnlyViewPermissionGets403OnPost(): void
|
||||||
|
{
|
||||||
|
// Prouve qu'avoir `view` ne suffit pas a POSTer (manage requis).
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createViewClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'view_only',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ /api/categories/{id} — PATCH ============
|
||||||
|
|
||||||
|
#[DataProvider('personaProvider')]
|
||||||
|
public function testPersonaWithoutManagePermissionGets403OnPatch(string $personaLabel): void
|
||||||
|
{
|
||||||
|
$category = $this->createCategory();
|
||||||
|
$client = $this->createPersonaClient($personaLabel);
|
||||||
|
$client->request('PATCH', '/api/categories/'.$category->getId(), [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'patched'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ /api/categories/{id} — DELETE ============
|
||||||
|
|
||||||
|
#[DataProvider('personaProvider')]
|
||||||
|
public function testPersonaWithoutManagePermissionGets403OnDelete(string $personaLabel): void
|
||||||
|
{
|
||||||
|
$category = $this->createCategory();
|
||||||
|
$client = $this->createPersonaClient($personaLabel);
|
||||||
|
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminGets204OnDelete(): void
|
||||||
|
{
|
||||||
|
$category = $this->createCategory();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('DELETE', '/api/categories/'.$category->getId());
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ /api/category_types — referentiel ============
|
||||||
|
|
||||||
|
#[DataProvider('personaProvider')]
|
||||||
|
public function testPersonaWithoutCatalogPermissionGets403OnCategoryTypes(string $personaLabel): void
|
||||||
|
{
|
||||||
|
$client = $this->createPersonaClient($personaLabel);
|
||||||
|
$client->request('GET', '/api/category_types');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{string}>
|
||||||
|
*/
|
||||||
|
public static function personaProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'Bureau' => ['Bureau'];
|
||||||
|
|
||||||
|
yield 'Compta' => ['Compta'];
|
||||||
|
|
||||||
|
yield 'Commerciale' => ['Commerciale'];
|
||||||
|
|
||||||
|
yield 'Usine' => ['Usine'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnonymousGets401OnCategoryTypes(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->request('GET', '/api/category_types');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminGets200OnCategoryTypes(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('GET', '/api/category_types');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUserWithViewPermissionGets200OnCategoryTypes(): void
|
||||||
|
{
|
||||||
|
// Le referentiel reutilise la meme permission catalog.categories.view.
|
||||||
|
$client = $this->createViewClient();
|
||||||
|
$client->request('GET', '/api/category_types');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
|
||||||
|
* automatiquement les 4 colonnes au prePersist (RG-1.15) et au preUpdate
|
||||||
|
* (RG-1.16), sans qu'aucun champ ne soit modifiable par l'API client.
|
||||||
|
*
|
||||||
|
* - POST authentifie : createdAt = updatedAt = now, createdBy = updatedBy = user
|
||||||
|
* - Persist hors HTTP (console context) : dates remplies, blame null
|
||||||
|
* - PATCH par un user different : updatedAt + updatedBy changent, createdAt /
|
||||||
|
* createdBy restent figes
|
||||||
|
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
|
||||||
|
* Doctrine declenche le subscriber)
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
public function testCreatedByAdminOnPost(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
|
||||||
|
/** @var User $admin */
|
||||||
|
$admin = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
||||||
|
self::assertNotNull($admin);
|
||||||
|
$adminId = $admin->getId();
|
||||||
|
|
||||||
|
$before = new DateTimeImmutable();
|
||||||
|
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $response->getStatusCode());
|
||||||
|
$createdId = $response->toArray()['id'];
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var Category $reloaded */
|
||||||
|
$reloaded = $em->getRepository(Category::class)->find($createdId);
|
||||||
|
|
||||||
|
// RG-1.15 — dates remplies, egales au prePersist
|
||||||
|
self::assertNotNull($reloaded->getCreatedAt());
|
||||||
|
self::assertNotNull($reloaded->getUpdatedAt());
|
||||||
|
self::assertGreaterThanOrEqual(
|
||||||
|
$before->getTimestamp(),
|
||||||
|
$reloaded->getCreatedAt()->getTimestamp(),
|
||||||
|
'createdAt doit etre post-test-start.',
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
$reloaded->getCreatedAt()->getTimestamp(),
|
||||||
|
$reloaded->getUpdatedAt()->getTimestamp(),
|
||||||
|
'Au POST, createdAt et updatedAt doivent etre identiques.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// RG-1.15 — blame remplis avec le user authentifie (admin)
|
||||||
|
self::assertNotNull($reloaded->getCreatedBy());
|
||||||
|
self::assertNotNull($reloaded->getUpdatedBy());
|
||||||
|
self::assertSame($adminId, $reloaded->getCreatedBy()->getId());
|
||||||
|
self::assertSame($adminId, $reloaded->getUpdatedBy()->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreatedByNullInConsoleContext(): void
|
||||||
|
{
|
||||||
|
// RG-1.15 : persist sans contexte HTTP -> Security::getUser() retourne
|
||||||
|
// null -> blame reste null, mais les dates restent remplies.
|
||||||
|
// On utilise la factory createCategory() qui fait un persist Doctrine
|
||||||
|
// direct (pas via le client HTTP).
|
||||||
|
$category = $this->createCategory(self::TEST_CATEGORY_PREFIX.'console');
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var Category $reloaded */
|
||||||
|
$reloaded = $em->getRepository(Category::class)->find($category->getId());
|
||||||
|
|
||||||
|
// Dates remplies par le subscriber.
|
||||||
|
self::assertNotNull($reloaded->getCreatedAt());
|
||||||
|
self::assertNotNull($reloaded->getUpdatedAt());
|
||||||
|
|
||||||
|
// Blame null (pas de Security::getUser() dispo hors HTTP).
|
||||||
|
self::assertNull(
|
||||||
|
$reloaded->getCreatedBy(),
|
||||||
|
'createdBy doit etre null hors contexte HTTP (RG-1.15 fallback).',
|
||||||
|
);
|
||||||
|
self::assertNull($reloaded->getUpdatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchUpdatesUpdatedFieldsOnly(): void
|
||||||
|
{
|
||||||
|
// Etape 1 : creation par admin pour figer createdBy=admin.
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$adminClient = $this->createAdminClient();
|
||||||
|
|
||||||
|
$response = $adminClient->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $response->getStatusCode());
|
||||||
|
$createdId = $response->toArray()['id'];
|
||||||
|
|
||||||
|
// Snapshot des valeurs initiales pour comparaison apres PATCH.
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var Category $initial */
|
||||||
|
$initial = $em->getRepository(Category::class)->find($createdId);
|
||||||
|
$initialCreatedAt = $initial->getCreatedAt();
|
||||||
|
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||||
|
$initialCreatedById = $initial->getCreatedBy()->getId();
|
||||||
|
|
||||||
|
// Decalage temporel suffisant pour que la precision PG (seconde)
|
||||||
|
// capte un updatedAt different.
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
|
||||||
|
$manage = $this->createManageClient();
|
||||||
|
$bobClient = $manage['client'];
|
||||||
|
|
||||||
|
/** @var User $bob */
|
||||||
|
$bob = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
|
||||||
|
$bobId = $bob->getId();
|
||||||
|
self::assertNotSame($initialCreatedById, $bobId, 'Le test exige deux users distincts.');
|
||||||
|
|
||||||
|
$bobClient->request('PATCH', '/api/categories/'.$createdId, [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'tsb_patched_by_bob'],
|
||||||
|
]);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
// Etape 3 : verifications RG-1.16
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var Category $patched */
|
||||||
|
$patched = $em->getRepository(Category::class)->find($createdId);
|
||||||
|
|
||||||
|
// createdAt / createdBy figes
|
||||||
|
self::assertSame(
|
||||||
|
$initialCreatedAt->getTimestamp(),
|
||||||
|
$patched->getCreatedAt()->getTimestamp(),
|
||||||
|
'createdAt doit etre fige au PATCH (RG-1.16).',
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
$initialCreatedById,
|
||||||
|
$patched->getCreatedBy()->getId(),
|
||||||
|
'createdBy doit etre fige au PATCH (RG-1.16).',
|
||||||
|
);
|
||||||
|
|
||||||
|
// updatedAt / updatedBy mis a jour
|
||||||
|
self::assertGreaterThan(
|
||||||
|
$initialUpdatedAt->getTimestamp(),
|
||||||
|
$patched->getUpdatedAt()->getTimestamp(),
|
||||||
|
'updatedAt doit avancer apres PATCH (RG-1.16).',
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
$bobId,
|
||||||
|
$patched->getUpdatedBy()->getId(),
|
||||||
|
'updatedBy doit refleter le user PATCH (RG-1.16).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
|
||||||
|
{
|
||||||
|
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
|
||||||
|
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$adminClient = $this->createAdminClient();
|
||||||
|
|
||||||
|
$response = $adminClient->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $response->getStatusCode());
|
||||||
|
$createdId = $response->toArray()['id'];
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var Category $initial */
|
||||||
|
$initial = $em->getRepository(Category::class)->find($createdId);
|
||||||
|
$initialUpdatedAt = $initial->getUpdatedAt();
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// Soft delete par un manager non-admin.
|
||||||
|
$manage = $this->createManageClient();
|
||||||
|
$bobClient = $manage['client'];
|
||||||
|
|
||||||
|
/** @var User $bob */
|
||||||
|
$bob = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
|
||||||
|
$bobId = $bob->getId();
|
||||||
|
|
||||||
|
$bobClient->request('DELETE', '/api/categories/'.$createdId);
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
/** @var Category $deleted */
|
||||||
|
$deleted = $em->getRepository(Category::class)->find($createdId);
|
||||||
|
|
||||||
|
// deletedAt rempli
|
||||||
|
self::assertNotNull($deleted->getDeletedAt(), 'deletedAt doit etre rempli apres DELETE.');
|
||||||
|
|
||||||
|
// updatedAt avance, updatedBy = bob
|
||||||
|
self::assertGreaterThan(
|
||||||
|
$initialUpdatedAt->getTimestamp(),
|
||||||
|
$deleted->getUpdatedAt()->getTimestamp(),
|
||||||
|
'updatedAt doit avancer au soft delete (RG-1.16).',
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
$bobId,
|
||||||
|
$deleted->getUpdatedBy()->getId(),
|
||||||
|
'updatedBy doit refleter l\'auteur du soft delete (RG-1.16).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests RG-1.07 : unicite case-insensitive de (LOWER(name), category_type_id)
|
||||||
|
* parmi les categories non soft-deleted. L'index Postgres partiel
|
||||||
|
* `uq_category_name_type_active` est traduit en 409 Conflict par le
|
||||||
|
* CategoryProcessor.
|
||||||
|
*
|
||||||
|
* Cas couverts :
|
||||||
|
* - doublon strict (meme name + meme type) → 409 ;
|
||||||
|
* - doublon case-insensitive (Vis / vis sur meme type) → 409 ;
|
||||||
|
* - meme name sur 2 types differents → les deux passent (pas de doublon) ;
|
||||||
|
* - recreation apres soft delete → 201 (l'index partiel libere le couple).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
public function testDuplicateNameSameTypeReturns409(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
// 1er POST : doit reussir.
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
// 2eme POST : meme name + meme type → doublon strict.
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'unique',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertSame(409, $response->getStatusCode());
|
||||||
|
|
||||||
|
// Message attendu par la spec RG-1.07.
|
||||||
|
$payload = $response->toArray(false);
|
||||||
|
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'existe déjà pour ce type',
|
||||||
|
$description,
|
||||||
|
'Le message d\'erreur 409 doit citer la spec ("existe deja pour ce type").',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDuplicateNameCaseInsensitiveReturns409(): void
|
||||||
|
{
|
||||||
|
// RG-1.07 : la collision est case-insensitive (index sur LOWER(name)).
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
// Meme prefix mais variation de casse → meme LOWER → collision.
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertSame(409, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSameNameDifferentTypeAllowed(): void
|
||||||
|
{
|
||||||
|
// RG-1.07 : la contrainte est SUR (name, type), pas sur name seul.
|
||||||
|
// Le meme nom doit etre acceptable sur deux types differents.
|
||||||
|
$type1 = $this->createCategoryType();
|
||||||
|
$type2 = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||||
|
'categoryType' => '/api/category_types/'.$type1->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'shared',
|
||||||
|
'categoryType' => '/api/category_types/'.$type2->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRecreateAfterSoftDeleteAllowed(): void
|
||||||
|
{
|
||||||
|
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
|
||||||
|
// Apres un soft delete, le couple (name, type) est libere et un
|
||||||
|
// nouveau POST identique doit reussir.
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
// 1) creation
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertSame(201, $response->getStatusCode());
|
||||||
|
$created = $response->toArray();
|
||||||
|
|
||||||
|
// 2) soft delete
|
||||||
|
$client->request('DELETE', '/api/categories/'.$created['id']);
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
// 3) recreation : meme name + meme type → autorise (couple libere).
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Catalog\Api;
|
||||||
|
|
||||||
|
use App\Module\Catalog\Domain\Entity\Category;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests des regles de validation POST/PATCH sur Category :
|
||||||
|
* - RG-1.02 : `name` obligatoire (NotBlank) ;
|
||||||
|
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
|
||||||
|
* - RG-1.04 : `name` longueur 2..120 (Length) ;
|
||||||
|
* - RG-1.05 : `categoryType` obligatoire ;
|
||||||
|
* - RG-1.06 : `categoryType` doit pointer un type existant.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CategoryValidationTest extends AbstractCatalogApiTestCase
|
||||||
|
{
|
||||||
|
// ============ RG-1.02 — name NotBlank ============
|
||||||
|
|
||||||
|
public function testNameRequiredReturns422(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
// name absent
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNameEmptyStringReturns422(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => '',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNameWhitespaceOnlyReturns422(): void
|
||||||
|
{
|
||||||
|
// Le Processor trim avant la validation : " " devient "" -> NotBlank
|
||||||
|
// doit declencher 422 (RG-1.02 combinee a RG-1.03).
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => ' ',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ RG-1.03 — name trim cote serveur ============
|
||||||
|
|
||||||
|
public function testNameIsTrimmedOnCreate(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$payloadName = ' '.self::TEST_CATEGORY_PREFIX.'trim ';
|
||||||
|
$expected = trim($payloadName);
|
||||||
|
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => $payloadName,
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
|
||||||
|
// Verification cote base : la valeur stockee est trimee.
|
||||||
|
$em = $this->getEm();
|
||||||
|
$em->clear();
|
||||||
|
$stored = $em->getRepository(Category::class)->findOneBy(['name' => $expected]);
|
||||||
|
self::assertNotNull($stored, 'La categorie trimee doit etre persistee sous "'.$expected.'"');
|
||||||
|
self::assertSame($expected, $stored->getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ RG-1.04 — longueur 2..120 ============
|
||||||
|
|
||||||
|
public function testNameTooShortReturns422(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => 'A',
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNameTooLongReturns422(): void
|
||||||
|
{
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => str_repeat('a', 121),
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNameAtMaxLengthIs201(): void
|
||||||
|
{
|
||||||
|
// Borne haute : 120 caracteres doit passer (l'index est sur LOWER, name
|
||||||
|
// est unique en collision avec d'autres tests donc on prefixe la marque
|
||||||
|
// test_cat_ pour le cleanup et completons jusqu'a 120 caracteres).
|
||||||
|
$prefix = self::TEST_CATEGORY_PREFIX;
|
||||||
|
$name = $prefix.str_repeat('z', 120 - strlen($prefix));
|
||||||
|
self::assertSame(120, strlen($name));
|
||||||
|
|
||||||
|
$type = $this->createCategoryType();
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => $name,
|
||||||
|
'categoryType' => '/api/category_types/'.$type->getId(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ RG-1.05 — categoryType obligatoire ============
|
||||||
|
|
||||||
|
public function testCategoryTypeRequiredReturns422(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
|
||||||
|
// categoryType absent
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCategoryTypeNullIsRejected(): void
|
||||||
|
{
|
||||||
|
// `categoryType: null` echoue a la deserialization IRI (API Platform
|
||||||
|
// renvoie 400) bien avant la validation Assert\NotNull. La spec § 4.3
|
||||||
|
// accepte les deux : on assert le contrat fort "ne passe pas en BDD".
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'null_type',
|
||||||
|
'categoryType' => null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertContains(
|
||||||
|
$response->getStatusCode(),
|
||||||
|
[400, 422],
|
||||||
|
'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ RG-1.06 — categoryType doit exister ============
|
||||||
|
|
||||||
|
public function testCategoryTypeMustExistReturns4xx(): void
|
||||||
|
{
|
||||||
|
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
|
||||||
|
// (resolution IRI echouee) ou 422 (validation NotNull declenchee).
|
||||||
|
// La spec § 4.3 accepte les deux : on assert le contrat "ne passe pas".
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$response = $client->request('POST', '/api/categories', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
|
||||||
|
'categoryType' => '/api/category_types/9999999',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertContains(
|
||||||
|
$response->getStatusCode(),
|
||||||
|
[400, 404, 422],
|
||||||
|
'IRI categoryType inexistante doit etre rejetee (400/404/422 selon API Platform).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Shared\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
|
use App\Shared\Infrastructure\Doctrine\TimestampableBlamableSubscriber;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||||
|
use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du TimestampableBlamableSubscriber.
|
||||||
|
*
|
||||||
|
* On exerce directement prePersist / preUpdate avec un EntityManager et une
|
||||||
|
* Security stubbes — aucun boot de kernel, aucun acces BDD. Les entites de test
|
||||||
|
* sont des fixtures internes (cf. bas de fichier).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TimestampableBlamableSubscriberTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testPrePersistWithUser(): void
|
||||||
|
{
|
||||||
|
$user = $this->createStub(UserInterface::class);
|
||||||
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||||
|
$entity = new FullAuditableFixture();
|
||||||
|
|
||||||
|
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||||
|
|
||||||
|
// Les 4 colonnes sont remplies : dates posees, blame = user courant.
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
|
||||||
|
self::assertSame($entity->getCreatedAt(), $entity->getUpdatedAt());
|
||||||
|
self::assertSame($user, $entity->getCreatedBy());
|
||||||
|
self::assertSame($user, $entity->getUpdatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPrePersistWithoutUser(): void
|
||||||
|
{
|
||||||
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null));
|
||||||
|
$entity = new FullAuditableFixture();
|
||||||
|
|
||||||
|
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||||
|
|
||||||
|
// Hors contexte HTTP (CLI / cron) : dates remplies, blame laisse a null.
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
|
||||||
|
self::assertNull($entity->getCreatedBy());
|
||||||
|
self::assertNull($entity->getUpdatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreUpdate(): void
|
||||||
|
{
|
||||||
|
$user = $this->createStub(UserInterface::class);
|
||||||
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||||
|
|
||||||
|
// On simule une entite deja persistee : createdAt fige dans le passe,
|
||||||
|
// createdBy positionne par une creation anterieure.
|
||||||
|
$createdAt = new DateTimeImmutable('2020-01-01 10:00:00');
|
||||||
|
$entity = new FullAuditableFixture();
|
||||||
|
$entity->setCreatedAt($createdAt);
|
||||||
|
$entity->setUpdatedAt($createdAt);
|
||||||
|
|
||||||
|
$subscriber->preUpdate($this->preUpdateArgs($entity));
|
||||||
|
|
||||||
|
// updatedAt avance, createdAt reste fige, updatedBy = user courant.
|
||||||
|
self::assertSame($createdAt, $entity->getCreatedAt());
|
||||||
|
self::assertGreaterThan($createdAt, $entity->getUpdatedAt());
|
||||||
|
self::assertSame($user, $entity->getUpdatedBy());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPartialEntityTimestampableOnly(): void
|
||||||
|
{
|
||||||
|
$user = $this->createStub(UserInterface::class);
|
||||||
|
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
|
||||||
|
$entity = new TimestampableOnlyFixture();
|
||||||
|
|
||||||
|
// Entite Timestampable mais NON Blamable : seules les dates sont posees,
|
||||||
|
// aucun appel de blame (et aucune erreur).
|
||||||
|
$subscriber->prePersist($this->prePersistArgs($entity));
|
||||||
|
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
|
||||||
|
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security stubbee renvoyant l'utilisateur fourni (ou null).
|
||||||
|
*/
|
||||||
|
private function securityReturning(?UserInterface $user): Security
|
||||||
|
{
|
||||||
|
$security = $this->createStub(Security::class);
|
||||||
|
$security->method('getUser')->willReturn($user);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prePersistArgs(object $entity): PrePersistEventArgs
|
||||||
|
{
|
||||||
|
return new PrePersistEventArgs($entity, $this->createStub(EntityManagerInterface::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function preUpdateArgs(object $entity): PreUpdateEventArgs
|
||||||
|
{
|
||||||
|
$changeSet = [];
|
||||||
|
|
||||||
|
return new PreUpdateEventArgs($entity, $this->createStub(EntityManagerInterface::class), $changeSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixture interne : entite metier complete (Timestampable + Blamable) via le
|
||||||
|
* Trait reel teste.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class FullAuditableFixture implements TimestampableInterface, BlamableInterface
|
||||||
|
{
|
||||||
|
use TimestampableBlamableTrait;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixture interne : entite Timestampable seule (sans Blamable), pour verifier
|
||||||
|
* la dissociation des deux contrats par le Subscriber.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TimestampableOnlyFixture implements TimestampableInterface
|
||||||
|
{
|
||||||
|
private ?DateTimeImmutable $createdAt = null;
|
||||||
|
private ?DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(DateTimeImmutable $createdAt): void
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = $updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user