From 5db644d22e34cd353f2e91566ca203dd40b86a36 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Wed, 27 May 2026 09:03:54 +0000 Subject: [PATCH] docs(catalog) : M0 categories specs (back + front) (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Contexte Premier passage du workflow MALIO sur un module concret. Cette MR introduit **uniquement la documentation** (2 specs Markdown) du **Module 0 — Gestion des catégories**. Aucun code applicatif n'est touché. La spec back a été cadrée sur l'archi DDD réelle de Starseed (cf. `.claude/rules/architecture.md` + `backend.md`) après audit du repo (modules `Core`, `Commercial`, `Sites`). Elle introduit aussi un **nouveau module `Catalog`** (bounded context "référentiels partagés") — non créé dans cette MR, il viendra avec les tickets de dev. ## Contenu | Fichier | Lignes | Objet | |---|---|---| | `docs/specs/M0-categories/spec-back.md` | 700 | Spec back v1.1 : modèle data, API REST (5 endpoints + CategoryType lecture seule), 13 RG (RG-1.01 → RG-1.13), validation, autorisation, audit, tests, hors-périmètre | | `docs/specs/M0-categories/spec-front.md` | 113 | Spec front V0 client (validée 2026-05-22) : UI admin (datatable + drawer), 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier), permissions par rôle | ## Décisions d'archi (auto-validation back-only) Toutes les décisions sont documentées dans `spec-back.md § 2` : - **Module `Catalog`** créé séparément de `Commercial` pour rester réutilisable par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas) - **IDs INT IDENTITY** (cohérent avec `User`, `Role`, etc.) - **Soft delete** via `deleted_at TIMESTAMP(0) WITHOUT TIME ZONE NULL` — *pattern introduit par ce module* (aucune autre entité Starseed ne le portait) - **Index unique partiel Postgres** sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL` → unicité case-insensitive parmi non soft-deleted, recréation possible après suppression logique - **Granularité permissions = `view` + `manage`** (aligné sur `core.users.view` + `core.users.manage`) - **Référentiel `CategoryType`** = entité séparée, table vide à la livraison, valeurs seedées plus tard (HP-1) ## Règles métier (RG-1.01 → RG-1.13) Chaque RG est numérotée, stable, testable, et constituera un critère d'acceptation côté ticket Lesstime (cf. `spec-back.md § 7`). Couverture par catégorie : - Autorisation : RG-1.01 (Admin only) - Champ `name` : RG-1.02 (obligatoire), RG-1.03 (trim), RG-1.04 (longueur 2-120) - Champ `categoryType` : RG-1.05 (obligatoire), RG-1.06 (référence valide) - Unicité : RG-1.07 (case-insensitive sur couple, hors soft-deleted) - Liste : RG-1.08 (exclut soft-deleted), RG-1.09 (flag `?includeDeleted=true`), RG-1.10 (tri `name ASC`) - Détail : RG-1.11 (404 si soft-deleted) - Suppression : RG-1.12 (soft delete), RG-1.13 (`deletedAt` non modifiable via PATCH) ## Découpe en tickets Lesstime TaskGroup `#22 — M0 — Gestion des catégories` créé sur le projet **STARSEED**. 9 tickets en backlog : | # | Ticket Lesstime | Effort | Tag | |---|---|---|---| | 0.1 | `#43` Migrer les tables Category et CategoryType | S | Backend | | 0.2 | `#44` Créer les entités Category et CategoryType | M | Backend | | 0.3 | `#45` Implémenter Provider et Processor Category | M | Backend | | 0.4 | `#46` Exposer le référentiel CategoryType en lecture seule | S | Backend | | 0.5 | `#47` Déclarer le module Catalog et synchroniser RBAC | S | Backend | | 0.6 | `#48` Écrire les tests PHPUnit RG-1.01 à RG-1.13 | M | Backend | | 0.7 | `#49` Créer la page Gestion des catégories (datatable + drawer) | L | Frontend | | 0.8 | `#50` Implémenter les composables useCategoriesAdmin et useCategoryForm | M | Frontend | | 0.9 | `#51` Écrire les tests Vitest des composables Catalog | S | Frontend | **Total estimé** : ~15-25h (9 mini-MR de 1-4h). ## Hors-périmètre (HP) Pas codés / pas inclus dans cette spec (cf. `spec-back.md § 9`) : - HP-1 : CRUD du référentiel `CategoryType` (spec dédiée à venir) - HP-2 : Référencement par les Tiers (les FK `category_id` sur Clients/Fournisseurs/Prestas viendront avec leurs modules) - HP-3 : Restauration d'une catégorie soft-deleted - HP-4 : Hard delete - HP-5 : i18n du `name` - HP-6 : Recherche serveur / filtres avancés - HP-7 : Catégories hiérarchiques (parent / enfant) - HP-8 : Seed des rôles métier Bureau / Compta / Commerciale / Usine ## Checklist review Pas de code → review légère, lecture des 2 `.md` suffit. - [ ] Frontmatter YAML cohérent (module, version, dates, validation client, taskgroup, tags) - [ ] Décisions d'archi argumentées (`spec-back.md § 2`) - [ ] Modèle data réaliste (SQL Postgres rédigé directement, pas de pseudo-code) - [ ] API REST complète (codes HTTP succès + erreurs, payloads, exemples) - [ ] RG numérotées et testables (un humain peut écrire un test PHPUnit à partir de chaque RG sans réinventer) - [ ] Mapping rôles MALIO ↔ permissions RBAC clair (cohérent avec règle ABSOLUE n°8 — 3 sources à toucher) - [ ] Hors-périmètre explicite ## Stratégie de merge **Squash merge** vers `develop` (1 PR = 1 commit propre dans l'historique). ## Lien Lesstime - TaskGroup : `#22 — M0 — Gestion des catégories` (projet ERP / Starseed) - Tickets en backlog : `#43` → `#51` > ⚠️ **À faire manuellement après merge** dans l'UI Lesstime : rattacher les 9 tickets au groupe #22 et leur poser leur effort + tag + priorité (bug typing MCP empêche le wire automatique sur des champs `integer` sans `type` explicite dans le schéma). --------- Co-authored-by: Matthieu Tholot Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/12 Reviewed-by: Autin Co-authored-by: THOLOT DECHENE Matthieu Co-committed-by: THOLOT DECHENE Matthieu --- docs/specs/M0-categories/spec-back.md | 1042 ++++++++++++++++++++++++ docs/specs/M0-categories/spec-front.md | 113 +++ 2 files changed, 1155 insertions(+) create mode 100644 docs/specs/M0-categories/spec-back.md create mode 100644 docs/specs/M0-categories/spec-front.md diff --git a/docs/specs/M0-categories/spec-back.md b/docs/specs/M0-categories/spec-back.md new file mode 100644 index 0000000..330ccb1 --- /dev/null +++ b/docs/specs/M0-categories/spec-back.md @@ -0,0 +1,1042 @@ +--- +# === IDENTITÉ === +module: M0 +nom: "Gestion des catégories" +ecran: gestion-categories +type: feature # feature back + UI admin (datatable + drawer) +pipeline: ui+back # tickets back ET front (UI admin standard, pas de Figma) +owner: Matthieu +backup: Tristan +date: 2026-05-26 +version: 1.2 # 1.0 draft ; 1.1 aligné Starseed ; 1.2 + pattern Timestampable/Blamable Shared + +# === LIENS === +lien_spec_front: ./spec-front.md +figma: null # pas de maquette — UI admin standard (datatable + drawer) +dependances: [] # M0 = premier module, aucune dépendance +regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14, RG-1.15, RG-1.16, RG-1.17] +roles: [Admin, Bureau, Compta, Commerciale, Usine] + +# === VALIDATION CLIENT === +# Pour le skill ticket-writer : statut = validee (validation implicite, périmètre projet validé en amont). +# UI admin interne, pas de Figma, pas de validation client externe requise. +client_validation_1: + statut: validee + date: 2026-05-22 + canal: ecrit + valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet" + resume: "UI admin standard (datatable + drawer), pas de validation client #1 externe requise (workflow back-only + UI admin standard sans Figma)." + trace_archivee: null +date_validation: 2026-05-22 +validateur_client: "Matthieu (CP MALIO) — validation implicite, périmètre projet" + +# === LIEN LESSTIME (rempli après push manuel le 2026-05-26) === +lesstime_taskgroup_id: 22 +lesstime_project_id: 6 # ERP / Starseed +statut_global: en_dev # tickets créés en backlog Lesstime + +# === TAGS LESSTIME suggérés pour les tickets === +tags: [Backend, Frontend] +--- + +# M0 — Gestion des catégories (back + UI admin) + +## 1. Contexte + +Premier module à intégrer le workflow MALIO. Permet à un administrateur de gérer un référentiel de **catégories** dans Starseed (CRM/ERP). Une catégorie porte un `name` libre et un `type` (FK vers le référentiel `category_type`). Elle servira plus tard à classifier les tiers (clients, fournisseurs, prestataires). + +**Contraintes structurantes :** + +- **Admin uniquement** sur toute la chaîne (Bureau / Compta / Commerciale / Usine : aucun accès, ni lecture ni écriture). Implémenté via la permission RBAC `catalog.categories.view` / `catalog.categories.manage`, jamais attribuée aux autres rôles métier. +- **Pas de hard delete** : soft delete via `deleted_at`. La liste exclut les soft-deleted par défaut. +- **Pas de Figma** : UI admin standard (datatable + drawer d'édition), composants `@malio/layer-ui`. +- **Nouveau module Starseed `Catalog`** créé pour ce M0. Bounded context « référentiels partagés » — n'appartient pas à `Commercial` pour rester réutilisable par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas). +- **Note** : le référentiel `category_type` n'est pas géré par ce module (voir § 9 Hors-périmètre). Migration crée la table vide ; le seed initial sera défini ultérieurement. + +## 2. Décisions d'archi (auto-validation back-only) + +> Section obligatoire pour les specs sans validation client externe (cf. WORKFLOW_ERP.md § 1.bis). Traces écrites des choix techniques. + +### 2.1 Module `Catalog` (nouveau) + +Création du module DDD `App\Module\Catalog` avec la même structure que `Core` / `Commercial` / `Sites` : + +``` +src/Module/Catalog/ +├── CatalogModule.php # constantes ID/LABEL/REQUIRED + permissions() +├── Domain/ +│ ├── Entity/ +│ │ ├── Category.php # #[ApiResource] + #[Auditable] +│ │ └── CategoryType.php # #[ApiResource(GetCollection + Get seulement)] +│ └── Repository/ +│ ├── CategoryRepositoryInterface.php +│ └── CategoryTypeRepositoryInterface.php +└── Infrastructure/ + ├── ApiPlatform/ + │ └── State/ + │ ├── Provider/ + │ │ └── CategoryProvider.php # filtre soft-delete + includeDeleted (admin) + │ └── Processor/ + │ └── CategoryProcessor.php # POST / PATCH / DELETE (soft) + └── Doctrine/ + ├── DoctrineCategoryRepository.php + ├── DoctrineCategoryTypeRepository.php + └── Filter/SoftDeletedCategoryFilter.php # filtre Doctrine global (cf. § 4.2) +``` + +Wire dans `config/modules.php` (ajout d'une ligne) : + +```php +return [ + CoreModule::class, + CommercialModule::class, + SitesModule::class, + CatalogModule::class, // ← AJOUTÉ +]; +``` + +**Alternative écartée** : placer dans `Core` (pollue le module noyau avec du métier de référentiel) ou dans `Commercial` (empêche M-Fournisseurs / M-Prestas de réutiliser proprement sans dépendre de Commercial). + +### 2.2 IDs : entier auto-increment Postgres natif + +Convention Starseed confirmée (cf. `Domain/Entity/User.php`, `migrations/Version20260407095546.php`) : tous les IDs sont des `INT GENERATED BY DEFAULT AS IDENTITY`. **PAS de CUID** dans Starseed. La spec applique donc `INT IDENTITY` pour `category.id` et `category_type.id`. + +**Alternative écartée** : `uuid` (utilisé seulement pour `audit_log.id` à cause de la nature append-only / forte croissance). + +### 2.3 Soft delete : pattern à introduire + +Aucun pattern soft delete existant dans Starseed (vérifié, aucune entité ne porte `deleted_at`). Le M0 introduit le pattern : + +- Colonne `deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL` sur `category`. +- Filtre Doctrine global enregistré pour cette entité (active par défaut, désactivable via un flag dans le `CategoryProvider`). +- Le PATCH ne peut pas écrire `deletedAt` (denormalization group exclut le champ). +- Le DELETE pose `deleted_at = now()` via `CategoryProcessor` (override du remove processor Doctrine ORM standard, pattern aligné sur `UserProcessor`). + +**Alternative écartée** : pas de delete du tout (V0 client n'en parle pas) — refusée car le besoin opérationnel reviendra immanquablement. Mieux vaut intégrer le pattern dès le M0 et le réutiliser ailleurs. + +### 2.4 Unicité partielle Postgres + +Index unique partiel sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL`. Permet de recréer une catégorie avec le même `(name, type)` après suppression logique. Postgres supporte nativement (`CREATE UNIQUE INDEX ... WHERE`). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job. + +### 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 (``) gère l'affichage paginé en mémoire. + +### 2.7 Permissions RBAC — granularité + +Pattern Starseed (cf. `CoreModule::permissions()`) : `view` + `manage`, **pas** la granularité `view / create / edit / delete`. Aligné sur `core.users.view` + `core.users.manage`. + +Pour M0 : + +- `catalog.categories.view` — voir la liste + détail (GET) + lecture du référentiel `category_type` (GET) +- `catalog.categories.manage` — créer + modifier + supprimer (POST / PATCH / DELETE) + +Les deux permissions seront attachées **uniquement au rôle métier `Admin`** dans `AppFixtures` et `SeedE2ECommand`. Bureau / Compta / Commerciale / Usine n'en reçoivent aucune → 403 systématique. + +### 2.8 Pattern Timestampable + Blamable Shared (nouveau, transverse) + +**Objectif** : poser une fois pour toutes le pattern « 4 colonnes `created_at/updated_at/created_by/updated_by` automatisées sur toute entité métier » dans `Shared/`. Réutilisable par Catalog (M0), puis par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas) et au-delà. Décidé en V1.2 de la spec (auparavant : pas de colonnes locales, tout dans `audit_log` — choix révisé pour les besoins d'affichage courant). + +#### Composants + +``` +src/Shared/ +├── Domain/ +│ ├── Contract/ +│ │ ├── TimestampableInterface.php (nouveau — contrat lu par le Subscriber) +│ │ └── BlamableInterface.php (nouveau) +│ └── Trait/ +│ └── TimestampableBlamableTrait.php (nouveau — colonnes + getters/setters) +└── Infrastructure/ + └── Doctrine/ + └── TimestampableBlamableSubscriber.php (nouveau — remplit prePersist/preUpdate) +``` + +**Stratégie : Trait + Interfaces + Subscriber** : + +- Le **Trait** porte les 4 propriétés Doctrine + leurs getters/setters → **le dev ajoute juste `use TimestampableBlamableTrait;` dans son entité** et tout est là. +- Les **Interfaces** servent de marqueur typé pour le Subscriber (qui fait `instanceof TimestampableInterface`). +- Le **Subscriber** est un listener Doctrine global qui remplit automatiquement les valeurs. + +**Conséquence pour le dev d'une nouvelle entité métier** : 3 lignes à ajouter à l'entité (1 `use` du Trait, 2 `implements` d'interfaces). Aucune autre démarche. La migration ajoute les 4 colonnes en SQL. + +#### `TimestampableInterface` + +```php +namespace App\Shared\Domain\Contract; + +interface TimestampableInterface +{ + public function getCreatedAt(): ?DateTimeImmutable; + public function setCreatedAt(DateTimeImmutable $createdAt): void; + + public function getUpdatedAt(): ?DateTimeImmutable; + public function setUpdatedAt(DateTimeImmutable $updatedAt): void; +} +``` + +#### `BlamableInterface` + +```php +namespace App\Shared\Domain\Contract; + +use Symfony\Component\Security\Core\User\UserInterface; + +interface BlamableInterface +{ + public function getCreatedBy(): ?UserInterface; + public function setCreatedBy(?UserInterface $user): void; + + public function getUpdatedBy(): ?UserInterface; + public function setUpdatedBy(?UserInterface $user): void; +} +``` + +> Le type-hint utilise `Symfony\Component\Security\Core\User\UserInterface` (déjà implémentée par `App\Module\Core\Domain\Entity\User`) pour éviter de coupler `Shared` à `Module/Core`. Résolution Doctrine via `resolve_target_entities` (cf. § 2.1). + +#### `TimestampableBlamableTrait` + +```php +namespace App\Shared\Domain\Trait; + +use DateTimeImmutable; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Serializer\Attribute\Groups; + +/** + * Trait Doctrine qui porte les 4 colonnes Timestampable + Blamable. + * + * Usage : `use TimestampableBlamableTrait;` dans l'entité, + + * `implements TimestampableInterface, BlamableInterface`. Le + * TimestampableBlamableSubscriber remplit les colonnes automatiquement. + * + * Les Groups Serializer utilisent une convention `default:read` agrégée : + * pour exposer les 4 colonnes dans une réponse API d'une entité X, ajouter + * `default:read` au normalizationContext aux côtés du groupe `x:read` + * (ou exposer directement via le groupe x:read si on préfère pas la convention). + */ +trait TimestampableBlamableTrait +{ + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + #[Groups(['default:read'])] + private ?DateTimeImmutable $createdAt = null; + + #[ORM\Column(name: 'updated_at', type: 'datetime_immutable')] + #[Groups(['default:read'])] + private ?DateTimeImmutable $updatedAt = null; + + #[ORM\ManyToOne(targetEntity: UserInterface::class)] + #[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['default:read'])] + private ?UserInterface $createdBy = null; + + #[ORM\ManyToOne(targetEntity: UserInterface::class)] + #[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + #[Groups(['default:read'])] + private ?UserInterface $updatedBy = null; + + public function getCreatedAt(): ?DateTimeImmutable { return $this->createdAt; } + public function setCreatedAt(DateTimeImmutable $createdAt): void { $this->createdAt = $createdAt; } + + public function getUpdatedAt(): ?DateTimeImmutable { return $this->updatedAt; } + public function setUpdatedAt(DateTimeImmutable $updatedAt): void { $this->updatedAt = $updatedAt; } + + public function getCreatedBy(): ?UserInterface { return $this->createdBy; } + public function setCreatedBy(?UserInterface $user): void { $this->createdBy = $user; } + + public function getUpdatedBy(): ?UserInterface { return $this->updatedBy; } + public function setUpdatedBy(?UserInterface $user): void { $this->updatedBy = $user; } +} +``` + +> Sur Category, le `category:read` doit inclure `default:read` côté config Serializer (ou les groupes du trait doivent être ajustés à `category:read`). À trancher au moment du dev — convention `default:read` proposée pour réutilisation cross-modules. + +#### `TimestampableBlamableSubscriber` + +Listener Doctrine `prePersist` + `preUpdate`. Pattern aligné sur `AuditListener` (cf. `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`). + +```php +namespace App\Shared\Infrastructure\Doctrine; + +use App\Shared\Domain\Contract\BlamableInterface; +use App\Shared\Domain\Contract\TimestampableInterface; +use DateTimeImmutable; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; +use Doctrine\ORM\Event\PrePersistEventArgs; +use Doctrine\ORM\Event\PreUpdateEventArgs; +use Doctrine\ORM\Events; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Security\Core\User\UserInterface; + +#[AsDoctrineListener(event: Events::prePersist)] +#[AsDoctrineListener(event: Events::preUpdate)] +final class TimestampableBlamableSubscriber +{ + public function __construct(private readonly Security $security) {} + + public function prePersist(PrePersistEventArgs $args): void + { + $entity = $args->getObject(); + $now = new DateTimeImmutable(); + $user = $this->security->getUser(); + + if ($entity instanceof TimestampableInterface) { + $entity->setCreatedAt($now); + $entity->setUpdatedAt($now); + } + + if ($entity instanceof BlamableInterface && $user instanceof UserInterface) { + $entity->setCreatedBy($user); + $entity->setUpdatedBy($user); + } + // Si pas d'utilisateur authentifié (CLI, cron, migration) : on laisse + // les FK Blamable à null. L'affichage front gère "Système" pour null. + } + + public function preUpdate(PreUpdateEventArgs $args): void + { + $entity = $args->getObject(); + $user = $this->security->getUser(); + + if ($entity instanceof TimestampableInterface) { + $entity->setUpdatedAt(new DateTimeImmutable()); + } + + if ($entity instanceof BlamableInterface && $user instanceof UserInterface) { + $entity->setUpdatedBy($user); + } + } +} +``` + +#### Décision sur la nullability + +| Champ | Nullability BDD | Justification | +|---|---|---| +| `created_at` | **NOT NULL** | Le subscriber le remplit systématiquement au `prePersist`. Jamais vide. | +| `updated_at` | **NOT NULL** | Idem. Au `prePersist` égal à `created_at`. | +| `created_by` | **nullable** (`ON DELETE SET NULL`) | Permet les créations hors contexte HTTP (cron, console, migration). Permet la suppression d'un user sans bloquer les entités existantes. | +| `updated_by` | **nullable** (`ON DELETE SET NULL`) | Idem. | + +Affichage front : si `created_by IS NULL` → libellé `« Système »` côté Nuxt. + +#### Application à `Category` au M0 + +```php +class Category implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; // ← apporte les 4 colonnes + getters/setters + // ... reste métier (id, name, categoryType, deletedAt) +} +``` + +Aucune ligne supplémentaire côté entité — le Trait porte les props, le Subscriber les remplit. + +#### Propagation aux futures entités — règle dure + +Toute nouvelle entité Doctrine sous `src/Module/*/Domain/Entity/` qui représente un **agrégat métier** (et non un référentiel statique purement infra) **doit** : + +1. `use TimestampableBlamableTrait;` +2. `implements TimestampableInterface, BlamableInterface` +3. Inclure les 4 colonnes dans sa migration Doctrine + +À documenter dans `.claude/rules/backend.md` après merge du M0 (cf. § 2.8.bis ci-dessous pour les garde-fous techniques). + +#### 2.8.bis Garde-fous pour ne pas oublier (proposition) + +Plusieurs niveaux possibles, du plus léger au plus contraignant : + +| Niveau | Mécanisme | Détecte quoi | +|---|---|---| +| **L1 — Convention** | Règle ajoutée à `.claude/rules/backend.md` | Rappel au dev. Aucun blocage technique. | +| **L2 — PHPStan custom rule** | `tests/PHPStan/EntityMustBeTimestampableBlamableRule.php` qui vérifie que toute classe annotée `#[ORM\Entity]` sous `src/Module/*/Domain/Entity/` implémente les 2 interfaces | Catch en lint avant commit. | +| **L3 — Test PHPUnit** | `tests/Architecture/EntitiesAreTimestampableBlamableTest.php` qui scanne `src/Module/*/Domain/Entity/`, instancie chaque entité et vérifie `instanceof` | Catch en CI. Bonus : peut whitelister explicitement les entités exclues (référentiels statiques type `CategoryType`). | +| **L4 — Hook pre-commit** | Extension du `pre-commit` actuel qui boucle sur les fichiers PHP staged et grep | Catch avant push. Plus invasif. | + +**Reco** : **L1 + L3** au M0. L1 documente, L3 garantit (CI rouge si oubli). L2 et L4 sont des bonus si tu vois la dette s'accumuler. + +Exemple de test L3 : + +```php +namespace App\Tests\Architecture; + +use App\Shared\Domain\Contract\BlamableInterface; +use App\Shared\Domain\Contract\TimestampableInterface; +use Doctrine\ORM\Mapping\Entity; +use PHPUnit\Framework\TestCase; +use ReflectionClass; +use Symfony\Component\Finder\Finder; + +final class EntitiesAreTimestampableBlamableTest extends TestCase +{ + /** Entités exclues : référentiels statiques sans intérêt à auditer */ + private const EXCLUDED = [ + \App\Module\Catalog\Domain\Entity\CategoryType::class, + // ajouter ici les futurs référentiels stables + ]; + + public function testAllBusinessEntitiesImplementBothInterfaces(): void + { + $finder = (new Finder()) + ->files() + ->in(__DIR__ . '/../../src/Module') + ->path('Domain/Entity') + ->name('*.php'); + + foreach ($finder as $file) { + $fqcn = $this->extractFqcn($file->getRealPath()); + if (!$fqcn || in_array($fqcn, self::EXCLUDED, true)) { + continue; + } + + $reflection = new ReflectionClass($fqcn); + if ($reflection->isAbstract() || $reflection->getAttributes(Entity::class) === []) { + continue; + } + + $this->assertTrue( + $reflection->implementsInterface(TimestampableInterface::class) + && $reflection->implementsInterface(BlamableInterface::class), + sprintf( + 'L\'entité %s doit implémenter TimestampableInterface ET BlamableInterface (utiliser TimestampableBlamableTrait). ' + . 'Si c\'est un référentiel statique justifié, ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.', + $fqcn, + ), + ); + } + } + + private function extractFqcn(string $path): ?string { /* extrait namespace+class du fichier */ } +} +``` + +Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0. + +#### Tests Subscriber + +Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui `use` le Trait + implements les interfaces, vérifier que `prePersist` + `preUpdate` remplissent les 4 colonnes. À écrire dans le ticket 0.0. + +## 3. Modèle de données + +### 3.1 Diagramme + +```mermaid +erDiagram + CATEGORY_TYPE ||--o{ CATEGORY : "classe" + USER ||--o{ CATEGORY : "created_by / updated_by" + CATEGORY { + int id PK "INT IDENTITY" + string name "VARCHAR(120) NOT NULL" + int category_type_id FK "INT NOT NULL" + timestamp deleted_at "nullable (soft delete)" + timestamp created_at "NOT NULL — Subscriber" + timestamp updated_at "NOT NULL — Subscriber" + int created_by FK "nullable, ON DELETE SET NULL" + int updated_by FK "nullable, ON DELETE SET NULL" + } + CATEGORY_TYPE { + int id PK "INT IDENTITY" + string code "VARCHAR(40) NOT NULL UNIQUE" + string label "VARCHAR(120) NOT NULL" + } + AUDIT_LOG }o..o{ CATEGORY : "trace via #[Auditable]" +``` + +`audit_log` (déjà en place dans Core) trace automatiquement les changements sur `Category` (qui portera `#[Auditable]`). Pas de FK matérielle Postgres entre `audit_log` et `category` : `audit_log.entity_id` est un `VARCHAR(64)` (cf. migration `Version20260420202749`). + +### 3.2 Migration Doctrine — SQL Postgres + +**Placement** : `migrations/VersionYYYYMMDDHHMMSS.php`, namespace racine `DoctrineMigrations` (règle ABSOLUE Starseed n°11 : les migrations d'init des entités d'un module vivent au namespace racine pour éviter le bug de tri FQCN de Doctrine Migrations 3.x). + +```php +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 + ['category:read']], + provider: CategoryProvider::class, + ), + new Get( + security: "is_granted('catalog.categories.view')", + normalizationContext: ['groups' => ['category:read']], + provider: CategoryProvider::class, + ), + new Post( + security: "is_granted('catalog.categories.manage')", + normalizationContext: ['groups' => ['category:read']], + denormalizationContext: ['groups' => ['category:write']], + processor: CategoryProcessor::class, + ), + new Patch( + security: "is_granted('catalog.categories.manage')", + normalizationContext: ['groups' => ['category:read']], + denormalizationContext: ['groups' => ['category:write']], + processor: CategoryProcessor::class, + ), + new Delete( + security: "is_granted('catalog.categories.manage')", + processor: CategoryProcessor::class, + ), + ], +)] +#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)] +#[ORM\Table(name: 'category')] +#[Auditable] +class Category implements TimestampableInterface, BlamableInterface +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['category:read'])] + private ?int $id = null; + + #[ORM\Column(length: 120)] + #[Assert\NotBlank(message: 'Le nom est obligatoire.')] + #[Assert\Length(min: 2, max: 120)] + #[Groups(['category:read', 'category:write'])] + private ?string $name = null; + + #[ORM\ManyToOne(targetEntity: CategoryType::class)] + #[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] + #[Assert\NotNull(message: 'Type de catégorie obligatoire.')] + #[Groups(['category:read', 'category:write'])] + private ?CategoryType $categoryType = null; + + /** + * Soft delete : null = active, valeur = supprimee logiquement le {date}. + * Pas exposee en ecriture (DELETE → CategoryProcessor pose la valeur). + */ + #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] + #[Groups(['category:read'])] + private ?DateTimeImmutable $deletedAt = null; + + // === Timestampable + Blamable === + // Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs + // getters/setters viennent du trait Shared. Le TimestampableBlamableSubscriber + // les remplit automatiquement au prePersist / preUpdate. Aucun code à ajouter ici. + use \App\Shared\Domain\Trait\TimestampableBlamableTrait; + + // getters / setters métier (name, categoryType, deletedAt) classiques — omis ici. +} +``` + +### 3.4 Entité `CategoryType` — squelette + +Lecture seule au M0. Pas de POST / PATCH / DELETE exposé. + +```php +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('catalog.categories.view')", + normalizationContext: ['groups' => ['category_type:read']], + ), + new Get( + security: "is_granted('catalog.categories.view')", + normalizationContext: ['groups' => ['category_type:read']], + ), + ], +)] +#[ORM\Entity(repositoryClass: DoctrineCategoryTypeRepository::class)] +#[ORM\Table(name: 'category_type')] +class CategoryType +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['category_type:read', 'category:read'])] + private ?int $id = null; + + #[ORM\Column(length: 40, unique: true)] + #[Groups(['category_type:read', 'category:read'])] + private ?string $code = null; + + #[ORM\Column(length: 120)] + #[Groups(['category_type:read', 'category:read'])] + private ?string $label = null; + + // getters / setters +} +``` + +> Le groupe `category:read` est ajouté sur les propriétés de `CategoryType` pour qu'il soit **embarqué** dans la réponse `Category` (pattern Starseed cf. `.claude/rules/backend.md § Serialization`). + +## 4. API REST (API Platform) + +Toutes les routes sont préfixées `/api` (cf. `config/routes/api_platform.yaml`). Toutes les opérations sont **réservées au rôle Admin** via les permissions RBAC (cf. § 5). + +### 4.1 `GET /api/categories` — Liste + +- **Security** : `is_granted('catalog.categories.view')` +- **Query params** : + - `includeDeleted=true|false` (default `false`) — désactivé par défaut, le `CategoryProvider` filtre `deleted_at IS NULL` + - `categoryType=` (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 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 `` : `` + 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.0** | **Créer le pattern Timestampable + Blamable Shared** *(prérequis transverse, ajouté en v1.2)* | `#463` | `#52` | S | Backend | +| 0.1 | Migrer les tables Category et CategoryType | `#454` | `#43` | S | Backend | +| 0.2 | Créer les entités Category et CategoryType | `#455` | `#44` | M | Backend | +| 0.3 | Implémenter Provider et Processor Category | `#456` | `#45` | M | Backend | +| 0.4 | Exposer le référentiel CategoryType en lecture seule | `#457` | `#46` | S | Backend | +| 0.5 | Déclarer le module Catalog et synchroniser RBAC | `#458` | `#47` | S | Backend | +| 0.6 | Écrire les tests PHPUnit RG-1.01 à RG-1.17 | `#459` | `#48` | M | Backend | +| 0.7 | Créer la page Gestion des catégories (datatable + drawer) | `#460` | `#49` | L | Frontend | +| 0.8 | Implémenter les composables useCategoriesAdmin et useCategoryForm | `#461` | `#50` | M | Frontend | +| 0.9 | Écrire les tests Vitest des composables Catalog | `#462` | `#51` | S | Frontend | + +**Total estimé** : ~17-27h (médian ~22h), 10 mini-MR de 1-4h. + +### Actions manuelles à faire dans Lesstime (Matthieu) + +1. Aller sur le projet **STARSEED** (#6) → TaskGroup **#22 « M0 — Gestion des catégories »** +2. Pour chaque ticket `#43` à `#51` : + - **Rattacher** au TaskGroup #22 (drag & drop ou champ Group) + - **Effort** : lire le titre (`S` / `M` / `L`) et sélectionner dans Lesstime + - **Tag** : lire le titre (`Backend` / `Frontend`) et sélectionner + - **Priorité** : `Moyen` par défaut +3. Vérifier que le statut reste `null` (backlog) — DoR pas encore cochée +4. Mettre à jour `statut_global` ici en `validee_client` (au lieu de `en_dev`) si tu veux que la spec reflète l'état "tickets en backlog, pas encore pris" diff --git a/docs/specs/M0-categories/spec-front.md b/docs/specs/M0-categories/spec-front.md new file mode 100644 index 0000000..d851c8c --- /dev/null +++ b/docs/specs/M0-categories/spec-front.md @@ -0,0 +1,113 @@ +--- +# === IDENTITÉ === +module: M0 +nom: "Gestion des catégories" +ecran: gestion-categories +owner_spec: Matthieu +backup_spec: Tristan +version: V0 +date_redaction: 2026-05-22 + +# === LIENS === +maquette_figma: null # pas de Figma — UI admin standard +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] +roles: [Admin, Bureau, Compta, Commerciale, Usine] +lien_spec_back: ./spec-back.md + +# === VALIDATION CLIENT #1 === +client_validation_1: + statut: validee # V0 client validée le 22/05/2026 + date: 2026-05-22 + canal: ecrit + valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet" + resume: "Module 0 — Gestion des catégories. Page admin (datatable + drawer). 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier). Admin only." + trace_archivee: "uploads/c4ebb6b4-M0categories.docx (V0 d'origine .docx) — restitué ci-dessous en Markdown." + +# === LIEN LESSTIME === +lesstime_taskgroup_id: 22 +lesstime_project_id: 6 # ERP / Starseed +statut_global: en_dev # tickets créés en backlog Lesstime le 2026-05-26 +--- + +# Module 0 — Gestion des catégories (V0 front) + +> **Origine** : spec front V0 livrée le 22/05/2026 (`c4ebb6b4-M0categories.docx` + `f665acfb-M0categoriesV0.pdf`). Restitution Markdown fidèle pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute reformulation et précision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md). + +## But + +Permettre à un administrateur Starseed de gérer un référentiel de **catégories** depuis l'interface admin du logiciel. Ces catégories seront utilisées plus tard pour classifier les tiers (clients, fournisseurs, prestataires). + +## Accès + +- **Depuis** : menu principal → **Administration** → entrée « Gestion des catégories » +- **Rôles autorisés** : **Admin uniquement** (Bureau / Compta / Commerciale / Usine n'ont **aucun** accès, ni lecture ni écriture). + +## Navigation + +L'écran est la page d'entrée du Module **Administration**. Titre de la page : « **Gestion des catégories** ». + +- Affichage principal : un **datatable** listant toutes les catégories existantes. +- **Clic sur une ligne** → ouverture d'un **drawer** latéral en mode **consultation / modification** (cf. § Action « Consulter »). +- **Bouton « + Ajouter »** (en haut à droite du datatable) → ouverture d'un **drawer** en mode **création** (cf. § Action « Ajouter »). +- Pas d'onglet, pas de pagination explicite (volumétrie cible faible). + +## Actions + +| Action | Déclencheur | Comportement | +|---|---|---| +| **Ajouter** | Clic sur le bouton « + Ajouter » | Ouvre le drawer en mode création, formulaire vide. Validation → POST → la catégorie apparaît dans le datatable. | +| **Consulter** | Clic sur une ligne du datatable | Ouvre le drawer avec les champs pré-remplis en lecture (et passage en édition si l'utilisateur modifie un champ). | +| **Modifier** | Modification d'un champ dans le drawer ouvert en consultation | Validation → PATCH → la ligne du datatable se met à jour. | + +> **Note V0** : la **suppression** n'était pas mentionnée dans la V0 client. Côté workflow MALIO, suite à la revue back (cf. `spec-back.md` § Q3), un soft delete est ajouté (corbeille logique). L'UI peut intégrer ce point lors d'une V1 — au M0 le bouton « Supprimer » n'est pas obligatoire, mais doit être facilement ajoutable. + +## Formulaire — Champs + +Le formulaire (drawer) contient **2 champs**, tous deux obligatoires : + +| Champ | Type | Obligatoire | Contenu / valeur par défaut | Règle | +|---|---|---|---|---| +| **Nom** | Texte libre | **Oui** | vide à la création | Pas de règle métier détaillée en V0. Détails côté back : RG-1.02 / RG-1.03 / RG-1.04 (obligatoire, trim, longueur 2–120). | +| **Type de catégorie** | Select | **Oui** | vide à la création | Le contenu du Select n'était pas précisé en V0. Décision back : entité de référence `CategoryType` séparée (RG-1.05 / RG-1.06). Le référentiel sera alimenté plus tard (cf. HP-1 dans `spec-back.md`). | + +> **Note V0** : la V0 ne précisait ni si le `Type de catégorie` est un enum hardcodé ni si c'est une autre entité. Décision tranchée côté back avant découpe en tickets : **entité de référence** (`category_types`), table créée vide au M0. + +## Permissions par rôle + +| Rôle | Vue (`GET`) | Création (`POST`) | Édition (`PATCH`) | Suppression (`DELETE`) | +|---|---|---|---|---| +| **Admin** | ✅ | ✅ | ✅ | ✅ (soft delete — ajout post-V0) | +| Bureau | ❌ | ❌ | ❌ | ❌ | +| Compta | ❌ | ❌ | ❌ | ❌ | +| Commerciale | ❌ | ❌ | ❌ | ❌ | +| Usine | ❌ | ❌ | ❌ | ❌ | + +→ Les rôles non-Admin ne voient **pas** l'entrée de menu et reçoivent **403** sur toute requête vers les endpoints `/api/categories/*` (cf. RG-1.01 dans `spec-back.md`). + +## Composants UI à utiliser (Starseed / `@malio/layer-ui`) + +- **Datatable** : `` (avec colonnes `Nom` + `Type` + actions, tri par défaut sur Nom). +- **Drawer** : drawer latéral standard `@malio/layer-ui` (à confirmer côté front avec le composant exact). +- **Input texte** : `` pour le champ Nom. +- **Select** : `` pour le champ Type de catégorie, alimenté par `GET /api/category_types`. +- **Bouton** : `` (« + Ajouter », « Enregistrer », « Annuler »). +- **Toasts succès / erreur** : standards via `useApi()`. + +## Points laissés ouverts par la V0 (résolus côté back) + +| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) | +|---|---|---| +| 1 | Suppression non mentionnée | **Soft delete** ajouté (RG-1.12 + RG-1.13). UI peut ajouter le bouton plus tard. | +| 2 | Unicité du nom non précisée | **Unicité sur `(name, type)` case-insensitive**, parmi non-soft-deleted (RG-1.07). | +| 3 | Nature du `Type de catégorie` (enum vs entité) | **Entité de référence** `CategoryType` (table vide au M0, créée par migration). | +| 4 | Volumétrie & pagination | **300 max** → pagination front (``), pas de pagination serveur. Tri serveur `name ASC` par défaut. | +| 5 | Audit / traçabilité | Pattern `#[Auditable]` Starseed standard. Trace dans la table `audit_log` (qui / quoi / quand / diff). **Pas** de colonnes `created_by` / `updated_by` sur l'entité (cohérent avec User / Role dans Starseed). Historique consultable via `/api/audit-log?entityType=Category&entityId={id}`. | +| 6 | Référencement par d'autres entités | **Aucune FK entrante au M0.** Les modules Tiers (M-Clients / M-Fournisseurs / M-Prestas) ajouteront leur propre `category_id` plus tard. | + +--- + +## 📦 Tickets Lesstime générés + +**TaskGroup Lesstime** : `#22 — M0 — Gestion des catégories` (projet `ERP / Starseed`, projectId=6) + +> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).