Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9645caabd | |||
| eb94204c55 | |||
| 58d0c499d4 | |||
| 2b1071bedb | |||
| ec648ff2ff | |||
| fced2c2cfd | |||
| a6b8e7145e | |||
| f619a6969d | |||
| 64c3b9b6ec | |||
| ce0e274743 | |||
| f12a378126 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.154'
|
app.version: '0.1.155'
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ statut_global: pret_a_dev
|
|||||||
# === DÉPENDANCES AMONT ===
|
# === DÉPENDANCES AMONT ===
|
||||||
depend_de:
|
depend_de:
|
||||||
- Catalog # Module hôte (REQUIRED). Category + CategoryType (ajout du type PRODUIT) + nouveau StorageType + Product
|
- 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
|
- Core # User, Role, Permission, Audit, JWT
|
||||||
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + CategoryInterface
|
- 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). |
|
| 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`. |
|
| 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). |
|
| 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). |
|
| 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). |
|
| 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). |
|
| 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.
|
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 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).
|
- Entité `StorageType` (`Catalog`) : `id`, `code` (slug MAJUSCULE stable, unique), `label` (FR affiché). **Plus de relation `sites`.**
|
||||||
- **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.
|
- **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 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).
|
- 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, 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).
|
- **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)
|
### 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)
|
### 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);
|
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 (
|
CREATE TABLE storage_type_site (
|
||||||
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE CASCADE,
|
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE CASCADE,
|
||||||
site_id INT NOT NULL REFERENCES site(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'])]
|
#[Groups(['product:read', 'product:write'])]
|
||||||
private Collection $sites;
|
private Collection $sites;
|
||||||
|
|
||||||
/** @var Collection<int, StorageType> Types de stockage (≥ 1, filtrés par sites — RG-6.06). */
|
/** @var Collection<int, StorageType> Types de stockage (≥ 1 — RG-6.06, référentiel plat). */
|
||||||
#[ORM\ManyToMany(targetEntity: StorageType::class)]
|
#[ORM\ManyToMany(targetEntity: StorageType::class)]
|
||||||
#[ORM\JoinTable(name: 'product_storage_type')]
|
#[ORM\JoinTable(name: 'product_storage_type')]
|
||||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
|
#[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();
|
$this->storageTypes = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT)
|
// 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).
|
// 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 ...
|
// ... 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).
|
- 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.)*
|
- 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.
|
- **`?pagination=false`** : échappatoire select (référentiel borné ≤ quelques dizaines) — règle frontend.
|
||||||
- `normalizationContext: ['storage_type:read']` ; tri `label ASC`.
|
- `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.
|
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).
|
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).
|
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.
|
- Réponse `201` avec le produit complet.
|
||||||
|
|
||||||
### 4.4 `PATCH /api/products/{id}` (modification)
|
### 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.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.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.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.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.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.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). |
|
| **RG-6.10** | back+front | Onglets « Fournisseurs » / « Clients » = **hors périmètre V0** (placeholder), dépendent du module Contrat inexistant (HP-M6-01). **Front** : (a) NON affichés à l'ajout — n'apparaissent qu'après validation du formulaire principal (écran de modification) ; (b) visibilité conditionnée par l'état (cf. C3, « Aucun » = `OTHER`) : « Fournisseurs » si `PURCHASE` ou `OTHER`, « Clients » si `SALE` ou `OTHER`. |
|
||||||
|
|
||||||
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`
|
## 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.
|
- **`ProductStatesValidationTest`** : ≥ 1 état (RG-6.02) ; valeurs hors enum rejetées.
|
||||||
- **`ProductConditionalFieldsTest`** : `manufactured`/`containsMolasses` forcés `false` si pas `SALE` (RG-6.03).
|
- **`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).
|
- **`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).
|
- **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`.
|
- **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 |
|
| 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-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-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-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). |
|
| **HP-M6-05** | Contrainte SQL « `category` de type PRODUIT » au niveau base (au M6 : validation applicative seulement, § 2.5). |
|
||||||
@@ -670,6 +675,7 @@ Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via `#[Assert\Call
|
|||||||
| 6 | Tests PHPUnit RG-6.01→6.10 + capture contrat JSON | Backend |
|
| 6 | Tests PHPUnit RG-6.01→6.10 + capture contrat JSON | Backend |
|
||||||
| 7 | Page liste `/admin/products` (usePaginatedList) + drawer filtre + export | Frontend |
|
| 7 | Page liste `/admin/products` (usePaginatedList) + drawer filtre + export | Frontend |
|
||||||
| 8 | Écran Ajouter (champs conditionnels SALE, selects filtrés catégorie/stockage) | Frontend |
|
| 8 | Écran Ajouter (champs conditionnels SALE, selects filtrés catégorie/stockage) | Frontend |
|
||||||
|
| 8.bis | Écran **Consultation** (lecture seule) `/admin/products/{id}` : clic sur une ligne → consultation (pas l'édition directe), bouton « Modifier » → édition. **Règle ERP-193 (calque client/fournisseur)** : champs vides + checkbox non cochées masqués, et **onglets vides masqués** → les coquilles Fournisseurs/Clients (placeholder, module Contrat inexistant) ne sont **pas affichées en consultation** (elles restent visibles à l'édition). | Frontend |
|
||||||
| 9 | Écran Modification (« Enregistrer ») + onglets **placeholder « en cours de dev »** (Fournisseurs / Clients) | Frontend |
|
| 9 | Écran Modification (« Enregistrer ») + onglets **placeholder « en cours de dev »** (Fournisseurs / Clients) | Frontend |
|
||||||
| 10 | i18n + libellé audit (`catalog_product`) | Frontend |
|
| 10 | i18n + libellé audit (`catalog_product`) | Frontend |
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"categories": "Gestion des catégories",
|
"categories": "Gestion des catégories",
|
||||||
"products": "Catalogue produit"
|
"products": "Produits"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -1020,6 +1020,74 @@
|
|||||||
"duplicate": "Une catégorie nommée « {name} » existe déjà.",
|
"duplicate": "Une catégorie nommée « {name} » existe déjà.",
|
||||||
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"title": "Catalogue produit",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"export": "Exporter",
|
||||||
|
"empty": "Aucun produit pour l'instant.",
|
||||||
|
"column": {
|
||||||
|
"name": "Nom",
|
||||||
|
"code": "Numéro",
|
||||||
|
"category": "Catégorie"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"PURCHASE": "Achat",
|
||||||
|
"SALE": "Vendu",
|
||||||
|
"OTHER": "Autre"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"search": "Recherche",
|
||||||
|
"category": "Catégorie",
|
||||||
|
"categoryAll": "Toutes les catégories",
|
||||||
|
"state": "État",
|
||||||
|
"stateAll": "Tous les états",
|
||||||
|
"site": "Sites",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
|
"reset": "Réinitialiser"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Ajouter un produit",
|
||||||
|
"back": "Retour au catalogue",
|
||||||
|
"submit": "Valider",
|
||||||
|
"states": "État du produit",
|
||||||
|
"sites": "Site",
|
||||||
|
"name": "Nom du produit",
|
||||||
|
"code": "Code produit",
|
||||||
|
"category": "Catégorie produit",
|
||||||
|
"storageTypes": "Type de stockage",
|
||||||
|
"manufactured": "Fabriqué",
|
||||||
|
"containsMolasses": "Contient de la mélasse",
|
||||||
|
"duplicateCode": "Un produit portant ce code existe déjà."
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Modifier le produit",
|
||||||
|
"back": "Retour",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"loading": "Chargement du produit…",
|
||||||
|
"notFound": "Produit introuvable."
|
||||||
|
},
|
||||||
|
"consultation": {
|
||||||
|
"title": "Fiche produit",
|
||||||
|
"back": "Retour au catalogue",
|
||||||
|
"loading": "Chargement du produit…",
|
||||||
|
"notFound": "Produit introuvable."
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"edit": "Modifier"
|
||||||
|
},
|
||||||
|
"tab": {
|
||||||
|
"suppliers": "Fournisseurs",
|
||||||
|
"clients": "Clients",
|
||||||
|
"placeholder": "Cet onglet est en cours de développement"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
|
"exportError": "L'export du catalogue produit a échoué. Réessayez.",
|
||||||
|
"createSuccess": "Produit créé avec succès",
|
||||||
|
"updateSuccess": "Produit mis à jour avec succès"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Onglets « Fournisseurs » / « Clients » de la fiche produit — HORS PERIMETRE
|
||||||
|
V0 (HP-M6-01, RG-6.10) : ils dependent d'un module Contrat inexistant.
|
||||||
|
Rendu en placeholder « en cours de développement » (meme composant que les
|
||||||
|
onglets non-dev des fiches M1→M4). AUCUN appel API, AUCUN champ saisissable.
|
||||||
|
|
||||||
|
Visibilite conditionnee par l'etat du produit (cf. spec C3, « Aucun » = OTHER) :
|
||||||
|
- « Fournisseurs » : visible si l'etat contient Achat (PURCHASE) ou Aucun (OTHER) ;
|
||||||
|
- « Clients » : visible si l'etat contient Vendu (SALE) ou Aucun (OTHER).
|
||||||
|
Si aucun onglet n'est applicable (etat vide), rien n'est rendu.
|
||||||
|
-->
|
||||||
|
<MalioTabList v-if="tabs.length" v-model="activeTab" :tabs="tabs" class="mt-[60px]">
|
||||||
|
<template #suppliers><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
|
||||||
|
<template #clients><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
|
||||||
|
</MalioTabList>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Etats du produit (codes enum PURCHASE / SALE / OTHER) pilotant la visibilite. */
|
||||||
|
states: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// RG (spec C3) : « Fournisseurs » si Achat ou Aucun ; « Clients » si Vendu ou Aucun.
|
||||||
|
const showSuppliers = computed(() => props.states.includes('PURCHASE') || props.states.includes('OTHER'))
|
||||||
|
const showClients = computed(() => props.states.includes('SALE') || props.states.includes('OTHER'))
|
||||||
|
|
||||||
|
// Icone (Iconify) par onglet, alignee sur la convention des fiches existantes.
|
||||||
|
const tabs = computed(() => {
|
||||||
|
const list: { key: string, label: string, icon: string }[] = []
|
||||||
|
if (showSuppliers.value) {
|
||||||
|
list.push({ key: 'suppliers', label: t('admin.products.tab.suppliers'), icon: 'mdi:truck-outline' })
|
||||||
|
}
|
||||||
|
if (showClients.value) {
|
||||||
|
list.push({ key: 'clients', label: t('admin.products.tab.clients'), icon: 'mdi:account-group-outline' })
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTab = ref('suppliers')
|
||||||
|
|
||||||
|
// Si l'onglet actif disparait suite a un changement d'etat, retombe sur le premier
|
||||||
|
// onglet encore disponible (evite un onglet actif fantome).
|
||||||
|
watch(tabs, (list) => {
|
||||||
|
if (list.length && !list.some(tab => tab.key === activeTab.value)) {
|
||||||
|
activeTab.value = list[0].key
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, nextTick } from 'vue'
|
||||||
|
import ProductPlaceholderTabs from '../ProductPlaceholderTabs.vue'
|
||||||
|
|
||||||
|
// i18n auto-import : retourne la cle telle quelle.
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
|
||||||
|
// Stub de MalioTabList : expose les `key` des onglets recus (data-tab) pour
|
||||||
|
// verifier la visibilite conditionnee par l'etat, sans dependre de la lib UI.
|
||||||
|
const TabListStub = defineComponent({
|
||||||
|
props: { tabs: { type: Array, default: () => [] }, modelValue: { type: String, default: '' } },
|
||||||
|
setup(props) {
|
||||||
|
return () => h(
|
||||||
|
'div',
|
||||||
|
{ 'data-testid': 'tablist' },
|
||||||
|
(props.tabs as { key: string }[]).map(tab => h('span', { 'data-tab': tab.key })),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const PlaceholderStub = defineComponent({ setup() { return () => h('div') } })
|
||||||
|
|
||||||
|
function mountTabs(states: string[]) {
|
||||||
|
return mount(ProductPlaceholderTabs, {
|
||||||
|
props: { states },
|
||||||
|
global: { stubs: { MalioTabList: TabListStub, ComingSoonPlaceholder: PlaceholderStub } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabKeys = (wrapper: ReturnType<typeof mountTabs>): string[] =>
|
||||||
|
wrapper.findAll('[data-tab]').map(node => node.attributes('data-tab') ?? '')
|
||||||
|
|
||||||
|
describe('ProductPlaceholderTabs — visibilite conditionnee par l\'etat', () => {
|
||||||
|
it('Achat (PURCHASE) : affiche uniquement « Fournisseurs »', () => {
|
||||||
|
expect(tabKeys(mountTabs(['PURCHASE']))).toEqual(['suppliers'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Vendu (SALE) : affiche uniquement « Clients »', () => {
|
||||||
|
expect(tabKeys(mountTabs(['SALE']))).toEqual(['clients'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Aucun (OTHER) : affiche les deux onglets', () => {
|
||||||
|
expect(tabKeys(mountTabs(['OTHER']))).toEqual(['suppliers', 'clients'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Achat + Vendu : affiche les deux onglets', () => {
|
||||||
|
expect(tabKeys(mountTabs(['PURCHASE', 'SALE']))).toEqual(['suppliers', 'clients'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('etat vide : ne rend aucun onglet (MalioTabList absent)', () => {
|
||||||
|
const wrapper = mountTabs([])
|
||||||
|
expect(wrapper.find('[data-testid="tablist"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retombe sur le premier onglet visible si l\'actif disparait', async () => {
|
||||||
|
// OTHER -> suppliers actif par defaut ; passage a SALE retire « Fournisseurs ».
|
||||||
|
const wrapper = mountTabs(['OTHER'])
|
||||||
|
await wrapper.setProps({ states: ['SALE'] })
|
||||||
|
await nextTick()
|
||||||
|
// Seul « Clients » subsiste : pas d'onglet actif fantome (verifie via le modelValue).
|
||||||
|
const tablist = wrapper.findComponent(TabListStub)
|
||||||
|
expect(tablist.props('modelValue')).toBe('clients')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
|
import { useProductForm } from '../useProductForm'
|
||||||
|
|
||||||
|
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: mockPatch,
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('useToast', () => ({
|
||||||
|
success: mockToastSuccess,
|
||||||
|
error: mockToastError,
|
||||||
|
}))
|
||||||
|
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
|
||||||
|
// (elle consomme useToast/useI18n deja stubbes) pour tester l'integration 422/409.
|
||||||
|
vi.stubGlobal('useFormErrors', useFormErrors)
|
||||||
|
vi.stubGlobal('useI18n', () => ({
|
||||||
|
t: (key: string, params?: Record<string, unknown>) =>
|
||||||
|
params ? `${key}::${JSON.stringify(params)}` : key,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** 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', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
mockToastSuccess.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
|
||||||
|
// 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' }] })
|
||||||
|
}
|
||||||
|
if (url === '/categories') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/categories/12', name: 'Céréales' }] })
|
||||||
|
}
|
||||||
|
if (url === '/storage_types') {
|
||||||
|
return Promise.resolve(STORAGE_TYPES)
|
||||||
|
}
|
||||||
|
return Promise.resolve({ member: [] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RG-6.03 — champs conditionnels « Vendu »', () => {
|
||||||
|
it('isSale est vrai uniquement si states contient SALE', () => {
|
||||||
|
const { form, isSale } = useProductForm()
|
||||||
|
expect(isSale.value).toBe(false)
|
||||||
|
form.states = ['PURCHASE']
|
||||||
|
expect(isSale.value).toBe(false)
|
||||||
|
form.states = ['PURCHASE', 'SALE']
|
||||||
|
expect(isSale.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remet manufactured / containsMolasses a false quand SALE est retire', async () => {
|
||||||
|
const { form, isSale } = useProductForm()
|
||||||
|
form.states = ['SALE']
|
||||||
|
form.manufactured = true
|
||||||
|
form.containsMolasses = true
|
||||||
|
await nextTick()
|
||||||
|
expect(isSale.value).toBe(true)
|
||||||
|
|
||||||
|
form.states = ['PURCHASE']
|
||||||
|
await nextTick()
|
||||||
|
expect(form.manufactured).toBe(false)
|
||||||
|
expect(form.containsMolasses).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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('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
|
||||||
|
|
||||||
|
setStorageTypes(['/api/storage_types/9'])
|
||||||
|
setSites(['/api/sites/1'])
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submit — POST /products', () => {
|
||||||
|
function fillValidForm(form: ReturnType<typeof useProductForm>['form']): void {
|
||||||
|
form.code = 'ble-01'
|
||||||
|
form.name = 'Blé tendre'
|
||||||
|
form.states = ['PURCHASE', 'SALE']
|
||||||
|
form.siteIris = ['/api/sites/1']
|
||||||
|
form.categoryIri = '/api/categories/12'
|
||||||
|
form.storageTypeIris = ['/api/storage_types/9']
|
||||||
|
form.manufactured = true
|
||||||
|
form.containsMolasses = false
|
||||||
|
}
|
||||||
|
|
||||||
|
it('poste le payload (relations en IRI) et retourne true au succes', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 34 })
|
||||||
|
const { form, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/products',
|
||||||
|
{
|
||||||
|
code: 'ble-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE', 'SALE'],
|
||||||
|
manufactured: true,
|
||||||
|
containsMolasses: false,
|
||||||
|
category: '/api/categories/12',
|
||||||
|
sites: ['/api/sites/1'],
|
||||||
|
storageTypes: ['/api/storage_types/9'],
|
||||||
|
},
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('force manufactured / containsMolasses a false hors « Vendu » (RG-6.03)', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 35 })
|
||||||
|
const { form, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
// L'utilisateur retire « Vendu » apres avoir coche les booleens.
|
||||||
|
form.states = ['PURCHASE']
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
const payload = mockPost.mock.calls[0][1]
|
||||||
|
expect(payload.manufactured).toBe(false)
|
||||||
|
expect(payload.containsMolasses).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omet `category` du payload quand aucune categorie n\'est choisie', async () => {
|
||||||
|
// Envoyer category:null casserait la denormalisation back (type IRI
|
||||||
|
// attendu) et court-circuiterait les autres violations -> on l'omet.
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 40 })
|
||||||
|
const { form, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
form.categoryIri = null
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
const payload = mockPost.mock.calls[0][1]
|
||||||
|
expect(payload).not.toHaveProperty('category')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un 409 doublon de code sur errors.code + toast explicite', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||||
|
const { form, errors, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(errors.code).toBe('admin.products.form.duplicateCode')
|
||||||
|
expect(mockToastError).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe une 422 inline par champ (errors.code) sans toast', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'code', message: 'Le code produit est obligatoire.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { form, errors, submit } = useProductForm()
|
||||||
|
fillValidForm(form)
|
||||||
|
form.code = null
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(errors.code).toBe('Le code produit est obligatoire.')
|
||||||
|
expect(mockToastError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RG-6.08 — mode edition (prefill + PATCH)', () => {
|
||||||
|
// Produit charge (memes cles que la reponse reelle § 4.0.bis : @id sur les relations).
|
||||||
|
const PRODUCT = {
|
||||||
|
id: 34,
|
||||||
|
code: 'BLE-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE', 'SALE'],
|
||||||
|
manufactured: true,
|
||||||
|
containsMolasses: false,
|
||||||
|
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||||
|
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault', code: '86', postalCode: '86100', city: 'C', color: '#000', fullAddress: 'x' }],
|
||||||
|
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||||
|
createdAt: '', updatedAt: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
expect(form.name).toBe('Blé tendre')
|
||||||
|
expect(form.states).toEqual(['PURCHASE', 'SALE'])
|
||||||
|
expect(form.categoryIri).toBe('/api/categories/12')
|
||||||
|
expect(form.siteIris).toEqual(['/api/sites/1'])
|
||||||
|
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
|
||||||
|
expect(form.manufactured).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => {
|
||||||
|
// Le PATCH renvoie le produit normalise : submit re-prefill le form a partir
|
||||||
|
// de la reponse (l'utilisateur reste sur l'ecran, pas de redirection).
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...PRODUCT, code: 'BLE-01', name: 'Blé tendre' })
|
||||||
|
const { prefill, submit } = useProductForm()
|
||||||
|
await prefill(PRODUCT)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/products/34',
|
||||||
|
expect.objectContaining({ code: 'BLE-01', name: 'Blé tendre' }),
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-affiche les valeurs normalisees du serveur apres un PATCH (RG-6.07, pas de redirection)', async () => {
|
||||||
|
// Le back normalise code (trim+UPPER) et name (trim) : le form doit refleter
|
||||||
|
// la reponse serveur, pas la saisie locale.
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...PRODUCT, code: 'BLE-01', name: 'Blé tendre' })
|
||||||
|
const { form, prefill, submit } = useProductForm()
|
||||||
|
await prefill(PRODUCT)
|
||||||
|
form.code = 'ble-01 '
|
||||||
|
form.name = ' Blé tendre '
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
expect(form.code).toBe('BLE-01')
|
||||||
|
expect(form.name).toBe('Blé tendre')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un 409 doublon de code aussi en edition', async () => {
|
||||||
|
mockPatch.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||||
|
const { errors, prefill, submit } = useProductForm()
|
||||||
|
await prefill(PRODUCT)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(errors.code).toBe('admin.products.form.duplicateCode')
|
||||||
|
expect(mockToastError).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Product } from '~/modules/catalog/types/product'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chargement d'un produit unique (ecran « Modification produit », M6 — ERP-206).
|
||||||
|
* Lit le detail via `GET /api/products/{id}` — meme structure que la ligne de
|
||||||
|
* liste (category / sites / storageTypes embarques, § 4.0.bis).
|
||||||
|
*
|
||||||
|
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
|
||||||
|
* Hydra complet (IRI `@id` des relations, necessaires au pre-remplissage des
|
||||||
|
* selects). Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||||
|
*/
|
||||||
|
export function useProduct(id: number | string) {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const product = ref<Product | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
/** Charge le detail du produit. En cas d'echec : `error = true`, `product = null`. */
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = false
|
||||||
|
try {
|
||||||
|
product.value = await api.get<Product>(
|
||||||
|
`/products/${id}`,
|
||||||
|
{},
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
error.value = true
|
||||||
|
product.value = null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { product, loading, error, load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* 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) et la soumission
|
||||||
|
* `POST /api/products` avec mapping des erreurs 422/409 inline
|
||||||
|
* (useFormErrors). Reference : ecran « Ajouter un client » / « Ajouter un
|
||||||
|
* prestataire » (formulaire principal).
|
||||||
|
*
|
||||||
|
* Etat 100 % local a l'instance.
|
||||||
|
*/
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
useSiteOptions,
|
||||||
|
useCategoryOptions,
|
||||||
|
useStorageTypeOptions,
|
||||||
|
} from '~/modules/catalog/composables/useProductOptions'
|
||||||
|
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
|
||||||
|
|
||||||
|
export function useProductForm() {
|
||||||
|
const api = useApi()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const formErrors = useFormErrors()
|
||||||
|
|
||||||
|
const sites = useSiteOptions()
|
||||||
|
const categories = useCategoryOptions({ typeCode: 'PRODUIT' })
|
||||||
|
const storage = useStorageTypeOptions()
|
||||||
|
|
||||||
|
// ── Etat du formulaire ───────────────────────────────────────────────────
|
||||||
|
// Les relations sont stockees en IRI (envoyees telles quelles au POST) ;
|
||||||
|
// `states` porte les codes enum ; les booleens conditionnels RG-6.03 a part.
|
||||||
|
const form = reactive({
|
||||||
|
code: null as string | null,
|
||||||
|
name: null as string | null,
|
||||||
|
states: [] as string[],
|
||||||
|
siteIris: [] as string[],
|
||||||
|
categoryIri: null as string | null,
|
||||||
|
storageTypeIris: [] as string[],
|
||||||
|
manufactured: false,
|
||||||
|
containsMolasses: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
// Id du produit edite (null = creation). Pilote l'URL/methode du submit (RG-6.08 :
|
||||||
|
// « Modification » = meme formulaire/regles que « Ajouter », bouton « Enregistrer »).
|
||||||
|
const productId = ref<number | null>(null)
|
||||||
|
|
||||||
|
// RG-6.03 : « Fabriqué » / « Contient de la mélasse » saisissables uniquement
|
||||||
|
// si l'etat contient « Vendu » (SALE).
|
||||||
|
const isSale = computed(() => form.states.includes('SALE'))
|
||||||
|
|
||||||
|
// Quand l'etat ne contient plus SALE, on remet les booleens a false : le back
|
||||||
|
// les forcerait de toute facon (RG-6.03), on evite de soumettre une valeur
|
||||||
|
// fantome saisie avant de retirer « Vendu ».
|
||||||
|
watch(isSale, (sale) => {
|
||||||
|
if (!sale) {
|
||||||
|
form.manufactured = false
|
||||||
|
form.containsMolasses = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Met a jour les etats (multi-select). */
|
||||||
|
function setStates(states: string[]): void {
|
||||||
|
form.states = states
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met a jour la categorie (select simple). */
|
||||||
|
function setCategory(iri: string | null): void {
|
||||||
|
form.categoryIri = iri
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met a jour les types de stockage (multi-select). */
|
||||||
|
function setStorageTypes(iris: string[]): void {
|
||||||
|
form.storageTypeIris = iris
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met a jour les sites de disponibilite (multi-select, RG-6.04). */
|
||||||
|
function setSites(iris: string[]): void {
|
||||||
|
form.siteIris = iris
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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).
|
||||||
|
* 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<void> {
|
||||||
|
productId.value = product.id
|
||||||
|
form.code = product.code
|
||||||
|
form.name = product.name
|
||||||
|
form.states = [...product.states]
|
||||||
|
form.categoryIri = product.category?.['@id'] ?? null
|
||||||
|
form.siteIris = product.sites.map(s => s['@id'])
|
||||||
|
form.manufactured = product.manufactured
|
||||||
|
form.containsMolasses = product.containsMolasses
|
||||||
|
form.storageTypeIris = product.storageTypes.map(st => st['@id'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soumet le formulaire. Retourne true au succes (la page redirige), false sinon.
|
||||||
|
* Creation → `POST /products` ; edition (productId non nul, RG-6.08) →
|
||||||
|
* `PATCH /products/{id}` (mode merge-patch gere par useApi). 422 → mapping
|
||||||
|
* inline par champ (useFormErrors) ; 409 doublon de code → erreur inline sur
|
||||||
|
* `code` + toast explicite (RG-6.01, unicite re-validee aussi en edition).
|
||||||
|
*/
|
||||||
|
async function submit(): Promise<boolean> {
|
||||||
|
if (submitting.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
formErrors.clearErrors()
|
||||||
|
const editing = productId.value !== null
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
// Chaine vide (jamais null) : les setters back setCode/setName attendent
|
||||||
|
// un `string` non-nullable -> envoyer null leverait une erreur de type
|
||||||
|
// (denormalisation) qui court-circuiterait toutes les autres violations.
|
||||||
|
// Avec '', la contrainte NotBlank renvoie un message propre par champ.
|
||||||
|
code: form.code ?? '',
|
||||||
|
name: form.name ?? '',
|
||||||
|
states: form.states,
|
||||||
|
// RG-6.03 : booleens forces a false hors « Vendu » (le back les
|
||||||
|
// re-force, on garde le payload coherent).
|
||||||
|
manufactured: isSale.value ? form.manufactured : false,
|
||||||
|
containsMolasses: isSale.value ? form.containsMolasses : false,
|
||||||
|
sites: form.siteIris,
|
||||||
|
storageTypes: form.storageTypeIris,
|
||||||
|
}
|
||||||
|
// `category` attend un IRI (string) : envoyer null declencherait une
|
||||||
|
// erreur de denormalisation API Platform qui court-circuiterait TOUTES
|
||||||
|
// les autres violations. On omet la cle quand aucune categorie n'est
|
||||||
|
// choisie -> la contrainte NotNull renvoie un message propre, et les
|
||||||
|
// autres champs sont valides dans la meme 422 (mapping inline ERP-101).
|
||||||
|
if (form.categoryIri) {
|
||||||
|
payload.category = form.categoryIri
|
||||||
|
}
|
||||||
|
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
|
||||||
|
if (editing) {
|
||||||
|
const updated = await api.patch<Product>(`/products/${productId.value}`, payload, options)
|
||||||
|
toast.success({ title: t('admin.products.toast.updateSuccess') })
|
||||||
|
// L'utilisateur garde la main (pas de redirection, calque client/
|
||||||
|
// fournisseur) : on reaffiche les valeurs normalisees renvoyees par le
|
||||||
|
// serveur (code trim+UPPER, name trim — RG-6.07) directement dans le form.
|
||||||
|
await prefill(updated)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.post('/products', payload, options)
|
||||||
|
toast.success({ title: t('admin.products.toast.createSuccess') })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const status = (error as { response?: { status?: number } })?.response?.status
|
||||||
|
if (status === 409) {
|
||||||
|
// Doublon de code (RG-6.01) : inline sur le champ + toast explicite.
|
||||||
|
const message = t('admin.products.form.duplicateCode')
|
||||||
|
formErrors.setError('code', message)
|
||||||
|
toast.error({ title: t('admin.products.toast.error'), message })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
formErrors.handleApiError(error, { fallbackMessage: t('admin.products.toast.error') })
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
productId,
|
||||||
|
errors: formErrors.errors,
|
||||||
|
submitting,
|
||||||
|
isSale,
|
||||||
|
siteOptions: sites.options,
|
||||||
|
categoryOptions: categories.options,
|
||||||
|
storageTypeOptions: storage.options,
|
||||||
|
setStates,
|
||||||
|
setCategory,
|
||||||
|
setStorageTypes,
|
||||||
|
setSites,
|
||||||
|
loadReferentials,
|
||||||
|
prefill,
|
||||||
|
submit,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Composables d'options des selects du formulaire produit (M6 — ERP-205).
|
||||||
|
*
|
||||||
|
* Chaque referentiel est borne (quelques dizaines d'entrees) : on le recupere en
|
||||||
|
* entier via l'echappatoire `?pagination=false`, avec l'en-tete
|
||||||
|
* `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe
|
||||||
|
* Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle
|
||||||
|
* quelle dans le payload POST (relations ManyToOne / ManyToMany).
|
||||||
|
*
|
||||||
|
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Chaque appel cree
|
||||||
|
* sa propre instance ; le formulaire en consomme une via `useProductForm`.
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
|
||||||
|
export interface RefOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Membre Hydra minimal commun aux referentiels consommes ici. */
|
||||||
|
interface HydraMember {
|
||||||
|
'@id': string
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupere une collection complete (pagination desactivee) et la projette en
|
||||||
|
* options `{ value: IRI, label }`. Resilient : l'appelant gere l'echec (liste vide).
|
||||||
|
*/
|
||||||
|
async function fetchOptions(
|
||||||
|
url: string,
|
||||||
|
query: Record<string, string | string[]>,
|
||||||
|
toLabel: (member: HydraMember) => string,
|
||||||
|
): Promise<RefOption[]> {
|
||||||
|
const res = await useApi().get<{ member?: HydraMember[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false', ...query },
|
||||||
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
|
)
|
||||||
|
return (res.member ?? []).map(m => ({ value: m['@id'], label: toLabel(m) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sites de disponibilite (libelle = nom du site). */
|
||||||
|
export function useSiteOptions() {
|
||||||
|
const options = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
options.value = await fetchOptions('/sites', {}, s => s.name ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { options, load }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categories produit. Filtrees au type voulu (`?typeCode=PRODUIT` pour le produit,
|
||||||
|
* RG-6.05) cote serveur — le provider Category supporte deja `typeCode`.
|
||||||
|
*/
|
||||||
|
export function useCategoryOptions(params: { typeCode: string }) {
|
||||||
|
const options = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
options.value = await fetchOptions('/categories', { typeCode: params.typeCode }, c => c.name ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { options, load }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<RefOption[]>([])
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
options.value = await fetchOptions('/storage_types', {}, s => s.label ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { options, load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
|
||||||
|
const PRODUCT = {
|
||||||
|
id: 34,
|
||||||
|
code: 'BLE-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE', 'SALE'],
|
||||||
|
manufactured: true,
|
||||||
|
containsMolasses: false,
|
||||||
|
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||||
|
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
|
||||||
|
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||||
|
createdAt: '', updatedAt: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fx = vi.hoisted(() => ({ load: vi.fn() }))
|
||||||
|
|
||||||
|
vi.mock('~/modules/catalog/composables/useProduct', async () => {
|
||||||
|
const { ref } = await import('vue')
|
||||||
|
return {
|
||||||
|
useProduct: () => ({
|
||||||
|
product: ref(PRODUCT),
|
||||||
|
loading: ref(false),
|
||||||
|
error: ref(false),
|
||||||
|
load: fx.load,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||||
|
|
||||||
|
const ViewPage = (await import('../admin/products/[id]/index.vue')).default
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Input lecture seule : expose le label + la valeur affichee (model-value).
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||||
|
setup(props) { return () => h('input', { 'data-label': props.label, 'data-value': String(props.modelValue ?? '') }) },
|
||||||
|
})
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||||
|
})
|
||||||
|
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioCheckbox: CheckboxStub,
|
||||||
|
ProductPlaceholderTabs: TabsStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { ViewPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(ViewPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Consultation produit (page /admin/products/{id})', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fx.load.mockReset().mockResolvedValue(undefined)
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockNavigateTo.mockReset()
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge le produit au montage', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(fx.load).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirige vers la liste sans la permission view', async () => {
|
||||||
|
mockCan.mockReturnValue(false)
|
||||||
|
await mountPage()
|
||||||
|
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche les champs en lecture seule (libelles mappes)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
const valueOf = (label: string) =>
|
||||||
|
wrapper.find(`[data-label="${label}"]`).attributes('data-value')
|
||||||
|
expect(valueOf('admin.products.form.name')).toBe('Blé tendre')
|
||||||
|
expect(valueOf('admin.products.form.code')).toBe('BLE-01')
|
||||||
|
expect(valueOf('admin.products.form.category')).toBe('Céréales')
|
||||||
|
expect(valueOf('admin.products.form.sites')).toBe('Chatellerault')
|
||||||
|
expect(valueOf('admin.products.form.storageTypes')).toBe('Tas')
|
||||||
|
// Etats : libelles i18n joints.
|
||||||
|
expect(valueOf('admin.products.form.states')).toBe('admin.products.state.PURCHASE, admin.products.state.SALE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bouton « Modifier » (manage) → ecran d\'edition', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="admin.products.action.edit"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque « Modifier » sans la permission manage', async () => {
|
||||||
|
// view OK mais manage refuse.
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.action.edit"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche AUCUN onglet en consultation (coquilles vides masquees, ERP-193)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque un champ vide / une checkbox non cochee (ERP-193, isFilled)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
// containsMolasses = false dans le fixture => case masquee.
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
|
||||||
|
// manufactured = true => case affichee.
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
|
||||||
|
const PRODUCT = {
|
||||||
|
id: 34,
|
||||||
|
code: 'BLE-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE'],
|
||||||
|
manufactured: false,
|
||||||
|
containsMolasses: false,
|
||||||
|
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||||
|
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
|
||||||
|
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
|
||||||
|
createdAt: '', updatedAt: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holders crees dans les factories (vue initialise au moment de l'import page).
|
||||||
|
const fx = vi.hoisted(() => ({
|
||||||
|
isSale: null as unknown as { value: boolean },
|
||||||
|
submit: vi.fn(),
|
||||||
|
prefill: vi.fn(),
|
||||||
|
loadReferentials: vi.fn(),
|
||||||
|
load: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
|
||||||
|
const { ref, reactive } = await import('vue')
|
||||||
|
fx.isSale = ref(false)
|
||||||
|
return {
|
||||||
|
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
|
||||||
|
useProductForm: () => ({
|
||||||
|
form: reactive({
|
||||||
|
code: null, name: null, states: [], siteIris: [],
|
||||||
|
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
|
||||||
|
}),
|
||||||
|
errors: reactive({}),
|
||||||
|
submitting: ref(false),
|
||||||
|
isSale: fx.isSale,
|
||||||
|
siteOptions: ref([]),
|
||||||
|
categoryOptions: ref([]),
|
||||||
|
storageTypeOptions: ref([]),
|
||||||
|
setStates: vi.fn(),
|
||||||
|
setCategory: vi.fn(),
|
||||||
|
setStorageTypes: vi.fn(),
|
||||||
|
setSites: vi.fn(),
|
||||||
|
loadReferentials: fx.loadReferentials,
|
||||||
|
prefill: fx.prefill,
|
||||||
|
submit: fx.submit,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('~/modules/catalog/composables/useProduct', async () => {
|
||||||
|
const { ref } = await import('vue')
|
||||||
|
return {
|
||||||
|
useProduct: () => ({
|
||||||
|
product: ref(PRODUCT),
|
||||||
|
loading: ref(false),
|
||||||
|
error: ref(false),
|
||||||
|
load: fx.load,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||||
|
|
||||||
|
const EditPage = (await import('../admin/products/[id]/edit.vue')).default
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||||
|
setup(props) { return () => h('input', { 'data-label': props.label }) },
|
||||||
|
})
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' } },
|
||||||
|
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||||
|
})
|
||||||
|
// Placeholder : rendu sans aucun appel API (juste un marqueur).
|
||||||
|
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioSelect: InputStub,
|
||||||
|
MalioSelectCheckbox: InputStub,
|
||||||
|
MalioCheckbox: CheckboxStub,
|
||||||
|
ProductPlaceholderTabs: TabsStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { EditPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fx.submit.mockReset().mockResolvedValue(true)
|
||||||
|
fx.prefill.mockReset().mockResolvedValue(undefined)
|
||||||
|
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
|
||||||
|
fx.load.mockReset().mockResolvedValue(undefined)
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockNavigateTo.mockReset()
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
fx.isSale.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge le produit et pre-remplit le formulaire au montage', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(fx.load).toHaveBeenCalled()
|
||||||
|
expect(fx.prefill).toHaveBeenCalledWith(PRODUCT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirige vers la consultation sans la permission manage', async () => {
|
||||||
|
mockCan.mockReturnValue(false)
|
||||||
|
await mountPage()
|
||||||
|
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products/34')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bouton « Enregistrer » : submit (PATCH) SANS redirection (l\'utilisateur garde la main)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="admin.products.edit.save"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(fx.submit).toHaveBeenCalled()
|
||||||
|
// On reste sur l'ecran d'edition : aucune navigation au succes (calque client/fournisseur).
|
||||||
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche les onglets placeholder (rendu sans appel API)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, Suspense } from 'vue'
|
||||||
|
|
||||||
|
// ── Mock du composable form (sa logique est testee a part : useProductForm.spec).
|
||||||
|
// Ici on teste le WIRING de la page : rendu conditionnel RG-6.03 + submit→redirect.
|
||||||
|
// Les refs partagees sont creees DANS la factory (vue est initialise au moment ou
|
||||||
|
// la page est importee) et exposees via un holder hoiste pour pilotage par test.
|
||||||
|
const fx = vi.hoisted(() => ({
|
||||||
|
isSale: null as unknown as { value: boolean },
|
||||||
|
submit: vi.fn(),
|
||||||
|
loadReferentials: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
|
||||||
|
const { ref, reactive } = await import('vue')
|
||||||
|
fx.isSale = ref(false)
|
||||||
|
return {
|
||||||
|
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
|
||||||
|
useProductForm: () => ({
|
||||||
|
form: reactive({
|
||||||
|
code: null, name: null, states: [], siteIris: [],
|
||||||
|
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
|
||||||
|
}),
|
||||||
|
errors: reactive({}),
|
||||||
|
submitting: ref(false),
|
||||||
|
isSale: fx.isSale,
|
||||||
|
siteOptions: ref([]),
|
||||||
|
categoryOptions: ref([]),
|
||||||
|
storageTypeOptions: ref([]),
|
||||||
|
setStates: vi.fn(),
|
||||||
|
setCategory: vi.fn(),
|
||||||
|
setStorageTypes: vi.fn(),
|
||||||
|
setSites: vi.fn(),
|
||||||
|
loadReferentials: fx.loadReferentials,
|
||||||
|
submit: fx.submit,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
vi.stubGlobal('navigateTo', mockNavigateTo)
|
||||||
|
|
||||||
|
const NewPage = (await import('../admin/products/new.vue')).default
|
||||||
|
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const InputStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||||
|
setup(props) { return () => h('input', { 'data-label': props.label }) },
|
||||||
|
})
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Placeholder (onglets Fournisseurs/Clients) : marqueur sans appel API.
|
||||||
|
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
MalioButtonIcon: ButtonStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioInputText: InputStub,
|
||||||
|
MalioSelect: InputStub,
|
||||||
|
MalioSelectCheckbox: InputStub,
|
||||||
|
MalioCheckbox: CheckboxStub,
|
||||||
|
ProductPlaceholderTabs: TabsStub,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage() {
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
components: { NewPage },
|
||||||
|
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
|
||||||
|
}), { global: { stubs } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Écran Ajouter un produit (page /admin/products/new)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fx.submit.mockReset().mockResolvedValue(true)
|
||||||
|
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockNavigateTo.mockReset()
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
fx.isSale.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirige vers la liste sans la permission manage', async () => {
|
||||||
|
mockCan.mockReturnValue(false)
|
||||||
|
await mountPage()
|
||||||
|
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge les referentiels au montage', async () => {
|
||||||
|
await mountPage()
|
||||||
|
expect(fx.loadReferentials).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-6.03 : masque Fabriqué / mélasse tant que l\'etat ne contient pas « Vendu »', async () => {
|
||||||
|
fx.isSale.value = false
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-6.03 : affiche Fabriqué / mélasse quand l\'etat contient « Vendu »', async () => {
|
||||||
|
fx.isSale.value = true
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« Valider » : submit puis retour a la liste au succes', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(fx.submit).toHaveBeenCalled()
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/admin/products')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne redirige pas si submit echoue (erreurs inline)', async () => {
|
||||||
|
fx.submit.mockResolvedValueOnce(false)
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'affiche PAS les onglets Fournisseurs/Clients a l\'ajout (avant validation)', async () => {
|
||||||
|
const wrapper = await mountPage()
|
||||||
|
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref } from 'vue'
|
||||||
|
|
||||||
|
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||||
|
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
||||||
|
// runtime de test (happy-dom). Meme philosophie que les specs M1→M5.
|
||||||
|
const mockPush = vi.hoisted(() => vi.fn())
|
||||||
|
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||||
|
const mockCan = vi.hoisted(() => vi.fn())
|
||||||
|
const mockSetFilters = vi.hoisted(() => vi.fn())
|
||||||
|
const mockFetch = vi.hoisted(() => vi.fn())
|
||||||
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('useHead', () => undefined)
|
||||||
|
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||||
|
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||||
|
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||||
|
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||||
|
// usePaginatedList est l'auto-import pilotant la liste : on controle items +
|
||||||
|
// setFilters + fetch. La ligne reproduit le contrat JSON reel (§ 4.0.bis).
|
||||||
|
vi.stubGlobal('usePaginatedList', () => ({
|
||||||
|
items: ref<Array<Record<string, unknown>>>([
|
||||||
|
{
|
||||||
|
id: 34,
|
||||||
|
code: 'BLE-TENDRE-01',
|
||||||
|
name: 'Blé tendre',
|
||||||
|
states: ['PURCHASE', 'SALE'],
|
||||||
|
manufactured: true,
|
||||||
|
containsMolasses: true,
|
||||||
|
category: { id: 12, name: 'Céréales', code: 'CEREALES' },
|
||||||
|
sites: [{ id: 1, name: 'Chatellerault', code: '86' }],
|
||||||
|
storageTypes: [{ id: 9, code: 'TAS', label: 'Tas' }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
totalItems: ref(1),
|
||||||
|
currentPage: ref(1),
|
||||||
|
itemsPerPage: ref(10),
|
||||||
|
itemsPerPageOptions: ref([10, 25, 50]),
|
||||||
|
fetch: mockFetch,
|
||||||
|
goToPage: vi.fn(),
|
||||||
|
setItemsPerPage: vi.fn(),
|
||||||
|
setFilters: mockSetFilters,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||||
|
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||||
|
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||||
|
globalThis.URL.revokeObjectURL = vi.fn()
|
||||||
|
|
||||||
|
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||||
|
const ProductsIndex = (await import('../admin/products/index.vue')).default
|
||||||
|
|
||||||
|
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||||
|
const ButtonStub = defineComponent({
|
||||||
|
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||||
|
emits: ['click'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DataTableStub = defineComponent({
|
||||||
|
props: { items: { type: Array, default: () => [] } },
|
||||||
|
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('div', { 'data-testid': 'datatable' },
|
||||||
|
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||||
|
h('tr', {
|
||||||
|
'data-row-id': it.id,
|
||||||
|
'data-name': it.name,
|
||||||
|
'data-code': it.code,
|
||||||
|
'data-category': it.categoryName,
|
||||||
|
'onClick': () => emit('row-click', it),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const DrawerStub = defineComponent({
|
||||||
|
props: { modelValue: { type: Boolean, default: false } },
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
|
||||||
|
|
||||||
|
const PageHeaderStub = defineComponent({
|
||||||
|
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const CheckboxStub = defineComponent({
|
||||||
|
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
|
||||||
|
emits: ['update:model-value'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('input', {
|
||||||
|
'type': 'checkbox',
|
||||||
|
'data-id': props.id,
|
||||||
|
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const SelectStub = defineComponent({
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number, null] as unknown as () => string | number | null, default: null },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
emptyOptionLabel: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
emits: ['update:model-value'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
return () => h('select', {
|
||||||
|
'data-empty-label': props.emptyOptionLabel,
|
||||||
|
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLSelectElement).value),
|
||||||
|
}, (props.options as Array<{ value: string | number, label: string }>).map(o =>
|
||||||
|
h('option', { value: o.value }, o.label),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
return mount(ProductsIndex, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
PageHeader: PageHeaderStub,
|
||||||
|
MalioButton: ButtonStub,
|
||||||
|
MalioDataTable: DataTableStub,
|
||||||
|
MalioDrawer: DrawerStub,
|
||||||
|
MalioAccordion: SlotStub,
|
||||||
|
MalioAccordionItem: SlotStub,
|
||||||
|
MalioInputText: InputTextStub,
|
||||||
|
MalioSelect: SelectStub,
|
||||||
|
MalioCheckbox: CheckboxStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Catalogue produit (page /admin/products)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPush.mockReset()
|
||||||
|
mockApiGet.mockReset().mockImplementation((url: string) => {
|
||||||
|
if (url === '/categories') {
|
||||||
|
return Promise.resolve({ member: [{ '@id': '/api/categories/12', id: 12, name: 'Céréales' }] })
|
||||||
|
}
|
||||||
|
if (url === '/sites') {
|
||||||
|
return Promise.resolve({ member: [{ id: 1, name: 'Chatellerault' }] })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ member: [] })
|
||||||
|
})
|
||||||
|
mockCan.mockReset().mockReturnValue(true)
|
||||||
|
mockSetFilters.mockReset()
|
||||||
|
mockFetch.mockReset()
|
||||||
|
mockToastError.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charge la liste au montage', async () => {
|
||||||
|
mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockFetch).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe les colonnes Nom / Numéro / Catégorie sur le JSON réel (§ 4.0.bis)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
const row = wrapper.find('tr[data-row-id="34"]')
|
||||||
|
expect(row.attributes('data-name')).toBe('Blé tendre')
|
||||||
|
expect(row.attributes('data-code')).toBe('BLE-TENDRE-01')
|
||||||
|
expect(row.attributes('data-category')).toBe('Céréales')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.manage')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||||
|
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigue vers la consultation au clic sur une ligne', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('tr[data-row-id="34"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/admin/products/34')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigue vers la création au clic sur « + Ajouter »', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-label="admin.products.add"]').trigger('click')
|
||||||
|
expect(mockPush).toHaveBeenCalledWith('/admin/products/new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appelle l\'export XLSX sur /products/export.xlsx en blob', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('[data-label="admin.products.export"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(mockApiGet).toHaveBeenCalledWith(
|
||||||
|
'/products/export.xlsx',
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('répercute les sites cochés dans setFilters (filtre multi, clé siteId[])', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||||
|
{ 'siteId[]': ['1'] },
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
|
||||||
|
expect(mockPush).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('répercute l\'état sélectionné dans setFilters (param state)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('select[data-empty-label="admin.products.filters.stateAll"]').setValue('SALE')
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||||
|
{ state: 'SALE' },
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('répercute la catégorie sélectionnée dans setFilters (param categoryId)', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('select[data-empty-label="admin.products.filters.categoryAll"]').setValue('12')
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith(
|
||||||
|
{ categoryId: '12' },
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('badge filtres actifs + Réinitialiser vide l\'état appliqué', async () => {
|
||||||
|
const wrapper = mountPage()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
|
||||||
|
|
||||||
|
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
|
||||||
|
expect(wrapper.find('[data-label="admin.products.filters.title (1)"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Réinitialiser → query propre (setFilters avec objet vide).
|
||||||
|
await wrapper.find('[data-label="admin.products.filters.reset"]').trigger('click')
|
||||||
|
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour vers le catalogue + nom du produit. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('admin.products.edit.back')"
|
||||||
|
v-bind="{ ariaLabel: t('admin.products.edit.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etats de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.edit.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.edit.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="product">
|
||||||
|
<!-- ── Formulaire principal pre-rempli (mêmes champs/regles que l'ajout,
|
||||||
|
RG-6.01→6.07). Bouton « Enregistrer » → PATCH (RG-6.08). -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.states"
|
||||||
|
:options="stateOptions"
|
||||||
|
:label="t('admin.products.form.states')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.states"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
|
||||||
|
/>
|
||||||
|
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('admin.products.form.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
|
:label="t('admin.products.form.name')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.name"
|
||||||
|
/>
|
||||||
|
<!-- Code modifiable techniquement ; l'unicite reste re-validee serveur (RG-6.01). -->
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.code"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:label="t('admin.products.form.code')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.code"
|
||||||
|
/>
|
||||||
|
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="form.categoryIri"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('admin.products.form.category')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="errors.category"
|
||||||
|
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<!-- Type de stockage : multi-select obligatoire (>= 1). Referentiel plat :
|
||||||
|
tous les types (plus de filtrage par site, RG-6.06). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.storageTypeIris"
|
||||||
|
:options="storageTypeOptions"
|
||||||
|
:label="t('admin.products.form.storageTypes')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.storageTypes"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
|
||||||
|
/>
|
||||||
|
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
|
||||||
|
uniquement si l'Etat contient « Vendu ». -->
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="isSale"
|
||||||
|
v-model="form.manufactured"
|
||||||
|
:label="t('admin.products.form.manufactured')"
|
||||||
|
group-class="self-center"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="isSale"
|
||||||
|
v-model="form.containsMolasses"
|
||||||
|
:label="t('admin.products.form.containsMolasses')"
|
||||||
|
group-class="self-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('admin.products.edit.save')"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="onSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). Visibilite
|
||||||
|
conditionnee par l'etat : Fournisseurs si Achat/Aucun, Clients si
|
||||||
|
Vendu/Aucun (cf. ProductPlaceholderTabs). -->
|
||||||
|
<ProductPlaceholderTabs :states="form.states" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
|
||||||
|
import { useProduct } from '~/modules/catalog/composables/useProduct'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
const productId = route.params.id as string
|
||||||
|
|
||||||
|
// Gating de la route : la modification est reservee a `manage` ; sinon retour
|
||||||
|
// consultation (la lecture seule reste accessible avec `view`).
|
||||||
|
if (!can('catalog.products.manage')) {
|
||||||
|
await navigateTo(`/admin/products/${productId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { product, loading, error, load } = useProduct(productId)
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
submitting,
|
||||||
|
isSale,
|
||||||
|
siteOptions,
|
||||||
|
categoryOptions,
|
||||||
|
storageTypeOptions,
|
||||||
|
setStates,
|
||||||
|
setCategory,
|
||||||
|
setStorageTypes,
|
||||||
|
setSites,
|
||||||
|
loadReferentials,
|
||||||
|
prefill,
|
||||||
|
submit,
|
||||||
|
} = useProductForm()
|
||||||
|
|
||||||
|
const headerTitle = computed(() => product.value?.name ?? t('admin.products.edit.title'))
|
||||||
|
|
||||||
|
useHead({ title: headerTitle })
|
||||||
|
|
||||||
|
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
|
||||||
|
const stateOptions = computed(() =>
|
||||||
|
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Retour vers la consultation du produit (fleche d'en-tete). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push(`/admin/products/${productId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soumet la modification (PATCH). Au succes : on RESTE sur l'ecran d'edition
|
||||||
|
* (l'utilisateur garde la main, calque client/fournisseur) — le toast de succes et
|
||||||
|
* la reaffichage des valeurs normalisees sont geres par `submit()`. La navigation
|
||||||
|
* reste manuelle (fleche retour -> consultation).
|
||||||
|
*/
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
await submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Referentiels (selects) + detail du produit charges en parallele.
|
||||||
|
await Promise.all([
|
||||||
|
loadReferentials().catch(() => {}),
|
||||||
|
load(),
|
||||||
|
])
|
||||||
|
// Pre-remplissage une fois le produit charge (echec de chargement => message).
|
||||||
|
if (product.value) {
|
||||||
|
await prefill(product.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour catalogue + nom du produit + action « Modifier ». -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('admin.products.consultation.back')"
|
||||||
|
v-bind="{ ariaLabel: t('admin.products.consultation.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-12">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:pencil-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('admin.products.action.edit')"
|
||||||
|
@click="goEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etats de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.consultation.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.consultation.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="product">
|
||||||
|
<!-- ── Bloc principal (lecture seule) — meme disposition que l'ajout/edition.
|
||||||
|
Champs non remplis masques (ERP-193, isFilled). -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(statesLabel)"
|
||||||
|
:model-value="statesLabel"
|
||||||
|
:label="t('admin.products.form.states')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(sitesLabel)"
|
||||||
|
:model-value="sitesLabel"
|
||||||
|
:label="t('admin.products.form.sites')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(product.name)"
|
||||||
|
:model-value="product.name"
|
||||||
|
:label="t('admin.products.form.name')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(product.code)"
|
||||||
|
:model-value="product.code"
|
||||||
|
:label="t('admin.products.form.code')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(categoryLabel)"
|
||||||
|
:model-value="categoryLabel"
|
||||||
|
:label="t('admin.products.form.category')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isFilled(storageTypesLabel)"
|
||||||
|
:model-value="storageTypesLabel"
|
||||||
|
:label="t('admin.products.form.storageTypes')"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<!-- RG-6.03 : « Fabriqué » / « Contient de la mélasse » affiches
|
||||||
|
uniquement si l'etat contient « Vendu » ET la case est cochee. -->
|
||||||
|
<div v-if="isSale && isFilled(product.manufactured)" class="flex h-12 items-center">
|
||||||
|
<MalioCheckbox
|
||||||
|
id="product-view-manufactured"
|
||||||
|
:label="t('admin.products.form.manufactured')"
|
||||||
|
:model-value="product.manufactured"
|
||||||
|
disabled
|
||||||
|
:reserve-message-space="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="isSale && isFilled(product.containsMolasses)" class="flex h-12 items-center">
|
||||||
|
<MalioCheckbox
|
||||||
|
id="product-view-molasses"
|
||||||
|
:label="t('admin.products.form.containsMolasses')"
|
||||||
|
:model-value="product.containsMolasses"
|
||||||
|
disabled
|
||||||
|
:reserve-message-space="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pas d'onglet en consultation (ERP-193) : on masque les onglets vides.
|
||||||
|
Les onglets Fournisseurs / Clients sont des coquilles non implementees
|
||||||
|
(placeholder, module Contrat inexistant, HP-M6-01) => aucune donnee a
|
||||||
|
afficher, donc rien n'est rendu ici. Ils restent visibles a l'edition
|
||||||
|
(preview + regle d'etat). Quand le module Contrat existera, ce bloc
|
||||||
|
affichera les onglets effectivement remplis (calque client/fournisseur). -->
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useProduct } from '~/modules/catalog/composables/useProduct'
|
||||||
|
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
// Gating de la route : la consultation est reservee a `view` (catalogue admin-only).
|
||||||
|
if (!can('catalog.products.view')) {
|
||||||
|
await navigateTo('/admin/products')
|
||||||
|
}
|
||||||
|
|
||||||
|
const productId = route.params.id as string
|
||||||
|
|
||||||
|
const { product, loading, error, load } = useProduct(productId)
|
||||||
|
|
||||||
|
// L'edition est reservee a `manage` ; le bouton « Modifier » suit cette permission.
|
||||||
|
const canManage = computed(() => can('catalog.products.manage'))
|
||||||
|
|
||||||
|
const headerTitle = computed(() => product.value?.name ?? t('admin.products.consultation.title'))
|
||||||
|
|
||||||
|
useHead({ title: t('admin.products.consultation.title') })
|
||||||
|
|
||||||
|
// RG-6.03 : « Vendu » conditionne l'affichage des booleens fabriqué / mélasse.
|
||||||
|
const isSale = computed(() => product.value?.states.includes('SALE') ?? false)
|
||||||
|
|
||||||
|
// ── Libelles lecture seule (relations embarquees mappees en texte) ───────────
|
||||||
|
const statesLabel = computed(() =>
|
||||||
|
(product.value?.states ?? []).map(code => t(`admin.products.state.${code}`)).join(', '),
|
||||||
|
)
|
||||||
|
const sitesLabel = computed(() =>
|
||||||
|
(product.value?.sites ?? []).map(site => site.name).join(', '),
|
||||||
|
)
|
||||||
|
const categoryLabel = computed(() => product.value?.category?.name ?? '')
|
||||||
|
const storageTypesLabel = computed(() =>
|
||||||
|
(product.value?.storageTypes ?? []).map(type => type.label).join(', '),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Retour vers le catalogue produit (fleche d'en-tete). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/admin/products')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bascule vers l'ecran de modification. */
|
||||||
|
function goEdit(): void {
|
||||||
|
router.push(`/admin/products/${productId}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('admin.products.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter (meme
|
||||||
|
design que le Repertoire transporteurs / la Gestion des categories). -->
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="tertiary"
|
||||||
|
:label="filterButtonLabel"
|
||||||
|
icon-name="mdi:tune"
|
||||||
|
icon-position="left"
|
||||||
|
icon-size="24"
|
||||||
|
@click="openFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
:label="t('admin.products.add')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Datatable branchee sur usePaginatedList : pagination serveur, tri
|
||||||
|
name ASC par defaut (cote back, § 4.1). Colonnes Nom / Numero /
|
||||||
|
Categorie (docx p.3). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
:empty-message="t('admin.products.empty')"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="primary"
|
||||||
|
:label="t('admin.products.export')"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportXlsx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||||
|
« Voir les résultats ». Meme pattern que les repertoires M1→M5.
|
||||||
|
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
|
||||||
|
<MalioDrawer
|
||||||
|
v-model="filterDrawerOpen"
|
||||||
|
drawer-class="max-w-[450px]"
|
||||||
|
body-class="p-0"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.products.filters.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<!-- Recherche : code + nom (param `search`, partiel insensible a la casse). -->
|
||||||
|
<MalioAccordionItem :title="t('admin.products.filters.search')" value="search">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="draftSearch"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Categorie : select simple (param `categoryId`). Referentiel borne
|
||||||
|
aux categories de type PRODUIT (RG-6.05). -->
|
||||||
|
<MalioAccordionItem :title="t('admin.products.filters.category')" value="category">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="draftCategoryId"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:empty-option-label="t('admin.products.filters.categoryAll')"
|
||||||
|
@update:model-value="(v: string | number | null) => draftCategoryId = v === null || v === '' ? null : Number(v)"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Etat : select simple (param `state`, enum PURCHASE / SALE / OTHER). -->
|
||||||
|
<MalioAccordionItem :title="t('admin.products.filters.state')" value="state">
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="draftState"
|
||||||
|
:options="stateOptions"
|
||||||
|
:empty-option-label="t('admin.products.filters.stateAll')"
|
||||||
|
@update:model-value="(v: string | number | null) => draftState = v === null || v === '' ? null : String(v)"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Site(s) : cases a cocher (multi, param `siteId[]`). Un produit
|
||||||
|
remonte s'il est disponible sur AU MOINS UN des sites coches. -->
|
||||||
|
<MalioAccordionItem :title="t('admin.products.filters.site')" value="site">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in siteOptions"
|
||||||
|
:id="`filter-site-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftSiteIds.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="t('admin.products.filters.reset')"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('admin.products.filters.apply')"
|
||||||
|
button-class="w-[170px]"
|
||||||
|
@click="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import type { Product } from '~/modules/catalog/types/product'
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('admin.products.title') })
|
||||||
|
|
||||||
|
// Catalogue produit admin-only (docx p.3) : « + Ajouter » reserve a `manage`.
|
||||||
|
// « Filtrer » / « Exporter » suivent `view` (gate page-level). L'item sidebar
|
||||||
|
// est deja masque cote back pour les roles sans `view` (RBAC § 5.2).
|
||||||
|
const canManage = computed(() => can('catalog.products.manage'))
|
||||||
|
const canView = computed(() => can('catalog.products.view'))
|
||||||
|
|
||||||
|
// Pagination serveur via le composable partage. Le ProductProvider applique
|
||||||
|
// deja name ASC (§ 4.1) — pas de defaultSort cote front tant qu'aucun
|
||||||
|
// OrderFilter n'est expose.
|
||||||
|
const {
|
||||||
|
items: products,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadProducts,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
setFilters,
|
||||||
|
} = usePaginatedList<Product>({ url: '/products' })
|
||||||
|
|
||||||
|
// Mappe les produits en objets « plats » pour MalioDataTable (items typees
|
||||||
|
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||||
|
// implicite, contrairement a l'interface Product. Meme pattern que M1→M5.
|
||||||
|
const rows = computed(() => products.value.map(product => ({
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
code: product.code,
|
||||||
|
categoryName: product.category?.name ?? '',
|
||||||
|
})))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: t('admin.products.column.name') },
|
||||||
|
{ key: 'code', label: t('admin.products.column.code') },
|
||||||
|
{ key: 'categoryName', label: t('admin.products.column.category') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Clic sur une ligne → ecran de consultation (lecture seule) /admin/products/{id}. */
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
router.push(`/admin/products/${item.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate(): void {
|
||||||
|
router.push('/admin/products/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Referentiels des filtres ─────────────────────────────────────────────────
|
||||||
|
// Charges une fois (pagination desactivee, referentiels bornes). Categories
|
||||||
|
// filtrees au type PRODUIT (RG-6.05) ; sites = tous les sites actifs.
|
||||||
|
const categoryOptions = ref<FilterOption[]>([])
|
||||||
|
const siteOptions = ref<FilterOption[]>([])
|
||||||
|
|
||||||
|
// Etats produit (miroir de l'enum back Product::STATE_*). Le libelle est resolu
|
||||||
|
// par i18n. Select simple cote filtre (`?state=` n'accepte qu'une valeur).
|
||||||
|
const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
|
||||||
|
|
||||||
|
const stateOptions = computed(() =>
|
||||||
|
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface HydraMember { '@id': string, id: number, name?: string, postalCode?: string }
|
||||||
|
|
||||||
|
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||||
|
async function fetchAll<T extends HydraMember>(
|
||||||
|
url: string,
|
||||||
|
query: Record<string, string> = {},
|
||||||
|
): Promise<T[]> {
|
||||||
|
const res = await api.get<{ member?: T[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false', ...query },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
return res.member ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les referentiels des filtres en parallele et de maniere resiliente :
|
||||||
|
* un referentiel en echec (403/500) reste vide sans casser l'autre.
|
||||||
|
*/
|
||||||
|
async function loadFilterReferentials(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchAll('/categories', { typeCode: 'PRODUIT' })
|
||||||
|
.then((cats) => { categoryOptions.value = cats.map(c => ({ value: c.id, label: c.name ?? '' })) }),
|
||||||
|
fetchAll('/sites')
|
||||||
|
.then((sitesList) => { siteOptions.value = sitesList.map(s => ({ value: s.id, label: s.name ?? '' })) }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtres (drawer) ─────────────────────────────────────────────────────────
|
||||||
|
// Deux niveaux d'etat (pattern repertoires M1→M5) :
|
||||||
|
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||||
|
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
|
||||||
|
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||||
|
const filterDrawerOpen = ref(false)
|
||||||
|
|
||||||
|
const draftSearch = ref('')
|
||||||
|
const draftCategoryId = ref<number | null>(null)
|
||||||
|
const draftState = ref<string | null>(null)
|
||||||
|
const draftSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const appliedSearch = ref('')
|
||||||
|
const appliedCategoryId = ref<number | null>(null)
|
||||||
|
const appliedState = ref<string | null>(null)
|
||||||
|
const appliedSiteIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
if (appliedSearch.value.trim() !== '') count++
|
||||||
|
if (appliedCategoryId.value !== null) count++
|
||||||
|
if (appliedState.value !== null) count++
|
||||||
|
if (appliedSiteIds.value.length > 0) count++
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterButtonLabel = computed(() => {
|
||||||
|
const base = t('admin.products.filters.title')
|
||||||
|
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la reouverture
|
||||||
|
// reflete les filtres actifs.
|
||||||
|
function openFilters(): void {
|
||||||
|
draftSearch.value = appliedSearch.value
|
||||||
|
draftCategoryId.value = appliedCategoryId.value
|
||||||
|
draftState.value = appliedState.value
|
||||||
|
draftSiteIds.value = [...appliedSiteIds.value]
|
||||||
|
filterDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coche / decoche un site dans le brouillon (filtre multi). */
|
||||||
|
function toggleSite(id: number, selected: boolean): void {
|
||||||
|
draftSiteIds.value = selected
|
||||||
|
? [...draftSiteIds.value, id]
|
||||||
|
: draftSiteIds.value.filter(s => s !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
|
||||||
|
* `siteId[]` pour que PHP la parse en tableau (OR cote back). Les filtres vides
|
||||||
|
* sont omis pour une query propre.
|
||||||
|
*/
|
||||||
|
function buildFilterPayload(): Record<string, string | string[]> {
|
||||||
|
const payload: Record<string, string | string[]> = {}
|
||||||
|
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||||
|
if (appliedCategoryId.value !== null) payload.categoryId = String(appliedCategoryId.value)
|
||||||
|
if (appliedState.value !== null) payload.state = appliedState.value
|
||||||
|
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = appliedSiteIds.value.map(String)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
|
||||||
|
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
|
||||||
|
function applyFilters(): void {
|
||||||
|
appliedSearch.value = draftSearch.value.trim()
|
||||||
|
appliedCategoryId.value = draftCategoryId.value
|
||||||
|
appliedState.value = draftState.value
|
||||||
|
appliedSiteIds.value = [...draftSiteIds.value]
|
||||||
|
|
||||||
|
setFilters(buildFilterPayload(), { replace: true })
|
||||||
|
filterDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||||
|
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||||
|
function resetFilters(): void {
|
||||||
|
draftSearch.value = ''
|
||||||
|
draftCategoryId.value = null
|
||||||
|
draftState.value = null
|
||||||
|
draftSiteIds.value = []
|
||||||
|
|
||||||
|
appliedSearch.value = ''
|
||||||
|
appliedCategoryId.value = null
|
||||||
|
appliedState.value = null
|
||||||
|
appliedSiteIds.value = []
|
||||||
|
|
||||||
|
setFilters({}, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export XLSX ──────────────────────────────────────────────────────────────
|
||||||
|
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
async function exportXlsx(): Promise<void> {
|
||||||
|
if (exporting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||||
|
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||||
|
// contenu faute d'overload blob sur le client partage (meme pattern M2→M5).
|
||||||
|
const blob = await api.get<Blob>('/products/export.xlsx', buildFilterPayload(), {
|
||||||
|
responseType: 'blob',
|
||||||
|
toast: false,
|
||||||
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
|
|
||||||
|
triggerDownload(blob, 'catalogue-produits.xlsx')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({
|
||||||
|
title: t('admin.products.toast.error'),
|
||||||
|
message: t('admin.products.toast.exportError'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||||
|
function triggerDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProducts()
|
||||||
|
loadFilterReferentials()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour vers le catalogue + titre. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('admin.products.form.back')"
|
||||||
|
v-bind="{ ariaLabel: t('admin.products.form.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('admin.products.form.title') }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Formulaire principal de creation ───────────────────────────────
|
||||||
|
Bouton « Valider » TOUJOURS actif (ERP-101) : la validation
|
||||||
|
autoritaire est serveur, les erreurs 422 reviennent inline. -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.states"
|
||||||
|
:options="stateOptions"
|
||||||
|
:label="t('admin.products.form.states')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.states"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
|
||||||
|
/>
|
||||||
|
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('admin.products.form.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name"
|
||||||
|
:mask="FREE_TEXT_MASK"
|
||||||
|
:label="t('admin.products.form.name')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.name"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.code"
|
||||||
|
:mask="CODE_ALNUM_MASK"
|
||||||
|
:label="t('admin.products.form.code')"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.code"
|
||||||
|
/>
|
||||||
|
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="form.categoryIri"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('admin.products.form.category')"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="errors.category"
|
||||||
|
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<!-- Type de stockage : multi-select obligatoire (>= 1). Referentiel plat :
|
||||||
|
tous les types (plus de filtrage par site, RG-6.06). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="form.storageTypeIris"
|
||||||
|
:options="storageTypeOptions"
|
||||||
|
:label="t('admin.products.form.storageTypes')"
|
||||||
|
:display-tag="true"
|
||||||
|
:required="true"
|
||||||
|
:error="errors.storageTypes"
|
||||||
|
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
|
||||||
|
/>
|
||||||
|
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
|
||||||
|
uniquement si l'Etat contient « Vendu ». -->
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="isSale"
|
||||||
|
v-model="form.manufactured"
|
||||||
|
:label="t('admin.products.form.manufactured')"
|
||||||
|
group-class="self-center"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="isSale"
|
||||||
|
v-model="form.containsMolasses"
|
||||||
|
:label="t('admin.products.form.containsMolasses')"
|
||||||
|
group-class="self-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('admin.products.form.submit')"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="onSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Onglets Fournisseurs / Clients (placeholder, HP-M6-01) : NON affiches a
|
||||||
|
l'ajout. Ils n'apparaissent qu'apres validation du formulaire principal
|
||||||
|
(ecran de modification), une fois le produit cree. -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
|
||||||
|
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('admin.products.form.title') })
|
||||||
|
|
||||||
|
// Gating de la route : la creation est reservee a `manage` (catalogue admin-only).
|
||||||
|
if (!can('catalog.products.manage')) {
|
||||||
|
await navigateTo('/admin/products')
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
submitting,
|
||||||
|
isSale,
|
||||||
|
siteOptions,
|
||||||
|
categoryOptions,
|
||||||
|
storageTypeOptions,
|
||||||
|
setStates,
|
||||||
|
setCategory,
|
||||||
|
setStorageTypes,
|
||||||
|
setSites,
|
||||||
|
loadReferentials,
|
||||||
|
submit,
|
||||||
|
} = useProductForm()
|
||||||
|
|
||||||
|
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
|
||||||
|
const stateOptions = computed(() =>
|
||||||
|
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Retour vers le catalogue produit (fleche d'en-tete). */
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/admin/products')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soumet la creation ; au succes, retour a la liste. */
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
const ok = await submit()
|
||||||
|
if (ok) {
|
||||||
|
router.push('/admin/products')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||||
|
loadReferentials().catch(() => {})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Types front du module Catalog (M6 — Catalogue produit).
|
||||||
|
*
|
||||||
|
* Contrats API consommes :
|
||||||
|
* - GET /api/products → HydraCollection<Product>
|
||||||
|
* - GET /api/products/{id} → Product
|
||||||
|
* - GET /api/products/export.xlsx → binaire XLSX (export complet, filtres actifs)
|
||||||
|
*
|
||||||
|
* Notes (cf. spec-back § 4.0.bis, contrat JSON capture en ERP-203) :
|
||||||
|
* - `category` est embarque (objet, pas IRI) ; idem `sites` / `storageTypes`
|
||||||
|
* (tableaux d'objets bornes). On n'a besoin que de `category.name` en liste.
|
||||||
|
* - `states` est un tableau de chaines (PURCHASE / SALE / OTHER).
|
||||||
|
* - `skip_null_values` actif cote back : ne pas presumer la presence des nulls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Type de categorie embarque dans `category.categoryTypes` (RG-6.05). */
|
||||||
|
export interface ProductCategoryType {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Categorie embarquee dans un produit (lecture seule, sous-ensemble utile au front). */
|
||||||
|
export interface ProductCategory {
|
||||||
|
/** IRI Hydra, ex. `/api/categories/12` — utilise pour pre-selectionner le select en edition. */
|
||||||
|
'@id': string
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
categoryTypes?: ProductCategoryType[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Site de disponibilite embarque dans un produit (groupe `site:read`). */
|
||||||
|
export interface ProductSite {
|
||||||
|
/** IRI Hydra, ex. `/api/sites/1` — utilise pour pre-selectionner le multi-select en edition. */
|
||||||
|
'@id': string
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
postalCode: string
|
||||||
|
city: string
|
||||||
|
color: string
|
||||||
|
fullAddress: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type de stockage embarque dans un produit (referentiel borne, § 2.4). */
|
||||||
|
export interface ProductStorageType {
|
||||||
|
/** IRI Hydra, ex. `/api/storage_types/9` — utilise pour pre-selectionner le multi-select en edition. */
|
||||||
|
'@id': string
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produit metier — tel qu'il est lu depuis l'API. L'entite porte le pattern
|
||||||
|
* Timestampable+Blamable (cf. spec-back § 2.8).
|
||||||
|
*/
|
||||||
|
export interface Product {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
/** Etats : sous-ensemble de PURCHASE / SALE / OTHER (RG-6.02). */
|
||||||
|
states: string[]
|
||||||
|
manufactured: boolean
|
||||||
|
containsMolasses: boolean
|
||||||
|
category: ProductCategory | null
|
||||||
|
sites: ProductSite[]
|
||||||
|
storageTypes: ProductStorageType[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6 Catalog — `storage_type` devient un referentiel PLAT + seed prod-safe.
|
||||||
|
*
|
||||||
|
* Contexte : le rattachement « tel type de stockage dispo sur tel site » ne releve
|
||||||
|
* PAS du referentiel `storage_type`. Il sera porte par la future entite Stockage
|
||||||
|
* (module Stockage : un stockage = 1 site + 1 type) et derive des stockages reels.
|
||||||
|
* On retire donc la jointure M2M `storage_type_site` (creee par Version20260625110000)
|
||||||
|
* et le filtrage `?siteId[]=` du multi-select produit (RG-6.06 revue : le select liste
|
||||||
|
* desormais TOUS les types).
|
||||||
|
*
|
||||||
|
* Seed : `storage_type` n'avait jusqu'ici qu'une fixture (purge Doctrine), donc une
|
||||||
|
* table VIDE en prod (les fixtures n'y tournent pas). On aligne sur les referentiels
|
||||||
|
* comptables (payment_type / bank / country, Version20260601000000 / ...100000) :
|
||||||
|
* un `INSERT ... ON CONFLICT (code) DO NOTHING` idempotent qui seede prod ET survit a
|
||||||
|
* tout. En dev/test, StorageTypeFixtures re-seede apres la purge (source unique : les
|
||||||
|
* 10 memes valeurs Figma, PROVISOIRE — HP-M6-02 / ERP-201).
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : la table `storage_type`
|
||||||
|
* et la jointure droppee ici ont ete creees au namespace racine (Version20260625110000) ;
|
||||||
|
* un namespace modulaire trierait par FQCN alphabetique AVANT et casserait l'ordre sur
|
||||||
|
* base vide (drop d'une table pas encore creee).
|
||||||
|
*/
|
||||||
|
final class Version20260626100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'M6 Catalog : storage_type referentiel plat (drop storage_type_site) + seed idempotent des types de stockage (prod-safe).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1. storage_type devient plat : la dispo par site releve du futur module Stockage.
|
||||||
|
$this->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.$_$');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ArrayParameterType;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6 Catalog — seed prod-safe des `Category` de type PRODUIT.
|
||||||
|
*
|
||||||
|
* Contexte : le `CategoryType` PRODUIT est seede en migration (Version20260625110000),
|
||||||
|
* mais ses `Category` (Cereales, Oleagineux, Aliments du betail, Engrais) ne vivaient
|
||||||
|
* que dans `CategoryFixtures` (dev/test) — table `category` VIDE en prod, donc le
|
||||||
|
* select « Categorie » du formulaire produit serait vide. On aligne sur les autres
|
||||||
|
* taxonomies (CLIENT / FOURNISSEUR / PRESTATAIRE / ADRESSE, deja seedees en migration)
|
||||||
|
* : seed idempotent ici (prod), re-seed dev/test par les fixtures apres purge.
|
||||||
|
*
|
||||||
|
* Aucune colonne creee/modifiee -> pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
|
||||||
|
* la migration ne fait que des INSERT de donnees de reference.
|
||||||
|
*
|
||||||
|
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : depend de
|
||||||
|
* `category` / `category_type` / `category_category_type` (creees au namespace racine)
|
||||||
|
* et du type PRODUIT (Version20260625110000) ; le tri par timestamp garantit l'ordre.
|
||||||
|
*
|
||||||
|
* Idempotence : `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et
|
||||||
|
* chaque ligne de jonction (miroir Version20260612080000 / ERP-84). Codes = slug
|
||||||
|
* MAJUSCULE deterministe (meme sortie que CategoryCodeGenerator), provisoires — a
|
||||||
|
* affiner avec le metier (ERP-201).
|
||||||
|
*/
|
||||||
|
final class Version20260626110000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Categories produit (provisoires, Figma/metier) : nom => code stable. Le code
|
||||||
|
* reste unique parmi les actifs (uq_category_code) et le nom unique globalement
|
||||||
|
* (uq_category_name_active) — aucune collision avec les taxonomies existantes.
|
||||||
|
*/
|
||||||
|
private const array PRODUCT_CATEGORIES = [
|
||||||
|
'Céréales' => 'CEREALES',
|
||||||
|
'Oléagineux' => 'OLEAGINEUX',
|
||||||
|
'Aliments du bétail' => 'ALIMENTS_DU_BETAIL',
|
||||||
|
'Engrais' => 'ENGRAIS',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'M6 Catalog : seed prod-safe des categories de type PRODUIT (Cereales, Oleagineux, Aliments du betail, Engrais).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Le type PRODUIT existe deja (Version20260625110000) ; re-assert defensif
|
||||||
|
// et idempotent pour rendre cette migration auto-portante.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
foreach (self::PRODUCT_CATEGORIES as $name => $code) {
|
||||||
|
// 1. Categorie (si le code est libre parmi les actifs). created_at/updated_at
|
||||||
|
// NOT NULL -> NOW() ; le blame reste null (seed hors contexte HTTP).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category (name, code, created_at, updated_at)
|
||||||
|
SELECT :name, :code, NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SQL, ['name' => $name, 'code' => $code]);
|
||||||
|
|
||||||
|
// 2. Jonction M2M categorie <-> type PRODUIT (modele courant).
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO category_category_type (category_id, category_type_id)
|
||||||
|
SELECT c.id, ct.id
|
||||||
|
FROM category c
|
||||||
|
CROSS JOIN category_type ct
|
||||||
|
WHERE c.code = :code AND c.deleted_at IS NULL
|
||||||
|
AND ct.code = 'PRODUIT'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM category_category_type cct
|
||||||
|
WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
|
||||||
|
)
|
||||||
|
SQL, ['code' => $code]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Best-effort : retire les categories seedees (par code) rattachees au type
|
||||||
|
// PRODUIT — la jonction part en CASCADE cote category. Echoue si un produit
|
||||||
|
// reference encore l'une d'elles (FK RESTRICT product.category_id), attendu.
|
||||||
|
$this->addSql(
|
||||||
|
'DELETE FROM category WHERE code IN (:codes) '
|
||||||
|
.'AND id IN (SELECT category_id FROM category_category_type cct '
|
||||||
|
."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'PRODUIT')",
|
||||||
|
['codes' => array_values(self::PRODUCT_CATEGORIES)],
|
||||||
|
['codes' => ArrayParameterType::STRING],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,12 +49,12 @@ use function in_array;
|
|||||||
* contient SALE, sinon forces false serveur.
|
* contient SALE, sinon forces false serveur.
|
||||||
* - RG-6.04 : `sites` >= 1.
|
* - RG-6.04 : `sites` >= 1.
|
||||||
* - RG-6.05 : `category` de type PRODUIT (validee applicativement, Callback ERP-200).
|
* - 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
|
* 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).
|
* 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
|
* Processor + une contrainte d'entite Assert\Callback en ERP-200 (chaque 422
|
||||||
* porte un propertyPath exploitable par useFormErrors — mapping inline, ERP-101).
|
* porte un propertyPath exploitable par useFormErrors — mapping inline, ERP-101).
|
||||||
*
|
*
|
||||||
@@ -81,12 +81,18 @@ use function in_array;
|
|||||||
security: "is_granted('catalog.products.manage')",
|
security: "is_granted('catalog.products.manage')",
|
||||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
denormalizationContext: ['groups' => ['product:write']],
|
denormalizationContext: ['groups' => ['product:write']],
|
||||||
|
// Convertit les erreurs de denormalisation (type invalide / null sur une
|
||||||
|
// relation : category, sites, storageTypes) en violations 422 portant un
|
||||||
|
// propertyPath, au lieu d'un 400 qui court-circuite toute la validation
|
||||||
|
// (cf. Client/Supplier/WeighingTicket — mapping inline useFormErrors).
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
processor: ProductProcessor::class,
|
processor: ProductProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
security: "is_granted('catalog.products.manage')",
|
security: "is_granted('catalog.products.manage')",
|
||||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||||
denormalizationContext: ['groups' => ['product:write']],
|
denormalizationContext: ['groups' => ['product:write']],
|
||||||
|
collectDenormalizationErrors: true,
|
||||||
provider: ProductProvider::class,
|
provider: ProductProvider::class,
|
||||||
processor: ProductProcessor::class,
|
processor: ProductProcessor::class,
|
||||||
),
|
),
|
||||||
@@ -204,9 +210,9 @@ class Product implements TimestampableInterface, BlamableInterface
|
|||||||
private Collection $sites;
|
private Collection $sites;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Types de stockage du produit (>= 1, RG-6.06), filtres par les sites
|
* Types de stockage du produit (>= 1, RG-6.06). Referentiel plat : tous les
|
||||||
* selectionnes (provider StorageType, ERP-201). Cote inverse en ON DELETE
|
* types sont selectionnables (plus de filtrage par site). Cote inverse en
|
||||||
* RESTRICT : un type de stockage reference par un produit ne peut etre supprime.
|
* ON DELETE RESTRICT : un type reference par un produit ne peut etre supprime.
|
||||||
*
|
*
|
||||||
* @var Collection<int, StorageType>
|
* @var Collection<int, StorageType>
|
||||||
*/
|
*/
|
||||||
@@ -396,43 +402,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()
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,22 +9,19 @@ use ApiPlatform\Metadata\Get;
|
|||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageTypeProvider;
|
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageTypeProvider;
|
||||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository;
|
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 Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type de stockage : referentiel PROVISOIRE classifiant ou un produit peut etre
|
* 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
|
* stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste
|
||||||
* le mapping site definitifs d'Aurore (HP-M6-02) ; seede avec la liste Figma
|
* definitive d'Aurore (HP-M6-02 / ERP-201).
|
||||||
* (node 1503-34285) au ticket ERP-201.
|
|
||||||
*
|
*
|
||||||
* Relation `sites` (ManyToMany -> Site) : sites sur lesquels ce type de stockage
|
* Referentiel PLAT : un type de stockage n'est PAS rattache a des sites. La
|
||||||
* est disponible. Sert au filtrage du multi-select « Type de stockage » par les
|
* disponibilite « tel type sur tel site » releve de la future entite Stockage
|
||||||
* sites selectionnes dans le formulaire produit (RG-6.06). Non serialisee au M6
|
* (module Stockage : un stockage = 1 site + 1 type) et sera derivee des stockages
|
||||||
* (le filtrage est applique cote provider en ERP-201).
|
* 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
|
* 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`
|
* (CRUD admin = hors perimetre HP-M6-03), sous la permission `catalog.products.view`
|
||||||
@@ -39,10 +36,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
*/
|
*/
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
// Tri label ASC et filtre ?siteId[]= portes par le StorageTypeProvider
|
// Tri label ASC porte par le StorageTypeProvider : alimente le multi-select
|
||||||
// (ERP-201) : alimente le multi-select « Type de stockage » du formulaire
|
// « Type de stockage » du formulaire produit (TOUS les types — referentiel
|
||||||
// produit, filtre par les sites selectionnes (RG-6.06). Pagination Hydra +
|
// plat). Pagination Hydra + echappatoire ?pagination=false (referentiel borne).
|
||||||
// echappatoire ?pagination=false (referentiel borne).
|
|
||||||
new GetCollection(
|
new GetCollection(
|
||||||
security: "is_granted('catalog.products.view')",
|
security: "is_granted('catalog.products.view')",
|
||||||
normalizationContext: ['groups' => ['storage_type:read']],
|
normalizationContext: ['groups' => ['storage_type:read']],
|
||||||
@@ -75,24 +71,6 @@ class StorageType
|
|||||||
#[Groups(['storage_type:read'])]
|
#[Groups(['storage_type:read'])]
|
||||||
private ?string $label = null;
|
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<int, Site>
|
|
||||||
*/
|
|
||||||
#[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
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -121,28 +99,4 @@ class StorageType
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Site>
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,8 @@ interface StorageTypeRepositoryInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* QueryBuilder de la liste des types de stockage (consomme par le
|
* QueryBuilder de la liste des types de stockage (consomme par le
|
||||||
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2) et filtre
|
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2). Referentiel plat :
|
||||||
* optionnel `?siteId[]=` (RG-6.06) restreignant aux types disponibles sur
|
* plus de filtrage par site (la dispo par site releve du futur module Stockage).
|
||||||
* AU MOINS UN des sites passes.
|
|
||||||
*
|
|
||||||
* @param list<int> $siteIds
|
|
||||||
*/
|
*/
|
||||||
public function createListQueryBuilder(array $siteIds = []): QueryBuilder;
|
public function createListQueryBuilder(): QueryBuilder;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-33
@@ -18,12 +18,12 @@ use function is_int;
|
|||||||
use function is_string;
|
use function is_string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider StorageType (referentiel lecture seule, ERP-201) :
|
* Provider StorageType (referentiel plat lecture seule) :
|
||||||
* - LISTE : tri `label ASC` (defaut spec § 4.2), filtre `?siteId[]=` (RG-6.06 :
|
* - LISTE : tri `label ASC` (defaut spec § 4.2) et collection PAGINEE Hydra
|
||||||
* types disponibles sur au moins un des sites passes) et collection PAGINEE
|
* (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
|
||||||
* Hydra (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
|
* alimenter le multi-select « Type de stockage » du formulaire produit avec
|
||||||
* alimenter le multi-select « Type de stockage » du formulaire produit
|
* TOUS les types (referentiel borne — pagination_client_enabled). Plus de
|
||||||
* (referentiel borne — pagination_client_enabled).
|
* filtrage par site : la dispo par site releve du futur module Stockage.
|
||||||
* - ITEM : lookup simple par id.
|
* - ITEM : lookup simple par id.
|
||||||
*
|
*
|
||||||
* @implements ProviderInterface<StorageType>
|
* @implements ProviderInterface<StorageType>
|
||||||
@@ -39,7 +39,7 @@ final class StorageTypeProvider implements ProviderInterface
|
|||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null
|
||||||
{
|
{
|
||||||
if ($operation instanceof CollectionOperationInterface) {
|
if ($operation instanceof CollectionOperationInterface) {
|
||||||
$qb = $this->repository->createListQueryBuilder($this->readSiteIds($context));
|
$qb = $this->repository->createListQueryBuilder();
|
||||||
|
|
||||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||||
// (alimentation du multi-select, referentiel borne).
|
// (alimentation du multi-select, referentiel borne).
|
||||||
@@ -65,30 +65,4 @@ final class StorageTypeProvider implements ProviderInterface
|
|||||||
|
|
||||||
return $this->repository->findById((int) $id);
|
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<int>
|
|
||||||
*/
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,41 +6,39 @@ namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
|||||||
|
|
||||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||||
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
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\Bundle\FixturesBundle\Fixture;
|
||||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
|
||||||
use Doctrine\Persistence\ObjectManager;
|
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
|
* ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes et libelles ci-dessous
|
||||||
* site ci-dessous sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste et le
|
* sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste definitive (ERP-201).
|
||||||
* mapping definitifs par site. La liste actuelle reprend les 10 valeurs de la
|
* La liste actuelle reprend les 10 valeurs de la maquette Figma (node 1503-34285).
|
||||||
* maquette Figma (node 1503-34285) et les rattache PAR DEFAUT aux 3 sites
|
|
||||||
* (Chatellerault 86 / Saint-Jean 17 / Pommevic 82), faute de mapping reel.
|
|
||||||
*
|
*
|
||||||
* Pourquoi une fixture (et pas un seed de migration) : `storage_type` est une
|
* Referentiel PLAT : un type de stockage n'est plus rattache a des sites (la dispo
|
||||||
* entite managee par l'ORM, donc le purger Doctrine la vide avant chaque
|
* par site releve du futur module Stockage). Cette fixture ne seede donc que les
|
||||||
* `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test (le referentiel
|
* lignes `storage_type` ; la voie prod-safe est l'INSERT idempotent de la migration
|
||||||
* doit exister pour alimenter le formulaire produit et les tests du filtre
|
* Version20260626100000 (les fixtures ne tournent pas en prod). Source unique : les
|
||||||
* ?siteId[]= — ERP-203). Elle tourne dans TOUS les environnements (referentiel,
|
* memes 10 valeurs ici et dans la migration.
|
||||||
* pas une donnee de demo — miroir CategoryTypeFixtures).
|
*
|
||||||
|
* 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
|
* Idempotence : lookup par `code` parmi les types existants avant insertion
|
||||||
* (miroir CategoryTypeFixtures). `addSite()` est lui-meme idempotent (contains()).
|
* (miroir CategoryTypeFixtures). Rejouable sans doublon meme si le purger Doctrine
|
||||||
* Rejouable sans doublon meme si le purger Doctrine est desactive.
|
* 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).
|
|
||||||
*/
|
*/
|
||||||
class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
|
class StorageTypeFixtures extends Fixture
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Seed PROVISOIRE (Figma node 1503-34285) : code MAJUSCULE stable => libelle FR.
|
* 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<string, string>
|
* @var array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -57,27 +55,10 @@ class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
'ZONE' => 'Zone',
|
'ZONE' => 'Zone',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Noms des 3 sites de rattachement PROVISOIRE (86 / 17 / 82). Le nom est la cle
|
|
||||||
* de lookup stable cote SitesFixtures.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const DEFAULT_SITE_NAMES = ['Chatellerault', 'Saint-Jean', 'Pommevic'];
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly StorageTypeRepositoryInterface $storageTypeRepository,
|
private readonly StorageTypeRepositoryInterface $storageTypeRepository,
|
||||||
private readonly SiteProviderInterface $siteProvider,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, class-string>
|
|
||||||
*/
|
|
||||||
public function getDependencies(): array
|
|
||||||
{
|
|
||||||
return [SitesFixtures::class];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function load(ObjectManager $manager): void
|
public function load(ObjectManager $manager): void
|
||||||
{
|
{
|
||||||
// Index des types deja presents par code, pour ne pas creer de doublon.
|
// 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;
|
$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) {
|
foreach (self::TYPES as $code => $label) {
|
||||||
$storageType = $existingByCode[$code] ?? new StorageType();
|
$storageType = $existingByCode[$code] ?? new StorageType();
|
||||||
$storageType->setCode($code);
|
$storageType->setCode($code);
|
||||||
$storageType->setLabel($label);
|
$storageType->setLabel($label);
|
||||||
|
|
||||||
// Rattachement provisoire aux 3 sites (idempotent : addSite -> contains()).
|
|
||||||
foreach ($defaultSites as $site) {
|
|
||||||
$storageType->addSite($site);
|
|
||||||
}
|
|
||||||
|
|
||||||
$manager->persist($storageType);
|
$manager->persist($storageType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,32 +33,12 @@ class DoctrineStorageTypeRepository extends ServiceEntityRepository implements S
|
|||||||
return $this->findBy([], ['label' => 'ASC']);
|
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
|
// Tri alphabetique stable (multi-select du formulaire produit, § 4.2).
|
||||||
// relation `sites` n'est PAS serialisee (storage_type:read ne la porte pas)
|
// Referentiel plat : tous les types, plus de filtrage par site.
|
||||||
// -> pas d'eager-load : le filtre n'affecte pas la sortie, seulement la
|
return $this->createQueryBuilder('st')
|
||||||
// restriction des lignes.
|
|
||||||
$qb = $this->createQueryBuilder('st')
|
|
||||||
->orderBy('st.label', 'ASC')
|
->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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -587,12 +587,6 @@ final class ColumnCommentsCatalog
|
|||||||
'label' => 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).',
|
'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' => [
|
'product' => [
|
||||||
'_table' => 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.',
|
'_table' => 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.',
|
||||||
'id' => 'Identifiant interne auto-incremente.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
@@ -612,7 +606,7 @@ final class ColumnCommentsCatalog
|
|||||||
],
|
],
|
||||||
|
|
||||||
'product_storage_type' => [
|
'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.',
|
'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.',
|
'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ use Symfony\Contracts\HttpClient\ResponseInterface;
|
|||||||
* volee pour que les POST passent RG-6.05.
|
* volee pour que les POST passent RG-6.05.
|
||||||
* - `productCategory()` / `nonProductCategory()` : categories de test rattachees
|
* - `productCategory()` / `nonProductCategory()` : categories de test rattachees
|
||||||
* (ou non) au type PRODUIT.
|
* (ou non) au type PRODUIT.
|
||||||
* - `seedStorageType()` : type de stockage de test (prefixe code pour cleanup),
|
* - `seedStorageType()` : type de stockage de test (prefixe code pour cleanup ;
|
||||||
* rattachable a des sites precis (RG-6.06).
|
* referentiel plat, plus de rattachement par site).
|
||||||
* - `siteByCode()` / `firstSite()` : sites fixtures (86 / 17 / 82).
|
* - `siteByCode()` / `firstSite()` : sites fixtures (86 / 17 / 82).
|
||||||
* - `authView()` : user non-admin portant la permission `catalog.products.view`.
|
* - `authView()` : user non-admin portant la permission `catalog.products.view`.
|
||||||
* - `validProductPayload()` : payload POST de reference (IRIs category/sites/
|
* - `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).
|
// product_storage_type cascadent au niveau base (ON DELETE CASCADE).
|
||||||
$em->createQuery('DELETE FROM '.Product::class)->execute();
|
$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')
|
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
|
||||||
->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%')
|
->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%')
|
||||||
->execute()
|
->execute()
|
||||||
@@ -111,19 +111,16 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup),
|
* Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup).
|
||||||
* rattache aux sites passes (disponibilite — RG-6.06).
|
* 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();
|
$em = $this->getEm();
|
||||||
|
|
||||||
$storageType = new StorageType();
|
$storageType = new StorageType();
|
||||||
$storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
|
$storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
|
||||||
$storageType->setLabel($label);
|
$storageType->setLabel($label);
|
||||||
foreach ($sites as $site) {
|
|
||||||
$storageType->addSite($em->getReference(Site::class, (int) $site->getId()));
|
|
||||||
}
|
|
||||||
|
|
||||||
$em->persist($storageType);
|
$em->persist($storageType);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
@@ -169,7 +166,7 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
|
|||||||
protected function validProductPayload(array $overrides = []): array
|
protected function validProductPayload(array $overrides = []): array
|
||||||
{
|
{
|
||||||
$site = $this->firstSite();
|
$site = $this->firstSite();
|
||||||
$storageType = $this->seedStorageType('Tas test', $site);
|
$storageType = $this->seedStorageType('Tas test');
|
||||||
$category = $this->productCategory();
|
$category = $this->productCategory();
|
||||||
|
|
||||||
$base = [
|
$base = [
|
||||||
@@ -213,7 +210,7 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
|
|||||||
$product->setContainsMolasses(false);
|
$product->setContainsMolasses(false);
|
||||||
$product->setCategory($category ?? $this->productCategory());
|
$product->setCategory($category ?? $this->productCategory());
|
||||||
$product->addSite($em->getReference(Site::class, (int) $site->getId()));
|
$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);
|
$product->setDeletedAt($deletedAt);
|
||||||
|
|
||||||
$em->persist($product);
|
$em->persist($product);
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Module\Catalog\Api;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RG-6.06 : chaque type de stockage retenu doit etre disponible sur au moins un
|
|
||||||
* des sites selectionnes. Un type de stockage hors des sites du produit est
|
|
||||||
* rejete en 422 (Assert\Callback, propertyPath `storageTypes`).
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ProductStorageTypeBySiteTest extends AbstractProductApiTestCase
|
|
||||||
{
|
|
||||||
public function testStorageTypeNotOnSelectedSitesIsRejected(): void
|
|
||||||
{
|
|
||||||
$client = $this->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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user