---
# === IDENTITÉ ===
module: M0
nom: "Gestion des catégories"
ecran: gestion-categories
type: feature                       # feature back + UI admin (datatable + drawer)
pipeline: ui+back                   # tickets back ET front (UI admin standard, pas de Figma)
owner: Matthieu
backup: Tristan
date: 2026-05-26
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, RG-1.15, RG-1.16, RG-1.17]
roles: [Admin, Bureau, Compta, Commerciale, Usine]

# === VALIDATION CLIENT ===
# Pour le skill ticket-writer : statut = validee (validation implicite, périmètre projet validé en amont).
# UI admin interne, pas de Figma, pas de validation client externe requise.
client_validation_1:
  statut: validee
  date: 2026-05-22
  canal: ecrit
  valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
  resume: "UI admin standard (datatable + drawer), pas de validation client #1 externe requise (workflow back-only + UI admin standard sans Figma)."
  trace_archivee: null
date_validation: 2026-05-22
validateur_client: "Matthieu (CP MALIO) — validation implicite, périmètre projet"

# === LIEN LESSTIME (rempli après push manuel le 2026-05-26) ===
lesstime_taskgroup_id: 22
lesstime_project_id: 6              # ERP / Starseed
statut_global: en_dev               # tickets créés en backlog Lesstime

# === TAGS LESSTIME suggérés pour les tickets ===
tags: [Backend, Frontend]
---

# M0 — Gestion des catégories (back + UI admin)

## 1. Contexte

Premier module à intégrer le workflow MALIO. Permet à un administrateur de gérer un référentiel de **catégories** dans Starseed (CRM/ERP). Une catégorie porte un `name` libre et un `type` (FK vers le référentiel `category_type`). Elle servira plus tard à classifier les tiers (clients, fournisseurs, prestataires).

**Contraintes structurantes :**

