Files
Starseed/docs/specs/M0-categories/spec-back.md
T
Matthieu Tholot e4b8df105e
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m35s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 10m13s
docs(catalog) : mark CatalogModule REQUIRED=true after Tristan review #12
2026-05-26 16:15:16 +02:00

53 KiB

module, nom, ecran, type, pipeline, owner, backup, date, version, lien_spec_front, figma, dependances, regles_metier, roles, client_validation_1, date_validation, validateur_client, lesstime_taskgroup_id, lesstime_project_id, statut_global, tags
module nom ecran type pipeline owner backup date version lien_spec_front figma dependances regles_metier roles client_validation_1 date_validation validateur_client lesstime_taskgroup_id lesstime_project_id statut_global tags
M0 Gestion des catégories gestion-categories feature ui+back Matthieu Tristan 2026-05-26 1.2 ./spec-front.md null
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
Admin
Bureau
Compta
Commerciale
Usine
statut date canal valide_par resume trace_archivee
validee 2026-05-22 ecrit Matthieu (CP MALIO) — validation implicite, périmètre projet UI admin standard (datatable + drawer), pas de validation client #1 externe requise (workflow back-only + UI admin standard sans Figma). null
2026-05-22 Matthieu (CP MALIO) — validation implicite, périmètre projet 22 6 en_dev
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) :

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.

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

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

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

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

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

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 :

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

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

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

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

#[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) :
{
  "@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 :
{
  "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

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 » :
[
    'label'      => 'sidebar.catalog.categories',
    'to'         => '/admin/categories',
    'icon'       => 'mdi:tag-multiple-outline',
    'module'     => 'catalog',
    'permission' => 'catalog.categories.view',
],
  1. frontend/tests/e2e/_fixtures/personas.ts — attribuer les 2 permissions au persona Admin, ne rien changer pour les autres personas.

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

10. Liens & dépendances

Liens

  • Spec front (V0 client, 2026-05-22) : ./spec-front.md
  • Workflow : ../../WORKFLOW_ERP.md
  • Skill ticket-writer : ../../ticket-writer-SKILL.md
  • Template spec back : ../../templates/spec-back.md
  • Template ticket back : ../../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.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.13 #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é : ~15-25h (médian ~20h), 9 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"