Compare commits

..

5 Commits

Author SHA1 Message Date
Matthieu Tholot 3c9eaf5d69 docs(catalog) : add M0 categories specs (back + front)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m18s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 10m11s
2026-05-26 15:07:45 +02:00
gitea-actions 33599db5a3 chore: bump version to v0.1.39
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 22s
2026-05-19 13:56:08 +00:00
matthieu 34e75a35fb ci : add pull_request quality gate workflow (#11)
Auto Tag Develop / tag (push) Has been cancelled
2026-05-19 13:55:59 +00:00
gitea-actions 1696602abb chore: bump version to v0.1.38
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 22s
2026-05-19 06:38:13 +00:00
Matthieu cacd8718e5 chore(prod) : ajuster conf prod pour HTTP en reseau local
Auto Tag Develop / tag (push) Has been cancelled
- .env.prod.example : JWT_COOKIE_SECURE=0, CORS_ALLOW_ORIGIN en http
- prompt-rename-prod.md : retirer etape certbot/Let's Encrypt, verifier la resolution locale a la place
- deployment-docker.md : aligner DEFAULT_URI, CORS et JWT_COOKIE_SECURE sur HTTP
2026-05-19 08:38:03 +02:00
8 changed files with 964 additions and 30 deletions
+119
View File
@@ -0,0 +1,119 @@
name: Pull Request — Quality gate
# Lance les tests + lint + build sur chaque PR ciblant develop.
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
# E2E volontairement hors scope (cf. regle d'or testing.md).
on:
pull_request:
branches:
- develop
# Annule les runs obsoletes quand on repush sur la meme PR.
concurrency:
group: pr-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
backend:
name: Backend (PHP CS + PHPUnit)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
# Doivent matcher la DATABASE_URL ci-dessous. Le suffixe `_test`
# est applique automatiquement par Doctrine en APP_ENV=test.
POSTGRES_USER: app
POSTGRES_PASSWORD: '!ChangeMe!'
POSTGRES_DB: app
# Pas de `ports:` host mapping — le runner partage l'hote avec la
# prod (Postgres deja sur 5432) et les jobs Gitea Actions tournent
# en container sur un reseau Docker dedie : le service est joignable
# via son nom (`postgres`), pas via 127.0.0.1.
options: >-
--health-cmd "pg_isready -U app"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
APP_ENV: test
APP_SECRET: ci-secret-not-used
APP_DEBUG: 0
DEFAULT_URI: http://localhost/
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
JWT_PASSPHRASE: change_me_in_env_local
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
coverage: none
tools: composer:v2
- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-
- name: Install PHP dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: Generate JWT keypair
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
- name: PHP CS Fixer (dry-run)
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
- name: Bootstrap test database
run: |
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
php bin/console doctrine:migrations:migrate --env=test --no-interaction
php bin/console doctrine:schema:update --env=test --force --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction
- name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit
frontend:
name: Frontend (lint + Vitest + build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install Node dependencies
run: npm ci
- name: ESLint
run: npm run lint
- name: Unit tests (Vitest)
run: npm run test
- name: Build production (nuxt build)
run: npm run build:dist
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.37'
app.version: '0.1.39'
+2 -2
View File
@@ -152,7 +152,7 @@ DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/starseed_pro
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
JWT_COOKIE_SECURE=1
JWT_COOKIE_SECURE=0
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
@@ -161,7 +161,7 @@ JWT_COOKIE_TTL=86400
CORS_ALLOW_ORIGIN='^https?://starseed\.malio-dev\.fr$'
# App
DEFAULT_URI=https://starseed.malio-dev.fr
DEFAULT_URI=http://starseed.malio-dev.fr
```
### 6. Generer les cles JWT
+20 -25
View File
@@ -2,8 +2,9 @@
Copier-coller integralement dans une session Claude lancee **sur le serveur de prod** apres que :
- le push develop + build CI ont publie l'image `gitea.malio.fr/malio-dev/starseed:latest`,
- le DNS `starseed.malio-dev.fr` resout vers ce serveur,
- un certificat Let's Encrypt existe (ou est pret a etre genere) pour `starseed.malio-dev.fr`.
- la resolution reseau local (DNS interne ou `/etc/hosts` des postes clients) pour `starseed.malio-dev.fr` est en place.
> Setup : HTTP en reseau local, pas de TLS. Pas de Let's Encrypt.
---
@@ -11,7 +12,7 @@ Copier-coller integralement dans une session Claude lancee **sur le serveur de p
Tu es sur le serveur de production d'une app Symfony+Nuxt qui s'appelait **Coltura** et qui doit etre renommee en **Starseed**. Le rename cote code est deja fait et merge. Le repo Gitea s'appelle deja `starseed`. L'image `gitea.malio.fr/malio-dev/starseed:latest` est publiee.
Le DNS `starseed.malio-dev.fr` resout vers ce serveur. Le certificat Let's Encrypt pour ce nom de domaine est gere a la main par l'admin (a confirmer avant l'etape nginx).
L'app est servie en **HTTP sur reseau local** (pas de TLS, pas de Let's Encrypt). La resolution `starseed.malio-dev.fr` est faite via DNS interne ou `/etc/hosts` cote postes clients — pas de certificat a gerer.
Objectif : basculer la prod sur le nouveau nom (registry, container, DB, path FS, vhost) **sans perdre les donnees** et avec downtime minimal (mode maintenance pendant la migration).
@@ -30,9 +31,6 @@ ls -la /var/www/starseed/ 2>/dev/null | head -5
# 4. Vhost nginx system
sudo ls -la /etc/nginx/sites-enabled/ | grep -E "coltura|starseed"
# 5. Cert Let's Encrypt
sudo ls /etc/letsencrypt/live/ | grep -E "coltura|starseed"
```
**Apres confirmation de l'etat, executer dans cet ordre, en demandant validation utilisateur AVANT chaque etape destructive (DB drop, rm -rf, certificat) :**
@@ -43,7 +41,7 @@ sudo ls /etc/letsencrypt/live/ | grep -E "coltura|starseed"
cd /var/www/coltura
touch maintenance.on
# Verifier qu'une requete renvoie 503
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://coltura.malio-dev.fr/
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://coltura.malio-dev.fr/
```
Doit renvoyer `503`.
@@ -100,17 +98,18 @@ sudo test -f /var/www/starseed/.env && echo ".env OK"
Editer `/var/www/starseed/.env` :
- `DATABASE_URL` : remplacer `/coltura_prod` -> `/starseed_prod` (et user si renomme a etape 3)
- `CORS_ALLOW_ORIGIN` : remplacer `coltura.malio-dev.fr` -> `starseed.malio-dev.fr`
- `DEFAULT_URI` : `https://starseed.malio-dev.fr`
- `DEFAULT_URI` : `http://starseed.malio-dev.fr`
- `JWT_COOKIE_SECURE` : doit etre `0` (HTTP, pas de TLS) — verifier qu'il l'est deja
Diff attendu :
```diff
- DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/coltura_prod?..."
+ DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/starseed_prod?..."
- CORS_ALLOW_ORIGIN='^https?://coltura\.malio-dev\.fr$'
+ CORS_ALLOW_ORIGIN='^https?://starseed\.malio-dev\.fr$'
- DEFAULT_URI=https://coltura.malio-dev.fr
+ DEFAULT_URI=https://starseed.malio-dev.fr
- CORS_ALLOW_ORIGIN='^http://coltura\.malio-dev\.fr$'
+ CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
- DEFAULT_URI=http://coltura.malio-dev.fr
+ DEFAULT_URI=http://starseed.malio-dev.fr
```
### Etape 6 — Stopper et supprimer l'ancien container
@@ -144,9 +143,9 @@ sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=pr
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
```
### Etape 9 — Vhost nginx system + certificat
### Etape 9 — Vhost nginx system (HTTP only)
Copier le nouveau vhost (a jour avec `server_name starseed.malio-dev.fr` et `root /var/www/starseed/public`) :
Copier le nouveau vhost (a jour avec `server_name starseed.malio-dev.fr` et `root /var/www/starseed/public`, `listen 80` uniquement) :
```bash
sudo cp /var/www/starseed/infra/prod/nginx-proxy.conf /etc/nginx/sites-available/starseed.conf
@@ -155,14 +154,10 @@ sudo rm -f /etc/nginx/sites-enabled/coltura.conf
sudo nginx -t
```
**Avant de reload nginx**, generer le certificat Let's Encrypt pour le nouveau domaine (l'utilisateur doit confirmer ; certbot peut casser temporairement le vhost actuel pendant la challenge) :
Verifier la resolution reseau local avant reload :
```bash
# Verifier le DNS d'abord
dig +short starseed.malio-dev.fr
# Generer le cert (l'utilisateur valide)
sudo certbot --nginx -d starseed.malio-dev.fr --non-interactive --agree-tos -m matthieu@malio.fr
getent hosts starseed.malio-dev.fr || echo "ATTENTION : starseed.malio-dev.fr ne resout pas localement"
```
Puis :
@@ -176,9 +171,9 @@ sudo systemctl reload nginx
```bash
rm -f /var/www/starseed/maintenance.on
# Tests externes
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://starseed.malio-dev.fr/
curl -s https://starseed.malio-dev.fr/api/version
# Tests externes (HTTP local)
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://starseed.malio-dev.fr/
curl -s http://starseed.malio-dev.fr/api/version
```
`/api/version` doit renvoyer du JSON avec la version courante.
@@ -229,8 +224,8 @@ rm -f /var/www/coltura/maintenance.on
## Regles de comportement pour le Claude prod
- **Ne jamais skipper le backup** (etape 2).
- **Demander confirmation utilisateur** avant : `DROP DATABASE`, `rm -rf`, `certbot`, et avant de lever le mode maintenance final.
- **Demander confirmation utilisateur** avant : `DROP DATABASE`, `rm -rf`, et avant de lever le mode maintenance final.
- **Une seule operation destructive a la fois**, attendre le retour utilisateur entre chaque.
- **Logger systematiquement** la sortie des commandes critiques (pg_dump, docker compose up, certbot).
- **Logger systematiquement** la sortie des commandes critiques (pg_dump, docker compose up, nginx -t / reload).
- **Si une etape echoue**, NE PAS continuer — declencher le rollback.
- **Ne commit rien** sur le repo depuis le serveur prod.
+700
View File
@@ -0,0 +1,700 @@
---
# === IDENTITÉ ===
module: M0
nom: "Gestion des catégories"
ecran: gestion-categories
type: feature # feature back + UI admin (datatable + drawer)
pipeline: ui+back # tickets back ET front (UI admin standard, pas de Figma)
owner: Matthieu
backup: Tristan
date: 2026-05-26
version: 1.1 # 1.0 = draft initial ; 1.1 = aligné sur l'archi Starseed réelle
# === LIENS ===
lien_spec_front: ./spec-front.md
figma: null # pas de maquette — UI admin standard (datatable + drawer)
dependances: [] # M0 = premier module, aucune dépendance
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13, RG-1.14]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
# === VALIDATION CLIENT ===
# Pour le skill ticket-writer : statut = validee (validation implicite, périmètre projet validé en amont).
# UI admin interne, pas de Figma, pas de validation client externe requise.
client_validation_1:
statut: validee
date: 2026-05-22
canal: ecrit
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
resume: "UI admin standard (datatable + drawer), pas de validation client #1 externe requise (workflow back-only + UI admin standard sans Figma)."
trace_archivee: null
date_validation: 2026-05-22
validateur_client: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
# === LIEN LESSTIME (rempli après push manuel le 2026-05-26) ===
lesstime_taskgroup_id: 22
lesstime_project_id: 6 # ERP / Starseed
statut_global: en_dev # tickets créés en backlog Lesstime
# === TAGS LESSTIME suggérés pour les tickets ===
tags: [Backend, Frontend]
---
# M0 — Gestion des catégories (back + UI admin)
## 1. Contexte
Premier module à intégrer le workflow MALIO. Permet à un administrateur de gérer un référentiel de **catégories** dans Starseed (CRM/ERP). Une catégorie porte un `name` libre et un `type` (FK vers le référentiel `category_type`). Elle servira plus tard à classifier les tiers (clients, fournisseurs, prestataires).
**Contraintes structurantes :**
- **Admin uniquement** sur toute la chaîne (Bureau / Compta / Commerciale / Usine : aucun accès, ni lecture ni écriture). Implémenté via la permission RBAC `catalog.categories.view` / `catalog.categories.manage`, jamais attribuée aux autres rôles métier.
- **Pas de hard delete** : soft delete via `deleted_at`. La liste exclut les soft-deleted par défaut.
- **Pas de Figma** : UI admin standard (datatable + drawer d'édition), composants `@malio/layer-ui`.
- **Nouveau module Starseed `Catalog`** créé pour ce M0. Bounded context « référentiels partagés » — n'appartient pas à `Commercial` pour rester réutilisable par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas).
- **Note** : le référentiel `category_type` n'est pas géré par ce module (voir § 9 Hors-périmètre). Migration crée la table vide ; le seed initial sera défini ultérieurement.
## 2. Décisions d'archi (auto-validation back-only)
> Section obligatoire pour les specs sans validation client externe (cf. WORKFLOW_ERP.md § 1.bis). Traces écrites des choix techniques.
### 2.1 Module `Catalog` (nouveau)
Création du module DDD `App\Module\Catalog` avec la même structure que `Core` / `Commercial` / `Sites` :
```
src/Module/Catalog/
├── CatalogModule.php # constantes ID/LABEL/REQUIRED + permissions()
├── Domain/
│ ├── Entity/
│ │ ├── Category.php # #[ApiResource] + #[Auditable]
│ │ └── CategoryType.php # #[ApiResource(GetCollection + Get seulement)]
│ └── Repository/
│ ├── CategoryRepositoryInterface.php
│ └── CategoryTypeRepositoryInterface.php
└── Infrastructure/
├── ApiPlatform/
│ └── State/
│ ├── Provider/
│ │ └── CategoryProvider.php # filtre soft-delete + includeDeleted (admin)
│ └── Processor/
│ └── CategoryProcessor.php # POST / PATCH / DELETE (soft)
└── Doctrine/
├── DoctrineCategoryRepository.php
├── DoctrineCategoryTypeRepository.php
└── Filter/SoftDeletedCategoryFilter.php # filtre Doctrine global (cf. § 4.2)
```
Wire dans `config/modules.php` (ajout d'une ligne) :
```php
return [
CoreModule::class,
CommercialModule::class,
SitesModule::class,
CatalogModule::class, // ← AJOUTÉ
];
```
**Alternative écartée** : placer dans `Core` (pollue le module noyau avec du métier de référentiel) ou dans `Commercial` (empêche M-Fournisseurs / M-Prestas de réutiliser proprement sans dépendre de Commercial).
### 2.2 IDs : entier auto-increment Postgres natif
Convention Starseed confirmée (cf. `Domain/Entity/User.php`, `migrations/Version20260407095546.php`) : tous les IDs sont des `INT GENERATED BY DEFAULT AS IDENTITY`. **PAS de CUID** dans Starseed. La spec applique donc `INT IDENTITY` pour `category.id` et `category_type.id`.
**Alternative écartée** : `uuid` (utilisé seulement pour `audit_log.id` à cause de la nature append-only / forte croissance).
### 2.3 Soft delete : pattern à introduire
Aucun pattern soft delete existant dans Starseed (vérifié, aucune entité ne porte `deleted_at`). Le M0 introduit le pattern :
- Colonne `deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL` sur `category`.
- Filtre Doctrine global enregistré pour cette entité (active par défaut, désactivable via un flag dans le `CategoryProvider`).
- Le PATCH ne peut pas écrire `deletedAt` (denormalization group exclut le champ).
- Le DELETE pose `deleted_at = now()` via `CategoryProcessor` (override du remove processor Doctrine ORM standard, pattern aligné sur `UserProcessor`).
**Alternative écartée** : pas de delete du tout (V0 client n'en parle pas) — refusée car le besoin opérationnel reviendra immanquablement. Mieux vaut intégrer le pattern dès le M0 et le réutiliser ailleurs.
### 2.4 Unicité partielle Postgres
Index unique partiel sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL`. Permet de recréer une catégorie avec le même `(name, type)` après suppression logique. Postgres supporte nativement (`CREATE UNIQUE INDEX ... WHERE`). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job.
### 2.5 Audit : `#[Auditable]` Starseed standard
L'entité `Category` porte `#[Auditable]` (`App\Shared\Domain\Attribute\Auditable`). Le `AuditListener` (`src/Module/Core/Infrastructure/Doctrine/AuditListener.php`) intercepte `onFlush` + `postFlush` et écrit une ligne dans `audit_log` à chaque création / modification / suppression logique (le soft delete = un UPDATE pour Doctrine, donc tracé comme un UPDATE).
**Important** : `#[Auditable]` **ne crée PAS** automatiquement les colonnes `created_by` / `updated_by` / `created_at` / `updated_at` sur l'entité. Il trace les changements dans une table séparée `audit_log` (qui contient `performed_by` + `performed_at` + diff JSON). Conséquence pour le M0 :
- **Pas de colonnes `created_by` / `updated_by` / `created_at` / `updated_at` sur `category`.** Le « qui a créé / modifié / quand » est lisible via l'endpoint d'historique `GET /api/audit-log?entityType=Category&entityId={id}` (déjà fourni par Core).
- C'est cohérent avec les autres entités Starseed (User n'a qu'un `createdAt` géré manuellement dans le constructor, pas de `updatedAt` ; Role n'a rien).
Si plus tard un besoin de tri par `updated_at` côté front se fait sentir, on pourra rajouter la colonne. Au M0, on ne devine pas.
### 2.6 Pagination & tri
Volumétrie cible : **300 max** (cf. Q5 Matthieu). Pas de pagination serveur. L'endpoint liste renvoie tout d'un coup. Tri par défaut côté serveur : `name ASC` (via `OrderFilter` ou ordre par défaut du Provider). La pagination front (`<MalioDataTable>`) gère l'affichage paginé en mémoire.
### 2.7 Permissions RBAC — granularité
Pattern Starseed (cf. `CoreModule::permissions()`) : `view` + `manage`, **pas** la granularité `view / create / edit / delete`. Aligné sur `core.users.view` + `core.users.manage`.
Pour M0 :
- `catalog.categories.view` — voir la liste + détail (GET) + lecture du référentiel `category_type` (GET)
- `catalog.categories.manage` — créer + modifier + supprimer (POST / PATCH / DELETE)
Les deux permissions seront attachées **uniquement au rôle métier `Admin`** dans `AppFixtures` et `SeedE2ECommand`. Bureau / Compta / Commerciale / Usine n'en reçoivent aucune → 403 systématique.
## 3. Modèle de données
### 3.1 Diagramme
```mermaid
erDiagram
CATEGORY_TYPE ||--o{ CATEGORY : "classe"
CATEGORY {
int id PK "INT IDENTITY"
string name "VARCHAR(120) NOT NULL"
int category_type_id FK "INT NOT NULL"
timestamp deleted_at "nullable (soft delete)"
}
CATEGORY_TYPE {
int id PK "INT IDENTITY"
string code "VARCHAR(40) NOT NULL UNIQUE"
string label "VARCHAR(120) NOT NULL"
}
AUDIT_LOG }o..o{ CATEGORY : "trace via #[Auditable]"
```
`audit_log` (déjà en place dans Core) trace automatiquement les changements sur `Category` (qui portera `#[Auditable]`). Pas de FK matérielle Postgres entre `audit_log` et `category` : `audit_log.entity_id` est un `VARCHAR(64)` (cf. migration `Version20260420202749`).
### 3.2 Migration Doctrine — SQL Postgres
**Placement** : `migrations/VersionYYYYMMDDHHMMSS.php`, namespace racine `DoctrineMigrations` (règle ABSOLUE Starseed n°11 : les migrations d'init des entités d'un module vivent au namespace racine pour éviter le bug de tri FQCN de Doctrine Migrations 3.x).
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M0 — Catalog : creation des tables `category_type` (referentiel) et `category`.
*
* Le referentiel `category_type` est cree vide ; ses valeurs seront seedees
* ulterieurement (cf. spec-back M0 § 9 HP-1).
*
* Index unique partiel sur (LOWER(name), category_type_id) WHERE deleted_at
* IS NULL : permet la recreation d'une categorie apres suppression logique
* (cf. RG-1.07).
*/
final class VersionYYYYMMDDHHMMSS extends AbstractMigration
{
public function getDescription(): string
{
return 'M0 Catalog : tables category_type et category, index unique partiel.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE category_type (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(40) NOT NULL,
label VARCHAR(120) NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_category_type_code ON category_type (code)');
$this->addSql(<<<'SQL'
CREATE TABLE category (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(120) NOT NULL,
category_type_id INT NOT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_category_type
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT
)
SQL);
// Unicite (name, type) case-insensitive, seulement sur les non-soft-deleted.
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_category_name_type_active
ON category (LOWER(name), category_type_id)
WHERE deleted_at IS NULL
SQL);
$this->addSql('CREATE INDEX idx_category_deleted_at ON category (deleted_at)');
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE category');
$this->addSql('DROP TABLE category_type');
}
}
```
> Rappel convention Starseed/MALIO : noms de colonnes **toujours en minuscules snake_case** dans le SQL brut. Doctrine génère le camelCase côté entité, Postgres stocke en lowercase.
### 3.3 Entité `Category` — squelette (pattern Starseed)
```php
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
use App\Shared\Domain\Attribute\Auditable;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read']],
provider: CategoryProvider::class,
),
new Get(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read']],
provider: CategoryProvider::class,
),
new Post(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read']],
denormalizationContext: ['groups' => ['category:write']],
processor: CategoryProcessor::class,
),
new Patch(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read']],
denormalizationContext: ['groups' => ['category:write']],
processor: CategoryProcessor::class,
),
new Delete(
security: "is_granted('catalog.categories.manage')",
processor: CategoryProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')]
#[Auditable]
class Category
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['category:read'])]
private ?int $id = null;
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
#[Assert\Length(min: 2, max: 120)]
#[Groups(['category:read', 'category:write'])]
private ?string $name = null;
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
#[Groups(['category:read', 'category:write'])]
private ?CategoryType $categoryType = null;
/**
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
* Pas exposee en ecriture (DELETE → CategoryProcessor pose la valeur).
*/
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['category:read'])]
private ?DateTimeImmutable $deletedAt = null;
// getters / setters classiques (générés par PhpStorm) — omis ici.
}
```
### 3.4 Entité `CategoryType` — squelette
Lecture seule au M0. Pas de POST / PATCH / DELETE exposé.
```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=<id>` (optionnel) — filtre par type (via `SearchFilter` API Platform standard si activé)
- **Pagination** : aucune au M0 (volumétrie ≤ 300). Pagination front via `<MalioDataTable>`.
- **Tri par défaut** : `name ASC` (serveur, défini dans le `CategoryProvider`)
- **Réponse 200** (format JSON-LD Hydra standard API Platform) :
```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
<?php
declare(strict_types=1);
namespace App\Module\Catalog;
final class CatalogModule
{
public const string ID = 'catalog';
public const string LABEL = 'Catalogue';
public const bool REQUIRED = false;
/**
* Permissions RBAC exposees par le module Catalog. Granularite alignee
* sur Core (view + manage), pas view/create/edit/delete.
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'catalog.categories.view', 'label' => 'Voir les catégories'],
['code' => 'catalog.categories.manage', 'label' => 'Gérer les catégories (créer, éditer, supprimer)'],
];
}
}
```
À l'issue du dev : `make shell` puis `php bin/console app:sync-permissions` pour upserter les codes dans la table `permission`.
### 5.2 Mapping rôles MALIO ↔ permissions
Les 5 rôles MALIO (`Admin / Bureau / Compta / Commerciale / Usine`) sont des **rôles RBAC métier** matérialisés dans la table `role` (pas des rôles Symfony Security — il n'y a que `ROLE_USER` et `ROLE_ADMIN` côté Security). Pour le M0 :
| Permission | Admin | Bureau | Compta | Commerciale | Usine |
|---|---|---|---|---|---|
| `catalog.categories.view` | ✅ | ❌ | ❌ | ❌ | ❌ |
| `catalog.categories.manage` | ✅ | ❌ | ❌ | ❌ | ❌ |
Tout rôle qui ne porte aucune de ces deux permissions reçoit `403 Forbidden` sur les endpoints `/api/categories/*` et `/api/category_types*`. Un anonyme (sans JWT valide) reçoit `401`.
### 5.3 Synchronisation RBAC (3 sources OBLIGATOIRES)
Règle ABSOLUE Starseed n°8 — toute évolution RBAC touche **les 3 sources ensemble** :
1. **`config/sidebar.php`** — ajouter l'item « Gestion des catégories » dans la section « Administration » :
```php
[
'label' => 'sidebar.catalog.categories',
'to' => '/admin/categories',
'icon' => 'mdi:tag-multiple-outline',
'module' => 'catalog',
'permission' => 'catalog.categories.view',
],
```
2. **`frontend/tests/e2e/_fixtures/personas.ts`** — attribuer les 2 permissions au persona `Admin`, ne rien changer pour les autres personas.
3. **`src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`** — miroir back : seed le rôle métier `Admin` avec les permissions `catalog.categories.view` + `catalog.categories.manage`.
### 5.4 Vérification front
`usePermissions()` côté Nuxt : afficher / cacher l'item de menu « Gestion des catégories » selon `catalog.categories.view`. Les actions « Ajouter / Modifier / Supprimer » sont gatées sur `catalog.categories.manage`.
## 6. Audit & dates
### 6.1 Audit automatique via `#[Auditable]`
`Category` porte `#[Auditable]` (`App\Shared\Domain\Attribute\Auditable`). Le `AuditListener` du module Core (`src/Module/Core/Infrastructure/Doctrine/AuditListener.php`) :
- Intercepte `onFlush` : capture les insertions / updates / deletions de toute entité `#[Auditable]`.
- Intercepte `postFlush` : écrit une ligne dans la table `audit_log` via DBAL (connexion dédiée pour éviter la récursion).
- Trace `performed_by` (le user courant), `performed_at`, `changes` (diff JSONB), `request_id`, `ip_address`.
**Aucun code custom à écrire côté M0.** Il suffit que `Category` porte `#[Auditable]`. Le soft delete (UPDATE `deleted_at`) est tracé comme un UPDATE normal.
### 6.2 Pas de colonnes `created_by` / `updated_by` / `created_at` / `updated_at` sur `category`
Contrairement à ce que la V0 brute pourrait suggérer, **`#[Auditable]` n'ajoute PAS ces colonnes**. Il écrit dans `audit_log` séparément. Cohérent avec les autres entités Starseed (`User` n'a qu'un `createdAt` géré manuellement dans le constructor, pas de `updatedAt` ; `Role` n'a rien).
→ Pour répondre à « qui a créé / modifié / quand », interroger `GET /api/audit-log?entityType=Category&entityId={id}` (endpoint déjà fourni par `Core`).
→ Si plus tard un besoin de tri front par date de création se fait sentir, on rajoutera la colonne. **Au M0, on ne devine pas.**
## 7. Règles de gestion (RG)
> Chaque RG est numérotée, stable, et constituera un critère d'acceptation côté ticket Lesstime.
> Convention `RG-1.XX` (les rules numéros 1.XX = règles du M0, premier module workflow).
### Autorisation
- **RG-1.01** : Seul un utilisateur authentifié porteur de la permission RBAC `catalog.categories.view` peut consulter `/api/categories/*` ou `/api/category_types*`. Seul un utilisateur authentifié porteur de `catalog.categories.manage` peut faire POST / PATCH / DELETE. Sans permission → **403 Forbidden**. Sans authentification → **401 Unauthorized**. Au M0, ces deux permissions sont attribuées **uniquement au rôle métier Admin** (les rôles Bureau / Compta / Commerciale / Usine reçoivent donc systématiquement 403).
### Champ `name`
- **RG-1.02** : Le champ `name` est **obligatoire** à la création et à la modification. Vide / null / whitespace-only → **422** avec violation `name: "Le nom est obligatoire."` (Symfony `Assert\NotBlank`).
- **RG-1.03** : Le champ `name` est trim() côté serveur dans le `CategoryProcessor` avant validation et persistance (suppression des espaces de début/fin).
- **RG-1.04** : Le champ `name` a une longueur entre **2 et 120 caractères** (après trim). Hors borne → 422 (Symfony `Assert\Length(min: 2, max: 120)`).
### Champ `categoryType`
- **RG-1.05** : Le champ `categoryType` est **obligatoire** (IRI vers `/api/category_types/{id}`). Manquant / null → 422 avec violation `categoryType: "Type de catégorie obligatoire."` (Symfony `Assert\NotNull`).
- **RG-1.06** : La valeur de `categoryType` doit pointer vers un `CategoryType` existant. Sinon → 422 (Symfony / API Platform résolution IRI → 400 standard, mapping vers 422 ou 400 selon comportement API Platform à confirmer en dev).
### Unicité
- **RG-1.07** : Le couple `(LOWER(name), category_type_id)` est unique parmi les catégories **non soft-deleted**. Tentative de doublon (POST ou PATCH qui rend le couple en collision) → **409 Conflict**. Le `CategoryProcessor` attrape la `UniqueConstraintViolation` Postgres remontée par Doctrine et la traduit en 409 avec le message `"Une catégorie nommée \"{name}\" existe déjà pour ce type."`. L'index Postgres est partiel (`WHERE deleted_at IS NULL`), donc on peut recréer une catégorie avec le même `(name, type)` après suppression logique.
### Liste
- **RG-1.08** : `GET /api/categories` exclut **par défaut** les catégories soft-deleted (`deleted_at IS NOT NULL`). Implémenté dans le `CategoryProvider`.
- **RG-1.09** : Un utilisateur avec `catalog.categories.manage` peut demander à voir les soft-deleted via `?includeDeleted=true`. Pour les autres rôles (qui ont 403 de toute façon), ce paramètre est ignoré.
- **RG-1.10** : Tri par défaut côté serveur : `name ASC`. Pas d'autres tris au M0.
### Détail
- **RG-1.11** : `GET /api/categories/{id}` renvoie **404** si l'id n'existe pas, ou si la catégorie est soft-deleted ET `includeDeleted` n'est pas activé.
### Suppression
- **RG-1.12** : `DELETE /api/categories/{id}` est un **soft delete** (pose `deleted_at = now()`). Réponse `204`. Ne supprime jamais physiquement la ligne.
- **RG-1.13** : Le champ `deletedAt` n'est **jamais** modifiable via PATCH (groupe `category:write` ne le contient pas). Seul le DELETE peut le mettre à jour. Restaurer une catégorie supprimée n'est pas un cas d'usage au M0 (HP-3).
- **RG-1.14** : *(à activer post-M0, désactivée à la livraison initiale)* Quand les modules Tiers (M-Clients, M-Fournisseurs, M-Prestas) auront ajouté une FK `category_id` nullable, un DELETE sur une catégorie référencée par au moins un tiers → **409 Conflict** avec message `"Impossible de supprimer : N tier(s) référencent cette catégorie."`. **Au M0, cette règle est documentée mais non implémentée** (rien à référencer, donc rien à empêcher).
## 8. Tests à automatiser
### 8.1 Cas à couvrir (back — PHPUnit)
> Pattern Starseed (cf. `tests/` et CLAUDE.md § Tests). Helpers d'auth à utiliser : `createAdminClient()`, `createBureauClient()`, etc. (à créer côté SeedE2E si pas déjà en place).
- [ ] **RG-1.01** : `Bureau`, `Compta`, `Commerciale`, `Usine`, anonyme → 401 / 403 sur GET / POST / PATCH / DELETE.
- [ ] **RG-1.01** : `Admin` → 200 / 201 / 204 selon le verbe sur tous les endpoints.
- [ ] **RG-1.02 / RG-1.04** : POST avec `name` vide / null / whitespace / 1 caractère / 121 caractères → 422 avec violation `name`.
- [ ] **RG-1.03** : POST `name = " Vis "` → persistance `"Vis"` (trim auto via `CategoryProcessor`).
- [ ] **RG-1.05 / RG-1.06** : POST sans `categoryType` ou avec un IRI inexistant → 422.
- [ ] **RG-1.07** : POST `(name="Vis", type=MATIERE)` puis POST `(name="vis", type=MATIERE)` (case-insensitive) → 2e → 409.
- [ ] **RG-1.07** : POST `(name="Vis", type=MATIERE)` puis `(name="Vis", type=PRODUIT)` → les deux passent (types différents).
- [ ] **RG-1.07** : Soft-delete d'une catégorie, puis POST avec exactement les mêmes `(name, type)` → 201 (l'index partiel autorise).
- [ ] **RG-1.08 / RG-1.09** : GET liste sans flag → exclut les soft-deleted. GET liste avec `?includeDeleted=true` → inclut.
- [ ] **RG-1.10** : GET liste → tri `name ASC` par défaut.
- [ ] **RG-1.11** : GET détail d'une catégorie soft-deleted sans flag → 404. Avec flag → 200.
- [ ] **RG-1.12** : DELETE → 204, ligne toujours présente en BDD avec `deleted_at IS NOT NULL`.
- [ ] **RG-1.13** : PATCH avec body `{"deletedAt": null}` ou autre tentative d'écriture → champ ignoré (groupe write l'exclut).
- [ ] **Audit** : POST + PATCH + DELETE → un `audit_log` est créé à chaque fois, avec `entity_type='Category'`, `entity_id={id}`, `performed_by={user.id}`, `action` correct, `changes` JSONB correct.
- [ ] **Migration** : `make db-reset` → schéma à jour. Vérifier en Postgres (`\d category`) que `uq_category_name_type_active` apparaît comme index partiel.
### 8.2 Cas à couvrir (front — Vitest)
- [ ] Composable `useCategoriesAdmin()` : appel `useApi().get('/categories')` retourne la liste triée, soft-deleted exclus.
- [ ] Composable `useCategoryForm()` : validation client-side avant POST (name requis, longueur — miroir RG-1.02/RG-1.04).
- [ ] Composant `<CategoriesPage>` : `<MalioDataTable>` + bouton « + Ajouter » → ouverture drawer création. Clic ligne → drawer consultation.
- [ ] Permissions : si user sans `catalog.categories.view` (mock store), redirection 403 ; item sidebar masqué.
### 8.3 Tests E2E
**Non prévus au M0.** Règle ABSOLUE Starseed n°7 : pas de E2E sauf bug critique passé en prod. Vitest + PHPUnit suffisent.
## 9. Hors-périmètre (HP)
- **HP-1** : **CRUD du référentiel `CategoryType`.** Le M0 crée la table vide via migration et expose `GET /api/category_types` en lecture seule. Le seed initial (`PRODUIT` / `SERVICE` / `MATIERE` / `AUTRE` ?) et le module admin pour les gérer feront l'objet d'une spec dédiée plus tard.
- **HP-2** : **Référencement par les Tiers.** Les modules M-Clients / M-Fournisseurs / M-Prestas ajouteront une colonne `category_id` nullable dans leurs propres entités. **Aucun changement côté `Category`** au moment où ces modules arriveront, sauf activation de la RG-1.14 (blocage du soft-delete si référencée).
- **HP-3** : **Restauration d'une catégorie soft-deleted.** Pas prévue au M0. Si besoin futur → endpoint dédié `POST /api/categories/{id}/restore`, permission `catalog.categories.manage`.
- **HP-4** : **Hard delete.** Pas prévu (RGPD / purge → spec dédiée si besoin).
- **HP-5** : **Internationalisation du `name`.** Pas d'i18n sur le champ libre saisi par l'admin. L'UI elle-même est en FR fixe.
- **HP-6** : **Filtres avancés / recherche serveur** dans la liste. Pas pertinent à 300 entrées (pagination front).
- **HP-7** : **Catégories hiérarchiques** (parent / enfant). Pas demandé. Si besoin futur → migration ajout colonne `parent_id` + spec dédiée.
- **HP-8** : **Création des rôles métier Bureau / Compta / Commerciale / Usine.** Ces rôles font partie du modèle MALIO mais leur seed initial dans `role` + leur attribution aux users est hors du périmètre M0 (probablement un M-RBAC dédié, ou seedés dans `AppFixtures` / `SeedE2ECommand` au fil des modules).
## 10. Liens & dépendances
### Liens
- **Spec front (V0 client, 2026-05-22)** : [`./spec-front.md`](./spec-front.md)
- **Workflow** : [`../../WORKFLOW_ERP.md`](../../WORKFLOW_ERP.md)
- **Skill ticket-writer** : [`../../ticket-writer-SKILL.md`](../../ticket-writer-SKILL.md)
- **Template spec back** : [`../../templates/spec-back.md`](../../templates/spec-back.md)
- **Template ticket back** : [`../../templates/ticket-back.md`](../../templates/ticket-back.md)
- **CLAUDE.md Starseed** : `~/dev_malio/Starseed/CLAUDE.md`
- **Règles archi Starseed** : `~/dev_malio/Starseed/.claude/rules/architecture.md` (modular monolith DDD, namespace, modules)
- **Règles back Starseed** : `~/dev_malio/Starseed/.claude/rules/backend.md` (ApiResource sans controllers, RBAC, Auditable)
- **Spec audit** : `~/dev_malio/Starseed/doc/audit-log.md` (référence pour `#[Auditable]`)
### Dépendances amont (déjà en place dans Starseed)
- Module `Core` : RBAC (`module.resource.action`), commande `app:sync-permissions`, `PermissionVoter`, `AuditListener` + `AuditLogWriter`.
- Module `Shared/Domain/Attribute/` : `Auditable`, `AuditIgnore`.
- Table `user` (Lexik JWT) + table `role` + table `permission` + table `audit_log`.
- Endpoint `/api/audit-log` (déjà fourni par Core, lit la table `audit_log`).
### Specs futures qui dépendent du M0
- **M-? — Gestion du référentiel `CategoryType`** (HP-1).
- **M-Clients / M-Fournisseurs / M-Prestas** : ajout FK `category_id` nullable + activation RG-1.14.
- **M-RBAC** *(éventuel)* — seed des rôles métier Bureau / Compta / Commerciale / Usine (HP-8).
---
## 📦 Tickets Lesstime générés
**TaskGroup Lesstime** : `#22 — M0 — Gestion des catégories` (projet `ERP / Starseed`, projectId=6)
> ⚠️ **Bug typing MCP** : le proxy MCP Lesstime stringifie les paramètres scalaires sans `type: "number"` explicite dans le schéma (`groupId`, `effortId`, `priorityId`). Conséquence : les 9 tickets ont été créés **sans rattachement au groupe**, **sans effort** et **sans priorité**. Toutes les infos sont dans le **titre** (`[N.M / Tag / Effort]`) et le **début de la description**. À rattacher manuellement au groupe #22 dans l'UI Lesstime + à renseigner effort/priorité.
| # | Ticket | Task ID | Number Lesstime | Effort | Tag |
|---|---|---|---|---|---|
| 0.1 | Migrer les tables Category et CategoryType | `#454` | `#43` | S | Backend |
| 0.2 | Créer les entités Category et CategoryType | `#455` | `#44` | M | Backend |
| 0.3 | Implémenter Provider et Processor Category | `#456` | `#45` | M | Backend |
| 0.4 | Exposer le référentiel CategoryType en lecture seule | `#457` | `#46` | S | Backend |
| 0.5 | Déclarer le module Catalog et synchroniser RBAC | `#458` | `#47` | S | Backend |
| 0.6 | Écrire les tests PHPUnit RG-1.01 à RG-1.13 | `#459` | `#48` | M | Backend |
| 0.7 | Créer la page Gestion des catégories (datatable + drawer) | `#460` | `#49` | L | Frontend |
| 0.8 | Implémenter les composables useCategoriesAdmin et useCategoryForm | `#461` | `#50` | M | Frontend |
| 0.9 | Écrire les tests Vitest des composables Catalog | `#462` | `#51` | S | Frontend |
**Total estimé** : ~15-25h (médian ~20h), 9 mini-MR de 1-4h.
### Actions manuelles à faire dans Lesstime (Matthieu)
1. Aller sur le projet **STARSEED** (#6) → TaskGroup **#22 « M0 — Gestion des catégories »**
2. Pour chaque ticket `#43` à `#51` :
- **Rattacher** au TaskGroup #22 (drag & drop ou champ Group)
- **Effort** : lire le titre (`S` / `M` / `L`) et sélectionner dans Lesstime
- **Tag** : lire le titre (`Backend` / `Frontend`) et sélectionner
- **Priorité** : `Moyen` par défaut
3. Vérifier que le statut reste `null` (backlog) — DoR pas encore cochée
4. Mettre à jour `statut_global` ici en `validee_client` (au lieu de `en_dev`) si tu veux que la spec reflète l'état "tickets en backlog, pas encore pris"
+113
View File
@@ -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 2120). |
| **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** : `<MalioDataTable>` (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** : `<MalioInputText>` pour le champ Nom.
- **Select** : `<MalioSelect>` pour le champ Type de catégorie, alimenté par `GET /api/category_types`.
- **Bouton** : `<MalioButton>` (« + 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 (`<MalioDataTable>`), 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).
+3 -2
View File
@@ -5,8 +5,9 @@ APP_SECRET=CHANGE_ME_IN_PRODUCTION
DATABASE_URL="postgresql://starseed:CHANGE_ME@host.docker.internal:5432/starseed_prod?serverVersion=16&charset=utf8"
JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION
JWT_COOKIE_SECURE=1
# HTTP en reseau local => cookie non secure
JWT_COOKIE_SECURE=0
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
CORS_ALLOW_ORIGIN='^https://starseed\.malio-dev\.fr$'
CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
+6
View File
@@ -70,6 +70,7 @@ help:
@printf " \033[36m%-28s\033[0m %s\n" "install-e2e-deps" "One-time : Chromium + libs systeme (sudo)"
@printf "\n \033[1;33mQualite code\033[0m\n"
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-allow-risky" "Fix code style PHP (utilise par le pre-commit)"
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-check" "Dry-run du fixer (CI / verif avant push)"
@printf "\n Plus de details : \033[4mREADME.md\033[0m, \033[4mCLAUDE.md\033[0m\n\n"
env-init:
@@ -258,6 +259,11 @@ php-cs-fixer-allow-risky:
@echo "Fixing files: $(FILES)"
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES)
# Dry-run du fixer : echec si au moins un fichier n'est pas conforme.
# Utilise par la CI (Gitea pull_request) et avant un push manuel.
php-cs-fixer-check:
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff $(FILES)
test:
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)