Compare commits

...

2 Commits

Author SHA1 Message Date
tristan ca9dbe583a feat(catalog) : M7 — migration table storage (FK site/storage_type, unicité métier RG-7.01, états JSONB RG-7.04) (ERP-211) 2026-06-29 15:46:12 +02:00
tristan c9c6d043a7 feat(catalog) : M7 — permissions catalog.storages.* + sidebar + 3 miroirs RBAC (ERP-210)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 50s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 1m5s
2026-06-29 15:24:08 +02:00
6 changed files with 167 additions and 5 deletions
+10
View File
@@ -172,6 +172,16 @@ return [
'module' => 'catalog',
'permission' => 'catalog.categories.view',
],
// Stockage (M7, ERP-210). Admin-only : gate par `catalog.storages.view`
// et son module owner `catalog`. Reutilise le referentiel StorageType
// du M6. Place pres des autres items Catalog (produits, categories).
[
'label' => 'sidebar.catalog.storages',
'to' => '/admin/storages',
'icon' => 'mdi:warehouse',
'module' => 'catalog',
'permission' => 'catalog.storages.view',
],
[
'label' => 'sidebar.core.audit_log',
'to' => '/admin/audit-log',
+2 -1
View File
@@ -53,7 +53,8 @@
},
"catalog": {
"categories": "Gestion des catégories",
"products": "Catalogue produits"
"products": "Catalogue produits",
"storages": "Gestion des stockages"
}
},
"dashboard": {
+10 -4
View File
@@ -35,7 +35,7 @@ export interface Persona {
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
// la copie/i18n change.
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products'>
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products' | 'storages'>
}
const SHARED_PASSWORD = 'e2e-secret'
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
password: SHARED_PASSWORD,
isAdmin: true,
permissions: [],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'storages', 'audit-log'],
},
'user-full': {
key: 'user-full',
@@ -71,6 +71,12 @@ export const personas: Record<PersonaKey, Persona> = {
// `/admin/products` -> ajoute le lien `products` a expectedAdminLinks.
'catalog.products.view',
'catalog.products.manage',
// Stockage (M7, ERP-210). Admin-only : mappe sur le persona "tout",
// pas de nouveau persona (regle ABSOLUE n°7). L'item vit dans la
// section Administration sur la route `/admin/storages` -> ajoute le
// lien `storages` a expectedAdminLinks.
'catalog.storages.view',
'catalog.storages.manage',
// Commercial — Repertoire clients (M1). Mappe ici sur le persona
// "tout" en attendant les vrais roles metier (bureau/compta/
// commerciale/usine) seedes par ERP-74. Pas de nouveau persona
@@ -121,7 +127,7 @@ export const personas: Record<PersonaKey, Persona> = {
'logistique.weighing_tickets.view',
'logistique.weighing_tickets.manage',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'storages', 'audit-log'],
},
'user-readonly': {
key: 'user-readonly',
@@ -166,4 +172,4 @@ export function getPersona(key: PersonaKey): Persona {
return personas[key]
}
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'] as const
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'storages', 'audit-log'] as const
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M7 — Stockage (ERP-211) : creation du schema BDD de la table `storage`.
*
* Un stockage = 1 site + 1 type de stockage (referentiel storage_type du M6) + 1
* numero, avec des etats multi-valeur (JSONB), soft-delete prepare et colonnes
* Timestampable/Blamable (spec-back § 3.2).
*
* Contraintes metier portees ici :
* - RG-7.01 : unicite du couple (site, type, numero) parmi les stockages ACTIFS,
* via l'index UNIQUE partiel uq_storage_site_type_numero_active (WHERE deleted_at
* IS NULL) — un numero peut etre reutilise apres soft-delete.
* - RG-7.04 : au moins un etat, via chk_storage_states_not_empty
* (jsonb_array_length(states) >= 1). Comme pour product.states (M6), PAS de
* DEFAULT '[]'::jsonb : un tableau vide violerait ce CHECK ; la colonne est
* toujours renseignee par l'app (Processor/ORM).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire : la
* table storage porte des FK cross-module (site, storage_type, user). Le tri par
* timestamp au sein du namespace racine garantit l'ordre apres la creation de ces
* tables sur base vide ; un namespace modulaire trierait par FQCN alphabetique et
* casserait `make db-reset` (cf. Version20260625110000 pour le M6).
*
* Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`,
* horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe
* `datetime_immutable`). Chaque colonne porte son `COMMENT ON COLUMN` (regle n°12).
*
* NB schema:update (test-db-setup) : `storage` sera mappee en ORM au ticket suivant
* (entite Storage). D'ici la, `schema:update --force` la drope sur la base de TEST
* uniquement (sans impact : aucun test ne la reference encore, et dev/prod ne lancent
* jamais schema:update). Sa description sera ajoutee a ColumnCommentsCatalog au ticket
* entite (comme product / weighing_ticket).
*/
final class Version20260629120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-211 (M7) : creation de la table storage (FK site + storage_type, unicite metier partielle RG-7.01, etats JSONB RG-7.04, soft-delete + Timestampable/Blamable).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE storage (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
site_id INT NOT NULL,
storage_type_id INT NOT NULL,
numero VARCHAR(50) NOT NULL,
-- Pas de DEFAULT : un tableau vide violerait chk_storage_states_not_empty
-- (RG-7.04). La colonne est toujours renseignee par l'app (Processor/ORM).
states JSONB 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 chk_storage_states_not_empty
CHECK (jsonb_array_length(states) >= 1),
CONSTRAINT fk_storage_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT,
CONSTRAINT fk_storage_storage_type
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_storage_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_storage_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// RG-7.01 : unicite (site, type, numero) parmi les stockages actifs uniquement
// (index partiel) — un numero redevient disponible apres soft-delete.
$this->addSql('CREATE UNIQUE INDEX uq_storage_site_type_numero_active ON storage (site_id, storage_type_id, numero) WHERE deleted_at IS NULL');
$this->addSql('CREATE INDEX idx_storage_site ON storage (site_id)');
$this->addSql('CREATE INDEX idx_storage_storage_type ON storage (storage_type_id)');
$this->addSql('CREATE INDEX idx_storage_deleted_at ON storage (deleted_at)');
$this->addSql('CREATE INDEX idx_storage_created_by ON storage (created_by)');
$this->addSql('CREATE INDEX idx_storage_updated_by ON storage (updated_by)');
$this->comment('storage', '_table', 'Emplacements de stockage (M7 Catalog) un stockage = 1 site + 1 type (storage_type) + 1 numero, etats multi-valeur JSONB, soft-delete + Timestampable/Blamable.');
$this->comment('storage', 'id', 'Identifiant interne auto-incremente.');
$this->comment('storage', 'site_id', 'Site du stockage. FK -> site.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).');
$this->comment('storage', 'storage_type_id', 'Type de stockage (referentiel M6). FK -> storage_type.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).');
$this->comment('storage', 'numero', 'Numero du stockage ( 50), saisi. Unique par (site, type) parmi les actifs (RG-7.01, uq_storage_site_type_numero_active). Normalise serveur.');
$this->comment('storage', 'states', 'Etats du stockage (JSON) : tableau non vide (>= 1 element, RG-7.04, chk_storage_states_not_empty). Multi-valeur.');
$this->comment('storage', 'deleted_at', 'Horodatage du soft-delete technique null = ligne active. Une ligne supprimee sort de l unicite metier (index partiel uq_storage_site_type_numero_active).');
$this->addTimestampableBlamableComments('storage');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE storage');
}
/**
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
* en reutilisant le catalogue partage (source unique, ERP-67).
*/
private function addTimestampableBlamableComments(string $table): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
$this->comment($table, $column, $description);
}
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
+5
View File
@@ -47,6 +47,11 @@ final class CatalogModule
// Item sidebar dans la section Administration, sous « Repertoire transporteurs ».
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
// Stockage (M7, ERP-210) : admin-only. Reutilise le referentiel
// StorageType du M6. Item sidebar dans la section Administration,
// pres des items Catalog (produits, categories).
['code' => 'catalog.storages.view', 'label' => 'Voir les stockages'],
['code' => 'catalog.storages.manage', 'label' => 'Gérer les stockages (créer, éditer)'],
];
}
}
@@ -190,6 +190,10 @@ final class SeedE2ECommand extends Command
// p.3) : mappe sur le persona "tout". Miroir de personas.ts.
'catalog.products.view',
'catalog.products.manage',
// Stockage (M7, ERP-210). Admin-only : mappe sur le persona
// "tout". Miroir de personas.ts.
'catalog.storages.view',
'catalog.storages.manage',
// Commercial — Repertoire clients (M1). Mappe ici sur le
// persona "tout" en attendant les vrais roles metier
// (bureau/compta/commerciale/usine) seedes par ERP-74.