- **Admin uniquement** sur toute la chaîne (Bureau / Compta / Commerciale / Usine : aucun accès, ni lecture ni écriture). Implémenté via la permission RBAC `catalog.categories.view` / `catalog.categories.manage`, jamais attribuée aux autres rôles métier.
- **Pas de hard delete** : soft delete via `deleted_at`. La liste exclut les soft-deleted par défaut.
- **Pas de Figma** : UI admin standard (datatable + drawer d'édition), composants `@malio/layer-ui`.
- **Nouveau module Starseed `Catalog`** créé pour ce M0. Bounded context « référentiels partagés » — n'appartient pas à `Commercial` pour rester réutilisable par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas).
- **Note** : le référentiel `category_type` n'est pas géré par ce module (voir § 9 Hors-périmètre). Migration crée la table vide ; le seed initial sera défini ultérieurement.

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

> Section obligatoire pour les specs sans validation client externe (cf. WORKFLOW_ERP.md § 1.bis). Traces écrites des choix techniques.

### 2.1 Module `Catalog` (nouveau)

Création du module DDD `App\Module\Catalog` avec la même structure que `Core` / `Commercial` / `Sites` :

```
src/Module/Catalog/
├── CatalogModule.php                          # constantes ID/LABEL/REQUIRED + permissions()
├── Domain/
│   ├── Entity/
│   │   ├── Category.php                       # #[ApiResource] + #[Auditable]
│   │   └── CategoryType.php                   # #[ApiResource(GetCollection + Get seulement)]
│   └── Repository/
│       ├── CategoryRepositoryInterface.php
│       └── CategoryTypeRepositoryInterface.php
└── Infrastructure/
    ├── ApiPlatform/
    │   └── State/
    │       ├── Provider/
    │       │   └── CategoryProvider.php       # filtre soft-delete + includeDeleted (admin)
    │       └── Processor/
    │           └── CategoryProcessor.php      # POST / PATCH / DELETE (soft)
    └── Doctrine/
        ├── DoctrineCategoryRepository.php
        ├── DoctrineCategoryTypeRepository.php
        └── Filter/SoftDeletedCategoryFilter.php   # filtre Doctrine global (cf. § 4.2)
```

Wire dans `config/modules.php` (ajout d'une ligne) :

```php
return [
    CoreModule::class,
    CommercialModule::class,
    SitesModule::class,
    CatalogModule::class,   // ← AJOUTÉ
];
```

**Alternative écartée** : placer dans `Core` (pollue le module noyau avec du métier de référentiel) ou dans `Commercial` (empêche M-Fournisseurs / M-Prestas de réutiliser proprement sans dépendre de Commercial).

### 2.2 IDs : entier auto-increment Postgres natif

Convention Starseed confirmée (cf. `Domain/Entity/User.php`, `migrations/Version20260407095546.php`) : tous les IDs sont des `INT GENERATED BY DEFAULT AS IDENTITY`. **PAS de CUID** dans Starseed. La spec applique donc `INT IDENTITY` pour `category.id` et `category_type.id`.

**Alternative écartée** : `uuid` (utilisé seulement pour `audit_log.id` à cause de la nature append-only / forte croissance).

### 2.3 Soft delete : pattern à introduire

Aucun pattern soft delete existant dans Starseed (vérifié, aucune entité ne porte `deleted_at`). Le M0 introduit le pattern :

- Colonne `deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL` sur `category`.
- Filtre Doctrine global enregistré pour cette entité (active par défaut, désactivable via un flag dans le `CategoryProvider`).
- Le PATCH ne peut pas écrire `deletedAt` (denormalization group exclut le champ).
- Le DELETE pose `deleted_at = now()` via `CategoryProcessor` (override du remove processor Doctrine ORM standard, pattern aligné sur `UserProcessor`).

**Alternative écartée** : pas de delete du tout (V0 client n'en parle pas) — refusée car le besoin opérationnel reviendra immanquablement. Mieux vaut intégrer le pattern dès le M0 et le réutiliser ailleurs.

### 2.4 Unicité partielle Postgres

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.

> **🔗 Évolution ERP-78 (refonte taxonomie M1)** : `Category` porte désormais une colonne **`code`** (`VARCHAR(50)`, NOT NULL), slug MAJUSCULE auto-généré du nom (figé à la création, lecture seule via l'API), avec un **second index unique partiel** `uq_category_code (code) WHERE deleted_at IS NULL`. Ce code est la clé métier stable utilisée par le M1 Commercial (RG-1.03 / RG-1.29). Détail : `docs/specs/M1-clients/spec-back.md` § 3.3.

### 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).

`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).

**(b) Traces locales sur l'entité — `created_at` / `updated_at` / `created_by` / `updated_by`**

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

Volumétrie cible : **300 max** (cf. Q5 Matthieu). Pas de pagination serveur. L'endpoint liste renvoie tout d'un coup. Tri par défaut côté serveur : `name ASC` (via `OrderFilter` ou ordre par défaut du Provider). La pagination front (`<MalioDataTable>`) gère l'affichage paginé en mémoire.

### 2.7 Permissions RBAC — granularité

Pattern Starseed (cf. `CoreModule::permissions()`) : `view` + `manage`, **pas** la granularité `view / create / edit / delete`. Aligné sur `core.users.view` + `core.users.manage`.

Pour M0 :

- `catalog.categories.view` — voir la liste + détail (GET) + lecture du référentiel `category_type` (GET)
- `catalog.categories.manage` — créer + modifier + supprimer (POST / PATCH / DELETE)

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.

#### 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 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

```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"
        string code "VARCHAR(40) NOT NULL UNIQUE"
        string label "VARCHAR(120) NOT NULL"
    }
    AUDIT_LOG }o..o{ CATEGORY : "trace via #[Auditable]"
```

`audit_log` (déjà en place dans Core) trace automatiquement les changements sur `Category` (qui portera `#[Auditable]`). Pas de FK matérielle Postgres entre `audit_log` et `category` : `audit_log.entity_id` est un `VARCHAR(64)` (cf. migration `Version20260420202749`).

### 3.2 Migration Doctrine — SQL Postgres

**Placement** : `migrations/VersionYYYYMMDDHHMMSS.php`, namespace racine `DoctrineMigrations` (règle ABSOLUE Starseed n°11 : les migrations d'init des entités d'un module vivent au namespace racine pour éviter le bug de tri FQCN de Doctrine Migrations 3.x).

```php
<?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).
 */
final class VersionYYYYMMDDHHMMSS 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
    {
        $this->addSql('DROP TABLE category');
        $this->addSql('DROP TABLE category_type');
    }
}
```

> Rappel convention Starseed/MALIO : noms de colonnes **toujours en minuscules snake_case** dans le SQL brut. Doctrine génère le camelCase côté entité, Postgres stocke en lowercase.

### 3.3 Entité `Category` — squelette (pattern Starseed)

```php
<?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;

#[ApiResource(
    operations: [
        new GetCollection(
            security: "is_granted('catalog.categories.view')",
            normalizationContext: ['groups' => ['category:read']],
            provider: CategoryProvider::class,
        ),
        new Get(
            security: "is_granted('catalog.categories.view')",
            normalizationContext: ['groups' => ['category:read']],
            provider: CategoryProvider::class,
        ),
        new Post(
            security: "is_granted('catalog.categories.manage')",
            normalizationContext: ['groups' => ['category:read']],
            denormalizationContext: ['groups' => ['category:write']],
            processor: CategoryProcessor::class,
        ),
        new Patch(
            security: "is_granted('catalog.categories.manage')",
            normalizationContext: ['groups' => ['category:read']],
            denormalizationContext: ['groups' => ['category:write']],
            processor: CategoryProcessor::class,
        ),
        new Delete(
            security: "is_granted('catalog.categories.manage')",
            processor: CategoryProcessor::class,
        ),
    ],
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')]
#[Auditable]
class Category implements TimestampableInterface, BlamableInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['category:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 120)]
    #[Assert\NotBlank(message: 'Le nom est obligatoire.')]
    #[Assert\Length(min: 2, max: 120)]
    #[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 (DELETE → CategoryProcessor pose la valeur).
     */
    #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
    #[Groups(['category:read'])]
    private ?DateTimeImmutable $deletedAt = null;

    // === 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.
}
```

### 3.4 Entité `CategoryType` — squelette

Lecture seule au M0. Pas de POST / PATCH / DELETE exposé.

```php
#[ApiResource(
    operations: [
        new GetCollection(
            security: "is_granted('catalog.categories.view')",
            normalizationContext: ['groups' => ['category_type:read']],
        ),
        new Get(
            security: "is_granted('catalog.categories.view')",
            normalizationContext: ['groups' => ['category_type:read']],
        ),
    ],
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryTypeRepository::class)]
#[ORM\Table(name: 'category_type')]
class CategoryType
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['category_type:read', 'category:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 40, unique: true)]
    #[Groups(['category_type:read', 'category:read'])]
    private ?string $code = null;

    #[ORM\Column(length: 120)]
    #[Groups(['category_type:read', 'category:read'])]
    private ?string $label = null;

    // getters / setters
}
```

> Le groupe `category:read` est ajouté sur les propriétés de `CategoryType` pour qu'il soit **embarqué** dans la réponse `Category` (pattern Starseed cf. `.claude/rules/backend.md § Serialization`).

## 4. API REST (API Platform)

Toutes les routes sont préfixées `/api` (cf. `config/routes/api_platform.yaml`). Toutes les opérations sont **réservées au rôle Admin** via les permissions RBAC (cf. § 5).

### 4.1 `GET /api/categories` — Liste

- **Security** : `is_granted('catalog.categories.view')`
- **Query params** :
  - `includeDeleted=true|false` (default `false`) — désactivé par défaut, le `CategoryProvider` filtre `deleted_at IS NULL`
  - `categoryType=<id>` (optionnel) — filtre par type (via `SearchFilter` API Platform standard si activé)
- **Pagination** : aucune au M0 (volumétrie ≤ 300). Pagination front via `<MalioDataTable>`.
- **Tri par défaut** : `name ASC` (serveur, défini dans le `CategoryProvider`)
- **Réponse 200** (format JSON-LD Hydra standard API Platform) :

```json
{
  "@context": "/api/contexts/Category",
  "@id": "/api/categories",
  "@type": "Collection",
  "totalItems": 42,
  "member": [
    {
      "@id": "/api/categories/12",
      "@type": "Category",
      "id": 12,
      "name": "Vis tête fraisée",
      "categoryType": {
        "@id": "/api/category_types/3",
        "id": 3,
        "code": "MATIERE",
        "label": "Matière"
      },
      "deletedAt": null
    }
  ]
}
```

- **Codes** : `200 OK` / `401` (non authentifié) / `403` (pas la permission)

### 4.2 `GET /api/categories/{id}` — Détail

- **Security** : `is_granted('catalog.categories.view')`
- **Comportement** : 404 si soft-deleted ET `includeDeleted=false` (default).
- **Réponse 200** : identique à un élément de la liste ci-dessus.
- **Codes** : `200` / `404` / `401` / `403`

### 4.3 `POST /api/categories` — Création

- **Security** : `is_granted('catalog.categories.manage')`
- **Content-Type** : `application/ld+json`
- **Body** :

```json
{
  "name": "Vis tête fraisée",
  "categoryType": "/api/category_types/3"
}
```

- **Réponse 201** : la ressource créée (cf. § 4.1).
- **Codes** :
  - `201 Created`
  - `400 Bad Request` payload mal formé
  - `401` / `403`
  - `409 Conflict` si doublon `(LOWER(name), categoryType)` parmi les non-soft-deleted (RG-1.07). Détection : le `UniqueConstraintViolation` Postgres remonté par Doctrine est attrapé dans le `CategoryProcessor` et traduit en 409.
  - `422 Unprocessable Entity` si validation (Assert NotBlank, Assert Length, CategoryType inexistant…)

### 4.4 `PATCH /api/categories/{id}` — Modification

- **Security** : `is_granted('catalog.categories.manage')`
- **Content-Type** : `application/merge-patch+json`
- **Body** (partiel) : `{ "name": "Vis tête fraisée H7" }`
- **Réponse 200** : la ressource mise à jour.
- **Codes** : `200` / `400` / `401` / `403` / `404` / `409` / `422`
- **Champs modifiables** : `name`, `categoryType`. Le champ `deletedAt` n'est PAS dans le groupe `category:write`, donc impossible à modifier via PATCH (séparation propre du DELETE).

### 4.5 `DELETE /api/categories/{id}` — Suppression logique

- **Security** : `is_granted('catalog.categories.manage')`
- **Comportement** : le `CategoryProcessor` intercepte l'opération Delete, pose `deletedAt = new DateTimeImmutable()` puis flush. **Ne supprime jamais physiquement la ligne.**
- **Réponse 204 No Content**
- **Codes** : `204` / `401` / `403` / `404` (déjà soft-deleted ou inexistante) / `409` (RG-1.14 — à activer post-M0)

### 4.6 `GET /api/category_types` — Référentiel (lecture seule)

- **Security** : `is_granted('catalog.categories.view')` (réutilise la même permission, c'est lié)
- **Comportement** : liste de tous les `CategoryType`, triée par `label ASC`.
- **Pas d'écriture exposée au M0** — table vide à la livraison.

## 5. Autorisation

### 5.1 Déclaration des permissions

`src/Module/Catalog/CatalogModule.php` (pattern aligné sur `CoreModule.php`) :

```php
<?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 côté Client (et Fournisseur,
    // Prestataire). Désactiver Catalog casserait tout le métier au boot Doctrine.
    // Cf. review Tristan MR #12 : « Comment on fait si le module est désactivé
    // sachant que pour créer un client il est requis de sélectionner une catégorie. »
    public const bool REQUIRED = true;

    /**
     * Permissions RBAC exposees par le module Catalog. Granularite alignee
     * sur Core (view + manage), pas view/create/edit/delete.
     *
     * @return array<int, array{code: string, label: string}>
     */
    public static function permissions(): array
    {
        return [
            ['code' => 'catalog.categories.view', 'label' => 'Voir les catégories'],
            ['code' => 'catalog.categories.manage', 'label' => 'Gérer les catégories (créer, éditer, supprimer)'],
        ];
    }
}
```

À l'issue du dev : `make shell` puis `php bin/console app:sync-permissions` pour upserter les codes dans la table `permission`.

### 5.2 Mapping rôles MALIO ↔ permissions

Les 5 rôles MALIO (`Admin / Bureau / Compta / Commerciale / Usine`) sont des **rôles RBAC métier** matérialisés dans la table `role` (pas des rôles Symfony Security — il n'y a que `ROLE_USER` et `ROLE_ADMIN` côté Security). Pour le M0 :

| Permission                     | Admin | Bureau | Compta | Commerciale | Usine |
|---|---|---|---|---|---|
| `catalog.categories.view`      | ✅    | ❌     | ❌     | ❌          | ❌    |
| `catalog.categories.manage`    | ✅    | ❌     | ❌     | ❌          | ❌    |

Tout rôle qui ne porte aucune de ces deux permissions reçoit `403 Forbidden` sur les endpoints `/api/categories/*` et `/api/category_types*`. Un anonyme (sans JWT valide) reçoit `401`.

### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES)

Règle ABSOLUE Starseed n°8 — toute évolution RBAC touche **les 3 sources ensemble** :

1. **`config/sidebar.php`** — ajouter l'item « Gestion des catégories » dans la section « Administration » :

```php
[
    'label'      => 'sidebar.catalog.categories',
    'to'         => '/admin/categories',
    'icon'       => 'mdi:tag-multiple-outline',
    'module'     => 'catalog',
    'permission' => 'catalog.categories.view',
],
```

2. **`frontend/tests/e2e/_fixtures/personas.ts`** — attribuer les 2 permissions au persona `Admin`, ne rien changer pour les autres personas.

3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back : seed le rôle métier `Admin` avec les permissions `catalog.categories.view` + `catalog.categories.manage`.

### 5.4 Vérification front

`usePermissions()` côté Nuxt : afficher / cacher l'item de menu « Gestion des catégories » selon `catalog.categories.view`. Les actions « Ajouter / Modifier / Supprimer » sont gatées sur `catalog.categories.manage`.

## 6. Audit & dates

### 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`) :

- Intercepte `onFlush` : capture les insertions / updates / deletions de toute entité `#[Auditable]`.
- 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** côté Auditable. Il suffit que `Category` porte `#[Auditable]`. Le soft delete (UPDATE `deleted_at`) est tracé comme un UPDATE normal.

### 6.2 Timestampable + Blamable via Trait + Subscriber Shared

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éé / 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).

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)

> Chaque RG est numérotée, stable, et constituera un critère d'acceptation côté ticket Lesstime.
> Convention `RG-1.XX` (les rules numéros 1.XX = règles du M0, premier module workflow).

### Autorisation

- **RG-1.01** : Seul un utilisateur authentifié porteur de la permission RBAC `catalog.categories.view` peut consulter `/api/categories/*` ou `/api/category_types*`. Seul un utilisateur authentifié porteur de `catalog.categories.manage` peut faire POST / PATCH / DELETE. Sans permission → **403 Forbidden**. Sans authentification → **401 Unauthorized**. Au M0, ces deux permissions sont attribuées **uniquement au rôle métier Admin** (les rôles Bureau / Compta / Commerciale / Usine reçoivent donc systématiquement 403).

### Champ `name`

- **RG-1.02** : Le champ `name` est **obligatoire** à la création et à la modification. Vide / null / whitespace-only → **422** avec violation `name: "Le nom est obligatoire."` (Symfony `Assert\NotBlank`).
- **RG-1.03** : Le champ `name` est trim() côté serveur dans le `CategoryProcessor` avant validation et persistance (suppression des espaces de début/fin).
- **RG-1.04** : Le champ `name` a une longueur entre **2 et 120 caractères** (après trim). Hors borne → 422 (Symfony `Assert\Length(min: 2, max: 120)`).

### Champ `categoryType`

- **RG-1.05** : Le champ `categoryType` est **obligatoire** (IRI vers `/api/category_types/{id}`). Manquant / null → 422 avec violation `categoryType: "Type de catégorie obligatoire."` (Symfony `Assert\NotNull`).
- **RG-1.06** : La valeur de `categoryType` doit pointer vers un `CategoryType` existant. Sinon → 422 (Symfony / API Platform résolution IRI → 400 standard, mapping vers 422 ou 400 selon comportement API Platform à confirmer en dev).

### Unicité

- **RG-1.07** : Le couple `(LOWER(name), category_type_id)` est unique parmi les catégories **non soft-deleted**. Tentative de doublon (POST ou PATCH qui rend le couple en collision) → **409 Conflict**. Le `CategoryProcessor` attrape la `UniqueConstraintViolation` Postgres remontée par Doctrine et la traduit en 409 avec le message `"Une catégorie nommée \"{name}\" existe déjà pour ce type."`. L'index Postgres est partiel (`WHERE deleted_at IS NULL`), donc on peut recréer une catégorie avec le même `(name, type)` après suppression logique.

### Liste

- **RG-1.08** : `GET /api/categories` exclut **par défaut** les catégories soft-deleted (`deleted_at IS NOT NULL`). Implémenté dans le `CategoryProvider`.
- **RG-1.09** : Un utilisateur avec `catalog.categories.manage` peut demander à voir les soft-deleted via `?includeDeleted=true`. Pour les autres rôles (qui ont 403 de toute façon), ce paramètre est ignoré.
- **RG-1.10** : Tri par défaut côté serveur : `name ASC`. Pas d'autres tris au M0.

### Détail

- **RG-1.11** : `GET /api/categories/{id}` renvoie **404** si l'id n'existe pas, ou si la catégorie est soft-deleted ET `includeDeleted` n'est pas activé.

### Suppression

- **RG-1.12** : `DELETE /api/categories/{id}` est un **soft delete** (pose `deleted_at = now()`). Réponse `204`. Ne supprime jamais physiquement la ligne.
- **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)

> Pattern Starseed (cf. `tests/` et CLAUDE.md § Tests). Helpers d'auth à utiliser : `createAdminClient()`, `createBureauClient()`, etc. (à créer côté SeedE2E si pas déjà en place).

- [ ] **RG-1.01** : `Bureau`, `Compta`, `Commerciale`, `Usine`, anonyme → 401 / 403 sur GET / POST / PATCH / DELETE.
- [ ] **RG-1.01** : `Admin` → 200 / 201 / 204 selon le verbe sur tous les endpoints.
- [ ] **RG-1.02 / RG-1.04** : POST avec `name` vide / null / whitespace / 1 caractère / 121 caractères → 422 avec violation `name`.
- [ ] **RG-1.03** : POST `name = "  Vis  "` → persistance `"Vis"` (trim auto via `CategoryProcessor`).
- [ ] **RG-1.05 / RG-1.06** : POST sans `categoryType` ou avec un IRI inexistant → 422.
- [ ] **RG-1.07** : POST `(name="Vis", type=MATIERE)` puis POST `(name="vis", type=MATIERE)` (case-insensitive) → 2e → 409.
- [ ] **RG-1.07** : POST `(name="Vis", type=MATIERE)` puis `(name="Vis", type=PRODUIT)` → les deux passent (types différents).
- [ ] **RG-1.07** : Soft-delete d'une catégorie, puis POST avec exactement les mêmes `(name, type)` → 201 (l'index partiel autorise).
- [ ] **RG-1.08 / RG-1.09** : GET liste sans flag → exclut les soft-deleted. GET liste avec `?includeDeleted=true` → inclut.
- [ ] **RG-1.10** : GET liste → tri `name ASC` par défaut.
- [ ] **RG-1.11** : GET détail d'une catégorie soft-deleted sans flag → 404. Avec flag → 200.
- [ ] **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.
- [ ] **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)

