test(catalog) : ERP-203 — tests PHPUnit RG-6.01→6.10 + capture du contrat JSON produit
Couvre les RG produit (M6) en tests fonctionnels API et capture le contrat de serialisation reel (DoD spec-back § 4.0.bis). Tests (tests/Module/Catalog/Api/) : - AbstractProductApiTestCase : base commune (productType find-or-create PRODUIT, productCategory, seedStorageType par sites, validProductPayload, dump DoD). - ProductSerializationContractTest : POST reel + GET liste/detail ; category en objet embarque, sites/storageTypes en tableaux d'objets, states en tableau, manufactured/containsMolasses booleens presents. Dump regenerable via PRODUCT_DOD_DUMP=1. - ProductCodeUniquenessTest (RG-6.01) : 409 doublon actif, collision sur forme normalisee, reutilisation d'un code soft-deleted (index partiel). - ProductStatesValidationTest (RG-6.02) : >=1 etat requis, valeur hors enum 422. - ProductConditionalFieldsTest (RG-6.03) : manufactured/containsMolasses forces false sans SALE (POST et PATCH), conserves avec SALE. - ProductCategoryTypeTest (RG-6.05) : 422 si categorie non-PRODUIT. - ProductStorageTypeBySiteTest (RG-6.06) : 422 si storageType hors sites choisis. - ProductRBACMatrixTest : Admin 200/201 ; Bureau/Compta/Commerciale/Usine 403 (view + manage) ; view lit mais ne gere pas. Fix contrat de serialisation : le getter containsMolasses() ne respectait pas la convention d'accesseur (get/is/has) -> le serialiseur n'exposait PAS le champ dans le JSON, alors que le DoD l'exige. Renomme en isContainsMolasses() (+ unique appelant dans ProductExportController). Defaut capte par le test de contrat. Spec : JSON reel colle dans spec-back § 4.0.bis (remplace l'esquisse). Le contrat reel est plus riche : la liste porte deja sites/storageTypes embarques, category embarque categoryTypes + audit, createdBy/updatedBy en IRI, sites avec adresse. make test vert (897 tests) ; php-cs-fixer conforme.
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
---
|
||||
# === IDENTITÉ ===
|
||||
module: M6
|
||||
nom: "Catalogue produit"
|
||||
ecran: produits
|
||||
owner_spec: Matthieu
|
||||
backup_spec: Tristan
|
||||
version: V0.1
|
||||
date_redaction: 2026-06-24
|
||||
# Historique :
|
||||
# V0.1 (2026-06-24) — Spec back initiale. Restitution + précisions back du docx fonctionnel
|
||||
# « M6-produit-V0 » (V0, 15/06/2026, validation client en attente).
|
||||
# Décisions Matthieu (24/06) :
|
||||
# (1) Produit logé dans le module EXISTANT `Catalog` (pas de nouveau module) ;
|
||||
# item sidebar dans la section « Administration », sous « Répertoire transporteurs ».
|
||||
# (2) « Type de stockage » : référentiel minimal `StorageType` créé maintenant (provisoire,
|
||||
# en attendant la liste définitive d'Aurore), seedé avec la liste Figma (node 1503-34285).
|
||||
# (3) « Code produit » = « Numéro » de la liste : MÊME champ, saisi, UNIQUE global (409 doublon).
|
||||
# (4) « État du produit » : Achat / Vendu / Autre, multi-select, AU MOINS 1 requis
|
||||
# (corrige l'incohérence « Autre » vs « Aucun » du docx).
|
||||
# (5) PÉRIMÈTRE V0 = CRUD produit classique uniquement. Les onglets « Fournisseurs » et
|
||||
# « Clients » sont des PLACEHOLDERS « en cours de développement » (dépendent d'un module
|
||||
# Contrat inexistant) — hors périmètre, tracés HP-M6-01.
|
||||
|
||||
# === LIENS ===
|
||||
spec_front: ./spec-front.md
|
||||
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1503-34285&p=f&m=dev"
|
||||
trace_fonctionnelle: "uploads/M6-produit-V0.pdf (V0, 15/06/2026, validation client en attente)"
|
||||
|
||||
# === LIEN LESSTIME ===
|
||||
lesstime_project_id: 6
|
||||
lesstime_taskgroup_id: 36 # M6 — Catalogue produit (ERP-197 → ERP-207)
|
||||
statut_global: pret_a_dev
|
||||
|
||||
# === DÉPENDANCES AMONT ===
|
||||
depend_de:
|
||||
- Catalog # Module hôte (REQUIRED). Category + CategoryType (ajout du type PRODUIT) + nouveau StorageType + Product
|
||||
- Sites # Site (relation ManyToMany product↔site) + filtrage des types de stockage par site
|
||||
- Core # User, Role, Permission, Audit, JWT
|
||||
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + CategoryInterface
|
||||
---
|
||||
|
||||
# Spec back — Module 6 : Catalogue produit
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx `M6-produit-V0`, V0 du 15/06/2026, **validation client en attente**) avec tout ce qui touche au back : décisions d'archi, modèle de données, migration, API REST, RBAC, règles de gestion (RG-6.01 → RG-6.10), tests, hors-périmètre.
|
||||
|
||||
**Module cible** : module **EXISTANT `Catalog`** (`src/Module/Catalog/`) — DÉCISION Matthieu (24/06). Le docx parle d'un « Module 7 — Catalogue produit » rattaché à « l'Administration », mais le projet possède déjà un module `Catalog` (`ID = 'catalog'`, `REQUIRED = true`) qui porte `Category` / `CategoryType`. « Catalogue produit » y a sa place naturelle : on **n'ajoute pas de module**, on ajoute l'entité `Product` (+ le référentiel `StorageType`) au module `Catalog`. L'item de menu vit dans la section **Administration** de la sidebar, **sous « Répertoire transporteurs »** (cf. § 5.3).
|
||||
|
||||
> **RETEX obligatoire (M1→M5)** : ~80 % des frictions venaient du **contrat de sérialisation** (groupes / sous-ressources / embed), pas du métier. La section § 4.0 applique ce RETEX au M6. On réutilise le pattern Provider/Processor + normalisation serveur + Timestampable/Blamable + audit i18n + soft delete posé aux modules précédents, et la taxonomie `Category` codée (ERP-78).
|
||||
|
||||
**Dépendances déjà en place sur `develop`** :
|
||||
- `Catalog` → `Category` (taxonomie codée, soft delete, `CategoryInterface`) + `CategoryType` (référentiel statique, types CLIENT / FOURNISSEUR / PRESTATAIRE seedés). Le type **`PRODUIT` n'est PAS encore seedé** — le M6 l'ajoute (§ 2.5).
|
||||
- `Sites` → 3 sites Châtellerault (`code` 86) / Saint-Jean (17) / Pommevic (82) ; `Site.code` déjà mappé ; `SiteInterface`.
|
||||
- `Shared` → `TimestampableBlamableTrait` + `Subscriber` (ERP-52).
|
||||
- `Core` → User, Role, Permission, Audit, JWT.
|
||||
|
||||
## 1.bis Remise en question du docx (incohérences relevées + résolutions)
|
||||
|
||||
> Le docx V0 est volontairement léger. Voici les points ambigus ou contradictoires relevés à la relecture, et la décision retenue (validée Matthieu 24/06). **Toute la spec qui suit applique ces décisions.**
|
||||
|
||||
| # | Point du docx | Problème | Décision retenue |
|
||||
|---|---|---|---|
|
||||
| C1 | « Module 7 — Catalogue produit » / « Module Administration » | Le projet n'a pas de « Module 7 » ni de module « Administration » ; un module `Catalog` existe déjà. | **Produit logé dans `Catalog`** ; item sidebar dans la **section Administration**, sous « Répertoire transporteurs » (§ 2.1 / § 5.3). |
|
||||
| C2 | Colonnes liste = `Nom`, `Numéro`, `Catégorie` ; formulaire = `Nom`, `Code produit`, `Catégorie` | « Numéro » (liste) vs « Code produit » (formulaire) : 2 noms pour quoi ? | **Même champ** : `code` (= « Numéro » = « Code produit »), **saisi**, **unique global**, 409 sur doublon (RG-6.01). La colonne liste « Numéro » affiche `code`. |
|
||||
| C3 | « État du produit » : Multi-select **obligatoire**, valeurs *Achat / Vendu / Autre* | Les onglets parlent de *Achat / Vendu / **Aucun*** → « Autre » ≠ « Aucun ». Et « obligatoire » + « Aucun » se contredisent. | **Enum `PURCHASE` / `SALE` / `OTHER`**, multi-select, **≥ 1 obligatoire** (RG-6.02). « Aucun » des onglets = « ni Achat ni Vendu » (donc `OTHER` seul). |
|
||||
| C4 | « Type de stockage » : « *liste fournie par Aurore* en fonction des sites » | Aucun référentiel de stockage n'existe en base ; la vraie liste est en attente. | **Référentiel minimal `StorageType` créé maintenant** (provisoire), seedé avec la liste Figma (node 1503-34285) ; options **filtrées par les sites sélectionnés** (RG-6.06, § 2.4). À re-seeder quand Aurore livre la liste/le mapping site définitifs (HP-M6-02). |
|
||||
| C5 | « Catégorie produit » : « Liste des catégories produit » | Le type `PRODUIT` n'est pas seedé ; aucune catégorie produit n'existe. | Le M6 **seede le `CategoryType` PRODUIT** + quelques `Category` produit, et le select est **filtré `?typeCode=PRODUIT`** (RG-6.05, § 2.5). |
|
||||
| C6 | « Fabriqué » / « Contient de la mélasse » : « apparaît si État = Vendu » | Comportement front only ? Que stocke-t-on si l'état n'est plus Vendu ? | Booléens **conditionnés à `SALE`** : saisis seulement si l'état contient `SALE`, sinon **forcés `false` serveur** (RG-6.03). |
|
||||
| C7 | RBAC : Admin = Tout, tous les autres rôles = Non | Très restrictif (admin-only). Confirmé ? | **Confirmé** : `catalog.products.view` / `.manage` attribués **au seul rôle Admin** (§ 5.2). |
|
||||
| C8 | Onglets « Fournisseurs » / « Clients » (contrats, prestation de triage, contrats TAF) | Référencent une notion de **Contrat** (client/fournisseur) **inexistante** dans le code. | **Hors périmètre V0** : onglets rendus en **placeholder « en cours de développement »** (comme les autres onglets non encore dev). Tracé HP-M6-01 (§ 9). |
|
||||
|
||||
## 2. Décisions d'archi
|
||||
|
||||
### 2.1 Entité `Product` dans le module `Catalog`
|
||||
|
||||
Ajout au module **`Catalog`** (pas de nouveau module — C1) :
|
||||
- Entité racine **`Product`** sous `src/Module/Catalog/Domain/Entity/Product.php`.
|
||||
- Référentiel **`StorageType`** sous `src/Module/Catalog/Domain/Entity/StorageType.php` (§ 2.4).
|
||||
- Permissions `catalog.products.view` / `catalog.products.manage` ajoutées à `CatalogModule::permissions()` (§ 5.1).
|
||||
- Pas de nouveau layer front (le module `catalog` n'a pas de layer dédié — les écrans admin du Catalog vivent dans le shell `frontend/app/` / `frontend/shared/`, comme `/admin/categories`). Route Nuxt : `/admin/products` (cf. spec-front).
|
||||
|
||||
**Référentiels cross-module consommés en relation ORM partagée (PAS d'import de logique)** — comme M2→M5 : `Product` référence `Site` (Sites) via une **relation ORM** (ManyToMany). Donnée de référence partagée, aucun service/repository d'un autre module appelé. `Category` et `StorageType` appartiennent au **même** module `Catalog` → relations internes classiques.
|
||||
|
||||
### 2.2 IDs — convention `INT` (alignée Catalog / Core)
|
||||
|
||||
`Product` et `StorageType` s'alignent sur la convention du module `Catalog` : **`INT GENERATED BY DEFAULT AS IDENTITY`**. Horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le `TimestampableBlamableTrait` mappe `datetime_immutable`).
|
||||
|
||||
### 2.3 État du produit — multi-valeur `states` (C3 / RG-6.02)
|
||||
|
||||
`états` est un **multi-select** : un produit peut être à la fois `PURCHASE` et `SALE`. Modélisation : colonne **`states JSONB NOT NULL DEFAULT '[]'`** (tableau de chaînes), valeurs autorisées `PURCHASE` / `SALE` / `OTHER`, **≥ 1** (Callback + CHECK de non-vacuité).
|
||||
|
||||
> **Alternative écartée** : 3 colonnes booléennes (`is_purchase`/`is_sale`/`is_other`). Plus simple à requêter mais s'éloigne de la sémantique « multi-select » et multiplie les colonnes. Le JSONB est retenu pour la fidélité au champ unique du docx ; si un besoin de filtrage SQL fin apparaît (HP), on bascule sur une table de jonction `product_state`.
|
||||
|
||||
Pilotage des champs conditionnels (RG-6.03) : `manufactured` et `containsMolasses` ne sont **saisissables que si `states` contient `SALE`** ; sinon forcés `false` côté serveur (Processor) — pas de state machine.
|
||||
|
||||
### 2.4 Référentiel `StorageType` (C4 / RG-6.06) — PROVISOIRE
|
||||
|
||||
> **Décision Matthieu (24/06)** : créer un **référentiel minimal** en attendant la liste/mapping définitifs d'Aurore. Seed = liste Figma (node 1503-34285).
|
||||
|
||||
- Entité `StorageType` (`Catalog`) : `id`, `code` (slug MAJUSCULE stable, unique), `label` (FR affiché), relation **`sites` ManyToMany → Site** (sur quels sites ce type de stockage est disponible).
|
||||
- **Seed initial (10 valeurs, Figma)** : Boisseau, Boisseau dosage, Case, Cellule, Container, Cuve mélasse, Stockage big bag, Stockage palette, Tas, Zone. **Provisoirement rattachés aux 3 sites** (86/17/82) tant qu'Aurore n'a pas précisé le mapping réel par site.
|
||||
- Le champ produit « Type de stockage » est un **multi-select filtré par les sites sélectionnés** dans le formulaire : `GET /api/storage_types?siteId[]=…` ne renvoie que les types disponibles sur ces sites (RG-6.06).
|
||||
- **Provisoire** : codes, libellés et mapping site sont à revalider/re-seeder à réception de la liste Aurore (HP-M6-02). Référentiel en **lecture seule** au M6 (pas de CRUD admin du StorageType — HP-M6-03).
|
||||
|
||||
### 2.5 Catégorie produit — type `PRODUIT` (C5 / RG-6.05)
|
||||
|
||||
- Le M6 **seede le `CategoryType` `PRODUIT`** (code `PRODUIT`, label « Produit ») : ajout dans **`CategoryTypeFixtures::TYPES`** ET dans une **migration de seed** (miroir dev/prod, comme CLIENT/FOURNISSEUR/PRESTATAIRE — cf. `CategoryTypeFixtures` docblock).
|
||||
- Le M6 seede aussi quelques **`Category` de type PRODUIT** (ex. provisoires : « Céréales », « Oléagineux », « Aliments du bétail », « Engrais ») pour alimenter le select. Codes auto-générés par `CategoryCodeGenerator` (slug MAJUSCULE stable).
|
||||
- `Product.category` = **ManyToOne `Category`** (obligatoire). Le select du formulaire est **filtré `?typeCode=PRODUIT`** (provider Category existant — filtre `typeCode` déjà supporté). Lecture du référentiel via `catalog.categories.read_ref` ou `.view` (déjà en place).
|
||||
|
||||
> **Garde-fou** : on **ne contraint pas** en base que `category` soit de type PRODUIT (le filtrage est applicatif via le select + une validation `#[Assert\Callback]` côté Processor qui rejette une catégorie non-PRODUIT en 422). Justification : éviter un couplage SQL fragile au référentiel type.
|
||||
|
||||
### 2.6 Audit & traces temporelles
|
||||
|
||||
Pattern Starseed standard (miroir M1→M5) :
|
||||
- `#[Auditable]` sur `Product`. Pas de champ sensible (password/token) → pas d'`#[AuditIgnore]`.
|
||||
- Audit des relations (`category`, `sites`, `storageTypes`) tracé automatiquement (ManyToMany inclus).
|
||||
- `Product implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard).
|
||||
- **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.catalog_product` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)` = `catalog_product`).
|
||||
- `StorageType` = référentiel **statique** en lecture seule → **pas** de Timestampable/Blamable, **pas** `#[Auditable]` (whitelister dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`, miroir `CategoryType`).
|
||||
|
||||
### 2.7 Soft delete préparé ; pas de Delete exposé au M6
|
||||
|
||||
Le docx M6 ne prévoit **ni archivage ni suppression** (actions = + Ajouter / Exporter / Filtrer). On **n'expose pas** de `Delete`. On prépare néanmoins une colonne `deleted_at` (soft delete technique) **non exposée** (cohérent avec `Category` et le pattern M5). Le provider exclut par défaut les produits soft-deleted.
|
||||
|
||||
## 3. Modèle de données
|
||||
|
||||
### 3.1 Diagramme
|
||||
|
||||
```
|
||||
+------------------+ +------------------------+
|
||||
| site (Sites) | | category_type (Catalog)| + seed type PRODUIT (§ 2.5)
|
||||
+------------------+ +------------------------+
|
||||
^ ^ ^
|
||||
| | | (ManyToMany existant)
|
||||
product_ | | storage_type_ |
|
||||
site | | site +------------------+
|
||||
| | | category | (type PRODUIT)
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| product | | storage_type | ^
|
||||
| id (PK) | | id (PK) | | category_id (FK, NOT NULL)
|
||||
| code (UNIQUE) | | code (UNIQUE) |----------+
|
||||
| name | | label | (product.category ManyToOne)
|
||||
| states (JSONB) | +------------------+
|
||||
| manufactured | ^
|
||||
| contains_molasses| | product_storage_type (ManyToMany)
|
||||
| category_id (FK) |--------+
|
||||
| deleted_at |
|
||||
| created_at/by … |
|
||||
+------------------+
|
||||
^ ^
|
||||
| | product_site (ManyToMany) / product_storage_type (ManyToMany)
|
||||
+---+
|
||||
```
|
||||
|
||||
Tables de jonction : `product_site (product_id, site_id)`, `product_storage_type (product_id, storage_type_id)`, `storage_type_site (storage_type_id, site_id)`.
|
||||
|
||||
### 3.2 Migration Doctrine — SQL Postgres (illustratif)
|
||||
|
||||
Namespace : **`DoctrineMigrations` (racine `migrations/`)** — fichier `migrations/VersionYYYYMMDDHHMMSS.php` (postérieur aux migrations existantes).
|
||||
|
||||
> **Même justification qu'aux M1→M5** : FK cross-module (`user`, `site`, `category`) → le namespace modulaire casserait l'ordre sur `make db-reset` (exception racine de la règle ABSOLUE n°11).
|
||||
>
|
||||
> **Rappel règle ABSOLUE n°12** : chaque colonne créée DOIT recevoir son `COMMENT ON COLUMN` (FR, ≤ 200 car., sémantique + contrainte/RG). Les 4 colonnes Timestampable/Blamable passent par `addStandardTimestampableBlamableComments`.
|
||||
|
||||
```sql
|
||||
-- =====================================================================
|
||||
-- Référentiel des types de stockage (provisoire — § 2.4 / RG-6.06)
|
||||
-- =====================================================================
|
||||
CREATE TABLE storage_type (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code);
|
||||
|
||||
CREATE TABLE storage_type_site (
|
||||
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE CASCADE,
|
||||
site_id INT NOT NULL REFERENCES site(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (storage_type_id, site_id)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Table principale `product`
|
||||
-- =====================================================================
|
||||
CREATE TABLE product (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL, -- = « Numéro » liste, unique global (RG-6.01)
|
||||
name VARCHAR(255) NOT NULL,
|
||||
states JSONB NOT NULL DEFAULT '[]'::jsonb, -- PURCHASE|SALE|OTHER, >=1 (RG-6.02)
|
||||
manufactured BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
|
||||
contains_molasses BOOLEAN NOT NULL DEFAULT FALSE, -- saisi si SALE, sinon false (RG-6.03)
|
||||
category_id INT NOT NULL REFERENCES category(id) ON DELETE RESTRICT, -- type PRODUIT (RG-6.05)
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE, -- soft delete, non exposé (§ 2.7)
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
updated_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_product_states_not_empty CHECK (jsonb_array_length(states) >= 1)
|
||||
);
|
||||
-- Unicité GLOBALE du code parmi les actifs (soft delete toléré) — index partiel.
|
||||
CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_product_category ON product (category_id);
|
||||
CREATE INDEX idx_product_deleted_at ON product (deleted_at);
|
||||
CREATE INDEX idx_product_created_by ON product (created_by);
|
||||
CREATE INDEX idx_product_updated_by ON product (updated_by);
|
||||
|
||||
-- =====================================================================
|
||||
-- Jonctions produit ↔ sites / types de stockage
|
||||
-- =====================================================================
|
||||
CREATE TABLE product_site (
|
||||
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||
site_id INT NOT NULL REFERENCES site(id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY (product_id, site_id)
|
||||
);
|
||||
CREATE TABLE product_storage_type (
|
||||
product_id INT NOT NULL REFERENCES product(id) ON DELETE CASCADE,
|
||||
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY (product_id, storage_type_id)
|
||||
);
|
||||
|
||||
-- =====================================================================
|
||||
-- Seed du type de catégorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
|
||||
-- =====================================================================
|
||||
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
```
|
||||
|
||||
### 3.2.bis Commentaires SQL obligatoires (échantillon)
|
||||
|
||||
```php
|
||||
$this->addSql("COMMENT ON TABLE product IS 'Produits du catalogue (M6 Catalog) — état Achat/Vendu/Autre, sites de disponibilité, catégorie produit, types de stockage.'");
|
||||
$this->addSql("COMMENT ON COLUMN product.code IS 'Code produit (= « Numéro » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active.'");
|
||||
$this->addSql("COMMENT ON COLUMN product.states IS 'États du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02). Pilote les champs conditionnels.'");
|
||||
$this->addSql("COMMENT ON COLUMN product.manufactured IS '« Fabriqué » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
|
||||
$this->addSql("COMMENT ON COLUMN product.contains_molasses IS '« Contient de la mélasse » : saisi uniquement si states contient SALE, sinon forcé false serveur (RG-6.03).'");
|
||||
$this->addSql("COMMENT ON COLUMN product.category_id IS 'Catégorie produit (FK category, type PRODUIT) — obligatoire, validée applicativement (RG-6.05).'");
|
||||
$this->addSql("COMMENT ON COLUMN product.deleted_at IS 'Horodatage de suppression logique (soft delete) — non exposé au M6 ; la liste exclut les produits supprimés (§ 2.7).'");
|
||||
$this->addSql("COMMENT ON TABLE storage_type IS 'Référentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Silo, Tas… (RG-6.06).'");
|
||||
$this->addSql("COMMENT ON COLUMN storage_type.code IS 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).'");
|
||||
$this->addSql("COMMENT ON COLUMN storage_type.label IS 'Libellé FR affiché du type de stockage (ex. « Cuve mélasse »).'");
|
||||
// + COMMENT ON COLUMN sur les tables de jonction (product_site, product_storage_type, storage_type_site)
|
||||
$this->addStandardTimestampableBlamableComments($schema, 'product');
|
||||
```
|
||||
|
||||
### 3.3 Entité `Product` — squelette (extrait)
|
||||
|
||||
Pattern jumeau de `Category` (`#[Auditable]`, `TimestampableBlamableTrait`, soft delete). **Chaque propriété affichée porte un read-group** (RETEX M1).
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\ProductProcessor;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\ProductProvider;
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository;
|
||||
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagée (§ 2.1)
|
||||
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 Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
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.products.view')",
|
||||
normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
provider: ProductProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
provider: ProductProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('catalog.products.manage')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
processor: ProductProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('catalog.products.manage')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
provider: ProductProvider::class,
|
||||
processor: ProductProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M6 (docx) ; soft delete préparé non exposé (§ 2.7).
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
|
||||
#[ORM\Table(name: 'product')]
|
||||
#[Auditable]
|
||||
class Product implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
#[Groups(['product:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
/** Code produit (= « Numéro » liste), unique global, saisi (RG-6.01). */
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Assert\NotBlank(message: 'Le code produit est obligatoire.')]
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/** États (multi-select) ⊆ {PURCHASE, SALE, OTHER}, ≥ 1 (RG-6.02). */
|
||||
#[ORM\Column(type: 'json')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
||||
#[Assert\All([new Assert\Choice(choices: ['PURCHASE', 'SALE', 'OTHER'], message: 'État de produit invalide.')])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private array $states = [];
|
||||
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $manufactured = false; // saisi si SALE, sinon false (RG-6.03)
|
||||
|
||||
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $containsMolasses = false; // saisi si SALE, sinon false (RG-6.03)
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||
#[ORM\JoinColumn(name: 'category_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?Category $category = null; // type PRODUIT, validé Callback (RG-6.05)
|
||||
|
||||
/** @var Collection<int, Site> Sites de disponibilité (≥ 1, RG-6.04). */
|
||||
#[ORM\ManyToMany(targetEntity: Site::class)]
|
||||
#[ORM\JoinTable(name: 'product_site')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $sites;
|
||||
|
||||
/** @var Collection<int, StorageType> Types de stockage (≥ 1, filtrés par sites — RG-6.06). */
|
||||
#[ORM\ManyToMany(targetEntity: StorageType::class)]
|
||||
#[ORM\JoinTable(name: 'product_storage_type')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $storageTypes;
|
||||
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $deletedAt = null; // soft delete, non exposé (§ 2.7)
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->storageTypes = new ArrayCollection();
|
||||
}
|
||||
|
||||
// RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT)
|
||||
// + RG-6.06 (types de stockage ⊆ sites) : cohérence via #[Assert\Callback] (§ 7).
|
||||
// ... getters/setters ...
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠ `Site` appartient au module Sites — on consomme son read-group (`site:read`), **pas de logique inter-module** (§ 2.1). `Category` / `StorageType` sont dans le **même** module `Catalog`.
|
||||
|
||||
## 4. API REST (API Platform)
|
||||
|
||||
### 4.0 Contrat de sérialisation (RETEX M1 — section critique)
|
||||
|
||||
> **Leçon M1→M5** : pour **chaque champ affiché** (liste OU détail), les **3 maillons** : (a) groupe sur la propriété, (b) groupe dans le `normalizationContext` de l'opération, (c) read-group de l'entité imbriquée présent dans le contexte parent.
|
||||
|
||||
**Contexte par opération** :
|
||||
|
||||
| Opération | `normalizationContext` (groupes) |
|
||||
|---|---|
|
||||
| `GetCollection` (liste) | `product:read` + `category:read` + `site:read` + `storage_type:read` + `default:read` |
|
||||
| `Get` / `Post` / `Patch` (détail) | + `product:item:read` |
|
||||
|
||||
**LISTE — colonne datatable → maillons** (docx p.3 : Nom, Numéro, Catégorie) :
|
||||
|
||||
| Colonne affichée | Propriété (a) | Dans contexte liste (b) | Imbriqué (c) |
|
||||
|---|---|---|---|
|
||||
| Nom | `name` ∈ `product:read` | ✅ | — |
|
||||
| Numéro | `code` ∈ `product:read` | ✅ | — |
|
||||
| Catégorie | `category` ∈ `product:read` (embed) | ✅ | `category:read` ✅ (affiche `category.name`) |
|
||||
|
||||
**DÉTAIL — maillons** : `states`, `manufactured`, `containsMolasses` ∈ `product:read` ; `sites` (embed `site:read`) + `storageTypes` (embed `storage_type:read`) ∈ `product:read` (ensembles **bornés** → embed autorisé, ne viole pas la règle n°13). Rien de spécifique en `product:item:read` au-delà des relations (tout le produit tient en liste) — `product:item:read` réservé si on ajoute des champs détail-only ultérieurement.
|
||||
|
||||
### 4.0.bis Réponse JSON de référence (DoD — CAPTURÉ sur l'API réelle, ERP-203)
|
||||
|
||||
> **Definition of Done** (miroir M2→M5) : créer un produit via `POST /api/products`, appeler `GET /api/products` (liste) ET `GET /api/products/{id}` (détail), **coller la réponse JSON réelle** ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. Pièges re-testés : `category` en **objet embarqué** (pas IRI nu) ; `sites` / `storageTypes` en **tableaux d'objets** (pas tableaux d'IRI) ; `states` en tableau de chaînes ; `manufactured` / `containsMolasses` présents (booléens). `skip_null_values` actif → ne pas présumer la présence des champs null.
|
||||
>
|
||||
> **Capture réelle** (ERP-203) : produit créé par un `POST` réel puis relu, via `ProductSerializationContractTest` (régénérable : `PRODUCT_DOD_DUMP=1` → `/tmp/product-dod-{list,detail}.json`). Valeurs ci-dessous reformatées avec des libellés lisibles ; **les clés sont celles de la réponse réelle**. Écarts notables vs l'esquisse initiale, à connaître côté front :
|
||||
> - La **LISTE porte déjà `sites` + `storageTypes` embarqués** (la propriété `product:read` est dans le contexte liste ET détail) : pas besoin d'un appel détail pour les obtenir.
|
||||
> - `category` embarque **sa collection `categoryTypes`** (utile pour vérifier le type PRODUIT côté front, RG-6.05) **plus ses métadonnées d'audit** (`createdAt`/`updatedAt`/`createdBy`/`updatedBy`).
|
||||
> - `createdBy` / `updatedBy` (produit et catégorie) sortent en **IRI** (`/api/me` pour l'utilisateur courant), pas en objet User embarqué.
|
||||
> - chaque `site` embarque l'**adresse complète** (`street`, `postalCode`, `city`, `color`, `fullAddress` — groupe `site:read`).
|
||||
> - un `StorageType` n'expose que `id` / `code` / `label` (sa relation `sites` n'est pas sérialisée — § 2.4).
|
||||
|
||||
**`GET /api/products` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`) :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/Product",
|
||||
"@id": "/api/products",
|
||||
"@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/products/34",
|
||||
"@type": "Product",
|
||||
"id": 34,
|
||||
"code": "BLE-TENDRE-01",
|
||||
"name": "Blé tendre",
|
||||
"states": ["PURCHASE", "SALE"],
|
||||
"manufactured": true,
|
||||
"containsMolasses": true,
|
||||
"category": {
|
||||
"@id": "/api/categories/12",
|
||||
"@type": "Category",
|
||||
"id": 12,
|
||||
"name": "Céréales",
|
||||
"code": "CEREALES",
|
||||
"categoryTypes": [
|
||||
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:27+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:27+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
},
|
||||
"sites": [
|
||||
{
|
||||
"@id": "/api/sites/1",
|
||||
"@type": "Site",
|
||||
"id": 1,
|
||||
"name": "Chatellerault",
|
||||
"code": "86",
|
||||
"street": "14 All. d'Argenson",
|
||||
"postalCode": "86100",
|
||||
"city": "Châtellerault",
|
||||
"color": "#056CF2",
|
||||
"createdAt": "2026-06-25T11:32:33+02:00",
|
||||
"updatedAt": "2026-06-25T11:32:33+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
}
|
||||
],
|
||||
"storageTypes": [
|
||||
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:28+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:28+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
}
|
||||
],
|
||||
"view": { "@id": "/api/products?search=BLE-TENDRE-01", "@type": "PartialCollectionView" }
|
||||
}
|
||||
```
|
||||
|
||||
**`GET /api/products/34` (DÉTAIL)** — **même structure** que la ligne de liste (les `sites` / `storageTypes` sont déjà embarqués en liste ; `product:item:read` est réservé à d'éventuels champs détail-only ultérieurs) :
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"@context": "/api/contexts/Product",
|
||||
"@id": "/api/products/34",
|
||||
"@type": "Product",
|
||||
"id": 34,
|
||||
"code": "BLE-TENDRE-01",
|
||||
"name": "Blé tendre",
|
||||
"states": ["PURCHASE", "SALE"],
|
||||
"manufactured": true,
|
||||
"containsMolasses": true,
|
||||
"category": {
|
||||
"@id": "/api/categories/12",
|
||||
"@type": "Category",
|
||||
"id": 12,
|
||||
"name": "Céréales",
|
||||
"code": "CEREALES",
|
||||
"categoryTypes": [
|
||||
{ "@id": "/api/category_types/5", "@type": "CategoryType", "id": 5, "code": "PRODUIT", "label": "Produit" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:27+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:27+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
},
|
||||
"sites": [
|
||||
{
|
||||
"@id": "/api/sites/1",
|
||||
"@type": "Site",
|
||||
"id": 1,
|
||||
"name": "Chatellerault",
|
||||
"code": "86",
|
||||
"street": "14 All. d'Argenson",
|
||||
"postalCode": "86100",
|
||||
"city": "Châtellerault",
|
||||
"color": "#056CF2",
|
||||
"createdAt": "2026-06-25T11:32:33+02:00",
|
||||
"updatedAt": "2026-06-25T11:32:33+02:00",
|
||||
"fullAddress": "14 All. d'Argenson\n86100 Châtellerault"
|
||||
}
|
||||
],
|
||||
"storageTypes": [
|
||||
{ "@id": "/api/storage_types/9", "@type": "StorageType", "id": 9, "code": "TAS", "label": "Tas" }
|
||||
],
|
||||
"createdAt": "2026-06-25T12:09:28+02:00",
|
||||
"updatedAt": "2026-06-25T12:09:28+02:00",
|
||||
"createdBy": "/api/me",
|
||||
"updatedBy": "/api/me"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 Query params (LISTE)
|
||||
|
||||
| Param | Effet |
|
||||
|---|---|
|
||||
| `?page` / `?itemsPerPage` | pagination standard (10 / 25 / 50, défaut 10) |
|
||||
| `?search=` | recherche sur `code` et `name` |
|
||||
| `?categoryId=` ou `?categoryCode=` | filtre par catégorie (drawer « Filtrer », docx p.3) |
|
||||
| `?state=` | filtre par état (PURCHASE / SALE / OTHER) — drawer « Filtrer » |
|
||||
| `?siteId[]=` | filtre par site de disponibilité |
|
||||
| `?order[name]=asc` | tri (défaut : `name ASC`) |
|
||||
|
||||
Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\Doctrine\Orm\Paginator`, jamais d'array brut.
|
||||
|
||||
### 4.2 Référentiel `StorageType` — `GET /api/storage_types`
|
||||
|
||||
- Opérations : `GetCollection` + `Get` (lecture seule au M6 — § 2.4).
|
||||
- Sécurité : `is_granted('catalog.products.view')` (référentiel servant le formulaire produit). *(Si un autre rôle doit lire ce référentiel sans accès produit, ajouter une `read_ref` dédiée — non requis au M6 vu le RBAC admin-only.)*
|
||||
- **`?siteId[]=…`** : filtre les types disponibles sur les sites passés (alimente le multi-select « Type de stockage » filtré par les sites cochés — RG-6.06).
|
||||
- **`?pagination=false`** : échappatoire select (référentiel borné ≤ quelques dizaines) — règle frontend.
|
||||
- `normalizationContext: ['storage_type:read']` ; tri `label ASC`.
|
||||
|
||||
### 4.3 `POST /api/products` (création)
|
||||
|
||||
- Le client envoie : `code`, `name`, `states[]`, `manufactured`, `containsMolasses`, `category` (IRI), `sites[]` (IRI), `storageTypes[]` (IRI).
|
||||
- Le **Processor** (`ProductProcessor`) :
|
||||
1. Normalise `code` (trim + UPPER) et `name` (trim) — RG-6.07.
|
||||
2. Valide l'**unicité globale** du `code` parmi les actifs → **409** sur doublon (RG-6.01).
|
||||
3. Force `manufactured` / `containsMolasses` à `false` si `states` ne contient pas `SALE` (RG-6.03).
|
||||
4. Valide que `category` est de type **PRODUIT** (RG-6.05) et que `storageTypes ⊆` types disponibles sur les `sites` choisis (RG-6.06) → 422 sinon.
|
||||
- Réponse `201` avec le produit complet.
|
||||
|
||||
### 4.4 `PATCH /api/products/{id}` (modification)
|
||||
|
||||
- Mise à jour partielle, mêmes règles. Le **mode strict PATCH** s'applique (RETEX M1) : un champ hors-permission dans le payload = 403 global (ici un seul niveau `manage`, donc surface réduite).
|
||||
- Re-validation unicité `code` (en excluant le produit courant). Re-force des conditionnels (RG-6.03).
|
||||
|
||||
### 4.5 Export — `GET /api/products/export.xlsx`
|
||||
|
||||
- Exporte **toute la liste** des produits (docx : bouton « Exporter » → « Exporte toute la liste des produits »), filtres actifs appliqués.
|
||||
- Colonnes : Numéro (`code`), Nom, États (Achat/Vendu/Autre joints), Catégorie, Sites, Types de stockage, Fabriqué, Contient mélasse.
|
||||
- Génération via le helper XLSX standard projet (skill `xlsx`) — controller dédié (miroir `ClientExportController`) OU provider binaire ; **whitelisté pagination** (`EXCLUDED`) car export complet.
|
||||
|
||||
## 5. RBAC, module & sidebar
|
||||
|
||||
### 5.1 `CatalogModule::permissions()` — ajout
|
||||
|
||||
```php
|
||||
// Ajouts M6 (à insérer dans CatalogModule::permissions()) :
|
||||
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
|
||||
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
|
||||
```
|
||||
Synchronisation : `app:sync-permissions`.
|
||||
|
||||
### 5.2 Matrice rôle → permissions (docx p.3 — admin-only, C7)
|
||||
|
||||
| Rôle | `…products.view` | `…products.manage` |
|
||||
|---|:--:|:--:|
|
||||
| **Admin** | ✅ | ✅ |
|
||||
| **Bureau** | ❌ | ❌ |
|
||||
| **Compta** | ❌ | ❌ |
|
||||
| **Commerciale** | ❌ | ❌ |
|
||||
| **Usine** | ❌ | ❌ |
|
||||
|
||||
> Très restrictif : le Catalogue produit est **admin-only** (docx). Item sidebar masqué pour tous les autres rôles. (Si plus tard Bureau doit consulter, ajouter `catalog.products.view` à son rôle dans les 3 miroirs.)
|
||||
|
||||
### 5.3 Sidebar (`config/sidebar.php`)
|
||||
|
||||
Nouvel item dans la **section « Administration » existante**, placé **juste sous « Répertoire transporteurs »** (`/carriers`) — DÉCISION Matthieu (24/06) :
|
||||
|
||||
```php
|
||||
[
|
||||
'label' => 'sidebar.catalog.products',
|
||||
'to' => '/admin/products',
|
||||
'icon' => 'mdi:package-variant-closed',
|
||||
'module' => 'catalog',
|
||||
'permission' => 'catalog.products.view',
|
||||
],
|
||||
```
|
||||
|
||||
### 5.4 Règle ABSOLUE n°8 — 3 miroirs RBAC
|
||||
|
||||
Toute permission `catalog.products.*` doit être posée **simultanément** dans :
|
||||
1. `config/sidebar.php` (item + permission ci-dessus),
|
||||
2. `frontend/tests/e2e/_fixtures/personas.ts` (le persona **Admin** gagne `catalog.products.view/manage` + `expectedAdminLinks` ; les personas métier **ne** gagnent **rien**),
|
||||
3. `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` (miroir back du même persona Admin).
|
||||
|
||||
## 6. Normalisation serveur (RG-6.07)
|
||||
|
||||
`ProductFieldNormalizer` (miroir `CategoryProcessor` / `CarrierFieldNormalizer`), appelé par le Processor avant validation :
|
||||
- `code` → trim + UPPER (cohérent avec la stratégie de codes stables du Catalog).
|
||||
- `name` → trim (rejet 422 si vide après trim — RG-6.01/6.02 sur le name de Category, même garde-fou).
|
||||
|
||||
## 7. Règles de gestion (RG)
|
||||
|
||||
| RG | Source | Énoncé |
|
||||
|---|---|---|
|
||||
| **RG-6.01** | docx+back | `code` produit (= « Numéro » liste) obligatoire, **unique global** parmi les actifs, normalisé (trim/UPPER), **409** sur doublon. |
|
||||
| **RG-6.02** | docx+back | `states` = multi-select ⊆ {`PURCHASE`,`SALE`,`OTHER`}, **≥ 1** obligatoire (CHECK non-vide + `Assert\Count(min:1)`). |
|
||||
| **RG-6.03** | docx+back | « Fabriqué » et « Contient de la mélasse » saisissables **uniquement si `states` contient `SALE`** ; sinon forcés `false` serveur. |
|
||||
| **RG-6.04** | docx | `sites` (multi-select) obligatoire, **≥ 1** site. |
|
||||
| **RG-6.05** | docx+back | `category` obligatoire, limitée aux catégories de **type PRODUIT** (select filtré `?typeCode=PRODUIT` + validation Callback 422). |
|
||||
| **RG-6.06** | docx+back | `storageTypes` (multi-select) obligatoire, **≥ 1**, options **filtrées par les sites sélectionnés** ; référentiel `StorageType` **provisoire** (en attente Aurore). |
|
||||
| **RG-6.07** | back | Normalisation serveur : `code` trim+UPPER, `name` trim (§ 6). |
|
||||
| **RG-6.08** | back | « Modification » = même formulaire/mêmes règles que « Ajouter » ; bouton « Valider » → « Enregistrer » (docx p.7). Code & contraintes inchangés. |
|
||||
| **RG-6.09** | back | Liste : exclut par défaut les produits soft-deleted ; pas de Delete exposé (§ 2.7). |
|
||||
| **RG-6.10** | back | Onglets « Fournisseurs » / « Clients » = **hors périmètre V0** (placeholder), dépendent du module Contrat inexistant (HP-M6-01). |
|
||||
|
||||
Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres (non-vacuité `states`).
|
||||
|
||||
## 8. Tests (PHPUnit) — `make test`
|
||||
|
||||
- **`ProductSerializationContractTest`** : capture JSON liste + détail (DoD § 4.0.bis) ; `category`/`sites`/`storageTypes` embarqués (objets, pas IRI) ; `states` tableau ; booléens présents.
|
||||
- **`ProductCodeUniquenessTest`** : 409 sur doublon de `code` (actifs) ; réutilisation possible d'un code soft-deleted (index partiel).
|
||||
- **`ProductStatesValidationTest`** : ≥ 1 état (RG-6.02) ; valeurs hors enum rejetées.
|
||||
- **`ProductConditionalFieldsTest`** : `manufactured`/`containsMolasses` forcés `false` si pas `SALE` (RG-6.03).
|
||||
- **`ProductCategoryTypeTest`** : 422 si `category` n'est pas de type PRODUIT (RG-6.05).
|
||||
- **`ProductStorageTypeBySiteTest`** : 422 si un `storageType` n'est pas disponible sur les `sites` choisis (RG-6.06).
|
||||
- **RBAC** : Admin OK ; Bureau/Compta/Commerciale/Usine → 403 (view + manage).
|
||||
- **Architecture** (déjà en place, ne pas casser) : `ColumnsHaveSqlCommentTest`, `EntitiesAreTimestampableBlamableTest` (whitelister `StorageType`), `AuditableEntitiesHaveI18nLabelTest` (`catalog_product`), `CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
|
||||
|
||||
## 9. Hors périmètre (HP)
|
||||
|
||||
| Réf | Sujet |
|
||||
|---|---|
|
||||
| **HP-M6-01** | **Onglets « Fournisseurs » et « Clients »** du produit (liaison à des **contrats** client/fournisseur, « clients en prestation de triage », « contrats TAF »). Dépend d'un **module Contrat inexistant**. Rendus en **placeholder « en cours de développement »** au M6 (§ 1.bis C8, RG-6.10). À spécifier quand le module Contrat existera. |
|
||||
| **HP-M6-02** | Liste/mapping **définitifs des types de stockage par site** (fournis par Aurore). Re-seed du référentiel `StorageType` + révision du filtrage par site (§ 2.4). |
|
||||
| **HP-M6-03** | **CRUD admin du référentiel `StorageType`** (création/édition par un admin). Au M6 : lecture seule + seed. |
|
||||
| **HP-M6-04** | Archivage / suppression d'un produit (non prévu au docx — soft delete préparé mais non exposé, § 2.7). |
|
||||
| **HP-M6-05** | Contrainte SQL « `category` de type PRODUIT » au niveau base (au M6 : validation applicative seulement, § 2.5). |
|
||||
|
||||
## 10. Tickets Lesstime (à découper — back en tête)
|
||||
|
||||
| Ordre | Sujet | Tag |
|
||||
|---|---|---|
|
||||
| 0 | Permissions `catalog.products.view/manage` + sidebar (item sous Transporteurs) + 3 miroirs RBAC | Backend |
|
||||
| 1 | Migration : `storage_type` (+ jonction site) + `product` (+ jonctions) + seed type PRODUIT + COMMENT | Backend |
|
||||
| 2 | Entités `Product` + `StorageType` + Repositories + contrat sérialisation | Backend |
|
||||
| 3 | `ProductProvider` + `ProductProcessor` (unicité code, RG-6.03/6.05/6.06, normalisation) | Backend |
|
||||
| 4 | Référentiel `StorageType` exposé (`GetCollection` + filtre `?siteId[]`) + seed Figma + catégories PRODUIT | Backend |
|
||||
| 5 | Export XLSX | Backend |
|
||||
| 6 | Tests PHPUnit RG-6.01→6.10 + capture contrat JSON | Backend |
|
||||
| 7 | Page liste `/admin/products` (usePaginatedList) + drawer filtre + export | Frontend |
|
||||
| 8 | Écran Ajouter (champs conditionnels SALE, selects filtrés catégorie/stockage) | Frontend |
|
||||
| 9 | Écran Modification (« Enregistrer ») + onglets **placeholder « en cours de dev »** (Fournisseurs / Clients) | Frontend |
|
||||
| 10 | i18n + libellé audit (`catalog_product`) | Frontend |
|
||||
|
||||
## 📦 Tickets Lesstime générés
|
||||
|
||||
**TaskGroup Lesstime** : **#36 — M6 — Catalogue produit** (projet `ERP / Starseed`, projectId=6) — créé le 24/06/2026, 11 tickets au statut « Prêt à dev ». Back = **Matthieu**, Front = **Tristan**. Chaque ticket porte son prompt d'implémentation `.md` en pièce jointe (dossier `prompts/`).
|
||||
|
||||
| # | ERP | Ticket | Effort | Tag | Assigné |
|
||||
|---|---|---|---|---|---|
|
||||
| 1.1 | ERP-197 | Permissions catalog.products.* + sidebar + 3 miroirs RBAC | S | Backend | Matthieu |
|
||||
| 1.2 | ERP-198 | Migrer le schéma M6 (storage_type, product, jonctions, type PRODUIT) | M | Backend | Matthieu |
|
||||
| 1.3 | ERP-199 | Entités Product + StorageType + repositories + contrat sérialisation | M | Backend | Matthieu |
|
||||
| 1.4 | ERP-200 | ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06) | L | Backend | Matthieu |
|
||||
| 1.5 | ERP-201 | Exposer le référentiel StorageType + seed Figma + catégories PRODUIT | M | Backend | Matthieu |
|
||||
| 1.6 | ERP-202 | Export XLSX des produits | S | Backend | Matthieu |
|
||||
| 1.7 | ERP-203 | Tests PHPUnit RG-6.01→6.10 + capture du contrat JSON | M | Backend | Matthieu |
|
||||
| 1.8 | ERP-204 | Page liste /admin/products (datatable, filtre, export) | M | Frontend | Tristan |
|
||||
| 1.9 | ERP-205 | Écran Ajouter un produit (champs conditionnels, selects filtrés) | L | Frontend | Tristan |
|
||||
| 1.10 | ERP-206 | Écran Modification + onglets placeholder (Fournisseurs/Clients) | M | Frontend | Tristan |
|
||||
| 1.11 | ERP-207 | i18n + libellé audit catalog_product | S | Frontend | Tristan |
|
||||
Reference in New Issue
Block a user