From 4207a4ae12142e911a6845c554862713fb1eea35 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Thu, 25 Jun 2026 12:50:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(catalog)=20:=20M6=20=E2=80=94=20Catalogue?= =?UTF-8?q?=20produits=20(ERP-197=20=E2=86=92=20ERP-203)=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module **M6 — Catalogue produits** (ERP-197 → ERP-203), pile consolidée en une seule MR vers `develop` pour un CI unique. Contenu (commits) : - ERP-197 — permissions `catalog.products.*` + sidebar + 3 miroirs RBAC - ERP-198 — migration schéma M6 (storage_type, product, jonctions, type PRODUIT) - ERP-199 — entités Product + StorageType + repositories + contrat de sérialisation - ERP-200 — ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06, normalisation) - ERP-201 — référentiel StorageType exposé (filtre site) + seed Figma + catégories PRODUIT - ERP-202 — export XLSX du catalogue produits (filtres liste) - ERP-203 — tests PHPUnit RG-6.01→6.10 + capture du contrat JSON produit - fix review M6 — default jsonb mort (states) + constante préfixe storage-type de test Remplace et clôt les MR #148, #149, #150, #151, #152, #153 (commits intégralement inclus ici). --------- Co-authored-by: admin malio Co-authored-by: Matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/154 --- config/sidebar.php | 10 + docs/specs/M6-produit/spec-back.md | 692 ++++++++++++++++++ frontend/i18n/locales/fr.json | 4 +- frontend/tests/e2e/_fixtures/personas.ts | 14 +- makefile | 1 + migrations/Version20260625110000.php | 265 +++++++ .../Service/ProductFieldNormalizer.php | 55 ++ src/Module/Catalog/CatalogModule.php | 4 + src/Module/Catalog/Domain/Entity/Product.php | 438 +++++++++++ .../Catalog/Domain/Entity/StorageType.php | 148 ++++ .../Repository/ProductRepositoryInterface.php | 45 ++ .../StorageTypeRepositoryInterface.php | 31 + .../State/Processor/ProductProcessor.php | 124 ++++ .../State/Provider/ProductProvider.php | 185 +++++ .../State/Provider/StorageTypeProvider.php | 94 +++ .../Controller/ProductExportController.php | 261 +++++++ .../DataFixtures/CategoryFixtures.php | 14 +- .../DataFixtures/CategoryTypeFixtures.php | 9 +- .../DataFixtures/StorageTypeFixtures.php | 115 +++ .../Doctrine/DoctrineProductRepository.php | 150 ++++ .../DoctrineStorageTypeRepository.php | 64 ++ .../Infrastructure/Console/SeedE2ECommand.php | 4 + .../Database/ColumnCommentsCatalog.php | 41 ++ .../EntitiesAreTimestampableBlamableTest.php | 6 + .../Api/AbstractProductApiTestCase.php | 276 +++++++ .../Catalog/Api/ProductCategoryTypeTest.php | 45 ++ .../Catalog/Api/ProductCodeUniquenessTest.php | 82 +++ .../Api/ProductConditionalFieldsTest.php | 79 ++ .../Api/ProductExportControllerTest.php | 291 ++++++++ .../Catalog/Api/ProductRBACMatrixTest.php | 93 +++ .../Api/ProductSerializationContractTest.php | 116 +++ .../Api/ProductStatesValidationTest.php | 56 ++ .../Api/ProductStorageTypeBySiteTest.php | 56 ++ 33 files changed, 3859 insertions(+), 9 deletions(-) create mode 100644 docs/specs/M6-produit/spec-back.md create mode 100644 migrations/Version20260625110000.php create mode 100644 src/Module/Catalog/Application/Service/ProductFieldNormalizer.php create mode 100644 src/Module/Catalog/Domain/Entity/Product.php create mode 100644 src/Module/Catalog/Domain/Entity/StorageType.php create mode 100644 src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php create mode 100644 src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/ProductProcessor.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/ProductProvider.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php create mode 100644 src/Module/Catalog/Infrastructure/Controller/ProductExportController.php create mode 100644 src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php create mode 100644 src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php create mode 100644 src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php create mode 100644 tests/Module/Catalog/Api/AbstractProductApiTestCase.php create mode 100644 tests/Module/Catalog/Api/ProductCategoryTypeTest.php create mode 100644 tests/Module/Catalog/Api/ProductCodeUniquenessTest.php create mode 100644 tests/Module/Catalog/Api/ProductConditionalFieldsTest.php create mode 100644 tests/Module/Catalog/Api/ProductExportControllerTest.php create mode 100644 tests/Module/Catalog/Api/ProductRBACMatrixTest.php create mode 100644 tests/Module/Catalog/Api/ProductSerializationContractTest.php create mode 100644 tests/Module/Catalog/Api/ProductStatesValidationTest.php create mode 100644 tests/Module/Catalog/Api/ProductStorageTypeBySiteTest.php diff --git a/config/sidebar.php b/config/sidebar.php index 5f5d3a0..fd45eda 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -134,6 +134,16 @@ return [ 'module' => 'transport', 'permission' => 'transport.carriers.view', ], + // Catalogue produit (M6, ERP-197). Place juste sous le repertoire + // transporteurs (DECISION Matthieu 24/06). Admin-only : gate par + // `catalog.products.view` et son module owner `catalog`. + [ + 'label' => 'sidebar.catalog.products', + 'to' => '/admin/products', + 'icon' => 'mdi:package-variant-closed', + 'module' => 'catalog', + 'permission' => 'catalog.products.view', + ], [ 'label' => 'sidebar.core.roles', 'to' => '/admin/roles', diff --git a/docs/specs/M6-produit/spec-back.md b/docs/specs/M6-produit/spec-back.md new file mode 100644 index 0000000..15d8598 --- /dev/null +++ b/docs/specs/M6-produit/spec-back.md @@ -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 + ['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 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 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 | diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index d0ac263..6425406 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -52,7 +52,8 @@ "admin": "Sites" }, "catalog": { - "categories": "Gestion des catégories" + "categories": "Gestion des catégories", + "products": "Catalogue produit" } }, "dashboard": { @@ -816,6 +817,7 @@ "core_permission": "Permission", "sites_site": "Site", "catalog_category": "Catégorie", + "catalog_product": "Produit", "commercial_client": "Client", "commercial_clientaddress": "Adresse client", "commercial_clientcontact": "Contact client", diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index 3d7ace5..6596efa 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -35,7 +35,7 @@ export interface Persona { // sidebar-visibility pour driver la matrice. Les valeurs correspondent // aux slugs de route (`/admin/`), volontairement stables quand // la copie/i18n change. - expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'> + expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products'> } const SHARED_PASSWORD = 'e2e-secret' @@ -47,7 +47,7 @@ export const personas: Record = { password: SHARED_PASSWORD, isAdmin: true, permissions: [], - expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], + expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'], }, 'user-full': { key: 'user-full', @@ -65,6 +65,12 @@ export const personas: Record = { 'sites.bypass_scope', 'catalog.categories.view', 'catalog.categories.manage', + // Catalogue produit (M6, ERP-197). Admin-only (matrice docx p.3) : + // mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE + // n°7). L'item vit dans la section Administration sur la route + // `/admin/products` -> ajoute le lien `products` a expectedAdminLinks. + 'catalog.products.view', + 'catalog.products.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 @@ -110,7 +116,7 @@ export const personas: Record = { 'logistique.weighing_tickets.view', 'logistique.weighing_tickets.manage', ], - expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], + expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'], }, 'user-readonly': { key: 'user-readonly', @@ -155,4 +161,4 @@ export function getPersona(key: PersonaKey): Persona { return personas[key] } -export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const +export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'] as const diff --git a/makefile b/makefile index e6e43f1..67d070e 100644 --- a/makefile +++ b/makefile @@ -233,6 +233,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_product_code_active ON product (code) WHERE deleted_at IS NULL" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/migrations/Version20260625110000.php b/migrations/Version20260625110000.php new file mode 100644 index 0000000..ff769aa --- /dev/null +++ b/migrations/Version20260625110000.php @@ -0,0 +1,265 @@ + site (sur quels sites un type + * de stockage est disponible — alimente le filtrage du multi-select par site). + * - product : table principale (code unique global parmi les actifs, etats + * multi-valeur JSONB, champs conditionnels SALE, categorie de type PRODUIT, + * soft-delete prepare + Timestampable/Blamable). + * - product_site : jonction M2M product <-> site (sites de disponibilite, RG-6.04). + * - product_storage_type : jonction M2M product <-> storage_type (RG-6.06). + * + * Seed : ajout du `category_type` PRODUIT (miroir CategoryTypeFixtures, comme + * CLIENT/FOURNISSEUR/PRESTATAIRE/ADRESSE — § 2.5). Les `Category` de type PRODUIT et + * le seed Figma du referentiel storage_type suivent au ticket ERP-201. + * + * Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire : + * la table product porte des FK cross-module (user, site, category). Le tri par + * timestamp au sein du namespace racine garantit l'ordre apres la creation de ces + * tables sur base vide ; un namespace modulaire casserait `make db-reset` (cf. + * Version20260617150000 pour le M5). + * + * 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) : product / storage_type et leurs jonctions seront + * mappes en ORM au ticket suivant (entites Product + StorageType, ERP-199). D'ici la, + * `schema:update --force` les drope sur la base de TEST uniquement (sans impact : + * aucun test ne les reference encore, et dev/prod ne lancent jamais schema:update). + * Leurs descriptions seront ajoutees a ColumnCommentsCatalog au ticket entites (comme + * weighing_ticket : migration ERP-182, catalogue ERP-183). + */ +final class Version20260625110000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'ERP-198 (M6) : storage_type (+ jonction site) + product (+ jonctions site/stockage) + seed category_type PRODUIT.'; + } + + public function up(Schema $schema): void + { + $this->createStorageType(); + $this->createStorageTypeSite(); + $this->createProduct(); + $this->createProductSite(); + $this->createProductStorageType(); + $this->seedCategoryTypeProduit(); + } + + public function down(Schema $schema): void + { + // Ordre inverse des dependances FK. + $this->addSql('DROP TABLE IF EXISTS product_storage_type'); + $this->addSql('DROP TABLE IF EXISTS product_site'); + $this->addSql('DROP TABLE IF EXISTS product'); + $this->addSql('DROP TABLE IF EXISTS storage_type_site'); + $this->addSql('DROP TABLE IF EXISTS storage_type'); + // Retrait du type seede (best-effort : echoue si des categories le referencent + // encore — attendu, le down sert au dev sur base saine). + $this->addSql("DELETE FROM category_type WHERE code = 'PRODUIT'"); + } + + // ================================================================= + // Referentiel des types de stockage (PROVISOIRE) — § 2.4 / RG-6.06 + // ================================================================= + + private function createStorageType(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE storage_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_storage_type_code ON storage_type (code)'); + + $this->comment('storage_type', '_table', 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.'); + $this->comment('storage_type', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('storage_type', 'code', 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).'); + $this->comment('storage_type', 'label', 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).'); + } + + private function createStorageTypeSite(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE storage_type_site ( + storage_type_id INT NOT NULL, + site_id INT NOT NULL, + PRIMARY KEY (storage_type_id, site_id), + CONSTRAINT fk_storage_type_site_type + FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE CASCADE, + CONSTRAINT fk_storage_type_site_site + FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE + ) + SQL); + + $this->addSql('CREATE INDEX idx_storage_type_site_site ON storage_type_site (site_id)'); + + $this->comment('storage_type_site', '_table', 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).'); + $this->comment('storage_type_site', 'storage_type_id', 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.'); + $this->comment('storage_type_site', 'site_id', 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.'); + } + + // ================================================================= + // Table principale `product` + // ================================================================= + + private function createProduct(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE product ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + code VARCHAR(50) NOT NULL, + name VARCHAR(255) NOT NULL, + -- Pas de DEFAULT : un tableau vide violerait chk_product_states_not_empty + -- (RG-6.02). La colonne est toujours renseignee par l'app (Processor/ORM). + states JSONB NOT NULL, + manufactured BOOLEAN DEFAULT FALSE NOT NULL, + contains_molasses BOOLEAN DEFAULT FALSE NOT NULL, + category_id INT NOT NULL, + deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_product_states_not_empty + CHECK (jsonb_array_length(states) >= 1), + CONSTRAINT fk_product_category + FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT, + CONSTRAINT fk_product_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_product_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + + // Unicite GLOBALE du code parmi les actifs (soft-delete tolere) — index partiel. + $this->addSql('CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL'); + $this->addSql('CREATE INDEX idx_product_category ON product (category_id)'); + $this->addSql('CREATE INDEX idx_product_deleted_at ON product (deleted_at)'); + $this->addSql('CREATE INDEX idx_product_created_by ON product (created_by)'); + $this->addSql('CREATE INDEX idx_product_updated_by ON product (updated_by)'); + + $this->comment('product', '_table', 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.'); + $this->comment('product', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('product', 'code', 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).'); + $this->comment('product', 'name', 'Nom du produit (≤ 255). Normalise serveur (trim).'); + $this->comment('product', 'states', 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.'); + $this->comment('product', 'manufactured', '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).'); + $this->comment('product', 'contains_molasses', '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).'); + $this->comment('product', 'category_id', 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).'); + $this->comment('product', 'deleted_at', 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.'); + $this->addTimestampableBlamableComments('product'); + } + + private function createProductSite(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE product_site ( + product_id INT NOT NULL, + site_id INT NOT NULL, + PRIMARY KEY (product_id, site_id), + CONSTRAINT fk_product_site_product + FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE, + CONSTRAINT fk_product_site_site + FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT + ) + SQL); + + $this->addSql('CREATE INDEX idx_product_site_site ON product_site (site_id)'); + + $this->comment('product_site', '_table', 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).'); + $this->comment('product_site', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.'); + $this->comment('product_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.'); + } + + private function createProductStorageType(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE product_storage_type ( + product_id INT NOT NULL, + storage_type_id INT NOT NULL, + PRIMARY KEY (product_id, storage_type_id), + CONSTRAINT fk_product_storage_type_product + FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE, + CONSTRAINT fk_product_storage_type_type + FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT + ) + SQL); + + $this->addSql('CREATE INDEX idx_product_storage_type_type ON product_storage_type (storage_type_id)'); + + $this->comment('product_storage_type', '_table', 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).'); + $this->comment('product_storage_type', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.'); + $this->comment('product_storage_type', 'storage_type_id', 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.'); + } + + // ================================================================= + // Seed du type de categorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures + // ================================================================= + + private function seedCategoryTypeProduit(): void + { + // Idempotent via l'index unique uq_category_type_code (comme CLIENT/FOURNISSEUR/ + // PRESTATAIRE/ADRESSE). Les Category de type PRODUIT suivent en ERP-201. + $this->addSql(<<<'SQL' + INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit') + ON CONFLICT (code) DO NOTHING + SQL); + } + + // ================================================================= + // Helpers (identiques au M5 Version20260617150000) + // ================================================================= + + /** + * 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, + )); + } +} diff --git a/src/Module/Catalog/Application/Service/ProductFieldNormalizer.php b/src/Module/Catalog/Application/Service/ProductFieldNormalizer.php new file mode 100644 index 0000000..31afe21 --- /dev/null +++ b/src/Module/Catalog/Application/Service/ProductFieldNormalizer.php @@ -0,0 +1,55 @@ + "BLE-01". Conserve + * null tel quel ; une chaine vide apres trim devient null (c'est l'Assert\NotBlank + * de l'entite qui rejette le vide, pas le normalizer). + */ + public function normalizeCode(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_strtoupper($value, 'UTF-8'); + } + + /** + * Nom du produit trimme (RG-6.07), sans changement de casse. Une chaine vide + * apres trim devient null. + */ + public function normalizeName(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : $value; + } +} diff --git a/src/Module/Catalog/CatalogModule.php b/src/Module/Catalog/CatalogModule.php index 1465e57..c5154f9 100644 --- a/src/Module/Catalog/CatalogModule.php +++ b/src/Module/Catalog/CatalogModule.php @@ -43,6 +43,10 @@ final class CatalogModule // sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue // dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7. ['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'], + // Catalogue produit (M6, ERP-197) : admin-only (matrice docx p.3, C7). + // 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)'], ]; } } diff --git a/src/Module/Catalog/Domain/Entity/Product.php b/src/Module/Catalog/Domain/Entity/Product.php new file mode 100644 index 0000000..eceb5a1 --- /dev/null +++ b/src/Module/Catalog/Domain/Entity/Product.php @@ -0,0 +1,438 @@ + embed autorise, ne viole pas la regle n°13). Le groupe + * product:item:read est reserve pour d'eventuels champs detail-only ulterieurs. + * + * Regles de gestion (renvoyees au Processor/Provider, ERP-200) : + * - RG-6.01 : `code` unique global parmi les actifs, normalise serveur (trim/UPPER), + * 409 sur doublon (index partiel uq_product_code_active). + * - RG-6.02 : `states` = sous-ensemble non vide de {PURCHASE, SALE, OTHER}. + * - RG-6.03 : `manufactured` / `containsMolasses` saisis uniquement si states + * contient SALE, sinon forces false serveur. + * - RG-6.04 : `sites` >= 1. + * - RG-6.05 : `category` de type PRODUIT (validee applicativement, Callback ERP-200). + * - RG-6.06 : `storageTypes` >= 1, filtres par les sites selectionnes. + * + * Soft-delete prepare via `deletedAt` (non expose au M6, § 2.7) : pas de Delete + * dans les operations, la liste exclut les produits supprimes (Provider, ERP-200). + * + * Les RG inter-champs (RG-6.03/6.05/6.06) et l'unicite du code passent par le + * Processor + une contrainte d'entite Assert\Callback en ERP-200 (chaque 422 + * porte un propertyPath exploitable par useFormErrors — mapping inline, ERP-101). + * + * NB : `Site` appartient au module Sites, consomme en relation ORM partagee + * (§ 2.1) — on reutilise son read-group `site:read`, sans logique inter-module. + * `Category` et `StorageType` sont dans le meme module Catalog. + * + * @see ProductProvider Lecture (liste paginee filtree soft-delete + item) — ERP-200. + * @see ProductProcessor Ecriture (normalisation, unicite code, RG-6.03/05/06) — ERP-200. + */ +#[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 prepare non expose (§ 2.7). + ], +)] +#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)] +#[ORM\Table(name: 'product')] +// Index nommes pour matcher la migration (cf. Category). L'index unique partiel +// `uq_product_code_active` (code WHERE deleted_at IS NULL — unicite GLOBALE du +// code parmi les actifs, RG-6.01) reste possede par la seule migration : +// Doctrine ORM ne sait pas exprimer un index partiel via attribut. +#[ORM\Index(name: 'idx_product_category', columns: ['category_id'])] +#[ORM\Index(name: 'idx_product_deleted_at', columns: ['deleted_at'])] +#[ORM\Index(name: 'idx_product_created_by', columns: ['created_by'])] +#[ORM\Index(name: 'idx_product_updated_by', columns: ['updated_by'])] +#[Auditable] +class Product implements TimestampableInterface, BlamableInterface +{ + // === Timestampable + Blamable === + // Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs + // getters/setters viennent du Trait Shared, remplies automatiquement par le + // TimestampableBlamableSubscriber au prePersist / preUpdate. + use TimestampableBlamableTrait; + + /** Etats du produit (RG-6.02) — valeurs autorisees de la colonne JSONB `states`. */ + public const string STATE_PURCHASE = 'PURCHASE'; + public const string STATE_SALE = 'SALE'; + public const string STATE_OTHER = 'OTHER'; + + /** Code de type de categorie autorise pour un produit (RG-6.05). */ + private const string PRODUCT_CATEGORY_TYPE_CODE = 'PRODUIT'; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['product:read'])] + private ?int $id = null; + + // Code produit (= « Numero » de la liste), saisi, unique global parmi les + // actifs (RG-6.01). Normalise serveur (trim/UPPER) par le ProductProcessor. + #[ORM\Column(length: 50)] + #[Assert\NotBlank(message: 'Le code produit est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['product:read', 'product:write'])] + private ?string $code = null; + + #[ORM\Column(length: 255)] + #[Assert\NotBlank(message: 'Le nom du produit est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['product:read', 'product:write'])] + private ?string $name = null; + + /** + * Etats du produit (multi-select), sous-ensemble non vide de + * {PURCHASE, SALE, OTHER} (RG-6.02). Stocke en JSONB (tableau de chaines), + * non-vacuite garantie aussi par le CHECK chk_product_states_not_empty. + * + * Validation des valeurs via Assert\Choice(multiple: true) plutot que + * Assert\All([Choice]) : equivalent fonctionnel, et seul Choice est gere par + * le garde-fou EntityConstraintsHaveFrenchMessageTest. + * + * @var list + */ + // jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la + // migration (spec § 2.3 + CHECK chk_product_states_not_empty via + // jsonb_array_length). Sans `options: ['jsonb' => true]`, schema:update tente + // un ALTER states TYPE JSON qui casse le CHECK (jsonb_array_length(json) inconnu) + // et fait echouer make db-reset / test-db-setup. + #[ORM\Column(type: 'json', options: ['jsonb' => true])] + #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')] + #[Assert\Choice( + choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER], + multiple: true, + message: 'État de produit invalide.', + multipleMessage: 'État de produit invalide.', + )] + #[Groups(['product:read', 'product:write'])] + private array $states = []; + + // « Fabrique » : saisi uniquement si states contient SALE, sinon force false + // serveur (RG-6.03). + #[ORM\Column(options: ['default' => false])] + #[Groups(['product:read', 'product:write'])] + private bool $manufactured = false; + + // « Contient de la melasse » : saisi uniquement si states contient SALE, + // sinon force false serveur (RG-6.03). + #[ORM\Column(name: 'contains_molasses', options: ['default' => false])] + #[Groups(['product:read', 'product:write'])] + private bool $containsMolasses = false; + + // Categorie produit (obligatoire). Limitee aux categories de type PRODUIT, + // validee applicativement (RG-6.05, Callback ERP-200). FK ON DELETE RESTRICT : + // une categorie referencee par un produit ne peut etre supprimee. + #[ORM\ManyToOne(targetEntity: Category::class)] + #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] + #[Assert\NotNull(message: 'La catégorie produit est obligatoire.')] + #[Groups(['product:read', 'product:write'])] + private ?Category $category = null; + + /** + * Sites de disponibilite du produit (>= 1, RG-6.04). Relation ORM partagee + * vers Site (module Sites, § 2.1). Cote inverse en ON DELETE RESTRICT : un + * site reference par un produit ne peut etre supprime. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Site::class)] + #[ORM\JoinTable(name: 'product_site')] + #[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')] + #[Groups(['product:read', 'product:write'])] + private Collection $sites; + + /** + * Types de stockage du produit (>= 1, RG-6.06), filtres par les sites + * selectionnes (provider StorageType, ERP-201). Cote inverse en ON DELETE + * RESTRICT : un type de stockage reference par un produit ne peut etre supprime. + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: StorageType::class)] + #[ORM\JoinTable(name: 'product_storage_type')] + #[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')] + #[Groups(['product:read', 'product:write'])] + private Collection $storageTypes; + + /** + * Soft-delete technique : null = actif, valeur = supprime logiquement le {date}. + * Non expose au M6 (§ 2.7, aucun groupe) : prepare pour une future suppression + * (HP-M6-04). La liste exclut par defaut les produits supprimes (Provider). + */ + #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] + private ?DateTimeImmutable $deletedAt = null; + + public function __construct() + { + $this->sites = new ArrayCollection(); + $this->storageTypes = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + /** + * @return list + */ + public function getStates(): array + { + return $this->states; + } + + /** + * @param list $states + */ + public function setStates(array $states): static + { + $this->states = $states; + + return $this; + } + + public function isManufactured(): bool + { + return $this->manufactured; + } + + public function setManufactured(bool $manufactured): static + { + $this->manufactured = $manufactured; + + return $this; + } + + public function isContainsMolasses(): bool + { + return $this->containsMolasses; + } + + public function setContainsMolasses(bool $containsMolasses): static + { + $this->containsMolasses = $containsMolasses; + + return $this; + } + + public function getCategory(): ?Category + { + return $this->category; + } + + public function setCategory(?Category $category): static + { + $this->category = $category; + + return $this; + } + + /** + * @return Collection + */ + public function getSites(): Collection + { + return $this->sites; + } + + public function addSite(Site $site): static + { + if (!$this->sites->contains($site)) { + $this->sites->add($site); + } + + return $this; + } + + public function removeSite(Site $site): static + { + $this->sites->removeElement($site); + + return $this; + } + + /** + * @return Collection + */ + public function getStorageTypes(): Collection + { + return $this->storageTypes; + } + + public function addStorageType(StorageType $storageType): static + { + if (!$this->storageTypes->contains($storageType)) { + $this->storageTypes->add($storageType); + } + + return $this; + } + + public function removeStorageType(StorageType $storageType): static + { + $this->storageTypes->removeElement($storageType); + + return $this; + } + + public function getDeletedAt(): ?DateTimeImmutable + { + return $this->deletedAt; + } + + public function setDeletedAt(?DateTimeImmutable $deletedAt): static + { + $this->deletedAt = $deletedAt; + + return $this; + } + + /** + * RG-6.05 : la categorie d'un produit doit etre de type PRODUIT. Validee + * applicativement (pas de contrainte SQL au referentiel, § 2.5) via Callback + * + ->atPath('category') pour que la 422 porte un propertyPath consommable par + * useFormErrors (mapping inline, ERP-101). Le NotNull gere l'absence : on ne + * leve que si une categorie est presente ET non-PRODUIT. + */ + #[Assert\Callback] + public function validateCategoryIsProductType(ExecutionContextInterface $context): void + { + if (null === $this->category) { + return; + } + + if (!in_array(self::PRODUCT_CATEGORY_TYPE_CODE, $this->category->getCategoryTypeCodes(), true)) { + $context->buildViolation('La catégorie sélectionnée doit être de type Produit.') + ->atPath('category') + ->addViolation() + ; + } + } + + /** + * RG-6.06 : chaque type de stockage choisi doit etre disponible sur AU MOINS UN + * des sites choisis (intersection non vide). Validee via Callback + + * ->atPath('storageTypes'). On ne croise que si les deux collections sont non + * vides : leur absence est deja couverte par les Assert\Count(min: 1) dedies. + */ + #[Assert\Callback] + public function validateStorageTypesAvailableOnSelectedSites(ExecutionContextInterface $context): void + { + if ($this->sites->isEmpty() || $this->storageTypes->isEmpty()) { + return; + } + + // Ensemble des ids de sites selectionnes (lookup O(1)). + $selectedSiteIds = []; + foreach ($this->sites as $site) { + $selectedSiteIds[$site->getId()] = true; + } + + foreach ($this->storageTypes as $storageType) { + $available = false; + foreach ($storageType->getSites() as $storageTypeSite) { + if (isset($selectedSiteIds[$storageTypeSite->getId()])) { + $available = true; + + break; + } + } + + if (!$available) { + $context->buildViolation('Le type de stockage « {{ label }} » n\'est disponible sur aucun des sites sélectionnés.') + ->setParameter('{{ label }}', (string) $storageType->getLabel()) + ->atPath('storageTypes') + ->addViolation() + ; + } + } + } +} diff --git a/src/Module/Catalog/Domain/Entity/StorageType.php b/src/Module/Catalog/Domain/Entity/StorageType.php new file mode 100644 index 0000000..41cc563 --- /dev/null +++ b/src/Module/Catalog/Domain/Entity/StorageType.php @@ -0,0 +1,148 @@ + Site) : sites sur lesquels ce type de stockage + * est disponible. Sert au filtrage du multi-select « Type de stockage » par les + * sites selectionnes dans le formulaire produit (RG-6.06). Non serialisee au M6 + * (le filtrage est applique cote provider en ERP-201). + * + * Lecture seule au M6 : seules les operations GetCollection et Get sont exposees + * (CRUD admin = hors perimetre HP-M6-03), sous la permission `catalog.products.view` + * (referentiel servant le formulaire produit — § 4.2). + * + * Referentiel statique : pas de Timestampable/Blamable ni `#[Auditable]` + * (whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED, miroir + * CategoryType — cree par migration/seed, pas pilote utilisateur). Le groupe + * `storage_type:read` est porte par chaque propriete affichee pour que le type + * soit embarque dans la reponse d'un Product (cf. .claude/rules/backend.md + * § Serialization). + */ +#[ApiResource( + operations: [ + // Tri label ASC et filtre ?siteId[]= portes par le StorageTypeProvider + // (ERP-201) : alimente le multi-select « Type de stockage » du formulaire + // produit, filtre par les sites selectionnes (RG-6.06). Pagination Hydra + + // echappatoire ?pagination=false (referentiel borne). + new GetCollection( + security: "is_granted('catalog.products.view')", + normalizationContext: ['groups' => ['storage_type:read']], + provider: StorageTypeProvider::class, + ), + new Get( + security: "is_granted('catalog.products.view')", + normalizationContext: ['groups' => ['storage_type:read']], + provider: StorageTypeProvider::class, + ), + ], +)] +#[ORM\Entity(repositoryClass: DoctrineStorageTypeRepository::class)] +#[ORM\Table(name: 'storage_type')] +// Contrainte d'unicite nommee pour matcher la migration (cf. CategoryType). +#[ORM\UniqueConstraint(name: 'uq_storage_type_code', columns: ['code'])] +class StorageType +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['storage_type:read'])] + private ?int $id = null; + + #[ORM\Column(length: 40)] + #[Groups(['storage_type:read'])] + private ?string $code = null; + + #[ORM\Column(length: 120)] + #[Groups(['storage_type:read'])] + private ?string $label = null; + + /** + * Sites sur lesquels ce type de stockage est disponible (RG-6.06). Non + * exposee en serialisation au M6 : sert uniquement au filtrage `?siteId[]=` + * du referentiel (branche en ERP-201). + * + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Site::class)] + #[ORM\JoinTable(name: 'storage_type_site')] + #[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + private Collection $sites; + + public function __construct() + { + $this->sites = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): static + { + $this->label = $label; + + return $this; + } + + /** + * @return Collection + */ + public function getSites(): Collection + { + return $this->sites; + } + + public function addSite(Site $site): static + { + if (!$this->sites->contains($site)) { + $this->sites->add($site); + } + + return $this; + } + + public function removeSite(Site $site): static + { + $this->sites->removeElement($site); + + return $this; + } +} diff --git a/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php new file mode 100644 index 0000000..c217260 --- /dev/null +++ b/src/Module/Catalog/Domain/Repository/ProductRepositoryInterface.php @@ -0,0 +1,45 @@ + $siteIds + */ + public function createListQueryBuilder( + bool $includeDeleted = false, + ?string $search = null, + ?int $categoryId = null, + ?string $categoryCode = null, + ?string $state = null, + array $siteIds = [], + ): QueryBuilder; +} diff --git a/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php new file mode 100644 index 0000000..293ca7a --- /dev/null +++ b/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php @@ -0,0 +1,31 @@ + + */ + public function findAllOrderedByLabel(): array; + + /** + * QueryBuilder de la liste des types de stockage (consomme par le + * StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2) et filtre + * optionnel `?siteId[]=` (RG-6.06) restreignant aux types disponibles sur + * AU MOINS UN des sites passes. + * + * @param list $siteIds + */ + public function createListQueryBuilder(array $siteIds = []): QueryBuilder; +} diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/ProductProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/ProductProcessor.php new file mode 100644 index 0000000..1d86c58 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/ProductProcessor.php @@ -0,0 +1,124 @@ + 409 ; l'index partiel + * uq_product_code_active reste le filet anti-race au flush. + * 4. Persistance via le persist_processor Doctrine ORM. + * + * Mode strict PATCH (RETEX M1) : la security d'operation exige deja + * `catalog.products.manage` pour TOUS les champs ecrivables (un seul niveau de + * permission au M6 — § 5.2 admin-only). Il n'existe donc aucun champ « hors-permission » + * a re-gater finement (contrairement a l'archivage Carrier RG-4.14 ou au split + * comptable Client RG-1.28) : le 403 global est porte par la security d'operation, + * pas par un guard de champ ici. + * + * Les RG inter-champs RG-6.05 (categorie de type PRODUIT) et RG-6.06 (types de + * stockage disponibles sur les sites choisis) sont portees par des Assert\Callback + * + ->atPath() sur l'entite Product (jouees par API Platform AVANT ce processor), + * pour que chaque 422 porte un propertyPath consommable par useFormErrors (mapping + * inline, pas un toast — convention ERP-101). + * + * @implements ProcessorInterface + */ +final class ProductProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly ProductFieldNormalizer $normalizer, + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')] + private readonly ProductRepositoryInterface $repository, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof Product) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + // 1. RG-6.07 : normalisation serveur (code trim+UPPER, name trim). + $this->normalize($data); + + // 2. RG-6.03 : si l'etat ne contient pas SALE, les champs conditionnels + // « Fabrique » / « Contient de la melasse » sont forces false serveur. + if (!in_array(Product::STATE_SALE, $data->getStates(), true)) { + $data->setManufactured(false); + $data->setContainsMolasses(false); + } + + // 3. RG-6.01 : unicite GLOBALE du code parmi les actifs (exclut le produit + // courant en PATCH). Pre-check explicite -> 409 deterministe. + $code = (string) $data->getCode(); + if ('' !== $code && $this->repository->existsActiveByCode($code, $data->getId())) { + throw $this->duplicateCodeConflict($code); + } + + // 4. Persistance, avec filet anti-race sur l'index partiel. + try { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } catch (UniqueConstraintViolationException $e) { + // Insertion concurrente du meme code entre le pre-check et le flush + // (collision sur uq_product_code_active — unicite parmi les actifs). + throw $this->duplicateCodeConflict($code, $e); + } + } + + /** + * Normalisation serveur du produit (RG-6.07). Les setters ne sont touches que si + * une valeur est presente, pour ne jamais ecraser l'existant lors d'un PATCH + * partiel. Les casts (string) sont surs : NotBlank a deja rejete le vide en amont. + */ + private function normalize(Product $data): void + { + if (null !== $data->getCode()) { + $data->setCode((string) $this->normalizer->normalizeCode($data->getCode())); + } + + if (null !== $data->getName()) { + $data->setName((string) $this->normalizer->normalizeName($data->getName())); + } + } + + /** + * RG-6.01 : 409 sur doublon de code produit. Le front mappe ce conflit sur le + * champ `code` (setError('code', ...) + toast — convention useFormErrors ERP-101 + * / useCategoryForm RG-1.07) : le propertyPath exploitable est `code`. + */ + private function duplicateCodeConflict(string $code, ?Throwable $previous = null): ConflictHttpException + { + return new ConflictHttpException( + sprintf('Le code produit « %s » est déjà utilisé par un autre produit.', $code), + $previous, + ); + } +} diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/ProductProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/ProductProvider.php new file mode 100644 index 0000000..68a7551 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/ProductProvider.php @@ -0,0 +1,185 @@ + + */ +final class ProductProvider implements ProviderInterface +{ + /** Etats valides du filtre ?state= (enum borne, RG-6.02). */ + private const array VALID_STATES = [Product::STATE_PURCHASE, Product::STATE_SALE, Product::STATE_OTHER]; + + public function __construct( + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')] + private readonly ProductRepositoryInterface $repository, + private readonly Pagination $pagination, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Product|null + { + if ($operation instanceof CollectionOperationInterface) { + // includeDeleted toujours false : le soft-delete n'est pas expose au M6. + $qb = $this->repository->createListQueryBuilder( + false, + $this->readSearch($context), + $this->readCategoryId($context), + $this->readCategoryCode($context), + $this->readState($context), + $this->readSiteIds($context), + ); + + // Echappatoire ?pagination=false : collection complete sans Paginator. + if (!$this->pagination->isEnabled($operation, $context)) { + return $qb->getQuery()->getResult(); + } + + // Branche paginee standard : offset/limit via Pagination, enveloppe dans + // le Paginator ORM (fetchJoinCollection: true pour compter correctement + // malgre les fetch-joins to-many sites/storageTypes du QueryBuilder). + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true)); + } + + // Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete. + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $product = $this->repository->findById((int) $id); + if (null === $product) { + return null; + } + + // § 2.7 : un produit soft-deleted n'est jamais expose (404). + if (null !== $product->getDeletedAt()) { + return null; + } + + return $product; + } + + /** + * Lit le filtre `?search=` (recherche partielle code + name). Renvoie la valeur + * trimmee ou null si absente / vide. + */ + private function readSearch(array $context): ?string + { + $raw = $context['filters']['search'] ?? null; + + if (!is_string($raw)) { + return null; + } + + $raw = trim($raw); + + return '' === $raw ? null : $raw; + } + + /** + * Lit le filtre `?categoryId=` (drawer « Filtrer »). Renvoie l'id entier ou null + * si absent / non numerique. + */ + private function readCategoryId(array $context): ?int + { + $raw = $context['filters']['categoryId'] ?? null; + + if (is_int($raw)) { + return $raw; + } + + return is_string($raw) && ctype_digit($raw) ? (int) $raw : null; + } + + /** + * Lit le filtre `?categoryCode=` (drawer « Filtrer »). Renvoie le code trimme ou + * null si absent / vide. + */ + private function readCategoryCode(array $context): ?string + { + $raw = $context['filters']['categoryCode'] ?? null; + + if (!is_string($raw)) { + return null; + } + + $raw = trim($raw); + + return '' === $raw ? null : $raw; + } + + /** + * Lit le filtre `?state=` (PURCHASE / SALE / OTHER). Normalise en majuscules et + * n'accepte qu'une valeur de l'enum borne ; toute autre valeur est ignoree (null). + */ + private function readState(array $context): ?string + { + $raw = $context['filters']['state'] ?? null; + + if (!is_string($raw) || '' === trim($raw)) { + return null; + } + + $state = mb_strtoupper(trim($raw), 'UTF-8'); + + return in_array($state, self::VALID_STATES, true) ? $state : null; + } + + /** + * Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur + * scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques. + * + * @return list + */ + private function readSiteIds(array $context): array + { + $raw = $context['filters']['siteId'] ?? null; + + if (null === $raw) { + return []; + } + + $values = is_array($raw) ? $raw : [$raw]; + + $ids = []; + foreach ($values as $value) { + if (is_int($value) || (is_string($value) && ctype_digit($value))) { + $ids[] = (int) $value; + } + } + + return array_values(array_unique($ids)); + } +} diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php new file mode 100644 index 0000000..c8f53ee --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php @@ -0,0 +1,94 @@ + + */ +final class StorageTypeProvider implements ProviderInterface +{ + public function __construct( + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository')] + private readonly StorageTypeRepositoryInterface $repository, + private readonly Pagination $pagination, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null + { + if ($operation instanceof CollectionOperationInterface) { + $qb = $this->repository->createListQueryBuilder($this->readSiteIds($context)); + + // Echappatoire ?pagination=false : collection complete sans Paginator + // (alimentation du multi-select, referentiel borne). + if (!$this->pagination->isEnabled($operation, $context)) { + return $qb->getQuery()->getResult(); + } + + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + // Pas de fetch-join to-many (sites non serialisee) -> Paginator simple. + return new Paginator(new DoctrinePaginator($qb->getQuery())); + } + + // Get unitaire. + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + return $this->repository->findById((int) $id); + } + + /** + * Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur + * scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques. + * + * @return list + */ + private function readSiteIds(array $context): array + { + $raw = $context['filters']['siteId'] ?? null; + + if (null === $raw) { + return []; + } + + $values = is_array($raw) ? $raw : [$raw]; + + $ids = []; + foreach ($values as $value) { + if (is_int($value) || (is_string($value) && ctype_digit($value))) { + $ids[] = (int) $value; + } + } + + return array_values(array_unique($ids)); + } +} diff --git a/src/Module/Catalog/Infrastructure/Controller/ProductExportController.php b/src/Module/Catalog/Infrastructure/Controller/ProductExportController.php new file mode 100644 index 0000000..66cd82d --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Controller/ProductExportController.php @@ -0,0 +1,261 @@ + 'Achat', + Product::STATE_SALE => 'Vendu', + Product::STATE_OTHER => 'Autre', + ]; + + public function __construct( + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')] + private readonly ProductRepositoryInterface $repository, + private readonly SpreadsheetExporterInterface $exporter, + ) {} + + #[Route('/api/products/export.xlsx', name: 'catalog_products_export_xlsx', methods: ['GET'], priority: 1)] + #[IsGranted('catalog.products.view')] + public function __invoke(Request $request): Response + { + // Memes filtres que la vue liste (ProductProvider) pour que l'export + // reflete exactement ce que l'utilisateur voit a l'ecran : recherche + // (?search), categorie (?categoryId / ?categoryCode), etat (?state), + // sites (?siteId[]). includeDeleted reste false : le soft-delete n'est + // jamais expose au M6 (§ 2.7). + $search = $request->query->getString('search') ?: null; + $categoryId = $this->readIntOrNull($request->query->get('categoryId')); + $categoryCode = $request->query->getString('categoryCode') ?: null; + $state = $this->readState($request->query->get('state')); + $siteIds = $this->readIntList($request->query->all()['siteId'] ?? []); + + /** @var list $products */ + $products = $this->repository + ->createListQueryBuilder(false, $search, $categoryId, $categoryCode, $state, $siteIds) + ->getQuery() + ->getResult() + ; + + $binary = $this->exporter->export( + 'Catalogue produits', + $this->buildHeaders(), + $this->buildRows($products), + ); + + return $this->buildResponse($binary); + } + + /** + * Colonnes de l'export (spec § 4.5). + * + * @return list + */ + private function buildHeaders(): array + { + return [ + 'Numéro', + 'Nom', + 'États', + 'Catégorie', + 'Sites', + 'Types de stockage', + 'Fabriqué', + 'Contient mélasse', + ]; + } + + /** + * @param list $products + * + * @return iterable> + */ + private function buildRows(array $products): iterable + { + foreach ($products as $product) { + yield [ + $product->getCode(), + $product->getName(), + $this->formatStates($product), + $product->getCategory()?->getName(), + $this->formatSites($product), + $this->formatStorageTypes($product), + $product->isManufactured() ? 'Oui' : 'Non', + $product->isContainsMolasses() ? 'Oui' : 'Non', + ]; + } + } + + /** + * Libelles FR des etats du produit, dans l'ordre canonique (Achat, Vendu, + * Autre), joints par virgule. Une valeur inattendue est ignoree. + */ + private function formatStates(Product $product): string + { + $states = $product->getStates(); + + $labels = []; + foreach (self::STATE_LABELS as $code => $label) { + if (in_array($code, $states, true)) { + $labels[] = $label; + } + } + + return implode(', ', $labels); + } + + /** + * Libelles des sites de disponibilite du produit, dedupliques, tries, joints + * par virgule. + */ + private function formatSites(Product $product): string + { + $names = []; + foreach ($product->getSites() as $site) { + // @var Site $site + $name = $site->getName(); + if (null !== $name && '' !== $name) { + $names[$name] = true; + } + } + + return $this->joinSorted($names); + } + + /** + * Libelles des types de stockage du produit, dedupliques, tries, joints par + * virgule. + */ + private function formatStorageTypes(Product $product): string + { + $labels = []; + foreach ($product->getStorageTypes() as $storageType) { + // @var StorageType $storageType + $label = $storageType->getLabel(); + if (null !== $label && '' !== $label) { + $labels[$label] = true; + } + } + + return $this->joinSorted($labels); + } + + /** + * @param array $names ensemble de libelles (cles) + */ + private function joinSorted(array $names): string + { + $list = array_keys($names); + sort($list); + + return implode(', ', $list); + } + + private function buildResponse(string $binary): Response + { + $filename = sprintf('catalogue-produits-%s.xlsx', new DateTimeImmutable()->format('Ymd')); + + $response = new Response($binary); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } + + /** + * Lit le filtre `?state=` comme le ProductProvider : normalise en majuscules + * et n'accepte qu'une valeur de l'enum borne {PURCHASE, SALE, OTHER} ; toute + * autre valeur est ignoree (null). + */ + private function readState(mixed $raw): ?string + { + if (!is_string($raw) || '' === trim($raw)) { + return null; + } + + $state = mb_strtoupper(trim($raw), 'UTF-8'); + + return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null; + } + + /** + * Lit un identifiant entier positif unique (`?categoryId=`). Aligne sur + * ProductProvider (tolere int ou chaine numerique). + */ + private function readIntOrNull(mixed $raw): ?int + { + if (is_int($raw)) { + return $raw > 0 ? $raw : null; + } + + return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null; + } + + /** + * Normalise un filtre en liste d'identifiants entiers positifs (valeur unique + * ou liste, `?siteId[]=`). Aligne sur ProductProvider. + * + * @return list + */ + private function readIntList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } +} diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php index 794ff64..bdd3c0c 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php @@ -20,8 +20,9 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire; * (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories * prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type * ADRESSE porte les categories des blocs adresse (Siege, Contact issues, - * Facturation, Livraison, Approvisionnement, Methaniseur). Chaque categorie porte - * un `code` stable. + * Facturation, Livraison, Approvisionnement, Methaniseur) ; le type PRODUIT porte + * les categories produit du catalogue (M6 ERP-201 : Cereales, Oleagineux, Aliments + * du betail, Engrais). Chaque categorie porte un `code` stable. * Alimente le repertoire clients (ClientFixtures, module Commercial) avec des * donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29 * (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2). @@ -88,6 +89,15 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface 'Approvisionnement' => 'APPROVISIONNEMENT', 'Méthaniseur' => 'METHANISEUR', ], + // M6 (ERP-201) : categories produit alimentant le select du formulaire + // produit (filtre ?typeCode=PRODUIT). Codes = slug MAJUSCULE deterministe + // (meme sortie que CategoryCodeGenerator). Provisoires, a affiner avec le metier. + 'PRODUIT' => [ + 'Céréales' => 'CEREALES', + 'Oléagineux' => 'OLEAGINEUX', + 'Aliments du bétail' => 'ALIMENTS_DU_BETAIL', + 'Engrais' => 'ENGRAIS', + ], ]; public function __construct( diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php index 2b45d7c..8d58871 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php @@ -29,6 +29,10 @@ use Doctrine\Persistence\ObjectManager; * dediee au champ « Categorie » des blocs adresse (client + fournisseur). Mirroir * de la migration Version20260625100000. * + * M6 (ERP-201) : ajout du type PRODUIT (code PRODUIT, label « Produit »), taxonomie + * des categories produit du catalogue (Cereales, Oleagineux...). Mirroir du seed de + * la migration Version20260625110000 (ERP-198) : re-aligne dev/test apres purge. + * * Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une * entite managee par l ORM, donc le purger Doctrine la vide avant chaque * `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la @@ -45,14 +49,15 @@ class CategoryTypeFixtures extends Fixture /** * Source unique des types : code technique => libelle FR. Doit rester aligne * sur le seed des migrations Version20260602100000 (CLIENT), - * Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE) et - * Version20260625100000 (ADRESSE). + * Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE), + * Version20260625100000 (ADRESSE) et Version20260625110000 (PRODUIT, ERP-198). */ private const TYPES = [ 'CLIENT' => 'Client', 'FOURNISSEUR' => 'Fournisseur', 'PRESTATAIRE' => 'Prestataire', 'ADRESSE' => 'Adresse', + 'PRODUIT' => 'Produit', ]; public function __construct( diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php new file mode 100644 index 0000000..f8f1010 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php @@ -0,0 +1,115 @@ + libelle FR. + * A re-seeder a reception de la liste Aurore (HP-M6-02). + * + * @var array + */ + private const TYPES = [ + 'BOISSEAU' => 'Boisseau', + 'BOISSEAU_DOSAGE' => 'Boisseau dosage', + 'CASE' => 'Case', + 'CELLULE' => 'Cellule', + 'CONTAINER' => 'Container', + 'CUVE_MELASSE' => 'Cuve mélasse', + 'STOCKAGE_BIG_BAG' => 'Stockage big bag', + 'STOCKAGE_PALETTE' => 'Stockage palette', + 'TAS' => 'Tas', + 'ZONE' => 'Zone', + ]; + + /** + * Noms des 3 sites de rattachement PROVISOIRE (86 / 17 / 82). Le nom est la cle + * de lookup stable cote SitesFixtures. + * + * @var list + */ + private const DEFAULT_SITE_NAMES = ['Chatellerault', 'Saint-Jean', 'Pommevic']; + + public function __construct( + private readonly StorageTypeRepositoryInterface $storageTypeRepository, + private readonly SiteProviderInterface $siteProvider, + ) {} + + /** + * @return array + */ + public function getDependencies(): array + { + return [SitesFixtures::class]; + } + + public function load(ObjectManager $manager): void + { + // Index des types deja presents par code, pour ne pas creer de doublon. + $existingByCode = []; + foreach ($this->storageTypeRepository->findAllOrderedByLabel() as $type) { + $existingByCode[$type->getCode()] = $type; + } + + // Resolution des 3 sites par defaut via le contrat Shared (rattachement + // provisoire). Les objets resolus sont des Site managees (resolve_target_entities + // SiteInterface -> Site) : addSite() les accepte. + $defaultSites = []; + foreach (self::DEFAULT_SITE_NAMES as $name) { + $site = $this->siteProvider->findByName($name); + if (null !== $site) { + $defaultSites[] = $site; + } + } + + foreach (self::TYPES as $code => $label) { + $storageType = $existingByCode[$code] ?? new StorageType(); + $storageType->setCode($code); + $storageType->setLabel($label); + + // Rattachement provisoire aux 3 sites (idempotent : addSite -> contains()). + foreach ($defaultSites as $site) { + $storageType->addSite($site); + } + + $manager->persist($storageType); + } + + $manager->flush(); + } +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php new file mode 100644 index 0000000..c24e1a1 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineProductRepository.php @@ -0,0 +1,150 @@ + + */ +class DoctrineProductRepository extends ServiceEntityRepository implements ProductRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Product::class); + } + + public function findById(int $id): ?Product + { + return $this->find($id); + } + + public function save(Product $product): void + { + $this->getEntityManager()->persist($product); + $this->getEntityManager()->flush(); + } + + public function existsActiveByCode(string $code, ?int $excludeId = null): bool + { + $qb = $this->createQueryBuilder('p') + ->select('1') + ->andWhere('p.code = :code') + ->andWhere('p.deletedAt IS NULL') + ->setParameter('code', $code) + ->setMaxResults(1) + ; + + if (null !== $excludeId) { + $qb->andWhere('p.id != :excludeId')->setParameter('excludeId', $excludeId); + } + + return [] !== $qb->getQuery()->getResult(); + } + + public function createListQueryBuilder( + bool $includeDeleted = false, + ?string $search = null, + ?int $categoryId = null, + ?string $categoryCode = null, + ?string $state = null, + array $siteIds = [], + ): QueryBuilder { + // Eager-load des relations embarquees en liste (product:read) pour eviter + // un N+1 par produit : category (ManyToOne, sur), sites et storageTypes + // (ManyToMany BORNES — embed autorise, ne viole pas la regle n°13). Le + // provider enveloppe la requete dans un Paginator(fetchJoinCollection: true), + // compatible avec ces fetch-joins to-many (comptage par sous-requete d'ids). + $qb = $this->createQueryBuilder('p') + ->leftJoin('p.category', 'cat')->addSelect('cat') + ->leftJoin('p.sites', 's')->addSelect('s') + ->leftJoin('p.storageTypes', 'stp')->addSelect('stp') + ->orderBy('p.name', 'ASC') + ; + + // RG-6.09 : la liste exclut par defaut les produits soft-deleted. + if (!$includeDeleted) { + $qb->andWhere('p.deletedAt IS NULL'); + } + + // ?search= : recherche partielle case-insensitive sur code + name. Les + // metacaracteres LIKE (%, _, \) sont echappes pour rester litteraux. Les + // deux LIKE sont parenthese pour ne pas casser la precedence AND/OR avec + // les autres filtres (AND lie plus fort que OR en DQL). + if (null !== $search && '' !== trim($search)) { + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); + $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; + $qb->andWhere('(LOWER(p.code) LIKE :search OR LOWER(p.name) LIKE :search)') + ->setParameter('search', $pattern) + ; + } + + // ?categoryId= : filtre par categorie precise (id). + if (null !== $categoryId) { + $qb->andWhere('cat.id = :categoryId')->setParameter('categoryId', $categoryId); + } + + // ?categoryCode= : filtre par categorie precise (code stable). + if (null !== $categoryCode && '' !== trim($categoryCode)) { + $qb->andWhere('cat.code = :categoryCode')->setParameter('categoryCode', trim($categoryCode)); + } + + // ?state= : appartenance a la colonne JSONB `states`. DQL ne sait pas + // exprimer la containment jsonb -> on resout les ids matchant en SQL natif + // (operateur @>), puis on contraint le QueryBuilder. Ids vides -> condition + // toujours fausse (aucun produit), sans casser le reste de la requete. + if (null !== $state) { + $stateIds = $this->matchingStateIds($state); + if ([] === $stateIds) { + $qb->andWhere('1 = 0'); + } else { + $qb->andWhere('p.id IN (:stateIds)')->setParameter('stateIds', $stateIds); + } + } + + // ?siteId[]= : produit disponible sur AU MOINS UN des sites passes (OR). + // Sous-requete EXISTS correlee pour ne PAS restreindre la collection sites + // eager-loadee `s` (sinon les autres sites du produit disparaitraient du + // JSON) et eviter les lignes dupliquees (cf. DoctrineCategoryRepository). + if ([] !== $siteIds) { + $sub = $this->getEntityManager()->createQueryBuilder() + ->select('1') + ->from(Product::class, 'p_si') + ->join('p_si.sites', 's_si') + ->where('p_si = p') + ->andWhere('s_si.id IN (:siteIds)') + ; + $qb->andWhere($qb->expr()->exists($sub->getDQL())) + ->setParameter('siteIds', $siteIds) + ; + } + + return $qb; + } + + /** + * Ids des produits dont la colonne JSONB `states` contient l'etat donne, via + * l'operateur de containment Postgres `@>`. L'etat est borne a l'enum + * {PURCHASE, SALE, OTHER} en amont (ProductProvider) — pas de saisie libre ici. + * + * @return list + */ + private function matchingStateIds(string $state): array + { + $rows = $this->getEntityManager()->getConnection() + ->executeQuery( + 'SELECT id FROM product WHERE states @> CAST(:state AS JSONB)', + ['state' => (string) json_encode([$state])], + ) + ->fetchFirstColumn() + ; + + return array_map(static fn (mixed $id): int => (int) $id, $rows); + } +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php new file mode 100644 index 0000000..dfff050 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php @@ -0,0 +1,64 @@ + + */ +class DoctrineStorageTypeRepository extends ServiceEntityRepository implements StorageTypeRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, StorageType::class); + } + + public function findById(int $id): ?StorageType + { + return $this->find($id); + } + + /** + * @return list + */ + public function findAllOrderedByLabel(): array + { + return $this->findBy([], ['label' => 'ASC']); + } + + public function createListQueryBuilder(array $siteIds = []): QueryBuilder + { + // Tri alphabetique stable (multi-select du formulaire produit, § 4.2). La + // relation `sites` n'est PAS serialisee (storage_type:read ne la porte pas) + // -> pas d'eager-load : le filtre n'affecte pas la sortie, seulement la + // restriction des lignes. + $qb = $this->createQueryBuilder('st') + ->orderBy('st.label', 'ASC') + ; + + // ?siteId[]= : type disponible sur AU MOINS UN des sites passes (OR, RG-6.06). + // Sous-requete EXISTS correlee (meme strategie que DoctrineCategoryRepository + // / DoctrineProductRepository) pour eviter les lignes dupliquees du JOIN. + if ([] !== $siteIds) { + $sub = $this->getEntityManager()->createQueryBuilder() + ->select('1') + ->from(StorageType::class, 'st_si') + ->join('st_si.sites', 's_si') + ->where('st_si = st') + ->andWhere('s_si.id IN (:siteIds)') + ; + $qb->andWhere($qb->expr()->exists($sub->getDQL())) + ->setParameter('siteIds', $siteIds) + ; + } + + return $qb; + } +} diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php index bd24d5c..3763268 100644 --- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php +++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php @@ -186,6 +186,10 @@ final class SeedE2ECommand extends Command 'sites.bypass_scope', 'catalog.categories.view', 'catalog.categories.manage', + // Catalogue produit (M6, ERP-197). Admin-only (matrice docx + // p.3) : mappe sur le persona "tout". Miroir de personas.ts. + 'catalog.products.view', + 'catalog.products.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. diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 5a8415b..5898617 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -575,6 +575,47 @@ final class ColumnCommentsCatalog 'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.', 'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.', ] + self::timestampableBlamableComments(), + + // M6 Catalog (ERP-199) — tables desormais mappees par les entites + // Product / StorageType : schema:update (test) les recree sans COMMENT + // -> app:apply-column-comments les rejoue depuis ce catalogue. Strings + // identiques aux COMMENT de la migration Version20260625110000 (ERP-198). + 'storage_type' => [ + '_table' => 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).', + 'label' => 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).', + ], + + 'storage_type_site' => [ + '_table' => 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).', + 'storage_type_id' => 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.', + 'site_id' => 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.', + ], + + 'product' => [ + '_table' => 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).', + 'name' => 'Nom du produit (≤ 255). Normalise serveur (trim).', + 'states' => 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.', + 'manufactured' => '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).', + 'contains_molasses' => '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).', + 'category_id' => 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).', + 'deleted_at' => 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.', + ] + self::timestampableBlamableComments(), + + 'product_site' => [ + '_table' => 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).', + 'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.', + 'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.', + ], + + 'product_storage_type' => [ + '_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).', + 'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.', + 'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.', + ], ]; } diff --git a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php index 3f27d2c..008738b 100644 --- a/tests/Architecture/EntitiesAreTimestampableBlamableTest.php +++ b/tests/Architecture/EntitiesAreTimestampableBlamableTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Tests\Architecture; use App\Module\Catalog\Domain\Entity\CategoryType; +use App\Module\Catalog\Domain\Entity\StorageType; use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\Country; use App\Module\Commercial\Domain\Entity\PaymentDelay; @@ -55,6 +56,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase * - CategoryType : referentiel statique (codes de typage des categories), * pas de besoin de tracabilite user-driven (cree par migration/seed, * pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17. + * - StorageType (M6, ERP-199) : referentiel PROVISOIRE des types de stockage + * (en attente liste Aurore — HP-M6-02), cree par migration + seede (ERP-201), + * lecture seule au M6. Pas de tracabilite user-driven, meme justification que + * CategoryType. Cf. spec-back M6 § 2.4 + § 2.6. * - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels * comptables statiques (id/code/label/position), seedes par migration + * CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de @@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase Permission::class, Site::class, CategoryType::class, + StorageType::class, TvaMode::class, PaymentDelay::class, PaymentType::class, diff --git a/tests/Module/Catalog/Api/AbstractProductApiTestCase.php b/tests/Module/Catalog/Api/AbstractProductApiTestCase.php new file mode 100644 index 0000000..63b608a --- /dev/null +++ b/tests/Module/Catalog/Api/AbstractProductApiTestCase.php @@ -0,0 +1,276 @@ +getEm(); + + // Produits d'abord : ils referencent category / site / storage_type en FK + // RESTRICT, donc le parent ne pourrait pas purger les categories tant + // qu'un produit les pointe. Les jonctions product_site / + // product_storage_type cascadent au niveau base (ON DELETE CASCADE). + $em->createQuery('DELETE FROM '.Product::class)->execute(); + + // Types de stockage de test (prefixe code) — libere storage_type_site. + $em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix') + ->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%') + ->execute() + ; + + parent::tearDown(); + } + + /** + * Recupere le CategoryType `PRODUIT` (find-or-create). Le cleanup parent + * purge tous les category_type entre tests : on le recree au besoin pour que + * les POST produit satisfassent RG-6.05. + */ + protected function productType(): CategoryType + { + $em = $this->getEm(); + $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => self::PRODUCT_TYPE_CODE]); + if ($existing instanceof CategoryType) { + return $existing; + } + + $type = new CategoryType(); + $type->setCode(self::PRODUCT_TYPE_CODE); + $type->setLabel('Produit'); + $em->persist($type); + $em->flush(); + + return $type; + } + + /** + * Categorie de test rattachee au type PRODUIT (satisfait RG-6.05). + */ + protected function productCategory(?string $name = null): Category + { + // Nom laisse a null par defaut -> createCategory genere un nom aleatoire + // unique (uq_category_name_active impose LOWER(name) unique parmi les + // actives : deux categories de meme nom dans un test collisionneraient). + return $this->createCategory($name, $this->productType()); + } + + /** + * Categorie de test rattachee a un type NON-PRODUIT (viole RG-6.05). + */ + protected function nonProductCategory(): Category + { + return $this->createCategory(null, $this->createCategoryType()); + } + + /** + * Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup), + * rattache aux sites passes (disponibilite — RG-6.06). + */ + protected function seedStorageType(string $label = 'Tas de test', Site ...$sites): StorageType + { + $em = $this->getEm(); + + $storageType = new StorageType(); + $storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX)); + $storageType->setLabel($label); + foreach ($sites as $site) { + $storageType->addSite($em->getReference(Site::class, (int) $site->getId())); + } + + $em->persist($storageType); + $em->flush(); + + return $storageType; + } + + protected function siteByCode(string $code): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]); + self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code)); + + return $site; + } + + protected function firstSite(): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); + self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).'); + + return $site; + } + + /** + * Client non-admin portant seulement `catalog.products.view`. + */ + protected function authView(): Client + { + $creds = $this->createUserWithPermission('catalog.products.view'); + + return $this->authenticatedClient($creds['username'], $creds['password']); + } + + /** + * Payload POST de reference : un produit valide (categorie PRODUIT, 1 site, + * 1 type de stockage disponible sur ce site). Surchargeable par cle via + * $overrides (ex: ['states' => ['SALE'], 'code' => 'X']). + * + * @param array $overrides + * + * @return array + */ + protected function validProductPayload(array $overrides = []): array + { + $site = $this->firstSite(); + $storageType = $this->seedStorageType('Tas test', $site); + $category = $this->productCategory(); + + $base = [ + 'code' => $this->uniqueCode('TESTPRD'), + 'name' => 'Produit test', + 'states' => [Product::STATE_PURCHASE], + 'manufactured' => false, + 'containsMolasses' => false, + 'category' => $this->iri('categories', (int) $category->getId()), + 'sites' => [$this->iri('sites', (int) $site->getId())], + 'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())], + ]; + + return array_replace($base, $overrides); + } + + /** + * Seede un produit directement via l'EM (bypass Processor/Validator). Utile + * pour disposer d'un id existant (RBAC item, PATCH) ou d'un produit + * soft-deleted (reutilisation de code — RG-6.01). La categorie / le site / le + * type de stockage manquants sont crees a la volee. + * + * @param list $states + */ + protected function seedProductEntity( + ?string $code = null, + array $states = [Product::STATE_PURCHASE], + ?DateTimeImmutable $deletedAt = null, + ?Site $site = null, + ?StorageType $storageType = null, + ?Category $category = null, + ): Product { + $em = $this->getEm(); + $site ??= $this->firstSite(); + + $product = new Product(); + $product->setCode($code ?? $this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX)); + $product->setName('Produit seed'); + $product->setStates($states); + $product->setManufactured(false); + $product->setContainsMolasses(false); + $product->setCategory($category ?? $this->productCategory()); + $product->addSite($em->getReference(Site::class, (int) $site->getId())); + $product->addStorageType($storageType ?? $this->seedStorageType('Seed', $site)); + $product->setDeletedAt($deletedAt); + + $em->persist($product); + $em->flush(); + + return $product; + } + + /** + * Construit un IRI API Platform (`/api/{resource}/{id}`). + */ + protected function iri(string $resource, int $id): string + { + return sprintf('/api/%s/%d', $resource, $id); + } + + /** + * Code unique de test (prefixe + nonce). Deja en MAJUSCULE : stable apres la + * normalisation serveur (trim + UPPER, RG-6.07). + */ + protected function uniqueCode(string $prefix): string + { + return $prefix.'_'.strtoupper(substr(bin2hex(random_bytes(5)), 0, 10)); + } + + /** + * Extrait les `propertyPath` des violations d'une reponse 422 (sans lever sur + * le statut non-2xx). Sert a verifier que le back identifie bien le champ + * fautif (contrat consomme par useFormErrors cote front). + * + * @return list + */ + protected function violationPaths(ResponseInterface $response): array + { + $body = $response->toArray(false); + + return array_values(array_map( + static fn (array $violation): string => (string) ($violation['propertyPath'] ?? ''), + $body['violations'] ?? [], + )); + } + + /** + * Retrouve un membre d'une collection Hydra par son id (ou null). + * + * @param array $list + * + * @return null|array + */ + protected function memberById(array $list, int $id): ?array + { + foreach ($list['member'] ?? [] as $member) { + if (($member['id'] ?? null) === $id) { + return $member; + } + } + + return null; + } +} diff --git a/tests/Module/Catalog/Api/ProductCategoryTypeTest.php b/tests/Module/Catalog/Api/ProductCategoryTypeTest.php new file mode 100644 index 0000000..d2440e4 --- /dev/null +++ b/tests/Module/Catalog/Api/ProductCategoryTypeTest.php @@ -0,0 +1,45 @@ +createAdminClient(); + $category = $this->nonProductCategory(); + + $response = $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload([ + 'category' => $this->iri('categories', (int) $category->getId()), + ]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('category', $this->violationPaths($response)); + } + + public function testProductCategoryIsAccepted(): void + { + $client = $this->createAdminClient(); + $category = $this->productCategory(); + + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload([ + 'category' => $this->iri('categories', (int) $category->getId()), + ]), + ]); + + self::assertResponseStatusCodeSame(201); + } +} diff --git a/tests/Module/Catalog/Api/ProductCodeUniquenessTest.php b/tests/Module/Catalog/Api/ProductCodeUniquenessTest.php new file mode 100644 index 0000000..363cf21 --- /dev/null +++ b/tests/Module/Catalog/Api/ProductCodeUniquenessTest.php @@ -0,0 +1,82 @@ +createAdminClient(); + $code = $this->uniqueCode('TESTPRD'); + + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(['code' => $code]), + ]); + self::assertResponseStatusCodeSame(201); + + // Meme code -> conflit (RG-6.01). + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(['code' => $code]), + ]); + self::assertResponseStatusCodeSame(409); + } + + public function testNormalizedCodeCollides(): void + { + $client = $this->createAdminClient(); + $code = $this->uniqueCode('TESTPRD'); + + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(['code' => $code]), + ]); + self::assertResponseStatusCodeSame(201); + + // Variante minuscule + espaces : trim + UPPER serveur (RG-6.07) la ramene + // a la meme forme normalisee -> meme collision 409. + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(['code' => ' '.strtolower($code).' ']), + ]); + self::assertResponseStatusCodeSame(409); + } + + public function testSoftDeletedCodeCanBeReused(): void + { + $client = $this->createAdminClient(); + $code = $this->uniqueCode('TESTPRD'); + + // Produit soft-deleted portant le code (seede directement, hors index actif). + $this->seedProductEntity( + code: $code, + states: [Product::STATE_PURCHASE], + deletedAt: new DateTimeImmutable(), + ); + + // Le meme code est libre cote actifs -> creation acceptee (201). + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(['code' => $code]), + ]); + self::assertResponseStatusCodeSame(201); + } +} diff --git a/tests/Module/Catalog/Api/ProductConditionalFieldsTest.php b/tests/Module/Catalog/Api/ProductConditionalFieldsTest.php new file mode 100644 index 0000000..95e551f --- /dev/null +++ b/tests/Module/Catalog/Api/ProductConditionalFieldsTest.php @@ -0,0 +1,79 @@ +createAdminClient(); + + // Pas de SALE dans les etats mais champs conditionnels a true cote client. + $created = $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload([ + 'states' => ['PURCHASE', 'OTHER'], + 'manufactured' => true, + 'containsMolasses' => true, + ]), + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // Le serveur a force les deux a false (RG-6.03). + self::assertFalse($created['manufactured']); + self::assertFalse($created['containsMolasses']); + } + + public function testConditionalFieldsKeptWithSale(): void + { + $client = $this->createAdminClient(); + + $created = $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload([ + 'states' => ['SALE'], + 'manufactured' => true, + 'containsMolasses' => true, + ]), + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // SALE present -> les valeurs saisies sont conservees. + self::assertTrue($created['manufactured']); + self::assertTrue($created['containsMolasses']); + } + + public function testConditionalFieldsResetOnPatchRemovingSale(): void + { + $client = $this->createAdminClient(); + + $created = $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload([ + 'states' => ['SALE'], + 'manufactured' => true, + 'containsMolasses' => true, + ]), + ])->toArray(); + self::assertResponseStatusCodeSame(201); + + // On retire SALE en PATCH -> les conditionnels doivent retomber a false. + $patched = $client->request('PATCH', '/api/products/'.$created['id'], [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['states' => ['PURCHASE']], + ])->toArray(); + + self::assertResponseStatusCodeSame(200); + self::assertFalse($patched['manufactured']); + self::assertFalse($patched['containsMolasses']); + } +} diff --git a/tests/Module/Catalog/Api/ProductExportControllerTest.php b/tests/Module/Catalog/Api/ProductExportControllerTest.php new file mode 100644 index 0000000..99c9f1b --- /dev/null +++ b/tests/Module/Catalog/Api/ProductExportControllerTest.php @@ -0,0 +1,291 @@ +getEm(); + $em->createQuery('DELETE FROM '.Product::class)->execute(); + $em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix') + ->setParameter('prefix', self::TEST_STORAGE_PREFIX.'%') + ->execute() + ; + + parent::tearDown(); + } + + public function testExportReturnsXlsxResponseWithHeaderRow(): void + { + $client = $this->createAdminClient(); + $this->seedProduct('TEST_PRD_A', 'Export Alpha'); + + $response = $client->request('GET', self::EXPORT_URL); + + self::assertResponseIsSuccessful(); + $headers = $response->getHeaders(false); + self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); + + $disposition = $headers['content-disposition'][0] ?? ''; + self::assertStringContainsString('attachment; filename="catalogue-produits-', $disposition); + self::assertMatchesRegularExpression( + '/filename="catalogue-produits-\d{8}\.xlsx"/', + $disposition, + ); + + // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes. + $grid = $this->gridFromResponse($response->getContent()); + $headerCells = $grid[0]; + self::assertSame('Numéro', $headerCells[0]); + self::assertSame('Nom', $headerCells[1]); + self::assertContains('États', $headerCells); + self::assertContains('Catégorie', $headerCells); + self::assertContains('Sites', $headerCells); + self::assertContains('Types de stockage', $headerCells); + self::assertContains('Fabriqué', $headerCells); + self::assertContains('Contient mélasse', $headerCells); + + // Au moins une ligne de donnees (le produit seede). + self::assertContains('TEST_PRD_A', $this->codes($response->getContent())); + } + + public function testExportExcludesSoftDeletedByDefault(): void + { + $client = $this->createAdminClient(); + $this->seedProduct('TEST_PRD_ACTIVE', 'Active One'); + $this->seedProduct('TEST_PRD_DELETED', 'Deleted One', deletedAt: new DateTimeImmutable()); + + $codes = $this->codes($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('TEST_PRD_ACTIVE', $codes); + self::assertNotContains('TEST_PRD_DELETED', $codes); + } + + public function testExportRespectsSearchFilter(): void + { + $client = $this->createAdminClient(); + $this->seedProduct('TEST_PRD_SRCH', 'Searchable Alpha'); + $this->seedProduct('TEST_PRD_OTHER', 'Other Beta'); + + $codes = $this->codes( + $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), + ); + + self::assertContains('TEST_PRD_SRCH', $codes); + self::assertNotContains('TEST_PRD_OTHER', $codes); + } + + public function testExportRespectsStateFilter(): void + { + $client = $this->createAdminClient(); + $this->seedProduct('TEST_PRD_SALE', 'Sold One', [Product::STATE_SALE]); + $this->seedProduct('TEST_PRD_BUY', 'Bought One', [Product::STATE_PURCHASE]); + + $codes = $this->codes( + $client->request('GET', self::EXPORT_URL.'?state=SALE')->getContent(), + ); + + self::assertContains('TEST_PRD_SALE', $codes); + self::assertNotContains('TEST_PRD_BUY', $codes); + } + + public function testExportPopulatesAllBusinessColumns(): void + { + $client = $this->createAdminClient(); + + $site = $this->firstSite(); + $storageType = $this->seedStorageType('TEST_STP', 'Tas de test'); + $category = $this->createCategory('test_cat_export_produit'); + + $this->seedProduct( + 'TEST_PRD_FULL', + 'Complet', + [Product::STATE_PURCHASE, Product::STATE_SALE], + true, + true, + null, + $site, + $storageType, + $category, + ); + + $row = $this->rowForCode($client->request('GET', self::EXPORT_URL)->getContent(), 'TEST_PRD_FULL'); + self::assertNotNull($row, 'Le produit seede est absent de l\'export.'); + + // 0 Numéro | 1 Nom | 2 États | 3 Catégorie | 4 Sites | 5 Types de stockage | 6 Fabriqué | 7 Contient mélasse + self::assertSame('TEST_PRD_FULL', $row[0]); + self::assertSame('Complet', $row[1]); + self::assertSame('Achat, Vendu', $row[2]); + self::assertSame((string) $category->getName(), $row[3]); + self::assertSame((string) $site->getName(), $row[4]); + self::assertSame('Tas de test', $row[5]); + self::assertSame('Oui', $row[6]); + self::assertSame('Oui', $row[7]); + } + + public function testForbiddenWithoutProductsViewPermission(): void + { + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnauthorizedWhenAnonymous(): void + { + $client = self::createClient(); + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Seede un produit complet (categorie + 1 site + 1 type de stockage par + * defaut). Les relations omises sont creees a la volee. Persistance directe + * via l'EM : on bypasse le Processor/Validator (non teste ici). + * + * @param list $states + */ + private function seedProduct( + string $code, + string $name, + array $states = [Product::STATE_PURCHASE], + bool $manufactured = false, + bool $containsMolasses = false, + ?DateTimeImmutable $deletedAt = null, + ?Site $site = null, + ?StorageType $storageType = null, + ?Category $category = null, + ): Product { + $em = $this->getEm(); + + $product = new Product(); + $product->setCode($code); + $product->setName($name); + $product->setStates($states); + $product->setManufactured($manufactured); + $product->setContainsMolasses($containsMolasses); + $product->setCategory($category ?? $this->createCategory()); + $product->addSite($site ?? $this->firstSite()); + $product->addStorageType($storageType ?? $this->seedStorageType(self::TEST_STORAGE_PREFIX.strtoupper(substr(bin2hex(random_bytes(4)), 0, 8)))); + $product->setDeletedAt($deletedAt); + + $em->persist($product); + $em->flush(); + + return $product; + } + + /** + * Cree un type de stockage de test (code prefixe TEST_ pour le cleanup). + */ + private function seedStorageType(string $code, string $label = 'Type de stockage de test'): StorageType + { + $em = $this->getEm(); + + $storageType = new StorageType(); + $storageType->setCode($code); + $storageType->setLabel($label); + + $em->persist($storageType); + $em->flush(); + + return $storageType; + } + + /** + * Premier site seede (les sites existent en base de test, comme dans les + * autres tests d'export). + */ + private function firstSite(): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); + self::assertNotNull($site, 'Aucun site seede : impossible de seeder un produit.'); + + return $site; + } + + /** + * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. + * + * @return array> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Extrait la colonne « Numéro » (1re colonne) des lignes de donnees. + * + * @return list + */ + private function codes(string $binary): array + { + $grid = $this->gridFromResponse($binary); + $rows = array_slice($grid, 1); // saute l'en-tete + + return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows)); + } + + /** + * Renvoie la ligne de donnees dont la colonne « Numéro » vaut $code, ou null. + * + * @return null|array + */ + private function rowForCode(string $binary, string $code): ?array + { + $grid = $this->gridFromResponse($binary); + foreach (array_slice($grid, 1) as $row) { + if ((string) ($row[0] ?? '') === $code) { + return $row; + } + } + + return null; + } +} diff --git a/tests/Module/Catalog/Api/ProductRBACMatrixTest.php b/tests/Module/Catalog/Api/ProductRBACMatrixTest.php new file mode 100644 index 0000000..199ad71 --- /dev/null +++ b/tests/Module/Catalog/Api/ProductRBACMatrixTest.php @@ -0,0 +1,93 @@ + 403 partout. + * + * On prouve aussi que l'acces n'est pas « admin only » par hasard mais bien + * porte par les permissions : un non-admin avec `view` lit (200) mais ne peut + * pas creer (403, refus au niveau securite avant denormalisation). + * + * Note : on ne teste pas « un non-admin avec `manage` cree un produit » — ce role + * n'existe dans aucun persona (catalogue admin-only) et un tel user ne pourrait + * de toute facon pas resoudre les IRI sites / categories / storage_types lors de + * la denormalisation (ces ressources portent leur propre controle d'acces). La + * creation par un porteur de `manage` est couverte par l'Admin (qui bypass). + * + * @internal + */ +final class ProductRBACMatrixTest extends AbstractProductApiTestCase +{ + /** Personas metier sans permission produit (§ 5.2). */ + private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine']; + + public function testAdminHasFullAccess(): void + { + $client = $this->createAdminClient(); + + $client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testBusinessPersonasAreForbiddenEverywhere(): void + { + // Produit existant cible des operations item (seede par l'admin via l'EM). + $product = $this->seedProductEntity(); + $id = (int) $product->getId(); + + foreach (self::PERSONAS as $persona) { + $client = $this->createPersonaClient($persona); + + $client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les produits.'); + + $client->request('GET', '/api/products/'.$id, ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un produit.'); + + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(), + ]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de produit.'); + + $client->request('PATCH', '/api/products/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['name' => 'Renomme par '.$persona], + ]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un produit.'); + } + } + + public function testViewPermissionReadsButCannotManage(): void + { + $product = $this->seedProductEntity(); + $client = $this->authView(); + + $client->request('GET', '/api/products', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + $client->request('GET', '/api/products/'.$product->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // view sans manage : creation refusee au niveau securite (403 avant que la + // denormalisation ne tente de resoudre les IRI -> pas de 400 parasite). + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(), + ]); + self::assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Module/Catalog/Api/ProductSerializationContractTest.php b/tests/Module/Catalog/Api/ProductSerializationContractTest.php new file mode 100644 index 0000000..b32a76d --- /dev/null +++ b/tests/Module/Catalog/Api/ProductSerializationContractTest.php @@ -0,0 +1,116 @@ +createAdminClient(); + + // Produit cree par un POST reel (mix Achat + Vendu pour exercer les + // champs conditionnels au passage). + $created = $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload([ + 'states' => ['PURCHASE', 'SALE'], + 'manufactured' => true, + 'containsMolasses' => true, + ]), + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + $id = (int) $created['id']; + $code = (string) $created['code']; + + $detail = $client->request('GET', '/api/products/'.$id, [ + 'headers' => ['Accept' => self::LD], + ])->toArray(); + $list = $client->request('GET', '/api/products?search='.$code, [ + 'headers' => ['Accept' => self::LD], + ])->toArray(); + + // Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:). + self::assertArrayHasKey('member', $list); + self::assertArrayNotHasKey('hydra:member', $list); + + $row = $this->memberById($list, $id); + self::assertNotNull($row, 'Le produit cree doit apparaitre dans la liste filtree.'); + + // === Piege #4 : code present (= « Numero ») + name === + self::assertArrayHasKey('code', $row); + self::assertSame($code, $row['code']); + self::assertSame('Produit test', $row['name']); + + // === Piege #1 : category en OBJET embarque (pas IRI nu) === + self::assertIsArray($row['category'], 'category doit etre un objet embarque (category:read), pas un IRI nu.'); + self::assertArrayHasKey('name', $row['category']); + + // === Piege #3 : states tableau de chaines + booleens presents === + self::assertSame(['PURCHASE', 'SALE'], $row['states']); + self::assertArrayHasKey('manufactured', $row); + self::assertArrayHasKey('containsMolasses', $row); + self::assertTrue($row['manufactured']); + self::assertTrue($row['containsMolasses']); + + // === DETAIL : category embarque + sites / storageTypes en objets === + self::assertIsArray($detail['category']); + self::assertArrayHasKey('name', $detail['category']); + + // === Piege #2 : sites / storageTypes = tableaux d'OBJETS (pas IRI) === + self::assertIsArray($detail['sites']); + self::assertNotEmpty($detail['sites']); + self::assertIsArray($detail['sites'][0], 'sites doit etre un tableau d\'objets (site:read), pas d\'IRI.'); + self::assertArrayHasKey('name', $detail['sites'][0]); + + self::assertIsArray($detail['storageTypes']); + self::assertNotEmpty($detail['storageTypes']); + self::assertIsArray($detail['storageTypes'][0], 'storageTypes doit etre un tableau d\'objets (storage_type:read), pas d\'IRI.'); + self::assertArrayHasKey('label', $detail['storageTypes'][0]); + self::assertArrayHasKey('code', $detail['storageTypes'][0]); + + self::assertSame(['PURCHASE', 'SALE'], $detail['states']); + + $this->dumpDodIfRequested($list, $detail); + } + + /** + * DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si PRODUCT_DOD_DUMP + * est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis. + * + * @param array $list + * @param array $detail + */ + private function dumpDodIfRequested(array $list, array $detail): void + { + if (false === getenv('PRODUCT_DOD_DUMP')) { + return; + } + + $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + file_put_contents('/tmp/product-dod-list.json', json_encode($list, $flags)); + file_put_contents('/tmp/product-dod-detail.json', json_encode($detail, $flags)); + } +} diff --git a/tests/Module/Catalog/Api/ProductStatesValidationTest.php b/tests/Module/Catalog/Api/ProductStatesValidationTest.php new file mode 100644 index 0000000..1c66061 --- /dev/null +++ b/tests/Module/Catalog/Api/ProductStatesValidationTest.php @@ -0,0 +1,56 @@ + 422 (Assert\Count(min: 1)) sur le champ `states` ; + * - valeur hors enum -> 422 (Assert\Choice) sur le champ `states` ; + * - un seul etat valide -> 201 (borne basse acceptee). + * + * @internal + */ +final class ProductStatesValidationTest extends AbstractProductApiTestCase +{ + public function testEmptyStatesIsRejected(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(['states' => []]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('states', $this->violationPaths($response)); + } + + public function testUnknownStateValueIsRejected(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(['states' => ['PURCHASE', 'FOOBAR']]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('states', $this->violationPaths($response)); + } + + public function testSingleValidStateIsAccepted(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload(['states' => ['OTHER']]), + ]); + + self::assertResponseStatusCodeSame(201); + } +} diff --git a/tests/Module/Catalog/Api/ProductStorageTypeBySiteTest.php b/tests/Module/Catalog/Api/ProductStorageTypeBySiteTest.php new file mode 100644 index 0000000..e3cd4b4 --- /dev/null +++ b/tests/Module/Catalog/Api/ProductStorageTypeBySiteTest.php @@ -0,0 +1,56 @@ +createAdminClient(); + + $siteA = $this->siteByCode('86'); + $siteB = $this->siteByCode('17'); + + // Type de stockage disponible uniquement sur le site B... + $storageType = $this->seedStorageType('Cellule site B', $siteB); + + // ... mais produit declare sur le site A seulement -> 422. + $response = $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload([ + 'sites' => [$this->iri('sites', (int) $siteA->getId())], + 'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())], + ]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('storageTypes', $this->violationPaths($response)); + } + + public function testStorageTypeOnSelectedSiteIsAccepted(): void + { + $client = $this->createAdminClient(); + + $siteA = $this->siteByCode('86'); + $storageType = $this->seedStorageType('Tas site A', $siteA); + + $client->request('POST', '/api/products', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validProductPayload([ + 'sites' => [$this->iri('sites', (int) $siteA->getId())], + 'storageTypes' => [$this->iri('storage_types', (int) $storageType->getId())], + ]), + ]); + + self::assertResponseStatusCodeSame(201); + } +}