Compare commits

..

6 Commits

Author SHA1 Message Date
Matthieu 94b3acec05 ci : retire tout le caching (backend de cache runner injoignable, timeout 4m30)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m15s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m9s
Les logs montrent que chaque operation actions/cache attend ~4m30 avant
ETIMEDOUT sur le serveur de cache du runner Gitea (51.91.78.99:39531) :
- cache: npm de setup-node = tout le 'Setup Node 22' (271s)
- cache node_modules et cache .nuxt : timeouts additionnels
- cache Composer cote backend : meme risque

Node 22 est deja dans le tool-cache (install instantane), npm ci a froid
~30s, build ~20s : le caching n'apportait rien ici. A re-activer si le
serveur de cache du runner est repare.
2026-05-27 16:21:27 +02:00
Matthieu f5312686ab ci(frontend) : accelere le job PR (nuxt build + cache node_modules & build Nuxt/Vite)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m35s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 11m44s
- remplace build:dist (nuxt generate + prerender inutile en SPA) par nuxt build
- cache node_modules sur hash du lockfile, npm ci uniquement en cache miss
- regenere les types Nuxt (postinstall) en cache hit
- cache des artefacts .nuxt / Vite avec restore-keys pour eviter le build a froid
2026-05-27 15:46:54 +02:00
Matthieu d4f234ec55 docs(catalog) : document EXCLUDED rationale and add HP-9/HP-10
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m32s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 10m10s
2026-05-27 15:31:45 +02:00
Matthieu d0c3fb7558 feat(shared) : add Timestampable + Blamable Shared pattern (Trait + Interfaces + Subscriber + test) 2026-05-27 15:30:15 +02:00
gitea-actions 43d80df1e1 chore: bump version to v0.1.40
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 27s
2026-05-27 09:15:00 +00:00
matthieu 5db644d22e docs(catalog) : M0 categories specs (back + front) (#12)
Auto Tag Develop / tag (push) Successful in 10s
## Contexte

Premier passage du workflow MALIO sur un module concret. Cette MR introduit **uniquement la documentation** (2 specs Markdown) du **Module 0 — Gestion des catégories**. Aucun code applicatif n'est touché.

La spec back a été cadrée sur l'archi DDD réelle de Starseed (cf. `.claude/rules/architecture.md` + `backend.md`) après audit du repo (modules `Core`, `Commercial`, `Sites`). Elle introduit aussi un **nouveau module `Catalog`** (bounded context "référentiels partagés") — non créé dans cette MR, il viendra avec les tickets de dev.

## Contenu

| Fichier | Lignes | Objet |
|---|---|---|
| `docs/specs/M0-categories/spec-back.md` | 700 | Spec back v1.1 : modèle data, API REST (5 endpoints + CategoryType lecture seule), 13 RG (RG-1.01 → RG-1.13), validation, autorisation, audit, tests, hors-périmètre |
| `docs/specs/M0-categories/spec-front.md` | 113 | Spec front V0 client (validée 2026-05-22) : UI admin (datatable + drawer), 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier), permissions par rôle |

## Décisions d'archi (auto-validation back-only)

Toutes les décisions sont documentées dans `spec-back.md § 2` :

- **Module `Catalog`** créé séparément de `Commercial` pour rester réutilisable par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas)
- **IDs INT IDENTITY** (cohérent avec `User`, `Role`, etc.)
- **Soft delete** via `deleted_at TIMESTAMP(0) WITHOUT TIME ZONE NULL` — *pattern introduit par ce module* (aucune autre entité Starseed ne le portait)
- **Index unique partiel Postgres** sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL` → unicité case-insensitive parmi non soft-deleted, recréation possible après suppression logique
- **Granularité permissions = `view` + `manage`** (aligné sur `core.users.view` + `core.users.manage`)
- **Référentiel `CategoryType`** = entité séparée, table vide à la livraison, valeurs seedées plus tard (HP-1)

## Règles métier (RG-1.01 → RG-1.13)

Chaque RG est numérotée, stable, testable, et constituera un critère d'acceptation côté ticket Lesstime (cf. `spec-back.md § 7`).

Couverture par catégorie :
- Autorisation : RG-1.01 (Admin only)
- Champ `name` : RG-1.02 (obligatoire), RG-1.03 (trim), RG-1.04 (longueur 2-120)
- Champ `categoryType` : RG-1.05 (obligatoire), RG-1.06 (référence valide)
- Unicité : RG-1.07 (case-insensitive sur couple, hors soft-deleted)
- Liste : RG-1.08 (exclut soft-deleted), RG-1.09 (flag `?includeDeleted=true`), RG-1.10 (tri `name ASC`)
- Détail : RG-1.11 (404 si soft-deleted)
- Suppression : RG-1.12 (soft delete), RG-1.13 (`deletedAt` non modifiable via PATCH)

## Découpe en tickets Lesstime

TaskGroup `#22 — M0 — Gestion des catégories` créé sur le projet **STARSEED**. 9 tickets en backlog :

