53 KiB
module, nom, ecran, type, pipeline, owner, backup, date, version, lien_spec_front, figma, dependances, regles_metier, roles, client_validation_1, date_validation, validateur_client, lesstime_taskgroup_id, lesstime_project_id, statut_global, tags
| module | nom | ecran | type | pipeline | owner | backup | date | version | lien_spec_front | figma | dependances | regles_metier | roles | client_validation_1 | date_validation | validateur_client | lesstime_taskgroup_id | lesstime_project_id | statut_global | tags | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| M0 | Gestion des catégories | gestion-categories | feature | ui+back | Matthieu | Tristan | 2026-05-26 | 1.2 | ./spec-front.md | null |
|
|
|
2026-05-22 | Matthieu (CP MALIO) — validation implicite, périmètre projet | 22 | 6 | en_dev |
|
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
Catalogcréé pour ce M0. Bounded context « référentiels partagés » — n'appartient pas àCommercialpour rester réutilisable par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas). - Note : le référentiel
category_typen'est pas géré par ce module (voir § 9 Hors-périmètre). Migration crée la table vide ; le seed initial sera défini ultérieurement.
2. Décisions d'archi (auto-validation back-only)
Section obligatoire pour les specs sans validation client externe (cf. WORKFLOW_ERP.md § 1.bis). Traces écrites des choix techniques.
2.1 Module Catalog (nouveau)
Création du module DDD App\Module\Catalog avec la même structure que Core / Commercial / Sites :
src/Module/Catalog/
├── CatalogModule.php # constantes ID/LABEL/REQUIRED + permissions()
├── Domain/
│ ├── Entity/
│ │ ├── Category.php # #[ApiResource] + #[Auditable]
│ │ └── CategoryType.php # #[ApiResource(GetCollection + Get seulement)]
│ └── Repository/
│ ├── CategoryRepositoryInterface.php
│ └── CategoryTypeRepositoryInterface.php
└── Infrastructure/
├── ApiPlatform/
│ └── State/
│ ├── Provider/
│ │ └── CategoryProvider.php # filtre soft-delete + includeDeleted (admin)
│ └── Processor/
│ └── CategoryProcessor.php # POST / PATCH / DELETE (soft)
└── Doctrine/
├── DoctrineCategoryRepository.php
├── DoctrineCategoryTypeRepository.php
└── Filter/SoftDeletedCategoryFilter.php # filtre Doctrine global (cf. § 4.2)
Wire dans config/modules.php (ajout d'une ligne) :
return [
CoreModule::class,
CommercialModule::class,
SitesModule::class,
CatalogModule::class, // ← AJOUTÉ
];
Alternative écartée : placer dans Core (pollue le module noyau avec du métier de référentiel) ou dans Commercial (empêche M-Fournisseurs / M-Prestas de réutiliser proprement sans dépendre de Commercial).
2.2 IDs : entier auto-increment Postgres natif
Convention Starseed confirmée (cf. Domain/Entity/User.php, migrations/Version20260407095546.php) : tous les IDs sont des INT GENERATED BY DEFAULT AS IDENTITY. PAS de CUID dans Starseed. La spec applique donc INT IDENTITY pour category.id et category_type.id.
Alternative écartée : uuid (utilisé seulement pour audit_log.id à cause de la nature append-only / forte croissance).
2.3 Soft delete : pattern à introduire
Aucun pattern soft delete existant dans Starseed (vérifié, aucune entité ne porte deleted_at). Le M0 introduit le pattern :
- Colonne
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULLsurcategory. - 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()viaCategoryProcessor(override du remove processor Doctrine ORM standard, pattern aligné surUserProcessor).
Alternative écartée : pas de delete du tout (V0 client n'en parle pas) — refusée car le besoin opérationnel reviendra immanquablement. Mieux vaut intégrer le pattern dès le M0 et le réutiliser ailleurs.
2.4 Unicité partielle Postgres
Index unique partiel sur (LOWER(name), category_type_id) WHERE deleted_at IS NULL. Permet de recréer une catégorie avec le même (name, type) après suppression logique. Postgres supporte nativement (CREATE UNIQUE INDEX ... WHERE). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job.
2.5 Audit & traces temporelles — deux niveaux complémentaires
Deux mécanismes indépendants cohabitent :
(a) Audit complet — table audit_log via #[Auditable]
L'entité Category porte #[Auditable] (App\Shared\Domain\Attribute\Auditable). Le AuditListener (src/Module/Core/Infrastructure/Doctrine/AuditListener.php) intercepte onFlush + postFlush et écrit une ligne dans audit_log à chaque création / modification / suppression logique (le soft delete = un UPDATE pour Doctrine, donc tracé comme un UPDATE).
audit_log contient performed_by, performed_at, changes JSONB complet, ip_address, request_id. C'est l'historique détaillé et auditable d'une entité, consultable via GET /api/audit-log?entityType=Category&entityId={id} (endpoint fourni par Core).
(b) Traces locales sur l'entité — created_at / updated_at / created_by / updated_by
Pour l'affichage courant (datatable, drawer de détail, tri par date) sans avoir à requêter audit_log à chaque rendu, on ajoute 4 colonnes sur l'entité. Le pattern est global et automatique (cf. § 2.8) : un TimestampableBlamableSubscriber dans Shared/Infrastructure/Doctrine/ les remplit au prePersist / preUpdate pour toute entité implémentant TimestampableInterface et/ou BlamableInterface.
Pourquoi les deux :
| Cas d'usage | Mécanisme à utiliser |
|---|---|
| Afficher « créée le 22/05/2026 » dans le datatable | Colonne locale created_at (cheap, pas de jointure) |
| Trier la liste par « dernière modif » | Colonne locale updated_at (index possible) |
| Afficher « créée par Alice » dans le drawer | Colonne locale created_by (FK directe, JOIN simple) |
| Historique détaillé « qui a changé quoi à quel moment » | audit_log (diff JSONB complet, traçabilité forensique) |
| Recalculer un état à un instant T | audit_log (seul à avoir les diffs) |
Les colonnes locales sont une vue dénormalisée des dernières actions auditées. audit_log reste la source de vérité historique.
Pas de duplication problématique : created_at + updated_at sont peu coûteux (2 timestamps), et created_by + updated_by sont 2 FK vers user. Le audit_log, lui, garde tout l'historique (n lignes pour n modifications). Les deux ont des rôles distincts.
2.6 Pagination & tri
Volumétrie cible : 300 max (cf. Q5 Matthieu). Pas de pagination serveur. L'endpoint liste renvoie tout d'un coup. Tri par défaut côté serveur : name ASC (via OrderFilter ou ordre par défaut du Provider). La pagination front (<MalioDataTable>) gère l'affichage paginé en mémoire.
2.7 Permissions RBAC — granularité
Pattern Starseed (cf. CoreModule::permissions()) : view + manage, pas la granularité view / create / edit / delete. Aligné sur core.users.view + core.users.manage.
Pour M0 :
catalog.categories.view— voir la liste + détail (GET) + lecture du référentielcategory_type(GET)catalog.categories.manage— créer + modifier + supprimer (POST / PATCH / DELETE)
Les deux permissions seront attachées uniquement au rôle métier Admin dans AppFixtures et SeedE2ECommand. Bureau / Compta / Commerciale / Usine n'en reçoivent aucune → 403 systématique.
2.8 Pattern Timestampable + Blamable Shared (nouveau, transverse)
Objectif : poser une fois pour toutes le pattern « 4 colonnes created_at/updated_at/created_by/updated_by automatisées sur toute entité métier » dans Shared/. Réutilisable par Catalog (M0), puis par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas) et au-delà. Décidé en V1.2 de la spec (auparavant : pas de colonnes locales, tout dans audit_log — choix révisé pour les besoins d'affichage courant).
Composants
src/Shared/
├── Domain/
│ ├── Contract/
│ │ ├── TimestampableInterface.php (nouveau — contrat lu par le Subscriber)
│ │ └── BlamableInterface.php (nouveau)
│ └── Trait/
│ └── TimestampableBlamableTrait.php (nouveau — colonnes + getters/setters)
└── Infrastructure/
└── Doctrine/
└── TimestampableBlamableSubscriber.php (nouveau — remplit prePersist/preUpdate)
Stratégie : Trait + Interfaces + Subscriber :
- Le Trait porte les 4 propriétés Doctrine + leurs getters/setters → le dev ajoute juste
use TimestampableBlamableTrait;dans son entité et tout est là. - Les Interfaces servent de marqueur typé pour le Subscriber (qui fait
instanceof TimestampableInterface). - Le Subscriber est un listener Doctrine global qui remplit automatiquement les valeurs.
Conséquence pour le dev d'une nouvelle entité métier : 3 lignes à ajouter à l'entité (1 use du Trait, 2 implements d'interfaces). Aucune autre démarche. La migration ajoute les 4 colonnes en SQL.
TimestampableInterface
namespace App\Shared\Domain\Contract;
interface TimestampableInterface
{
public function getCreatedAt(): ?DateTimeImmutable;
public function setCreatedAt(DateTimeImmutable $createdAt): void;
public function getUpdatedAt(): ?DateTimeImmutable;
public function setUpdatedAt(DateTimeImmutable $updatedAt): void;
}
BlamableInterface
namespace App\Shared\Domain\Contract;
use Symfony\Component\Security\Core\User\UserInterface;
interface BlamableInterface
{
public function getCreatedBy(): ?UserInterface;
public function setCreatedBy(?UserInterface $user): void;
public function getUpdatedBy(): ?UserInterface;
public function setUpdatedBy(?UserInterface $user): void;
}
Le type-hint utilise
Symfony\Component\Security\Core\User\UserInterface(déjà implémentée parApp\Module\Core\Domain\Entity\User) pour éviter de couplerSharedàModule/Core. Résolution Doctrine viaresolve_target_entities(cf. § 2.1).
TimestampableBlamableTrait
namespace App\Shared\Domain\Trait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Trait Doctrine qui porte les 4 colonnes Timestampable + Blamable.
*
* Usage : `use TimestampableBlamableTrait;` dans l'entité, +
* `implements TimestampableInterface, BlamableInterface`. Le
* TimestampableBlamableSubscriber remplit les colonnes automatiquement.
*
* Les Groups Serializer utilisent une convention `default:read` agrégée :
* pour exposer les 4 colonnes dans une réponse API d'une entité X, ajouter
* `default:read` au normalizationContext aux côtés du groupe `x:read`
* (ou exposer directement via le groupe x:read si on préfère pas la convention).
*/
trait TimestampableBlamableTrait
{
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
#[Groups(['default:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(name: 'updated_at', type: 'datetime_immutable')]
#[Groups(['default:read'])]
private ?DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['default:read'])]
private ?UserInterface $createdBy = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['default:read'])]
private ?UserInterface $updatedBy = null;
public function getCreatedAt(): ?DateTimeImmutable { return $this->createdAt; }
public function setCreatedAt(DateTimeImmutable $createdAt): void { $this->createdAt = $createdAt; }
public function getUpdatedAt(): ?DateTimeImmutable { return $this->updatedAt; }
public function setUpdatedAt(DateTimeImmutable $updatedAt): void { $this->updatedAt = $updatedAt; }
public function getCreatedBy(): ?UserInterface { return $this->createdBy; }
public function setCreatedBy(?UserInterface $user): void { $this->createdBy = $user; }
public function getUpdatedBy(): ?UserInterface { return $this->updatedBy; }
public function setUpdatedBy(?UserInterface $user): void { $this->updatedBy = $user; }
}
Sur Category, le
category:readdoit incluredefault:readcôté config Serializer (ou les groupes du trait doivent être ajustés àcategory:read). À trancher au moment du dev — conventiondefault:readproposée pour réutilisation cross-modules.
TimestampableBlamableSubscriber
Listener Doctrine prePersist + preUpdate. Pattern aligné sur AuditListener (cf. src/Module/Core/Infrastructure/Doctrine/AuditListener.php).
namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\User\UserInterface;
#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final class TimestampableBlamableSubscriber
{
public function __construct(private readonly Security $security) {}
public function prePersist(PrePersistEventArgs $args): void
{
$entity = $args->getObject();
$now = new DateTimeImmutable();
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
$entity->setCreatedAt($now);
$entity->setUpdatedAt($now);
}
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
$entity->setCreatedBy($user);
$entity->setUpdatedBy($user);
}
// Si pas d'utilisateur authentifié (CLI, cron, migration) : on laisse
// les FK Blamable à null. L'affichage front gère "Système" pour null.
}
public function preUpdate(PreUpdateEventArgs $args): void
{
$entity = $args->getObject();
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new DateTimeImmutable());
}
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
$entity->setUpdatedBy($user);
}
}
}
Décision sur la nullability
| Champ | Nullability BDD | Justification |
|---|---|---|
created_at |
NOT NULL | Le subscriber le remplit systématiquement au prePersist. Jamais vide. |
updated_at |
NOT NULL | Idem. Au prePersist égal à created_at. |
created_by |
nullable (ON DELETE SET NULL) |
Permet les créations hors contexte HTTP (cron, console, migration). Permet la suppression d'un user sans bloquer les entités existantes. |
updated_by |
nullable (ON DELETE SET NULL) |
Idem. |
Affichage front : si created_by IS NULL → libellé « Système » côté Nuxt.
Application à Category au M0
class Category implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait; // ← apporte les 4 colonnes + getters/setters
// ... reste métier (id, name, categoryType, deletedAt)
}
Aucune ligne supplémentaire côté entité — le Trait porte les props, le Subscriber les remplit.
Propagation aux futures entités — règle dure
Toute nouvelle entité Doctrine sous src/Module/*/Domain/Entity/ qui représente un agrégat métier (et non un référentiel statique purement infra) doit :
use TimestampableBlamableTrait;implements TimestampableInterface, BlamableInterface- Inclure les 4 colonnes dans sa migration Doctrine
À documenter dans .claude/rules/backend.md après merge du M0 (cf. § 2.8.bis ci-dessous pour les garde-fous techniques).
2.8.bis Garde-fous pour ne pas oublier (proposition)
Plusieurs niveaux possibles, du plus léger au plus contraignant :
| Niveau | Mécanisme | Détecte quoi |
|---|---|---|
| L1 — Convention | Règle ajoutée à .claude/rules/backend.md |
Rappel au dev. Aucun blocage technique. |
| L2 — PHPStan custom rule | tests/PHPStan/EntityMustBeTimestampableBlamableRule.php qui vérifie que toute classe annotée #[ORM\Entity] sous src/Module/*/Domain/Entity/ implémente les 2 interfaces |
Catch en lint avant commit. |
| L3 — Test PHPUnit | tests/Architecture/EntitiesAreTimestampableBlamableTest.php qui scanne src/Module/*/Domain/Entity/, instancie chaque entité et vérifie instanceof |
Catch en CI. Bonus : peut whitelister explicitement les entités exclues (référentiels statiques type CategoryType). |
| L4 — Hook pre-commit | Extension du pre-commit actuel qui boucle sur les fichiers PHP staged et grep |
Catch avant push. Plus invasif. |
Reco : L1 + L3 au M0. L1 documente, L3 garantit (CI rouge si oubli). L2 et L4 sont des bonus si tu vois la dette s'accumuler.
Exemple de test L3 :
namespace App\Tests\Architecture;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use Doctrine\ORM\Mapping\Entity;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
final class EntitiesAreTimestampableBlamableTest extends TestCase
{
/** Entités exclues : référentiels statiques sans intérêt à auditer */
private const EXCLUDED = [
\App\Module\Catalog\Domain\Entity\CategoryType::class,
// ajouter ici les futurs référentiels stables
];
public function testAllBusinessEntitiesImplementBothInterfaces(): void
{
$finder = (new Finder())
->files()
->in(__DIR__ . '/../../src/Module')
->path('Domain/Entity')
->name('*.php');
foreach ($finder as $file) {
$fqcn = $this->extractFqcn($file->getRealPath());
if (!$fqcn || in_array($fqcn, self::EXCLUDED, true)) {
continue;
}
$reflection = new ReflectionClass($fqcn);
if ($reflection->isAbstract() || $reflection->getAttributes(Entity::class) === []) {
continue;
}
$this->assertTrue(
$reflection->implementsInterface(TimestampableInterface::class)
&& $reflection->implementsInterface(BlamableInterface::class),
sprintf(
'L\'entité %s doit implémenter TimestampableInterface ET BlamableInterface (utiliser TimestampableBlamableTrait). '
. 'Si c\'est un référentiel statique justifié, ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.',
$fqcn,
),
);
}
}
private function extractFqcn(string $path): ?string { /* extrait namespace+class du fichier */ }
}
Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0.
Tests Subscriber
Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui use le Trait + implements les interfaces, vérifier que prePersist + preUpdate remplissent les 4 colonnes. À écrire dans le ticket 0.0.
3. Modèle de données
3.1 Diagramme
erDiagram
CATEGORY_TYPE ||--o{ CATEGORY : "classe"
USER ||--o{ CATEGORY : "created_by / updated_by"
CATEGORY {
int id PK "INT IDENTITY"
string name "VARCHAR(120) NOT NULL"
int category_type_id FK "INT NOT NULL"
timestamp deleted_at "nullable (soft delete)"
timestamp created_at "NOT NULL — Subscriber"
timestamp updated_at "NOT NULL — Subscriber"
int created_by FK "nullable, ON DELETE SET NULL"
int updated_by FK "nullable, ON DELETE SET NULL"
}
CATEGORY_TYPE {
int id PK "INT IDENTITY"
string code "VARCHAR(40) NOT NULL UNIQUE"
string label "VARCHAR(120) NOT NULL"
}
AUDIT_LOG }o..o{ CATEGORY : "trace via #[Auditable]"
audit_log (déjà en place dans Core) trace automatiquement les changements sur Category (qui portera #[Auditable]). Pas de FK matérielle Postgres entre audit_log et category : audit_log.entity_id est un VARCHAR(64) (cf. migration Version20260420202749).
3.2 Migration Doctrine — SQL Postgres
Placement : migrations/VersionYYYYMMDDHHMMSS.php, namespace racine DoctrineMigrations (règle ABSOLUE Starseed n°11 : les migrations d'init des entités d'un module vivent au namespace racine pour éviter le bug de tri FQCN de Doctrine Migrations 3.x).
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M0 — Catalog : creation des tables `category_type` (referentiel) et `category`.
*
* Le referentiel `category_type` est cree vide ; ses valeurs seront seedees
* ulterieurement (cf. spec-back M0 § 9 HP-1).
*
* Index unique partiel sur (LOWER(name), category_type_id) WHERE deleted_at
* IS NULL : permet la recreation d'une categorie apres suppression logique
* (cf. RG-1.07).
*/
final class VersionYYYYMMDDHHMMSS extends AbstractMigration
{
public function getDescription(): string
{
return 'M0 Catalog : tables category_type et category, index unique partiel.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE category_type (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(40) NOT NULL,
label VARCHAR(120) NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_category_type_code ON category_type (code)');
$this->addSql(<<<'SQL'
CREATE TABLE category (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(120) NOT NULL,
category_type_id INT NOT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_category_type
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_category_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_category_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Unicite (name, type) case-insensitive, seulement sur les non-soft-deleted.
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_category_name_type_active
ON category (LOWER(name), category_type_id)
WHERE deleted_at IS NULL
SQL);
$this->addSql('CREATE INDEX idx_category_deleted_at ON category (deleted_at)');
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
$this->addSql('CREATE INDEX idx_category_created_by ON category (created_by)');
$this->addSql('CREATE INDEX idx_category_updated_by ON category (updated_by)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE category');
$this->addSql('DROP TABLE category_type');
}
}
Rappel convention Starseed/MALIO : noms de colonnes toujours en minuscules snake_case dans le SQL brut. Doctrine génère le camelCase côté entité, Postgres stocke en lowercase.
3.3 Entité Category — squelette (pattern Starseed)
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read']],
provider: CategoryProvider::class,
),
new Get(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read']],
provider: CategoryProvider::class,
),
new Post(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read']],
denormalizationContext: ['groups' => ['category:write']],
processor: CategoryProcessor::class,
),
new Patch(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read']],
denormalizationContext: ['groups' => ['category:write']],
processor: CategoryProcessor::class,
),
new Delete(
security: "is_granted('catalog.categories.manage')",
processor: CategoryProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')]
#[Auditable]
class Category implements TimestampableInterface, BlamableInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['category:read'])]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
#[Assert\Length(min: 2, max: 120)]
#[Groups(['category:read', 'category:write'])]
private ?string $name = null;
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
#[Groups(['category:read', 'category:write'])]
private ?CategoryType $categoryType = null;
/**
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
* Pas exposee en ecriture (DELETE → CategoryProcessor pose la valeur).
*/
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['category:read'])]
private ?DateTimeImmutable $deletedAt = null;
// === Timestampable + Blamable ===
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
// getters/setters viennent du trait Shared. Le TimestampableBlamableSubscriber
// les remplit automatiquement au prePersist / preUpdate. Aucun code à ajouter ici.
use \App\Shared\Domain\Trait\TimestampableBlamableTrait;
// getters / setters métier (name, categoryType, deletedAt) classiques — omis ici.
}
3.4 Entité CategoryType — squelette
Lecture seule au M0. Pas de POST / PATCH / DELETE exposé.
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category_type:read']],
),
new Get(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category_type:read']],
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryTypeRepository::class)]
#[ORM\Table(name: 'category_type')]
class CategoryType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['category_type:read', 'category:read'])]
private ?int $id = null;
#[ORM\Column(length: 40, unique: true)]
#[Groups(['category_type:read', 'category:read'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['category_type:read', 'category:read'])]
private ?string $label = null;
// getters / setters
}
Le groupe
category:readest ajouté sur les propriétés deCategoryTypepour qu'il soit embarqué dans la réponseCategory(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(defaultfalse) — désactivé par défaut, leCategoryProviderfiltredeleted_at IS NULLcategoryType=<id>(optionnel) — filtre par type (viaSearchFilterAPI Platform standard si activé)
- Pagination : aucune au M0 (volumétrie ≤ 300). Pagination front via
<MalioDataTable>. - Tri par défaut :
name ASC(serveur, défini dans leCategoryProvider) - Réponse 200 (format JSON-LD Hydra standard API Platform) :
{
"@context": "/api/contexts/Category",
"@id": "/api/categories",
"@type": "Collection",
"totalItems": 42,
"member": [
{
"@id": "/api/categories/12",
"@type": "Category",
"id": 12,
"name": "Vis tête fraisée",
"categoryType": {
"@id": "/api/category_types/3",
"id": 3,
"code": "MATIERE",
"label": "Matière"
},
"deletedAt": null
}
]
}
- Codes :
200 OK/401(non authentifié) /403(pas la permission)
4.2 GET /api/categories/{id} — Détail
- Security :
is_granted('catalog.categories.view') - Comportement : 404 si soft-deleted ET
includeDeleted=false(default). - Réponse 200 : identique à un élément de la liste ci-dessus.
- Codes :
200/404/401/403
4.3 POST /api/categories — Création
- Security :
is_granted('catalog.categories.manage') - Content-Type :
application/ld+json - Body :
{
"name": "Vis tête fraisée",
"categoryType": "/api/category_types/3"
}
- Réponse 201 : la ressource créée (cf. § 4.1).
- Codes :
201 Created400 Bad Requestpayload mal formé401/403409 Conflictsi doublon(LOWER(name), categoryType)parmi les non-soft-deleted (RG-1.07). Détection : leUniqueConstraintViolationPostgres remonté par Doctrine est attrapé dans leCategoryProcessoret traduit en 409.422 Unprocessable Entitysi 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 champdeletedAtn'est PAS dans le groupecategory: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
CategoryProcessorintercepte l'opération Delete, posedeletedAt = 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 parlabel ASC. - Pas d'écriture exposée au M0 — table vide à la livraison.
5. Autorisation
5.1 Déclaration des permissions
src/Module/Catalog/CatalogModule.php (pattern aligné sur CoreModule.php) :
<?php
declare(strict_types=1);
namespace App\Module\Catalog;
final class CatalogModule
{
public const string ID = 'catalog';
public const string LABEL = 'Catalogue';
// REQUIRED = true : Category sera FK NOT NULL côté Client (et Fournisseur,
// Prestataire). Désactiver Catalog casserait tout le métier au boot Doctrine.
// Cf. review Tristan MR #12 : « Comment on fait si le module est désactivé
// sachant que pour créer un client il est requis de sélectionner une catégorie. »
public const bool REQUIRED = true;
/**
* Permissions RBAC exposees par le module Catalog. Granularite alignee
* sur Core (view + manage), pas view/create/edit/delete.
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'catalog.categories.view', 'label' => 'Voir les catégories'],
['code' => 'catalog.categories.manage', 'label' => 'Gérer les catégories (créer, éditer, supprimer)'],
];
}
}
À l'issue du dev : make shell puis php bin/console app:sync-permissions pour upserter les codes dans la table permission.
5.2 Mapping rôles MALIO ↔ permissions
Les 5 rôles MALIO (Admin / Bureau / Compta / Commerciale / Usine) sont des rôles RBAC métier matérialisés dans la table role (pas des rôles Symfony Security — il n'y a que ROLE_USER et ROLE_ADMIN côté Security). Pour le M0 :
| Permission | Admin | Bureau | Compta | Commerciale | Usine |
|---|---|---|---|---|---|
catalog.categories.view |
✅ | ❌ | ❌ | ❌ | ❌ |
catalog.categories.manage |
✅ | ❌ | ❌ | ❌ | ❌ |
Tout rôle qui ne porte aucune de ces deux permissions reçoit 403 Forbidden sur les endpoints /api/categories/* et /api/category_types*. Un anonyme (sans JWT valide) reçoit 401.
5.3 Synchronisation RBAC (3 sources OBLIGATOIRES)
Règle ABSOLUE Starseed n°8 — toute évolution RBAC touche les 3 sources ensemble :
config/sidebar.php— ajouter l'item « Gestion des catégories » dans la section « Administration » :
[
'label' => 'sidebar.catalog.categories',
'to' => '/admin/categories',
'icon' => 'mdi:tag-multiple-outline',
'module' => 'catalog',
'permission' => 'catalog.categories.view',
],
-
frontend/tests/e2e/_fixtures/personas.ts— attribuer les 2 permissions au personaAdmin, ne rien changer pour les autres personas. -
src/Module/Core/Infrastructure/Console/SeedE2ECommand.php— miroir back : seed le rôle métierAdminavec les permissionscatalog.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 tableaudit_logvia 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.viewpeut consulter/api/categories/*ou/api/category_types*. Seul un utilisateur authentifié porteur decatalog.categories.managepeut 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
nameest obligatoire à la création et à la modification. Vide / null / whitespace-only → 422 avec violationname: "Le nom est obligatoire."(SymfonyAssert\NotBlank). - RG-1.03 : Le champ
nameest trim() côté serveur dans leCategoryProcessoravant validation et persistance (suppression des espaces de début/fin). - RG-1.04 : Le champ
namea une longueur entre 2 et 120 caractères (après trim). Hors borne → 422 (SymfonyAssert\Length(min: 2, max: 120)).
Champ categoryType
- RG-1.05 : Le champ
categoryTypeest obligatoire (IRI vers/api/category_types/{id}). Manquant / null → 422 avec violationcategoryType: "Type de catégorie obligatoire."(SymfonyAssert\NotNull). - RG-1.06 : La valeur de
categoryTypedoit pointer vers unCategoryTypeexistant. 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. LeCategoryProcessorattrape laUniqueConstraintViolationPostgres 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/categoriesexclut par défaut les catégories soft-deleted (deleted_at IS NOT NULL). Implémenté dans leCategoryProvider. - RG-1.09 : Un utilisateur avec
catalog.categories.managepeut 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 ETincludeDeletedn'est pas activé.
Suppression
- RG-1.12 :
DELETE /api/categories/{id}est un soft delete (posedeleted_at = now()). Réponse204. Ne supprime jamais physiquement la ligne. - RG-1.13 : Le champ
deletedAtn'est jamais modifiable via PATCH (groupecategory:writene 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_idnullable, 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), leTimestampableBlamableSubscriberremplitcreatedAt = updatedAt = now()etcreatedBy = updatedBy = user courant(ounullsi CLI / cron / migration sans contexte HTTP). Le client ne peut pas écrire ces 4 champs (groupecategory:writene les contient pas). - RG-1.16 : Au PATCH (
preUpdate), seulsupdatedAt = now()etupdatedBy = user courantsont modifiés.createdAtetcreatedByrestent figés à leur valeur initiale. Même si le PATCH est un soft-delete (passage dedeletedAtànow()via leCategoryProcessor), leupdatedAt/updatedBysont 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 leTimestampableBlamableTraitet implémenterTimestampableInterface+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 dansEntitiesAreTimestampableBlamableTest::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
namevide / null / whitespace / 1 caractère / 121 caractères → 422 avec violationname. - RG-1.03 : POST
name = " Vis "→ persistance"Vis"(trim auto viaCategoryProcessor). - RG-1.05 / RG-1.06 : POST sans
categoryTypeou 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 ASCpar 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_logest créé à chaque fois, avecentity_type='Category',entity_id={id},performed_by={user.id},actioncorrect,changesJSONB 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_atremplis ;created_by/updated_by = null. - RG-1.16 : PATCH par bob →
updated_atchange,updated_by = bob.id,created_atetcreated_byinchangés. - RG-1.16 : DELETE →
deleted_atrempli ETupdated_at/updated_bymis à jour (puisque c'est un UPDATE Doctrine). - RG-1.17 : Test architecture
EntitiesAreTimestampableBlamableTestpasse (cf. § 2.8.bis L3). WhitelistCategoryTypedocumentée. - Migration :
make db-reset→ schéma à jour. Vérifier en Postgres (\d category) queuq_category_name_type_activeapparaî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(): appeluseApi().get('/categories')retourne la liste triée, soft-deleted exclus. - Composable
useCategoryForm(): validation client-side avant POST (name requis, longueur — miroir RG-1.02/RG-1.04). - Composant
<CategoriesPage>:<MalioDataTable>+ bouton « + Ajouter » → ouverture drawer création. Clic ligne → drawer consultation. - Permissions : si user sans
catalog.categories.view(mock store), redirection 403 ; item sidebar masqué.
8.3 Tests E2E
Non prévus au M0. Règle ABSOLUE Starseed n°7 : pas de E2E sauf bug critique passé en prod. Vitest + PHPUnit suffisent.
9. Hors-périmètre (HP)
- HP-1 : CRUD du référentiel
CategoryType. Le M0 crée la table vide via migration et exposeGET /api/category_typesen 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_idnullable dans leurs propres entités. Aucun changement côtéCategoryau 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, permissioncatalog.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 dansAppFixtures/SeedE2ECommandau fil des modules).
10. Liens & dépendances
Liens
- Spec front (V0 client, 2026-05-22) :
./spec-front.md - Workflow :
../../WORKFLOW_ERP.md - Skill ticket-writer :
../../ticket-writer-SKILL.md - Template spec back :
../../templates/spec-back.md - Template ticket back :
../../templates/ticket-back.md - CLAUDE.md Starseed :
~/dev_malio/Starseed/CLAUDE.md - Règles archi Starseed :
~/dev_malio/Starseed/.claude/rules/architecture.md(modular monolith DDD, namespace, modules) - Règles back Starseed :
~/dev_malio/Starseed/.claude/rules/backend.md(ApiResource sans controllers, RBAC, Auditable) - Spec audit :
~/dev_malio/Starseed/doc/audit-log.md(référence pour#[Auditable])
Dépendances amont (déjà en place dans Starseed)
- Module
Core: RBAC (module.resource.action), commandeapp:sync-permissions,PermissionVoter,AuditListener+AuditLogWriter. - Module
Shared/Domain/Attribute/:Auditable,AuditIgnore. - Table
user(Lexik JWT) + tablerole+ tablepermission+ tableaudit_log. - Endpoint
/api/audit-log(déjà fourni par Core, lit la tableaudit_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_idnullable + 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)
- Aller sur le projet STARSEED (#6) → TaskGroup #22 « M0 — Gestion des catégories »
- 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é :
Moyenpar défaut
- Vérifier que le statut reste
null(backlog) — DoR pas encore cochée - Mettre à jour
statut_globalici envalidee_client(au lieu deen_dev) si tu veux que la spec reflète l'état "tickets en backlog, pas encore pris"