docs(catalog) : add Timestampable + Blamable Shared pattern (v1.2)

This commit is contained in:
Matthieu Tholot
2026-05-26 16:13:39 +02:00
parent 3c9eaf5d69
commit 723a220ec1
+354 -17
View File
@@ -8,13 +8,13 @@ pipeline: ui+back # tickets back ET front (UI admin standard,
owner: Matthieu
backup: Tristan
date: 2026-05-26
version: 1.1 # 1.0 = draft initial ; 1.1 = aligné sur l'archi Starseed réelle
version: 1.2 # 1.0 draft ; 1.1 aligné Starseed ; 1.2 + pattern Timestampable/Blamable Shared
# === LIENS ===
lien_spec_front: ./spec-front.md
figma: null # pas de maquette — UI admin standard (datatable + drawer)
dependances: [] # M0 = premier module, aucune dépendance
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]
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]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
# === VALIDATION CLIENT ===
@@ -118,16 +118,33 @@ Aucun pattern soft delete existant dans Starseed (vérifié, aucune entité ne p
Index unique partiel sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL`. Permet de recréer une catégorie avec le même `(name, type)` après suppression logique. Postgres supporte nativement (`CREATE UNIQUE INDEX ... WHERE`). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job.
### 2.5 Audit : `#[Auditable]` Starseed standard
### 2.5 Audit & traces temporelles — deux niveaux complémentaires
Deux mécanismes **indépendants** cohabitent :
**(a) Audit complet — table `audit_log` via `#[Auditable]`**
L'entité `Category` porte `#[Auditable]` (`App\Shared\Domain\Attribute\Auditable`). Le `AuditListener` (`src/Module/Core/Infrastructure/Doctrine/AuditListener.php`) intercepte `onFlush` + `postFlush` et écrit une ligne dans `audit_log` à chaque création / modification / suppression logique (le soft delete = un UPDATE pour Doctrine, donc tracé comme un UPDATE).
**Important** : `#[Auditable]` **ne crée PAS** automatiquement les colonnes `created_by` / `updated_by` / `created_at` / `updated_at` sur l'entité. Il trace les changements dans une table séparée `audit_log` (qui contient `performed_by` + `performed_at` + diff JSON). Conséquence pour le M0 :
`audit_log` contient `performed_by`, `performed_at`, `changes` JSONB complet, `ip_address`, `request_id`. C'est l'historique **détaillé et auditable** d'une entité, consultable via `GET /api/audit-log?entityType=Category&entityId={id}` (endpoint fourni par Core).
- **Pas de colonnes `created_by` / `updated_by` / `created_at` / `updated_at` sur `category`.** Le « qui a créé / modifié / quand » est lisible via l'endpoint d'historique `GET /api/audit-log?entityType=Category&entityId={id}` (déjà fourni par Core).
- C'est cohérent avec les autres entités Starseed (User n'a qu'un `createdAt` géré manuellement dans le constructor, pas de `updatedAt` ; Role n'a rien).
**(b) Traces locales sur l'entité — `created_at` / `updated_at` / `created_by` / `updated_by`**
Si plus tard un besoin de tri par `updated_at` côté front se fait sentir, on pourra rajouter la colonne. Au M0, on ne devine pas.
Pour l'affichage courant (datatable, drawer de détail, tri par date) sans avoir à requêter `audit_log` à chaque rendu, on ajoute 4 colonnes sur l'entité. Le pattern est **global et automatique** (cf. § 2.8) : un `TimestampableBlamableSubscriber` dans `Shared/Infrastructure/Doctrine/` les remplit au `prePersist` / `preUpdate` pour toute entité implémentant `TimestampableInterface` et/ou `BlamableInterface`.
**Pourquoi les deux** :
| Cas d'usage | Mécanisme à utiliser |
|---|---|
| Afficher « créée le 22/05/2026 » dans le datatable | Colonne locale `created_at` (cheap, pas de jointure) |
| Trier la liste par « dernière modif » | Colonne locale `updated_at` (index possible) |
| Afficher « créée par Alice » dans le drawer | Colonne locale `created_by` (FK directe, JOIN simple) |
| Historique détaillé « qui a changé quoi à quel moment » | `audit_log` (diff JSONB complet, traçabilité forensique) |
| Recalculer un état à un instant T | `audit_log` (seul à avoir les diffs) |
Les colonnes locales sont **une vue dénormalisée** des dernières actions auditées. `audit_log` reste la source de vérité historique.
**Pas de duplication problématique** : `created_at` + `updated_at` sont peu coûteux (2 timestamps), et `created_by` + `updated_by` sont 2 FK vers `user`. Le `audit_log`, lui, garde tout l'historique (n lignes pour n modifications). Les deux ont des rôles distincts.
### 2.6 Pagination & tri
@@ -144,6 +161,290 @@ Pour M0 :
Les deux permissions seront attachées **uniquement au rôle métier `Admin`** dans `AppFixtures` et `SeedE2ECommand`. Bureau / Compta / Commerciale / Usine n'en reçoivent aucune → 403 systématique.
### 2.8 Pattern Timestampable + Blamable Shared (nouveau, transverse)
**Objectif** : poser une fois pour toutes le pattern « 4 colonnes `created_at/updated_at/created_by/updated_by` automatisées sur toute entité métier » dans `Shared/`. Réutilisable par Catalog (M0), puis par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas) et au-delà. Décidé en V1.2 de la spec (auparavant : pas de colonnes locales, tout dans `audit_log` — choix révisé pour les besoins d'affichage courant).
#### Composants
```
src/Shared/
├── Domain/
│ ├── Contract/
│ │ ├── TimestampableInterface.php (nouveau — contrat lu par le Subscriber)
│ │ └── BlamableInterface.php (nouveau)
│ └── Trait/
│ └── TimestampableBlamableTrait.php (nouveau — colonnes + getters/setters)
└── Infrastructure/
└── Doctrine/
└── TimestampableBlamableSubscriber.php (nouveau — remplit prePersist/preUpdate)
```
**Stratégie : Trait + Interfaces + Subscriber** :
- Le **Trait** porte les 4 propriétés Doctrine + leurs getters/setters → **le dev ajoute juste `use TimestampableBlamableTrait;` dans son entité** et tout est là.
- Les **Interfaces** servent de marqueur typé pour le Subscriber (qui fait `instanceof TimestampableInterface`).
- Le **Subscriber** est un listener Doctrine global qui remplit automatiquement les valeurs.
**Conséquence pour le dev d'une nouvelle entité métier** : 3 lignes à ajouter à l'entité (1 `use` du Trait, 2 `implements` d'interfaces). Aucune autre démarche. La migration ajoute les 4 colonnes en SQL.
#### `TimestampableInterface`
```php
namespace App\Shared\Domain\Contract;
interface TimestampableInterface
{
public function getCreatedAt(): ?DateTimeImmutable;
public function setCreatedAt(DateTimeImmutable $createdAt): void;
public function getUpdatedAt(): ?DateTimeImmutable;
public function setUpdatedAt(DateTimeImmutable $updatedAt): void;
}
```
#### `BlamableInterface`
```php
namespace App\Shared\Domain\Contract;
use Symfony\Component\Security\Core\User\UserInterface;
interface BlamableInterface
{
public function getCreatedBy(): ?UserInterface;
public function setCreatedBy(?UserInterface $user): void;
public function getUpdatedBy(): ?UserInterface;
public function setUpdatedBy(?UserInterface $user): void;
}
```
> Le type-hint utilise `Symfony\Component\Security\Core\User\UserInterface` (déjà implémentée par `App\Module\Core\Domain\Entity\User`) pour éviter de coupler `Shared` à `Module/Core`. Résolution Doctrine via `resolve_target_entities` (cf. § 2.1).
#### `TimestampableBlamableTrait`
```php
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'entité, +
* `implements TimestampableInterface, BlamableInterface`. Le
* TimestampableBlamableSubscriber remplit les colonnes automatiquement.
*
* Les Groups Serializer utilisent une convention `default:read` agrégée :
* pour exposer les 4 colonnes dans une réponse API d'une entité X, ajouter
* `default:read` au normalizationContext aux côtés du groupe `x:read`
* (ou exposer directement via le groupe x:read si on préfère pas la convention).
*/
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; }
}
```
> Sur Category, le `category:read` doit inclure `default:read` côté config Serializer (ou les groupes du trait doivent être ajustés à `category:read`). À trancher au moment du dev — convention `default:read` proposée pour réutilisation cross-modules.
#### `TimestampableBlamableSubscriber`
Listener Doctrine `prePersist` + `preUpdate`. Pattern aligné sur `AuditListener` (cf. `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`).
```php
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;
#[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);
}
// Si pas d'utilisateur authentifié (CLI, cron, migration) : on laisse
// les FK Blamable à null. L'affichage front gère "Système" pour null.
}
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);
}
}
}
```
#### Décision sur la nullability
| Champ | Nullability BDD | Justification |
|---|---|---|
| `created_at` | **NOT NULL** | Le subscriber le remplit systématiquement au `prePersist`. Jamais vide. |
| `updated_at` | **NOT NULL** | Idem. Au `prePersist` égal à `created_at`. |
| `created_by` | **nullable** (`ON DELETE SET NULL`) | Permet les créations hors contexte HTTP (cron, console, migration). Permet la suppression d'un user sans bloquer les entités existantes. |
| `updated_by` | **nullable** (`ON DELETE SET NULL`) | Idem. |
Affichage front : si `created_by IS NULL` → libellé `« Système »` côté Nuxt.
#### Application à `Category` au M0
```php
class Category implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait; // ← apporte les 4 colonnes + getters/setters
// ... reste métier (id, name, categoryType, deletedAt)
}
```
Aucune ligne supplémentaire côté entité — le Trait porte les props, le Subscriber les remplit.
#### Propagation aux futures entités — règle dure
Toute nouvelle entité Doctrine sous `src/Module/*/Domain/Entity/` qui représente un **agrégat métier** (et non un référentiel statique purement infra) **doit** :
1. `use TimestampableBlamableTrait;`
2. `implements TimestampableInterface, BlamableInterface`
3. Inclure les 4 colonnes dans sa migration Doctrine
À documenter dans `.claude/rules/backend.md` après merge du M0 (cf. § 2.8.bis ci-dessous pour les garde-fous techniques).
#### 2.8.bis Garde-fous pour ne pas oublier (proposition)
Plusieurs niveaux possibles, du plus léger au plus contraignant :
| Niveau | Mécanisme | Détecte quoi |
|---|---|---|
| **L1 — Convention** | Règle ajoutée à `.claude/rules/backend.md` | Rappel au dev. Aucun blocage technique. |
| **L2 — PHPStan custom rule** | `tests/PHPStan/EntityMustBeTimestampableBlamableRule.php` qui vérifie que toute classe annotée `#[ORM\Entity]` sous `src/Module/*/Domain/Entity/` implémente les 2 interfaces | Catch en lint avant commit. |
| **L3 — Test PHPUnit** | `tests/Architecture/EntitiesAreTimestampableBlamableTest.php` qui scanne `src/Module/*/Domain/Entity/`, instancie chaque entité et vérifie `instanceof` | Catch en CI. Bonus : peut whitelister explicitement les entités exclues (référentiels statiques type `CategoryType`). |
| **L4 — Hook pre-commit** | Extension du `pre-commit` actuel qui boucle sur les fichiers PHP staged et grep | Catch avant push. Plus invasif. |
**Reco** : **L1 + L3** au M0. L1 documente, L3 garantit (CI rouge si oubli). L2 et L4 sont des bonus si tu vois la dette s'accumuler.
Exemple de test L3 :
```php
namespace App\Tests\Architecture;
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;
final class EntitiesAreTimestampableBlamableTest extends TestCase
{
/** Entités exclues : référentiels statiques sans intérêt à auditer */
private const EXCLUDED = [
\App\Module\Catalog\Domain\Entity\CategoryType::class,
// ajouter ici les futurs référentiels stables
];
public function testAllBusinessEntitiesImplementBothInterfaces(): void
{
$finder = (new Finder())
->files()
->in(__DIR__ . '/../../src/Module')
->path('Domain/Entity')
->name('*.php');
foreach ($finder as $file) {
$fqcn = $this->extractFqcn($file->getRealPath());
if (!$fqcn || in_array($fqcn, self::EXCLUDED, true)) {
continue;
}
$reflection = new ReflectionClass($fqcn);
if ($reflection->isAbstract() || $reflection->getAttributes(Entity::class) === []) {
continue;
}
$this->assertTrue(
$reflection->implementsInterface(TimestampableInterface::class)
&& $reflection->implementsInterface(BlamableInterface::class),
sprintf(
'L\'entité %s doit implémenter TimestampableInterface ET BlamableInterface (utiliser TimestampableBlamableTrait). '
. 'Si c\'est un référentiel statique justifié, ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.',
$fqcn,
),
);
}
}
private function extractFqcn(string $path): ?string { /* extrait namespace+class du fichier */ }
}
```
Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0.
#### 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.
## 3. Modèle de données
### 3.1 Diagramme
@@ -151,11 +452,16 @@ Les deux permissions seront attachées **uniquement au rôle métier `Admin`** d
```mermaid
erDiagram
CATEGORY_TYPE ||--o{ CATEGORY : "classe"
USER ||--o{ CATEGORY : "created_by / updated_by"
CATEGORY {
int id PK "INT IDENTITY"
string name "VARCHAR(120) NOT NULL"
int category_type_id FK "INT NOT NULL"
timestamp deleted_at "nullable (soft delete)"
timestamp created_at "NOT NULL — Subscriber"
timestamp updated_at "NOT NULL — Subscriber"
int created_by FK "nullable, ON DELETE SET NULL"
int updated_by FK "nullable, ON DELETE SET NULL"
}
CATEGORY_TYPE {
int id PK "INT IDENTITY"
@@ -216,9 +522,17 @@ final class VersionYYYYMMDDHHMMSS extends AbstractMigration
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
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);
@@ -231,6 +545,8 @@ final class VersionYYYYMMDDHHMMSS extends AbstractMigration
$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
@@ -262,6 +578,9 @@ use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProces
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;
@@ -300,7 +619,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')]
#[Auditable]
class Category
class Category implements TimestampableInterface, BlamableInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -328,7 +647,13 @@ class Category
#[Groups(['category:read'])]
private ?DateTimeImmutable $deletedAt = null;
// getters / setters classiques (générés par PhpStorm) — omis ici.
// === 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. Aucun code à ajouter ici.
use \App\Shared\Domain\Trait\TimestampableBlamableTrait;
// getters / setters métier (name, categoryType, deletedAt) classiques — omis ici.
}
```
@@ -537,7 +862,7 @@ Règle ABSOLUE Starseed n°8 — toute évolution RBAC touche **les 3 sources en
## 6. Audit & dates
### 6.1 Audit automatique via `#[Auditable]`
### 6.1 Audit complet via `#[Auditable]`
`Category` porte `#[Auditable]` (`App\Shared\Domain\Attribute\Auditable`). Le `AuditListener` du module Core (`src/Module/Core/Infrastructure/Doctrine/AuditListener.php`) :
@@ -545,15 +870,16 @@ Règle ABSOLUE Starseed n°8 — toute évolution RBAC touche **les 3 sources en
- Intercepte `postFlush` : écrit une ligne dans la table `audit_log` via DBAL (connexion dédiée pour éviter la récursion).
- Trace `performed_by` (le user courant), `performed_at`, `changes` (diff JSONB), `request_id`, `ip_address`.
**Aucun code custom à écrire côté M0.** Il suffit que `Category` porte `#[Auditable]`. Le soft delete (UPDATE `deleted_at`) est tracé comme un UPDATE normal.
**Aucun code custom à écrire côté M0** côté Auditable. Il suffit que `Category` porte `#[Auditable]`. Le soft delete (UPDATE `deleted_at`) est tracé comme un UPDATE normal.
### 6.2 Pas de colonnes `created_by` / `updated_by` / `created_at` / `updated_at` sur `category`
### 6.2 Timestampable + Blamable via Trait + Subscriber Shared
Contrairement à ce que la V0 brute pourrait suggérer, **`#[Auditable]` n'ajoute PAS ces colonnes**. Il écrit dans `audit_log` séparément. Cohérent avec les autres entités Starseed (`User` n'a qu'un `createdAt` géré manuellement dans le constructor, pas de `updatedAt` ; `Role` n'a rien).
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` sont portées par le **Trait `TimestampableBlamableTrait`** (cf. § 2.8) et remplies automatiquement par le **`TimestampableBlamableSubscriber`** au `prePersist` / `preUpdate`.
→ Pour répondre à « qui a créé / modifié / quand », interroger `GET /api/audit-log?entityType=Category&entityId={id}` (endpoint déjà fourni par `Core`).
→ Pour répondre à « qui a créé / quand » dans le datatable, exposer les 4 colonnes via le groupe Serializer (cf. § 3.3). Pas de jointure custom nécessaire.
→ Pour répondre à « tout l'historique des modifs », interroger `GET /api/audit-log?entityType=Category&entityId={id}` (endpoint déjà fourni par Core).
→ Si plus tard un besoin de tri front par date de création se fait sentir, on rajoutera la colonne. **Au M0, on ne devine pas.**
Les deux mécanismes sont indépendants : on peut désactiver `#[Auditable]` (par ex. pour une entité à très haute fréquence d'écriture) sans perdre Timestampable/Blamable, et inversement.
## 7. Règles de gestion (RG)
@@ -595,6 +921,12 @@ Contrairement à ce que la V0 brute pourrait suggérer, **`#[Auditable]` n'ajout
- **RG-1.13** : Le champ `deletedAt` n'est **jamais** modifiable via PATCH (groupe `category:write` ne le contient pas). Seul le DELETE peut le mettre à jour. Restaurer une catégorie supprimée n'est pas un cas d'usage au M0 (HP-3).
- **RG-1.14** : *(à activer post-M0, désactivée à la livraison initiale)* Quand les modules Tiers (M-Clients, M-Fournisseurs, M-Prestas) auront ajouté une FK `category_id` nullable, un DELETE sur une catégorie référencée par au moins un tiers → **409 Conflict** avec message `"Impossible de supprimer : N tier(s) référencent cette catégorie."`. **Au M0, cette règle est documentée mais non implémentée** (rien à référencer, donc rien à empêcher).
### Timestampable + Blamable
- **RG-1.15** : Au POST (`prePersist`), le `TimestampableBlamableSubscriber` remplit `createdAt = updatedAt = now()` et `createdBy = updatedBy = user courant` (ou `null` si CLI / cron / migration sans contexte HTTP). Le client ne peut pas écrire ces 4 champs (groupe `category:write` ne les contient pas).
- **RG-1.16** : Au PATCH (`preUpdate`), seuls `updatedAt = now()` et `updatedBy = user courant` sont modifiés. `createdAt` et `createdBy` restent **figés à leur valeur initiale**. Même si le PATCH est un soft-delete (passage de `deletedAt` à `now()` via le `CategoryProcessor`), le `updatedAt` / `updatedBy` sont aussi mis à jour (puisque c'est un UPDATE Doctrine).
- **RG-1.17** : Toute entité métier nouvellement créée sous `src/Module/*/Domain/Entity/` doit utiliser le `TimestampableBlamableTrait` et implémenter `TimestampableInterface` + `BlamableInterface` (cf. § 2.8 et § 2.8.bis pour les garde-fous techniques). Les référentiels statiques (ex: `CategoryType`) en sont exemptés, **mais doivent être explicitement whitelistés** dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`.
## 8. Tests à automatiser
### 8.1 Cas à couvrir (back — PHPUnit)
@@ -615,7 +947,12 @@ Contrairement à ce que la V0 brute pourrait suggérer, **`#[Auditable]` n'ajout
- [ ] **RG-1.12** : DELETE → 204, ligne toujours présente en BDD avec `deleted_at IS NOT NULL`.
- [ ] **RG-1.13** : PATCH avec body `{"deletedAt": null}` ou autre tentative d'écriture → champ ignoré (groupe write l'exclut).
- [ ] **Audit** : POST + PATCH + DELETE → un `audit_log` est créé à chaque fois, avec `entity_type='Category'`, `entity_id={id}`, `performed_by={user.id}`, `action` correct, `changes` JSONB correct.
- [ ] **Migration** : `make db-reset` → schéma à jour. Vérifier en Postgres (`\d category`) que `uq_category_name_type_active` apparaît comme index partiel.
- [ ] **RG-1.15** : POST avec admin → `created_at = updated_at = now()`, `created_by = updated_by = admin.id`.
- [ ] **RG-1.15** : POST via console (sans contexte HTTP, mockable via test) → `created_at/updated_at` remplis ; `created_by/updated_by = null`.
- [ ] **RG-1.16** : PATCH par bob → `updated_at` change, `updated_by = bob.id`, `created_at` et `created_by` inchangés.
- [ ] **RG-1.16** : DELETE → `deleted_at` rempli ET `updated_at` / `updated_by` mis à jour (puisque c'est un UPDATE Doctrine).
- [ ] **RG-1.17** : Test architecture `EntitiesAreTimestampableBlamableTest` passe (cf. § 2.8.bis L3). Whitelist `CategoryType` documentée.
- [ ] **Migration** : `make db-reset` → schéma à jour. Vérifier en Postgres (`\d category`) que `uq_category_name_type_active` apparaît comme index partiel et que les 4 colonnes timestampable/blamable sont présentes avec leurs FK / index.
### 8.2 Cas à couvrir (front — Vitest)