| # | Ticket Lesstime | Effort | Tag |
|---|---|---|---|
| 0.1 | `#43` Migrer les tables Category et CategoryType | S | Backend |
| 0.2 | `#44` Créer les entités Category et CategoryType | M | Backend |
| 0.3 | `#45` Implémenter Provider et Processor Category | M | Backend |
| 0.4 | `#46` Exposer le référentiel CategoryType en lecture seule | S | Backend |
| 0.5 | `#47` Déclarer le module Catalog et synchroniser RBAC | S | Backend |
| 0.6 | `#48` Écrire les tests PHPUnit RG-1.01 à RG-1.13 | M | Backend |
| 0.7 | `#49` Créer la page Gestion des catégories (datatable + drawer) | L | Frontend |
| 0.8 | `#50` Implémenter les composables useCategoriesAdmin et useCategoryForm | M | Frontend |
| 0.9 | `#51` Écrire les tests Vitest des composables Catalog | S | Frontend |

**Total estimé** : ~15-25h (9 mini-MR de 1-4h).

## Hors-périmètre (HP)

Pas codés / pas inclus dans cette spec (cf. `spec-back.md § 9`) :

- HP-1 : CRUD du référentiel `CategoryType` (spec dédiée à venir)
- HP-2 : Référencement par les Tiers (les FK `category_id` sur Clients/Fournisseurs/Prestas viendront avec leurs modules)
- HP-3 : Restauration d'une catégorie soft-deleted
- HP-4 : Hard delete
- HP-5 : i18n du `name`
- HP-6 : Recherche serveur / filtres avancés
- HP-7 : Catégories hiérarchiques (parent / enfant)
- HP-8 : Seed des rôles métier Bureau / Compta / Commerciale / Usine

## Checklist review

Pas de code → review légère, lecture des 2 `.md` suffit.

- [ ] Frontmatter YAML cohérent (module, version, dates, validation client, taskgroup, tags)
- [ ] Décisions d'archi argumentées (`spec-back.md § 2`)
- [ ] Modèle data réaliste (SQL Postgres rédigé directement, pas de pseudo-code)
- [ ] API REST complète (codes HTTP succès + erreurs, payloads, exemples)
- [ ] RG numérotées et testables (un humain peut écrire un test PHPUnit à partir de chaque RG sans réinventer)
- [ ] Mapping rôles MALIO ↔ permissions RBAC clair (cohérent avec règle ABSOLUE n°8 — 3 sources à toucher)
- [ ] Hors-périmètre explicite

## Stratégie de merge