- [ ] Composable `useCategoriesAdmin()` : appel `useApi().get('/categories')` retourne la liste triée, soft-deleted exclus.
- [ ] Composable `useCategoryForm()` : validation client-side avant POST (name requis, longueur — miroir RG-1.02/RG-1.04).
- [ ] Composant `<CategoriesPage>` : `<MalioDataTable>` + bouton « + Ajouter » → ouverture drawer création. Clic ligne → drawer consultation.
- [ ] Permissions : si user sans `catalog.categories.view` (mock store), redirection 403 ; item sidebar masqué.

### 8.3 Tests E2E

**Non prévus au M0.** Règle ABSOLUE Starseed n°7 : pas de E2E sauf bug critique passé en prod. Vitest + PHPUnit suffisent.

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

- **HP-1** : **CRUD du référentiel `CategoryType`.** Le M0 crée la table vide via migration et expose `GET /api/category_types` en lecture seule. Le seed initial (`PRODUIT` / `SERVICE` / `MATIERE` / `AUTRE` ?) et le module admin pour les gérer feront l'objet d'une spec dédiée plus tard.
- **HP-2** : **Référencement par les Tiers.** Les modules M-Clients / M-Fournisseurs / M-Prestas ajouteront une colonne `category_id` nullable dans leurs propres entités. **Aucun changement côté `Category`** au moment où ces modules arriveront, sauf activation de la RG-1.14 (blocage du soft-delete si référencée).
- **HP-3** : **Restauration d'une catégorie soft-deleted.** Pas prévue au M0. Si besoin futur → endpoint dédié `POST /api/categories/{id}/restore`, permission `catalog.categories.manage`.
- **HP-4** : **Hard delete.** Pas prévu (RGPD / purge → spec dédiée si besoin).
- **HP-5** : **Internationalisation du `name`.** Pas d'i18n sur le champ libre saisi par l'admin. L'UI elle-même est en FR fixe.
- **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-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

