diff --git a/docs/specs/M0-categories/spec-back.md b/docs/specs/M0-categories/spec-back.md index bd8daf3..aa09f7a 100644 --- a/docs/specs/M0-categories/spec-back.md +++ b/docs/specs/M0-categories/spec-back.md @@ -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)