**Squash merge** vers `develop` (1 PR = 1 commit propre dans l'historique).

## Lien Lesstime

- TaskGroup : `#22 — M0 — Gestion des catégories` (projet ERP / Starseed)
- Tickets en backlog : `#43` → `#51`

> ⚠️ **À faire manuellement après merge** dans l'UI Lesstime : rattacher les 9 tickets au groupe #22 et leur poser leur effort + tag + priorité (bug typing MCP empêche le wire automatique sur des champs `integer` sans `type` explicite dans le schéma).

---------

Co-authored-by: Matthieu Tholot <mtholot19@gmail.com>
Reviewed-on: #12
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-27 09:03:54 +00:00
12 changed files with 1696 additions and 12 deletions
+21
View File
@@ -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.
+12 -11
View File
@@ -60,14 +60,9 @@ jobs:
coverage: none coverage: none
tools: composer:v2 tools: composer:v2
- name: Cache Composer # Cache Composer retire : meme cause que cote front — le backend de cache
uses: actions/cache@v4 # du runner Gitea est injoignable (ETIMEDOUT) et fait timeouter le step
with: # ~4 min 30. A re-activer si le serveur de cache du runner est repare.
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-
- name: Install PHP dependencies - name: Install PHP dependencies
run: composer install --no-interaction --no-progress --prefer-dist run: composer install --no-interaction --no-progress --prefer-dist
@@ -99,12 +94,15 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# Pas de `cache: npm` : le backend de cache du runner Gitea est injoignable
# (ETIMEDOUT) et chaque tentative de restauration attend ~4 min 30 avant de
# timeout — c'est ce qui plombait le job. Node 22 est deja dans le
# tool-cache du runner (install instantane), et `npm ci` a froid ne prend
# que ~30s. A re-activer si le serveur de cache du runner est repare.
- name: Setup Node 22 - name: Setup Node 22
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
@@ -115,5 +113,8 @@ jobs:
- name: Unit tests (Vitest) - name: Unit tests (Vitest)
run: npm run test run: npm run test
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
# (SPA), le prerender de generate n'apporte rien a une quality gate — on
# veut seulement valider que le bundle compile.
- name: Build production (nuxt build) - name: Build production (nuxt build)
run: npm run build:dist run: npm run build
+4
View File
@@ -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
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.39' app.version: '0.1.40'
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
---
# === IDENTITÉ ===
module: M0
nom: "Gestion des catégories"
ecran: gestion-categories
owner_spec: Matthieu
backup_spec: Tristan
version: V0
date_redaction: 2026-05-22
# === LIENS ===
maquette_figma: null # pas de Figma — UI admin standard
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]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT #1 ===
client_validation_1:
statut: validee # V0 client validée le 22/05/2026
date: 2026-05-22
canal: ecrit
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
resume: "Module 0 — Gestion des catégories. Page admin (datatable + drawer). 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier). Admin only."
trace_archivee: "uploads/c4ebb6b4-M0categories.docx (V0 d'origine .docx) — restitué ci-dessous en Markdown."
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 22
lesstime_project_id: 6 # ERP / Starseed
statut_global: en_dev # tickets créés en backlog Lesstime le 2026-05-26
---
# Module 0 — Gestion des catégories (V0 front)
> **Origine** : spec front V0 livrée le 22/05/2026 (`c4ebb6b4-M0categories.docx` + `f665acfb-M0categoriesV0.pdf`). Restitution Markdown fidèle pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute reformulation et précision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
## But
Permettre à un administrateur Starseed de gérer un référentiel de **catégories** depuis l'interface admin du logiciel. Ces catégories seront utilisées plus tard pour classifier les tiers (clients, fournisseurs, prestataires).
## Accès
- **Depuis** : menu principal → **Administration** → entrée « Gestion des catégories »
- **Rôles autorisés** : **Admin uniquement** (Bureau / Compta / Commerciale / Usine n'ont **aucun** accès, ni lecture ni écriture).
## Navigation
L'écran est la page d'entrée du Module **Administration**. Titre de la page : « **Gestion des catégories** ».
- Affichage principal : un **datatable** listant toutes les catégories existantes.
- **Clic sur une ligne** → ouverture d'un **drawer** latéral en mode **consultation / modification** (cf. § Action « Consulter »).
- **Bouton « + Ajouter »** (en haut à droite du datatable) → ouverture d'un **drawer** en mode **création** (cf. § Action « Ajouter »).
- Pas d'onglet, pas de pagination explicite (volumétrie cible faible).
## Actions
| Action | Déclencheur | Comportement |
|---|---|---|
| **Ajouter** | Clic sur le bouton « + Ajouter » | Ouvre le drawer en mode création, formulaire vide. Validation → POST → la catégorie apparaît dans le datatable. |
| **Consulter** | Clic sur une ligne du datatable | Ouvre le drawer avec les champs pré-remplis en lecture (et passage en édition si l'utilisateur modifie un champ). |
| **Modifier** | Modification d'un champ dans le drawer ouvert en consultation | Validation → PATCH → la ligne du datatable se met à jour. |
> **Note V0** : la **suppression** n'était pas mentionnée dans la V0 client. Côté workflow MALIO, suite à la revue back (cf. `spec-back.md` § Q3), un soft delete est ajouté (corbeille logique). L'UI peut intégrer ce point lors d'une V1 — au M0 le bouton « Supprimer » n'est pas obligatoire, mais doit être facilement ajoutable.
## Formulaire — Champs
Le formulaire (drawer) contient **2 champs**, tous deux obligatoires :
| Champ | Type | Obligatoire | Contenu / valeur par défaut | Règle |
|---|---|---|---|---|
| **Nom** | Texte libre | **Oui** | vide à la création | Pas de règle métier détaillée en V0. Détails côté back : RG-1.02 / RG-1.03 / RG-1.04 (obligatoire, trim, longueur 2120). |
| **Type de catégorie** | Select | **Oui** | vide à la création | Le contenu du Select n'était pas précisé en V0. Décision back : entité de référence `CategoryType` séparée (RG-1.05 / RG-1.06). Le référentiel sera alimenté plus tard (cf. HP-1 dans `spec-back.md`). |
> **Note V0** : la V0 ne précisait ni si le `Type de catégorie` est un enum hardcodé ni si c'est une autre entité. Décision tranchée côté back avant découpe en tickets : **entité de référence** (`category_types`), table créée vide au M0.
## Permissions par rôle
| Rôle | Vue (`GET`) | Création (`POST`) | Édition (`PATCH`) | Suppression (`DELETE`) |
|---|---|---|---|---|
| **Admin** | ✅ | ✅ | ✅ | ✅ (soft delete — ajout post-V0) |
| Bureau | ❌ | ❌ | ❌ | ❌ |
| Compta | ❌ | ❌ | ❌ | ❌ |
| Commerciale | ❌ | ❌ | ❌ | ❌ |
| Usine | ❌ | ❌ | ❌ | ❌ |
→ Les rôles non-Admin ne voient **pas** l'entrée de menu et reçoivent **403** sur toute requête vers les endpoints `/api/categories/*` (cf. RG-1.01 dans `spec-back.md`).
## Composants UI à utiliser (Starseed / `@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (avec colonnes `Nom` + `Type` + actions, tri par défaut sur Nom).
- **Drawer** : drawer latéral standard `@malio/layer-ui` (à confirmer côté front avec le composant exact).
- **Input texte** : `<MalioInputText>` pour le champ Nom.
- **Select** : `<MalioSelect>` pour le champ Type de catégorie, alimenté par `GET /api/category_types`.
- **Bouton** : `<MalioButton>` (« + Ajouter », « Enregistrer », « Annuler »).
- **Toasts succès / erreur** : standards via `useApi()`.
## Points laissés ouverts par la V0 (résolus côté back)
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Suppression non mentionnée | **Soft delete** ajouté (RG-1.12 + RG-1.13). UI peut ajouter le bouton plus tard. |
| 2 | Unicité du nom non précisée | **Unicité sur `(name, type)` case-insensitive**, parmi non-soft-deleted (RG-1.07). |
| 3 | Nature du `Type de catégorie` (enum vs entité) | **Entité de référence** `CategoryType` (table vide au M0, créée par migration). |
| 4 | Volumétrie & pagination | **300 max** → pagination front (`<MalioDataTable>`), pas de pagination serveur. Tri serveur `name ASC` par défaut. |
| 5 | Audit / traçabilité | Pattern `#[Auditable]` Starseed standard. Trace dans la table `audit_log` (qui / quoi / quand / diff). **Pas** de colonnes `created_by` / `updated_by` sur l'entité (cohérent avec User / Role dans Starseed). Historique consultable via `/api/audit-log?entityType=Category&entityId={id}`. |
| 6 | Référencement par d'autres entités | **Aucune FK entrante au M0.** Les modules Tiers (M-Clients / M-Fournisseurs / M-Prestas) ajouteront leur propre `category_id` plus tard. |
---
## 📦 Tickets Lesstime générés
**TaskGroup Lesstime** : `#22 — M0 — Gestion des catégories` (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).
@@ -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,124 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
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).
*
* Les futurs referentiels statiques (ex: CategoryType au ticket 0.2)
* s'ajoutent ici avec une justification.
*/
private const EXCLUDED = [
User::class,
Role::class,
Permission::class,
Site::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,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;
}
}