### Liens

- **Spec front (V0 client, 2026-05-22)** : [`./spec-front.md`](./spec-front.md)
- **Workflow** : [`../../WORKFLOW_ERP.md`](../../WORKFLOW_ERP.md)
- **Skill ticket-writer** : [`../../ticket-writer-SKILL.md`](../../ticket-writer-SKILL.md)
- **Template spec back** : [`../../templates/spec-back.md`](../../templates/spec-back.md)
- **Template ticket back** : [`../../templates/ticket-back.md`](../../templates/ticket-back.md)
- **CLAUDE.md Starseed** : `~/dev_malio/Starseed/CLAUDE.md`
- **Règles archi Starseed** : `~/dev_malio/Starseed/.claude/rules/architecture.md` (modular monolith DDD, namespace, modules)
- **Règles back Starseed** : `~/dev_malio/Starseed/.claude/rules/backend.md` (ApiResource sans controllers, RBAC, Auditable)
- **Spec audit** : `~/dev_malio/Starseed/doc/audit-log.md` (référence pour `#[Auditable]`)

### Dépendances amont (déjà en place dans Starseed)

- Module `Core` : RBAC (`module.resource.action`), commande `app:sync-permissions`, `PermissionVoter`, `AuditListener` + `AuditLogWriter`.
- Module `Shared/Domain/Attribute/` : `Auditable`, `AuditIgnore`.
- Table `user` (Lexik JWT) + table `role` + table `permission` + table `audit_log`.
- Endpoint `/api/audit-log` (déjà fourni par Core, lit la table `audit_log`).

