Files
Starseed/docs/specs/M0-categories/spec-back.md
T
Matthieu Tholot 3c9eaf5d69
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m18s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 10m11s
docs(catalog) : add M0 categories specs (back + front)
2026-05-26 15:07:45 +02:00

36 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.1 ./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
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 : #[Auditable] Starseed standard

L'entité Category porte #[Auditable] (App\Shared\Domain\Attribute\Auditable). Le AuditListener (src/Module/Core/Infrastructure/Doctrine/AuditListener.php) intercepte onFlush + postFlush et écrit une ligne dans audit_log à chaque création / modification / suppression logique (le soft delete = un UPDATE pour Doctrine, donc tracé comme un UPDATE).

Important : #[Auditable] ne crée PAS automatiquement les colonnes created_by / updated_by / created_at / updated_at sur l'entité. Il trace les changements dans une table séparée audit_log (qui contient performed_by + performed_at + diff JSON). Conséquence pour le M0 :

  • Pas de colonnes created_by / updated_by / created_at / updated_at sur category. Le « qui a créé / modifié / quand » est lisible via l'endpoint d'historique GET /api/audit-log?entityType=Category&entityId={id} (déjà fourni par Core).
  • C'est cohérent avec les autres entités Starseed (User n'a qu'un createdAt géré manuellement dans le constructor, pas de updatedAt ; Role n'a rien).

Si plus tard un besoin de tri par updated_at côté front se fait sentir, on pourra rajouter la colonne. Au M0, on ne devine pas.

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.

3. Modèle de données

3.1 Diagramme

erDiagram
    CATEGORY_TYPE ||--o{ CATEGORY : "classe"
    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)"
    }
    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,
                PRIMARY KEY (id),
                CONSTRAINT fk_category_type
                    FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
            )
            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)');
    }

    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 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
{
    #[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;

    // getters / setters classiques (générés par PhpStorm) — 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';
    public const bool REQUIRED = false;

    /**
     * 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 automatique 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. Il suffit que Category porte #[Auditable]. Le soft delete (UPDATE deleted_at) est tracé comme un UPDATE normal.

6.2 Pas de colonnes created_by / updated_by / created_at / updated_at sur category

Contrairement à ce que la V0 brute pourrait suggérer, #[Auditable] n'ajoute PAS ces colonnes. Il écrit dans audit_log séparément. Cohérent avec les autres entités Starseed (User n'a qu'un createdAt géré manuellement dans le constructor, pas de updatedAt ; Role n'a rien).

→ Pour répondre à « qui a créé / modifié / quand », interroger GET /api/audit-log?entityType=Category&entityId={id} (endpoint déjà fourni par Core).

→ Si plus tard un besoin de tri front par date de création se fait sentir, on rajoutera la colonne. Au M0, on ne devine pas.

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

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.
  • Migration : make db-reset → schéma à jour. Vérifier en Postgres (\d category) que uq_category_name_type_active apparaît comme index partiel.

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"