--- # === 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.1 # 1.0 = draft initial ; 1.1 = aligné sur l'archi Starseed réelle # === 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] 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. ### 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 (``) 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 ```mermaid 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 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 ['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é. ```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=` (optionnel) — filtre par type (via `SearchFilter` API Platform standard si activé) - **Pagination** : aucune au M0 (volumétrie ≤ 300). Pagination front via ``. - **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 */ 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 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 `` : `` + 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`](./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.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"