### Specs futures qui dépendent du M0

- **M-? — Gestion du référentiel `CategoryType`** (HP-1).
- **M-Clients / M-Fournisseurs / M-Prestas** : ajout FK `category_id` nullable + activation RG-1.14.
- **M-RBAC** *(éventuel)* — seed des rôles métier Bureau / Compta / Commerciale / Usine (HP-8).

---

## 📦 Tickets Lesstime générés

**TaskGroup Lesstime** : `#22 — M0 — Gestion des catégories` (projet `ERP / Starseed`, projectId=6)

> ⚠️ **Bug typing MCP** : le proxy MCP Lesstime stringifie les paramètres scalaires sans `type: "number"` explicite dans le schéma (`groupId`, `effortId`, `priorityId`). Conséquence : les 9 tickets ont été créés **sans rattachement au groupe**, **sans effort** et **sans priorité**. Toutes les infos sont dans le **titre** (`[N.M / Tag / Effort]`) et le **début de la description**. À rattacher manuellement au groupe #22 dans l'UI Lesstime + à renseigner effort/priorité.

| # | Ticket | Task ID | Number Lesstime | Effort | Tag |
|---|---|---|---|---|---|
| **0.0** | **Créer le pattern Timestampable + Blamable Shared** *(prérequis transverse, ajouté en v1.2)* | `#463` | `#52` | S | Backend |
| 0.1 | Migrer les tables Category et CategoryType | `#454` | `#43` | S | Backend |
| 0.2 | Créer les entités Category et CategoryType | `#455` | `#44` | M | Backend |
| 0.3 | Implémenter Provider et Processor Category | `#456` | `#45` | M | Backend |
| 0.4 | Exposer le référentiel CategoryType en lecture seule | `#457` | `#46` | S | Backend |
| 0.5 | Déclarer le module Catalog et synchroniser RBAC | `#458` | `#47` | S | Backend |
| 0.6 | Écrire les tests PHPUnit RG-1.01 à RG-1.17 | `#459` | `#48` | M | Backend |
| 0.7 | Créer la page Gestion des catégories (datatable + drawer) | `#460` | `#49` | L | Frontend |
| 0.8 | Implémenter les composables useCategoriesAdmin et useCategoryForm | `#461` | `#50` | M | Frontend |
| 0.9 | Écrire les tests Vitest des composables Catalog | `#462` | `#51` | S | Frontend |

**Total estimé** : ~17-27h (médian ~22h), 10 mini-MR de 1-4h.

### Actions manuelles à faire dans Lesstime (Matthieu)

1. Aller sur le projet **STARSEED** (#6) → TaskGroup **#22 « M0 — Gestion des catégories »**
2. Pour chaque ticket `#43` à `#51` :
   - **Rattacher** au TaskGroup #22 (drag & drop ou champ Group)
   - **Effort** : lire le titre (`S` / `M` / `L`) et sélectionner dans Lesstime
   - **Tag** : lire le titre (`Backend` / `Frontend`) et sélectionner
   - **Priorité** : `Moyen` par défaut
3. Vérifier que le statut reste `null` (backlog) — DoR pas encore cochée
4. Mettre à jour `statut_global` ici en `validee_client` (au lieu de `en_dev`) si tu veux que la spec reflète l'état "tickets en backlog, pas encore pris"
