diff --git a/docs/specs/M6-produit/spec-back.md b/docs/specs/M6-produit/spec-back.md index 15d8598..188ced2 100644 --- a/docs/specs/M6-produit/spec-back.md +++ b/docs/specs/M6-produit/spec-back.md @@ -35,7 +35,7 @@ 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 + - Sites # Site (relation ManyToMany product↔site, RG-6.04) - Core # User, Role, Permission, Audit, JWT - Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + CategoryInterface --- @@ -65,7 +65,7 @@ Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx | 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). | +| 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, **plat**), seedé avec la liste Figma (node 1503-34285) ; multi-select listant **tous** les types (plus de filtrage par site — décision 26/06, § 2.4). La disponibilité par site relèvera du futur module **Stockage**. À re-seeder quand Aurore livre la liste définitive (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). | @@ -95,14 +95,16 @@ Ajout au module **`Catalog`** (pas de nouveau module — C1) : 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 +### 2.4 Référentiel `StorageType` (C4 / RG-6.06) — PROVISOIRE, référentiel PLAT > **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). +> +> **Décision Tristan (26/06)** : `StorageType` devient un **référentiel PLAT** — plus de rattachement aux sites. La disponibilité « tel type sur tel site » relèvera de la **future entité `Stockage`** (module Stockage : un stockage = 1 site + 1 type), dérivée des stockages réels. On **retire** donc la jointure `storage_type_site` et **tout filtrage du multi-select par site** (migration `Version20260626100000` : drop de la jointure + seed idempotent). Le référentiel est aussi seedé **en migration** (prod-safe, comme `payment_type`/`bank`/`country`), la fixture ne servant qu'au re-seed dev après purge. -- 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). +- Entité `StorageType` (`Catalog`) : `id`, `code` (slug MAJUSCULE stable, unique), `label` (FR affiché). **Plus de relation `sites`.** +- **Seed (10 valeurs, Figma)** : Boisseau, Boisseau dosage, Case, Cellule, Container, Cuve mélasse, Stockage big bag, Stockage palette, Tas, Zone. Seedées en migration (`ON CONFLICT (code) DO NOTHING`) **et** par `StorageTypeFixtures` (dev/test). +- Le champ produit « Type de stockage » est un **multi-select listant TOUS les types** : `GET /api/storage_types` (plus de paramètre `?siteId[]=`). +- **Provisoire** : codes et libellés 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) @@ -155,7 +157,7 @@ Le docx M6 ne prévoit **ni archivage ni suppression** (actions = + Ajouter / Ex +---+ ``` -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)`. +Tables de jonction : `product_site (product_id, site_id)`, `product_storage_type (product_id, storage_type_id)`. *(La jonction `storage_type_site` initialement créée par ERP-198 a été **supprimée** : `StorageType` est devenu un référentiel plat — migration `Version20260626100000`, décision 26/06, § 2.4.)* ### 3.2 Migration Doctrine — SQL Postgres (illustratif) @@ -176,6 +178,8 @@ CREATE TABLE storage_type ( ); CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code); +-- NB : storage_type_site (créée ici par ERP-198) est DROPPÉE par la migration +-- Version20260626100000 — StorageType est un référentiel plat (décision 26/06, § 2.4). 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, @@ -355,7 +359,7 @@ class Product implements TimestampableInterface, BlamableInterface #[Groups(['product:read', 'product:write'])] private Collection $sites; - /** @var Collection Types de stockage (≥ 1, filtrés par sites — RG-6.06). */ + /** @var Collection Types de stockage (≥ 1 — RG-6.06, référentiel plat). */ #[ORM\ManyToMany(targetEntity: StorageType::class)] #[ORM\JoinTable(name: 'product_storage_type')] #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')] @@ -371,8 +375,9 @@ class Product implements TimestampableInterface, BlamableInterface $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). + // RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT) : + // cohérence via #[Assert\Callback] (§ 7). RG-6.06 = simple Assert\Count(min:1) + // (référentiel plat, plus de contrainte de disponibilité par site). // ... getters/setters ... } ``` @@ -544,7 +549,7 @@ Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\ - 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). +- Référentiel **plat** : renvoie TOUS les types (plus de paramètre `?siteId[]=` — RG-6.06 revue, § 2.4). - **`?pagination=false`** : échappatoire select (référentiel borné ≤ quelques dizaines) — règle frontend. - `normalizationContext: ['storage_type:read']` ; tri `label ASC`. @@ -555,7 +560,7 @@ Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\ 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. + 4. Valide que `category` est de type **PRODUIT** (RG-6.05) → 422 sinon. `storageTypes` : `≥ 1` (RG-6.06, référentiel plat — plus de contrainte de disponibilité par site). - Réponse `201` avec le produit complet. ### 4.4 `PATCH /api/products/{id}` (modification) @@ -628,13 +633,13 @@ Toute permission `catalog.products.*` doit être posée **simultanément** dans | **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.06** | docx+back | `storageTypes` (multi-select) obligatoire, **≥ 1**. Référentiel `StorageType` **plat** (tous les types, **plus de filtrage par site** — décision 26/06, § 2.4) et **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`). +Cohérence inter-champs (RG-6.03 / 6.05) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres (non-vacuité `states`). RG-6.06 : simple `Assert\Count(min:1)` (référentiel plat, plus de validation de disponibilité par site). ## 8. Tests (PHPUnit) — `make test` @@ -643,7 +648,7 @@ Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via `#[Assert\Call - **`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). +- ~~**`ProductStorageTypeBySiteTest`**~~ : supprimé — `StorageType` est un référentiel plat (plus de disponibilité par site, RG-6.06 revue, § 2.4). - **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`. @@ -652,7 +657,7 @@ Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via `#[Assert\Call | 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-02** | Liste **définitive des types de stockage** (fournie par Aurore). Re-seed du référentiel `StorageType` (§ 2.4). La disponibilité par site relèvera du futur module **Stockage** (un stockage = 1 site + 1 type), pas de ce référentiel. | | **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). | diff --git a/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts index 3d82cd8..2c0b24a 100644 --- a/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts +++ b/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts @@ -29,23 +29,13 @@ vi.stubGlobal('useI18n', () => ({ params ? `${key}::${JSON.stringify(params)}` : key, })) -/** Reponse Hydra des types de stockage selon les sites demandes. */ -function storageMembersForSites(siteIds: string[]): { member: Array<{ '@id': string, label: string }> } { - // Site 1 → types 9 et 5 ; site 2 → type 7. Permet de tester la cascade. - const byId: Record> = { - '1': [ - { '@id': '/api/storage_types/9', label: 'Tas' }, - { '@id': '/api/storage_types/5', label: 'Cellule' }, - ], - '2': [{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' }], - } - const seen = new Map() - for (const id of siteIds) { - for (const m of byId[id] ?? []) { - seen.set(m['@id'], m) - } - } - return { member: [...seen.values()] } +/** Referentiel PLAT des types de stockage (renvoye tel quel, sans filtre site). */ +const STORAGE_TYPES = { + member: [ + { '@id': '/api/storage_types/9', label: 'Tas' }, + { '@id': '/api/storage_types/5', label: 'Cellule' }, + { '@id': '/api/storage_types/7', label: 'Cuve mélasse' }, + ], } describe('useProductForm', () => { @@ -56,8 +46,9 @@ describe('useProductForm', () => { mockToastSuccess.mockReset() mockToastError.mockReset() - // Routage des GET par url (referentiels + cascade stockage). - mockGet.mockImplementation((url: string, query: Record = {}) => { + // Routage des GET par url (referentiels). Le stockage est un referentiel + // plat : meme reponse quelle que soit la requete. + mockGet.mockImplementation((url: string) => { if (url === '/sites') { return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] }) } @@ -65,8 +56,7 @@ describe('useProductForm', () => { return Promise.resolve({ member: [{ '@id': '/api/categories/12', name: 'Céréales' }] }) } if (url === '/storage_types') { - const raw = (query['siteId[]'] ?? []) as string[] - return Promise.resolve(storageMembersForSites(raw)) + return Promise.resolve(STORAGE_TYPES) } return Promise.resolve({ member: [] }) }) @@ -97,44 +87,36 @@ describe('useProductForm', () => { }) }) - describe('RG-6.06 — cascade Site → Type de stockage', () => { - it('charge les types de stockage filtres par les sites selectionnes', async () => { - const { storageTypeOptions, setSites } = useProductForm() - await setSites(['/api/sites/1']) + describe('RG-6.06 — types de stockage (referentiel plat)', () => { + it('loadReferentials charge TOUS les types de stockage, sans filtre site', async () => { + const { storageTypeOptions, loadReferentials } = useProductForm() + await loadReferentials() - expect(mockGet).toHaveBeenCalledWith( - '/storage_types', - expect.objectContaining({ 'siteId[]': ['1'], pagination: 'false' }), - expect.any(Object), - ) + const storageCall = mockGet.mock.calls.find(c => c[0] === '/storage_types') + expect(storageCall).toBeDefined() + // Aucun filtre siteId envoye (referentiel plat). + expect(storageCall?.[1]).not.toHaveProperty('siteId[]') expect(storageTypeOptions.value.map(o => o.value)).toEqual([ '/api/storage_types/9', '/api/storage_types/5', + '/api/storage_types/7', ]) }) - it('retire de la selection les types devenus indisponibles', async () => { - const { form, setStorageTypes, setSites } = useProductForm() + it('setSites met a jour les sites sans recharger le stockage ni purger la selection', async () => { + const { form, setSites, setStorageTypes, loadReferentials } = useProductForm() + await loadReferentials() + const storageCallsBefore = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length - // Selection initiale sur le site 1 (types 9 et 5). - await setSites(['/api/sites/1']) - setStorageTypes(['/api/storage_types/9', '/api/storage_types/5']) - - // Bascule vers le site 2 (type 7 seul) : 9 et 5 ne sont plus dispo. - await setSites(['/api/sites/2']) - expect(form.storageTypeIris).toEqual([]) - }) - - it('vide options + selection quand plus aucun site n\'est selectionne', async () => { - const { form, storageTypeOptions, setStorageTypes, setSites } = useProductForm() - await setSites(['/api/sites/1']) setStorageTypes(['/api/storage_types/9']) + setSites(['/api/sites/1']) - await setSites([]) - expect(storageTypeOptions.value).toEqual([]) - expect(form.storageTypeIris).toEqual([]) - // Pas d'appel /storage_types inutile sans site. - expect(mockGet).not.toHaveBeenCalledWith('/storage_types', expect.objectContaining({ 'siteId[]': [] }), expect.any(Object)) + expect(form.siteIris).toEqual(['/api/sites/1']) + // Selection conservee : plus de cascade ni de purge par site. + expect(form.storageTypeIris).toEqual(['/api/storage_types/9']) + // setSites ne declenche aucun nouvel appel /storage_types. + const storageCallsAfter = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length + expect(storageCallsAfter).toBe(storageCallsBefore) }) }) @@ -235,8 +217,8 @@ describe('useProductForm', () => { createdAt: '', updatedAt: '', } - it('pre-remplit le formulaire depuis le produit (relations en IRI) + charge le stockage', async () => { - const { form, prefill, storageTypeOptions } = useProductForm() + it('pre-remplit le formulaire depuis le produit (relations en IRI)', async () => { + const { form, prefill } = useProductForm() await prefill(PRODUCT) expect(form.code).toBe('BLE-01') @@ -246,13 +228,6 @@ describe('useProductForm', () => { expect(form.siteIris).toEqual(['/api/sites/1']) expect(form.storageTypeIris).toEqual(['/api/storage_types/9']) expect(form.manufactured).toBe(true) - // Cascade : options de stockage chargees pour le site du produit. - expect(mockGet).toHaveBeenCalledWith( - '/storage_types', - expect.objectContaining({ 'siteId[]': ['1'] }), - expect.any(Object), - ) - expect(storageTypeOptions.value.map(o => o.value)).toContain('/api/storage_types/9') }) it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => { diff --git a/frontend/modules/catalog/composables/useProductForm.ts b/frontend/modules/catalog/composables/useProductForm.ts index ec4dc7e..094fea5 100644 --- a/frontend/modules/catalog/composables/useProductForm.ts +++ b/frontend/modules/catalog/composables/useProductForm.ts @@ -2,8 +2,8 @@ * Composable du formulaire de creation produit (M6 — ERP-205). * * Porte l'etat du formulaire principal, les referentiels des selects, les regles - * de gestion front (champs conditionnels RG-6.03, cascade site→stockage RG-6.06) - * et la soumission `POST /api/products` avec mapping des erreurs 422/409 inline + * de gestion front (champs conditionnels RG-6.03) et la soumission + * `POST /api/products` avec mapping des erreurs 422/409 inline * (useFormErrors). Reference : ecran « Ajouter un client » / « Ajouter un * prestataire » (formulaire principal). * @@ -20,12 +20,6 @@ import type { Product } from '~/modules/catalog/types/product' /** Etats produit (miroir de l'enum back Product::STATE_*). */ export const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const -/** Extrait l'id numerique d'un IRI Hydra (`/api/sites/1` → 1), sinon null. */ -function iriToId(iri: string): number | null { - const tail = iri.split('/').pop() - return tail !== undefined && /^\d+$/.test(tail) ? Number(tail) : null -} - export function useProductForm() { const api = useApi() const { t } = useI18n() @@ -85,34 +79,25 @@ export function useProductForm() { form.storageTypeIris = iris } - /** - * RG-6.06 (cascade) : a chaque changement de Site, recharge les options de Type - * de stockage filtrees par les sites choisis et retire de la selection les - * types devenus indisponibles. - */ - async function setSites(iris: string[]): Promise { + /** Met a jour les sites de disponibilite (multi-select, RG-6.04). */ + function setSites(iris: string[]): void { form.siteIris = iris - const siteIds = iris - .map(iriToId) - .filter((id): id is number => id !== null) - - await storage.load(siteIds) - - const available = new Set(storage.options.value.map(o => o.value)) - form.storageTypeIris = form.storageTypeIris.filter(iri => available.has(iri)) } - /** Charge les referentiels initiaux (sites + categories). Resilient. */ + /** + * Charge les referentiels initiaux (sites + categories + types de stockage). + * Resilient. Les types de stockage forment un referentiel plat : on les charge + * tous d'emblee (plus de cascade par site, RG-6.06 revue). + */ async function loadReferentials(): Promise { - await Promise.allSettled([sites.load(), categories.load()]) - // Les types de stockage se chargent a la 1re selection de sites (cascade). + await Promise.allSettled([sites.load(), categories.load(), storage.load()]) } /** * Pre-remplit le formulaire depuis un produit charge (mode edition, RG-6.08). * Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects). - * Charge au passage les options de Type de stockage pour les sites du produit, - * afin que le multi-select affiche les libelles et conserve la selection. + * Les options de Type de stockage sont chargees par loadReferentials (referentiel + * plat) : prefill se contente de mapper la selection. */ async function prefill(product: Product): Promise { productId.value = product.id @@ -123,11 +108,6 @@ export function useProductForm() { form.siteIris = product.sites.map(s => s['@id']) form.manufactured = product.manufactured form.containsMolasses = product.containsMolasses - - const siteIds = form.siteIris - .map(iriToId) - .filter((id): id is number => id !== null) - await storage.load(siteIds) form.storageTypeIris = product.storageTypes.map(st => st['@id']) } diff --git a/frontend/modules/catalog/composables/useProductOptions.ts b/frontend/modules/catalog/composables/useProductOptions.ts index 912cac3..7e97c6d 100644 --- a/frontend/modules/catalog/composables/useProductOptions.ts +++ b/frontend/modules/catalog/composables/useProductOptions.ts @@ -70,24 +70,15 @@ export function useCategoryOptions(params: { typeCode: string }) { } /** - * Types de stockage (libelle = `label`). Filtres par les sites selectionnes - * (`?siteId[]=…`, RG-6.06) : on ne charge que les types disponibles sur AU MOINS - * UN des sites passes. Sans site, la liste est videe (le multi-select depend des - * sites). + * Types de stockage (libelle = `label`). Referentiel PLAT : on charge TOUS les + * types, sans filtrage par site (RG-6.06 revue — la dispo par site releve du futur + * module Stockage). */ export function useStorageTypeOptions() { const options = ref([]) - async function load(siteIds: number[]): Promise { - if (siteIds.length === 0) { - options.value = [] - return - } - options.value = await fetchOptions( - '/storage_types', - { 'siteId[]': siteIds.map(String) }, - s => s.label ?? '', - ) + async function load(): Promise { + options.value = await fetchOptions('/storage_types', {}, s => s.label ?? '') } return { options, load } diff --git a/frontend/modules/catalog/pages/admin/products/[id]/edit.vue b/frontend/modules/catalog/pages/admin/products/[id]/edit.vue index 02a20c2..d2c7c03 100644 --- a/frontend/modules/catalog/pages/admin/products/[id]/edit.vue +++ b/frontend/modules/catalog/pages/admin/products/[id]/edit.vue @@ -31,8 +31,7 @@ :error="errors.states" @update:model-value="(v: (string | number)[]) => setStates(v.map(String))" /> - + - + - + - + addSql('DROP TABLE IF EXISTS storage_type_site'); + + // 2. Seed idempotent (miroir StorageTypeFixtures) : alimente la prod ou les + // fixtures ne tournent pas. ON CONFLICT (code) -> rejouable sans doublon + // (s'appuie sur l'index unique uq_storage_type_code). + $this->addSql(<<<'SQL' + INSERT INTO storage_type (code, label) VALUES + ('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') + ON CONFLICT (code) DO NOTHING + SQL); + } + + public function down(Schema $schema): void + { + // Retire uniquement les 10 types seedes ET restes orphelins (aucun produit ne + // les reference via product_storage_type). Sans le NOT EXISTS, le DELETE casse + // sur la FK RESTRICT product_storage_type.storage_type_id. Symetrique du + // ON CONFLICT DO NOTHING du up(). + $this->addSql(<<<'SQL' + DELETE FROM storage_type + WHERE code IN ( + 'BOISSEAU', 'BOISSEAU_DOSAGE', 'CASE', 'CELLULE', 'CONTAINER', + 'CUVE_MELASSE', 'STOCKAGE_BIG_BAG', 'STOCKAGE_PALETTE', 'TAS', 'ZONE' + ) + AND NOT EXISTS ( + SELECT 1 FROM product_storage_type pst WHERE pst.storage_type_id = storage_type.id + ) + SQL); + + // Recree la jointure M2M storage_type <-> site (etat anterieur a cette migration). + $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->addSql('COMMENT ON TABLE "storage_type_site" IS $_$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->addSql('COMMENT ON COLUMN "storage_type_site"."storage_type_id" IS $_$FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.$_$'); + $this->addSql('COMMENT ON COLUMN "storage_type_site"."site_id" IS $_$FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.$_$'); + } +} diff --git a/src/Module/Catalog/Domain/Entity/Product.php b/src/Module/Catalog/Domain/Entity/Product.php index eceb5a1..75605e7 100644 --- a/src/Module/Catalog/Domain/Entity/Product.php +++ b/src/Module/Catalog/Domain/Entity/Product.php @@ -49,12 +49,12 @@ use function in_array; * 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. + * - RG-6.06 : `storageTypes` >= 1 (referentiel plat — plus de filtrage par site). * * 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 + * Les RG inter-champs (RG-6.03/6.05) 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). * @@ -204,9 +204,9 @@ class Product implements TimestampableInterface, BlamableInterface 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. + * Types de stockage du produit (>= 1, RG-6.06). Referentiel plat : tous les + * types sont selectionnables (plus de filtrage par site). Cote inverse en + * ON DELETE RESTRICT : un type reference par un produit ne peut etre supprime. * * @var Collection */ @@ -396,43 +396,4 @@ class Product implements TimestampableInterface, BlamableInterface ; } } - - /** - * 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 index 41cc563..f263e71 100644 --- a/src/Module/Catalog/Domain/Entity/StorageType.php +++ b/src/Module/Catalog/Domain/Entity/StorageType.php @@ -9,22 +9,19 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageTypeProvider; use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository; -use App\Module\Sites\Domain\Entity\Site; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; /** * Type de stockage : referentiel PROVISOIRE classifiant ou un produit peut etre - * stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste et - * le mapping site definitifs d'Aurore (HP-M6-02) ; seede avec la liste Figma - * (node 1503-34285) au ticket ERP-201. + * stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste + * definitive d'Aurore (HP-M6-02 / ERP-201). * - * Relation `sites` (ManyToMany -> 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). + * Referentiel PLAT : un type de stockage n'est PAS rattache a des sites. La + * disponibilite « tel type sur tel site » releve de la future entite Stockage + * (module Stockage : un stockage = 1 site + 1 type) et sera derivee des stockages + * reels, pas portee par ce referentiel. Le multi-select « Type de stockage » du + * formulaire produit liste donc TOUS les types, sans filtrage par site (RG-6.06). * * 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` @@ -39,10 +36,9 @@ use Symfony\Component\Serializer\Attribute\Groups; */ #[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). + // Tri label ASC porte par le StorageTypeProvider : alimente le multi-select + // « Type de stockage » du formulaire produit (TOUS les types — referentiel + // plat). Pagination Hydra + echappatoire ?pagination=false (referentiel borne). new GetCollection( security: "is_granted('catalog.products.view')", normalizationContext: ['groups' => ['storage_type:read']], @@ -75,24 +71,6 @@ class StorageType #[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; @@ -121,28 +99,4 @@ class StorageType 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/StorageTypeRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php index 293ca7a..39ed7ae 100644 --- a/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php +++ b/src/Module/Catalog/Domain/Repository/StorageTypeRepositoryInterface.php @@ -21,11 +21,8 @@ interface StorageTypeRepositoryInterface /** * 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 + * StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2). Referentiel plat : + * plus de filtrage par site (la dispo par site releve du futur module Stockage). */ - public function createListQueryBuilder(array $siteIds = []): QueryBuilder; + public function createListQueryBuilder(): QueryBuilder; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php index c8f53ee..6010df0 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageTypeProvider.php @@ -18,12 +18,12 @@ use function is_int; use function is_string; /** - * Provider StorageType (referentiel lecture seule, ERP-201) : - * - LISTE : tri `label ASC` (defaut spec § 4.2), filtre `?siteId[]=` (RG-6.06 : - * types disponibles sur au moins un des sites passes) et collection PAGINEE - * Hydra (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour - * alimenter le multi-select « Type de stockage » du formulaire produit - * (referentiel borne — pagination_client_enabled). + * Provider StorageType (referentiel plat lecture seule) : + * - LISTE : tri `label ASC` (defaut spec § 4.2) et collection PAGINEE Hydra + * (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour + * alimenter le multi-select « Type de stockage » du formulaire produit avec + * TOUS les types (referentiel borne — pagination_client_enabled). Plus de + * filtrage par site : la dispo par site releve du futur module Stockage. * - ITEM : lookup simple par id. * * @implements ProviderInterface @@ -39,7 +39,7 @@ final class StorageTypeProvider implements ProviderInterface public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null { if ($operation instanceof CollectionOperationInterface) { - $qb = $this->repository->createListQueryBuilder($this->readSiteIds($context)); + $qb = $this->repository->createListQueryBuilder(); // Echappatoire ?pagination=false : collection complete sans Paginator // (alimentation du multi-select, referentiel borne). @@ -65,30 +65,4 @@ final class StorageTypeProvider implements ProviderInterface 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/DataFixtures/StorageTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php index f8f1010..e282adc 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/StorageTypeFixtures.php @@ -6,41 +6,39 @@ namespace App\Module\Catalog\Infrastructure\DataFixtures; use App\Module\Catalog\Domain\Entity\StorageType; use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface; -use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures; -use App\Shared\Domain\Contract\SiteProviderInterface; use Doctrine\Bundle\FixturesBundle\Fixture; -use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; /** - * Fixtures du module Catalog : seed du referentiel `storage_type` (M6). + * Fixtures du module Catalog : seed du referentiel PLAT `storage_type` (M6). * - * ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes, libelles ET mapping - * site ci-dessous sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste et le - * mapping definitifs par site. La liste actuelle reprend les 10 valeurs de la - * maquette Figma (node 1503-34285) et les rattache PAR DEFAUT aux 3 sites - * (Chatellerault 86 / Saint-Jean 17 / Pommevic 82), faute de mapping reel. + * ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes et libelles ci-dessous + * sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste definitive (ERP-201). + * La liste actuelle reprend les 10 valeurs de la maquette Figma (node 1503-34285). * - * Pourquoi une fixture (et pas un seed de migration) : `storage_type` est une - * entite managee par l'ORM, donc le purger Doctrine la vide avant chaque - * `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test (le referentiel - * doit exister pour alimenter le formulaire produit et les tests du filtre - * ?siteId[]= — ERP-203). Elle tourne dans TOUS les environnements (referentiel, - * pas une donnee de demo — miroir CategoryTypeFixtures). + * Referentiel PLAT : un type de stockage n'est plus rattache a des sites (la dispo + * par site releve du futur module Stockage). Cette fixture ne seede donc que les + * lignes `storage_type` ; la voie prod-safe est l'INSERT idempotent de la migration + * Version20260626100000 (les fixtures ne tournent pas en prod). Source unique : les + * memes 10 valeurs ici et dans la migration. + * + * Pourquoi une fixture EN PLUS de la migration : `storage_type` est une entite + * managee par l'ORM, donc le purger Doctrine la vide avant chaque + * `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test apres la purge + * (referentiel necessaire au formulaire produit et a ses tests). Elle tourne dans + * TOUS les environnements (referentiel, pas une donnee de demo — miroir + * CategoryTypeFixtures). * * Idempotence : lookup par `code` parmi les types existants avant insertion - * (miroir CategoryTypeFixtures). `addSite()` est lui-meme idempotent (contains()). - * Rejouable sans doublon meme si le purger Doctrine est desactive. - * - * Depend de SitesFixtures : les 3 sites doivent etre seedes avant qu'on puisse y - * rattacher les types de stockage. Les sites sont resolus via le contrat Shared - * SiteProviderInterface (pas d'import du module Sites — regle ABSOLUE n°1). + * (miroir CategoryTypeFixtures). Rejouable sans doublon meme si le purger Doctrine + * est desactive. */ -class StorageTypeFixtures extends Fixture implements DependentFixtureInterface +class StorageTypeFixtures extends Fixture { /** * Seed PROVISOIRE (Figma node 1503-34285) : code MAJUSCULE stable => libelle FR. - * A re-seeder a reception de la liste Aurore (HP-M6-02). + * A re-seeder a reception de la liste Aurore (HP-M6-02). Doit rester aligne sur + * la migration Version20260626100000. * * @var array */ @@ -57,27 +55,10 @@ class StorageTypeFixtures extends Fixture implements DependentFixtureInterface '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. @@ -86,27 +67,11 @@ class StorageTypeFixtures extends Fixture implements DependentFixtureInterface $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); } diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php index dfff050..e84d033 100644 --- a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageTypeRepository.php @@ -33,32 +33,12 @@ class DoctrineStorageTypeRepository extends ServiceEntityRepository implements S return $this->findBy([], ['label' => 'ASC']); } - public function createListQueryBuilder(array $siteIds = []): QueryBuilder + public function createListQueryBuilder(): 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') + // Tri alphabetique stable (multi-select du formulaire produit, § 4.2). + // Referentiel plat : tous les types, plus de filtrage par site. + return $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/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 5898617..9cc42c5 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -587,12 +587,6 @@ final class ColumnCommentsCatalog '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.', @@ -612,7 +606,7 @@ final class ColumnCommentsCatalog ], 'product_storage_type' => [ - '_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).', + '_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, RG-6.06 ; referentiel plat).', '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/Module/Catalog/Api/AbstractProductApiTestCase.php b/tests/Module/Catalog/Api/AbstractProductApiTestCase.php index 63b608a..957f490 100644 --- a/tests/Module/Catalog/Api/AbstractProductApiTestCase.php +++ b/tests/Module/Catalog/Api/AbstractProductApiTestCase.php @@ -24,8 +24,8 @@ use Symfony\Contracts\HttpClient\ResponseInterface; * volee pour que les POST passent RG-6.05. * - `productCategory()` / `nonProductCategory()` : categories de test rattachees * (ou non) au type PRODUIT. - * - `seedStorageType()` : type de stockage de test (prefixe code pour cleanup), - * rattachable a des sites precis (RG-6.06). + * - `seedStorageType()` : type de stockage de test (prefixe code pour cleanup ; + * referentiel plat, plus de rattachement par site). * - `siteByCode()` / `firstSite()` : sites fixtures (86 / 17 / 82). * - `authView()` : user non-admin portant la permission `catalog.products.view`. * - `validProductPayload()` : payload POST de reference (IRIs category/sites/ @@ -60,7 +60,7 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase // 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. + // Types de stockage de test (prefixe code). $em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix') ->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%') ->execute() @@ -111,19 +111,16 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase } /** - * Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup), - * rattache aux sites passes (disponibilite — RG-6.06). + * Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup). + * Referentiel plat : plus de rattachement a des sites (RG-6.06 revue). */ - protected function seedStorageType(string $label = 'Tas de test', Site ...$sites): StorageType + protected function seedStorageType(string $label = 'Tas de test'): 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(); @@ -169,7 +166,7 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase protected function validProductPayload(array $overrides = []): array { $site = $this->firstSite(); - $storageType = $this->seedStorageType('Tas test', $site); + $storageType = $this->seedStorageType('Tas test'); $category = $this->productCategory(); $base = [ @@ -213,7 +210,7 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase $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->addStorageType($storageType ?? $this->seedStorageType('Seed')); $product->setDeletedAt($deletedAt); $em->persist($product); diff --git a/tests/Module/Catalog/Api/ProductStorageTypeBySiteTest.php b/tests/Module/Catalog/Api/ProductStorageTypeBySiteTest.php deleted file mode 100644 index e3cd4b4..0000000 --- a/tests/Module/Catalog/Api/ProductStorageTypeBySiteTest.php +++ /dev/null @@ -1,56 +0,0 @@ -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); - } -}