Compare commits

..

4 Commits

Author SHA1 Message Date
tristan 58d0c499d4 fix(catalog) : M6 — code/name envoyés en chaîne vide (mapping 422 produit)
Le formulaire produit envoyait code/name à null quand vides (form.code || null).
Or les setters back setCode(string)/setName(string) sont non-nullables : null
déclenchait une erreur de type (dénormalisation, code générique mappé « Date
invalide » côté front) qui court-circuitait toutes les autres violations — d'où
seuls code/name affichés, en « Date invalide ».

On envoie '' (form.code ?? '') : la contrainte NotBlank renvoie un message FR
propre par champ, et la 422 porte les 6 violations d'un coup (code, name, states,
category, sites, storageTypes), chacune mappée inline (vérifié API).
2026-06-26 17:05:26 +02:00
tristan 2b1071bedb fix(catalog) : M6 — mapping inline des erreurs 422 du formulaire produit
Les operations Post/Patch de Product n'avaient pas collectDenormalizationErrors :
un null/type invalide sur une relation (category) levait un 400 qui
court-circuitait toute la validation -> aucune violation propertyPath, donc
aucune erreur mappee sous les champs (ajout comme modification).

- Product : collectDenormalizationErrors: true sur Post + Patch (miroir
  Client/Supplier/WeighingTicket) -> 422 avec propertyPath au lieu de 400.
- useProductForm : on omet la cle 'category' du payload quand aucune categorie
  n'est choisie (envoyer null casserait la denormalisation IRI et masquerait les
  autres violations) -> le back renvoie les 6 violations d'un coup, dont le
  NotNull propre sur category.
2026-06-26 16:29:56 +02:00
tristan ec648ff2ff feat(catalog) : M6 — seed prod-safe des catégories de type PRODUIT
Les Category de type PRODUIT (Céréales, Oléagineux, Aliments du bétail, Engrais)
ne vivaient que dans CategoryFixtures (dev/test) → table category vide en prod,
select « Catégorie » du formulaire produit vide. On aligne sur les autres
taxonomies (CLIENT/FOURNISSEUR/PRESTATAIRE/ADRESSE déjà seedées en migration) :
INSERT idempotent (NOT EXISTS) + jonction category_category_type, miroir du
pattern Version20260612080000. Re-seed dev/test conservé via les fixtures.
2026-06-26 16:12:44 +02:00
tristan fced2c2cfd feat(catalog) : M6 — StorageType référentiel plat + seed migration (drop storage_type_site)
La disponibilité « type de stockage par site » relèvera de la future entité
Stockage (site + type), pas du référentiel. On retire donc la jointure M2M
storage_type_site et le filtrage du multi-select par site (RG-6.06 revue) :

- migration : DROP storage_type_site + seed idempotent des 10 types (prod-safe,
  ON CONFLICT) ;
- StorageType : référentiel plat (plus de relation sites) ;
- Product : suppression du Assert\Callback de disponibilité par site ;
- provider/repository : /storage_types renvoie tous les types (plus de ?siteId[]) ;
- front : useStorageTypeOptions charge tout dans loadReferentials, setSites sans
  cascade/purge ;
- fixture, ColumnCommentsCatalog, tests et spec-back M6 alignés.
2026-06-26 15:39:11 +02:00
17 changed files with 371 additions and 426 deletions
+22 -17
View File
@@ -35,7 +35,7 @@ statut_global: pret_a_dev
# === DÉPENDANCES AMONT ===
depend_de:
- Catalog # Module hôte (REQUIRED). Category + CategoryType (ajout du type PRODUIT) + nouveau StorageType + Product
- Sites # Site (relation ManyToMany product↔site) + filtrage des types de stockage par site
- Sites # Site (relation ManyToMany product↔site, RG-6.04)
- Core # User, Role, Permission, Audit, JWT
- Shared # TimestampableBlamableTrait + Subscriber (ERP-52) + CategoryInterface
---
@@ -65,7 +65,7 @@ Cette spec **complète et précise** la [spec front V0.1](./spec-front.md) (docx
| C1 | « Module 7 — Catalogue produit » / « Module Administration » | Le projet n'a pas de « Module 7 » ni de module « Administration » ; un module `Catalog` existe déjà. | **Produit logé dans `Catalog`** ; item sidebar dans la **section Administration**, sous « Répertoire transporteurs » (§ 2.1 / § 5.3). |
| C2 | Colonnes liste = `Nom`, `Numéro`, `Catégorie` ; formulaire = `Nom`, `Code produit`, `Catégorie` | « Numéro » (liste) vs « Code produit » (formulaire) : 2 noms pour quoi ? | **Même champ** : `code` (= « Numéro » = « Code produit »), **saisi**, **unique global**, 409 sur doublon (RG-6.01). La colonne liste « Numéro » affiche `code`. |
| C3 | « État du produit » : Multi-select **obligatoire**, valeurs *Achat / Vendu / Autre* | Les onglets parlent de *Achat / Vendu / **Aucun*** → « Autre » ≠ « Aucun ». Et « obligatoire » + « Aucun » se contredisent. | **Enum `PURCHASE` / `SALE` / `OTHER`**, multi-select, **≥ 1 obligatoire** (RG-6.02). « Aucun » des onglets = « ni Achat ni Vendu » (donc `OTHER` seul). |
| C4 | « Type de stockage » : « *liste fournie par Aurore* en fonction des sites » | Aucun référentiel de stockage n'existe en base ; la vraie liste est en attente. | **Référentiel minimal `StorageType` créé maintenant** (provisoire), seedé avec la liste Figma (node 1503-34285) ; options **filtrées par les sites sélectionnés** (RG-6.06, § 2.4). À re-seeder quand Aurore livre la liste/le mapping site définitifs (HP-M6-02). |
| C4 | « Type de stockage » : « *liste fournie par Aurore* en fonction des sites » | Aucun référentiel de stockage n'existe en base ; la vraie liste est en attente. | **Référentiel minimal `StorageType` créé maintenant** (provisoire, **plat**), seedé avec la liste Figma (node 1503-34285) ; multi-select listant **tous** les types (plus de filtrage par site — décision 26/06, § 2.4). La disponibilité par site relèvera du futur module **Stockage**. À re-seeder quand Aurore livre la liste définitive (HP-M6-02). |
| C5 | « Catégorie produit » : « Liste des catégories produit » | Le type `PRODUIT` n'est pas seedé ; aucune catégorie produit n'existe. | Le M6 **seede le `CategoryType` PRODUIT** + quelques `Category` produit, et le select est **filtré `?typeCode=PRODUIT`** (RG-6.05, § 2.5). |
| C6 | « Fabriqué » / « Contient de la mélasse » : « apparaît si État = Vendu » | Comportement front only ? Que stocke-t-on si l'état n'est plus Vendu ? | Booléens **conditionnés à `SALE`** : saisis seulement si l'état contient `SALE`, sinon **forcés `false` serveur** (RG-6.03). |
| C7 | RBAC : Admin = Tout, tous les autres rôles = Non | Très restrictif (admin-only). Confirmé ? | **Confirmé** : `catalog.products.view` / `.manage` attribués **au seul rôle Admin** (§ 5.2). |
@@ -95,14 +95,16 @@ Ajout au module **`Catalog`** (pas de nouveau module — C1) :
Pilotage des champs conditionnels (RG-6.03) : `manufactured` et `containsMolasses` ne sont **saisissables que si `states` contient `SALE`** ; sinon forcés `false` côté serveur (Processor) — pas de state machine.
### 2.4 Référentiel `StorageType` (C4 / RG-6.06) — PROVISOIRE
### 2.4 Référentiel `StorageType` (C4 / RG-6.06) — PROVISOIRE, référentiel PLAT
> **Décision Matthieu (24/06)** : créer un **référentiel minimal** en attendant la liste/mapping définitifs d'Aurore. Seed = liste Figma (node 1503-34285).
>
> **Décision Tristan (26/06)** : `StorageType` devient un **référentiel PLAT** — plus de rattachement aux sites. La disponibilité « tel type sur tel site » relèvera de la **future entité `Stockage`** (module Stockage : un stockage = 1 site + 1 type), dérivée des stockages réels. On **retire** donc la jointure `storage_type_site` et **tout filtrage du multi-select par site** (migration `Version20260626100000` : drop de la jointure + seed idempotent). Le référentiel est aussi seedé **en migration** (prod-safe, comme `payment_type`/`bank`/`country`), la fixture ne servant qu'au re-seed dev après purge.
- Entité `StorageType` (`Catalog`) : `id`, `code` (slug MAJUSCULE stable, unique), `label` (FR affiché), relation **`sites` ManyToMany → Site** (sur quels sites ce type de stockage est disponible).
- **Seed initial (10 valeurs, Figma)** : Boisseau, Boisseau dosage, Case, Cellule, Container, Cuve mélasse, Stockage big bag, Stockage palette, Tas, Zone. **Provisoirement rattachés aux 3 sites** (86/17/82) tant qu'Aurore n'a pas précisé le mapping réel par site.
- Le champ produit « Type de stockage » est un **multi-select filtré par les sites sélectionnés** dans le formulaire : `GET /api/storage_types?siteId[]=…` ne renvoie que les types disponibles sur ces sites (RG-6.06).
- **Provisoire** : codes, libellés et mapping site sont à revalider/re-seeder à réception de la liste Aurore (HP-M6-02). Référentiel en **lecture seule** au M6 (pas de CRUD admin du StorageType — HP-M6-03).
- Entité `StorageType` (`Catalog`) : `id`, `code` (slug MAJUSCULE stable, unique), `label` (FR affiché). **Plus de relation `sites`.**
- **Seed (10 valeurs, Figma)** : Boisseau, Boisseau dosage, Case, Cellule, Container, Cuve mélasse, Stockage big bag, Stockage palette, Tas, Zone. Seedées en migration (`ON CONFLICT (code) DO NOTHING`) **et** par `StorageTypeFixtures` (dev/test).
- Le champ produit « Type de stockage » est un **multi-select listant TOUS les types** : `GET /api/storage_types` (plus de paramètre `?siteId[]=`).
- **Provisoire** : codes et libellés sont à revalider/re-seeder à réception de la liste Aurore (HP-M6-02). Référentiel en **lecture seule** au M6 (pas de CRUD admin du StorageType — HP-M6-03).
### 2.5 Catégorie produit — type `PRODUIT` (C5 / RG-6.05)
@@ -155,7 +157,7 @@ Le docx M6 ne prévoit **ni archivage ni suppression** (actions = + Ajouter / Ex
+---+
```
Tables de jonction : `product_site (product_id, site_id)`, `product_storage_type (product_id, storage_type_id)`, `storage_type_site (storage_type_id, site_id)`.
Tables de jonction : `product_site (product_id, site_id)`, `product_storage_type (product_id, storage_type_id)`. *(La jonction `storage_type_site` initialement créée par ERP-198 a été **supprimée** : `StorageType` est devenu un référentiel plat — migration `Version20260626100000`, décision 26/06, § 2.4.)*
### 3.2 Migration Doctrine — SQL Postgres (illustratif)
@@ -176,6 +178,8 @@ CREATE TABLE storage_type (
);
CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code);
-- NB : storage_type_site (créée ici par ERP-198) est DROPPÉE par la migration
-- Version20260626100000 — StorageType est un référentiel plat (décision 26/06, § 2.4).
CREATE TABLE storage_type_site (
storage_type_id INT NOT NULL REFERENCES storage_type(id) ON DELETE CASCADE,
site_id INT NOT NULL REFERENCES site(id) ON DELETE CASCADE,
@@ -355,7 +359,7 @@ class Product implements TimestampableInterface, BlamableInterface
#[Groups(['product:read', 'product:write'])]
private Collection $sites;
/** @var Collection<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\JoinTable(name: 'product_storage_type')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
@@ -371,8 +375,9 @@ class Product implements TimestampableInterface, BlamableInterface
$this->storageTypes = new ArrayCollection();
}
// RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT)
// + RG-6.06 (types de stockage ⊆ sites) : cohérence via #[Assert\Callback] (§ 7).
// RG-6.03 (champs conditionnels SALE) + RG-6.05 (catégorie de type PRODUIT) :
// cohérence via #[Assert\Callback] (§ 7). RG-6.06 = simple Assert\Count(min:1)
// (référentiel plat, plus de contrainte de disponibilité par site).
// ... getters/setters ...
}
```
@@ -544,7 +549,7 @@ Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\
- Opérations : `GetCollection` + `Get` (lecture seule au M6 — § 2.4).
- Sécurité : `is_granted('catalog.products.view')` (référentiel servant le formulaire produit). *(Si un autre rôle doit lire ce référentiel sans accès produit, ajouter une `read_ref` dédiée — non requis au M6 vu le RBAC admin-only.)*
- **`?siteId[]=…`** : filtre les types disponibles sur les sites passés (alimente le multi-select « Type de stockage » filtré par les sites cochés — RG-6.06).
- Référentiel **plat** : renvoie TOUS les types (plus de paramètre `?siteId[]=` — RG-6.06 revue, § 2.4).
- **`?pagination=false`** : échappatoire select (référentiel borné ≤ quelques dizaines) — règle frontend.
- `normalizationContext: ['storage_type:read']` ; tri `label ASC`.
@@ -555,7 +560,7 @@ Pagination obligatoire (règle ABSOLUE n°13) — provider ORM via `ApiPlatform\
1. Normalise `code` (trim + UPPER) et `name` (trim) — RG-6.07.
2. Valide l'**unicité globale** du `code` parmi les actifs → **409** sur doublon (RG-6.01).
3. Force `manufactured` / `containsMolasses` à `false` si `states` ne contient pas `SALE` (RG-6.03).
4. Valide que `category` est de type **PRODUIT** (RG-6.05) et que `storageTypes ⊆` types disponibles sur les `sites` choisis (RG-6.06) → 422 sinon.
4. Valide que `category` est de type **PRODUIT** (RG-6.05) → 422 sinon. `storageTypes` : `≥ 1` (RG-6.06, référentiel plat — plus de contrainte de disponibilité par site).
- Réponse `201` avec le produit complet.
### 4.4 `PATCH /api/products/{id}` (modification)
@@ -628,13 +633,13 @@ Toute permission `catalog.products.*` doit être posée **simultanément** dans
| **RG-6.03** | docx+back | « Fabriqué » et « Contient de la mélasse » saisissables **uniquement si `states` contient `SALE`** ; sinon forcés `false` serveur. |
| **RG-6.04** | docx | `sites` (multi-select) obligatoire, **≥ 1** site. |
| **RG-6.05** | docx+back | `category` obligatoire, limitée aux catégories de **type PRODUIT** (select filtré `?typeCode=PRODUIT` + validation Callback 422). |
| **RG-6.06** | docx+back | `storageTypes` (multi-select) obligatoire, **≥ 1**, options **filtrées par les sites sélectionnés** ; référentiel `StorageType` **provisoire** (en attente Aurore). |
| **RG-6.06** | docx+back | `storageTypes` (multi-select) obligatoire, **≥ 1**. Référentiel `StorageType` **plat** (tous les types, **plus de filtrage par site** — décision 26/06, § 2.4) et **provisoire** (en attente Aurore). |
| **RG-6.07** | back | Normalisation serveur : `code` trim+UPPER, `name` trim (§ 6). |
| **RG-6.08** | back | « Modification » = même formulaire/mêmes règles que « Ajouter » ; bouton « Valider » → « Enregistrer » (docx p.7). Code & contraintes inchangés. |
| **RG-6.09** | back | Liste : exclut par défaut les produits soft-deleted ; pas de Delete exposé (§ 2.7). |
| **RG-6.10** | back | Onglets « Fournisseurs » / « Clients » = **hors périmètre V0** (placeholder), dépendent du module Contrat inexistant (HP-M6-01). |
Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres (non-vacuité `states`).
Cohérence inter-champs (RG-6.03 / 6.05) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres (non-vacuité `states`). RG-6.06 : simple `Assert\Count(min:1)` (référentiel plat, plus de validation de disponibilité par site).
## 8. Tests (PHPUnit) — `make test`
@@ -643,7 +648,7 @@ Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via `#[Assert\Call
- **`ProductStatesValidationTest`** : ≥ 1 état (RG-6.02) ; valeurs hors enum rejetées.
- **`ProductConditionalFieldsTest`** : `manufactured`/`containsMolasses` forcés `false` si pas `SALE` (RG-6.03).
- **`ProductCategoryTypeTest`** : 422 si `category` n'est pas de type PRODUIT (RG-6.05).
- **`ProductStorageTypeBySiteTest`** : 422 si un `storageType` n'est pas disponible sur les `sites` choisis (RG-6.06).
- ~~**`ProductStorageTypeBySiteTest`**~~ : supprimé — `StorageType` est un référentiel plat (plus de disponibilité par site, RG-6.06 revue, § 2.4).
- **RBAC** : Admin OK ; Bureau/Compta/Commerciale/Usine → 403 (view + manage).
- **Architecture** (déjà en place, ne pas casser) : `ColumnsHaveSqlCommentTest`, `EntitiesAreTimestampableBlamableTest` (whitelister `StorageType`), `AuditableEntitiesHaveI18nLabelTest` (`catalog_product`), `CollectionsArePaginatedTest`, `EntityConstraintsHaveFrenchMessageTest`.
@@ -652,7 +657,7 @@ Cohérence inter-champs (RG-6.03 / 6.05 / 6.06) implémentée via `#[Assert\Call
| Réf | Sujet |
|---|---|
| **HP-M6-01** | **Onglets « Fournisseurs » et « Clients »** du produit (liaison à des **contrats** client/fournisseur, « clients en prestation de triage », « contrats TAF »). Dépend d'un **module Contrat inexistant**. Rendus en **placeholder « en cours de développement »** au M6 (§ 1.bis C8, RG-6.10). À spécifier quand le module Contrat existera. |
| **HP-M6-02** | Liste/mapping **définitifs des types de stockage par site** (fournis par Aurore). Re-seed du référentiel `StorageType` + révision du filtrage par site (§ 2.4). |
| **HP-M6-02** | Liste **définitive des types de stockage** (fournie par Aurore). Re-seed du référentiel `StorageType` (§ 2.4). La disponibilité par site relèvera du futur module **Stockage** (un stockage = 1 site + 1 type), pas de ce référentiel. |
| **HP-M6-03** | **CRUD admin du référentiel `StorageType`** (création/édition par un admin). Au M6 : lecture seule + seed. |
| **HP-M6-04** | Archivage / suppression d'un produit (non prévu au docx — soft delete préparé mais non exposé, § 2.7). |
| **HP-M6-05** | Contrainte SQL « `category` de type PRODUIT » au niveau base (au M6 : validation applicative seulement, § 2.5). |
@@ -29,23 +29,13 @@ vi.stubGlobal('useI18n', () => ({
params ? `${key}::${JSON.stringify(params)}` : key,
}))
/** Reponse Hydra des types de stockage selon les sites demandes. */
function storageMembersForSites(siteIds: string[]): { member: Array<{ '@id': string, label: string }> } {
// Site 1 → types 9 et 5 ; site 2 → type 7. Permet de tester la cascade.
const byId: Record<string, Array<{ '@id': string, label: string }>> = {
'1': [
{ '@id': '/api/storage_types/9', label: 'Tas' },
{ '@id': '/api/storage_types/5', label: 'Cellule' },
],
'2': [{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' }],
}
const seen = new Map<string, { '@id': string, label: string }>()
for (const id of siteIds) {
for (const m of byId[id] ?? []) {
seen.set(m['@id'], m)
}
}
return { member: [...seen.values()] }
/** Referentiel PLAT des types de stockage (renvoye tel quel, sans filtre site). */
const STORAGE_TYPES = {
member: [
{ '@id': '/api/storage_types/9', label: 'Tas' },
{ '@id': '/api/storage_types/5', label: 'Cellule' },
{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' },
],
}
describe('useProductForm', () => {
@@ -56,8 +46,9 @@ describe('useProductForm', () => {
mockToastSuccess.mockReset()
mockToastError.mockReset()
// Routage des GET par url (referentiels + cascade stockage).
mockGet.mockImplementation((url: string, query: Record<string, unknown> = {}) => {
// Routage des GET par url (referentiels). Le stockage est un referentiel
// plat : meme reponse quelle que soit la requete.
mockGet.mockImplementation((url: string) => {
if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
}
@@ -65,8 +56,7 @@ describe('useProductForm', () => {
return Promise.resolve({ member: [{ '@id': '/api/categories/12', name: 'Céréales' }] })
}
if (url === '/storage_types') {
const raw = (query['siteId[]'] ?? []) as string[]
return Promise.resolve(storageMembersForSites(raw))
return Promise.resolve(STORAGE_TYPES)
}
return Promise.resolve({ member: [] })
})
@@ -97,44 +87,36 @@ describe('useProductForm', () => {
})
})
describe('RG-6.06 — cascade Site → Type de stockage', () => {
it('charge les types de stockage filtres par les sites selectionnes', async () => {
const { storageTypeOptions, setSites } = useProductForm()
await setSites(['/api/sites/1'])
describe('RG-6.06 — types de stockage (referentiel plat)', () => {
it('loadReferentials charge TOUS les types de stockage, sans filtre site', async () => {
const { storageTypeOptions, loadReferentials } = useProductForm()
await loadReferentials()
expect(mockGet).toHaveBeenCalledWith(
'/storage_types',
expect.objectContaining({ 'siteId[]': ['1'], pagination: 'false' }),
expect.any(Object),
)
const storageCall = mockGet.mock.calls.find(c => c[0] === '/storage_types')
expect(storageCall).toBeDefined()
// Aucun filtre siteId envoye (referentiel plat).
expect(storageCall?.[1]).not.toHaveProperty('siteId[]')
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
'/api/storage_types/9',
'/api/storage_types/5',
'/api/storage_types/7',
])
})
it('retire de la selection les types devenus indisponibles', async () => {
const { form, setStorageTypes, setSites } = useProductForm()
it('setSites met a jour les sites sans recharger le stockage ni purger la selection', async () => {
const { form, setSites, setStorageTypes, loadReferentials } = useProductForm()
await loadReferentials()
const storageCallsBefore = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
// Selection initiale sur le site 1 (types 9 et 5).
await setSites(['/api/sites/1'])
setStorageTypes(['/api/storage_types/9', '/api/storage_types/5'])
// Bascule vers le site 2 (type 7 seul) : 9 et 5 ne sont plus dispo.
await setSites(['/api/sites/2'])
expect(form.storageTypeIris).toEqual([])
})
it('vide options + selection quand plus aucun site n\'est selectionne', async () => {
const { form, storageTypeOptions, setStorageTypes, setSites } = useProductForm()
await setSites(['/api/sites/1'])
setStorageTypes(['/api/storage_types/9'])
setSites(['/api/sites/1'])
await setSites([])
expect(storageTypeOptions.value).toEqual([])
expect(form.storageTypeIris).toEqual([])
// Pas d'appel /storage_types inutile sans site.
expect(mockGet).not.toHaveBeenCalledWith('/storage_types', expect.objectContaining({ 'siteId[]': [] }), expect.any(Object))
expect(form.siteIris).toEqual(['/api/sites/1'])
// Selection conservee : plus de cascade ni de purge par site.
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
// setSites ne declenche aucun nouvel appel /storage_types.
const storageCallsAfter = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length
expect(storageCallsAfter).toBe(storageCallsBefore)
})
})
@@ -189,6 +171,20 @@ describe('useProductForm', () => {
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()
@@ -235,8 +231,8 @@ describe('useProductForm', () => {
createdAt: '', updatedAt: '',
}
it('pre-remplit le formulaire depuis le produit (relations en IRI) + charge le stockage', async () => {
const { form, prefill, storageTypeOptions } = useProductForm()
it('pre-remplit le formulaire depuis le produit (relations en IRI)', async () => {
const { form, prefill } = useProductForm()
await prefill(PRODUCT)
expect(form.code).toBe('BLE-01')
@@ -246,13 +242,6 @@ describe('useProductForm', () => {
expect(form.siteIris).toEqual(['/api/sites/1'])
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
expect(form.manufactured).toBe(true)
// Cascade : options de stockage chargees pour le site du produit.
expect(mockGet).toHaveBeenCalledWith(
'/storage_types',
expect.objectContaining({ 'siteId[]': ['1'] }),
expect.any(Object),
)
expect(storageTypeOptions.value.map(o => o.value)).toContain('/api/storage_types/9')
})
it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => {
@@ -2,8 +2,8 @@
* Composable du formulaire de creation produit (M6 — ERP-205).
*
* Porte l'etat du formulaire principal, les referentiels des selects, les regles
* de gestion front (champs conditionnels RG-6.03, cascade site→stockage RG-6.06)
* et la soumission `POST /api/products` avec mapping des erreurs 422/409 inline
* de gestion front (champs conditionnels RG-6.03) et la soumission
* `POST /api/products` avec mapping des erreurs 422/409 inline
* (useFormErrors). Reference : ecran « Ajouter un client » / « Ajouter un
* prestataire » (formulaire principal).
*
@@ -20,12 +20,6 @@ import type { Product } from '~/modules/catalog/types/product'
/** Etats produit (miroir de l'enum back Product::STATE_*). */
export const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
/** Extrait l'id numerique d'un IRI Hydra (`/api/sites/1` → 1), sinon null. */
function iriToId(iri: string): number | null {
const tail = iri.split('/').pop()
return tail !== undefined && /^\d+$/.test(tail) ? Number(tail) : null
}
export function useProductForm() {
const api = useApi()
const { t } = useI18n()
@@ -85,34 +79,25 @@ export function useProductForm() {
form.storageTypeIris = iris
}
/**
* RG-6.06 (cascade) : a chaque changement de Site, recharge les options de Type
* de stockage filtrees par les sites choisis et retire de la selection les
* types devenus indisponibles.
*/
async function setSites(iris: string[]): Promise<void> {
/** Met a jour les sites de disponibilite (multi-select, RG-6.04). */
function setSites(iris: string[]): void {
form.siteIris = iris
const siteIds = iris
.map(iriToId)
.filter((id): id is number => id !== null)
await storage.load(siteIds)
const available = new Set(storage.options.value.map(o => o.value))
form.storageTypeIris = form.storageTypeIris.filter(iri => available.has(iri))
}
/** Charge les referentiels initiaux (sites + categories). Resilient. */
/**
* Charge les referentiels initiaux (sites + categories + types de stockage).
* Resilient. Les types de stockage forment un referentiel plat : on les charge
* tous d'emblee (plus de cascade par site, RG-6.06 revue).
*/
async function loadReferentials(): Promise<void> {
await Promise.allSettled([sites.load(), categories.load()])
// Les types de stockage se chargent a la 1re selection de sites (cascade).
await Promise.allSettled([sites.load(), categories.load(), storage.load()])
}
/**
* Pre-remplit le formulaire depuis un produit charge (mode edition, RG-6.08).
* Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects).
* Charge au passage les options de Type de stockage pour les sites du produit,
* afin que le multi-select affiche les libelles et conserve la selection.
* Les options de Type de stockage sont chargees par loadReferentials (referentiel
* plat) : prefill se contente de mapper la selection.
*/
async function prefill(product: Product): Promise<void> {
productId.value = product.id
@@ -123,11 +108,6 @@ export function useProductForm() {
form.siteIris = product.sites.map(s => s['@id'])
form.manufactured = product.manufactured
form.containsMolasses = product.containsMolasses
const siteIds = form.siteIris
.map(iriToId)
.filter((id): id is number => id !== null)
await storage.load(siteIds)
form.storageTypeIris = product.storageTypes.map(st => st['@id'])
}
@@ -146,18 +126,29 @@ export function useProductForm() {
formErrors.clearErrors()
const editing = productId.value !== null
try {
const payload = {
code: form.code || null,
name: form.name || null,
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,
category: form.categoryIri,
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) {
await api.patch(`/products/${productId.value}`, payload, options)
@@ -70,24 +70,15 @@ export function useCategoryOptions(params: { typeCode: string }) {
}
/**
* Types de stockage (libelle = `label`). Filtres par les sites selectionnes
* (`?siteId[]=…`, RG-6.06) : on ne charge que les types disponibles sur AU MOINS
* UN des sites passes. Sans site, la liste est videe (le multi-select depend des
* sites).
* Types de stockage (libelle = `label`). Referentiel PLAT : on charge TOUS les
* types, sans filtrage par site (RG-6.06 revue — la dispo par site releve du futur
* module Stockage).
*/
export function useStorageTypeOptions() {
const options = ref<RefOption[]>([])
async function load(siteIds: number[]): Promise<void> {
if (siteIds.length === 0) {
options.value = []
return
}
options.value = await fetchOptions(
'/storage_types',
{ 'siteId[]': siteIds.map(String) },
s => s.label ?? '',
)
async function load(): Promise<void> {
options.value = await fetchOptions('/storage_types', {}, s => s.label ?? '')
}
return { options, load }
@@ -31,8 +31,7 @@
:error="errors.states"
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
/>
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
Pilote la cascade Type de stockage (RG-6.06). -->
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04). -->
<MalioSelectCheckbox
:model-value="form.siteIris"
:options="siteOptions"
@@ -67,8 +66,8 @@
:error="errors.category"
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
/>
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
par les sites selectionnes (RG-6.06). -->
<!-- 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"
@@ -27,8 +27,7 @@
:error="errors.states"
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
/>
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
Pilote la cascade Type de stockage (RG-6.06). -->
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04). -->
<MalioSelectCheckbox
:model-value="form.siteIris"
:options="siteOptions"
@@ -62,8 +61,8 @@
:error="errors.category"
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
/>
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
par les sites selectionnes (RG-6.06). -->
<!-- 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"
+98
View File
@@ -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.$_$');
}
}
+101
View File
@@ -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],
);
}
}
+11 -44
View File
@@ -49,12 +49,12 @@ use function in_array;
* contient SALE, sinon forces false serveur.
* - RG-6.04 : `sites` >= 1.
* - RG-6.05 : `category` de type PRODUIT (validee applicativement, Callback ERP-200).
* - RG-6.06 : `storageTypes` >= 1, filtres par les sites selectionnes.
* - RG-6.06 : `storageTypes` >= 1 (referentiel plat — plus de filtrage par site).
*
* Soft-delete prepare via `deletedAt` (non expose au M6, § 2.7) : pas de Delete
* dans les operations, la liste exclut les produits supprimes (Provider, ERP-200).
*
* Les RG inter-champs (RG-6.03/6.05/6.06) et l'unicite du code passent par le
* Les RG inter-champs (RG-6.03/6.05) et l'unicite du code passent par le
* Processor + une contrainte d'entite Assert\Callback en ERP-200 (chaque 422
* porte un propertyPath exploitable par useFormErrors — mapping inline, ERP-101).
*
@@ -81,12 +81,18 @@ use function in_array;
security: "is_granted('catalog.products.manage')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['product:write']],
// 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,
),
new Patch(
security: "is_granted('catalog.products.manage')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['product:write']],
collectDenormalizationErrors: true,
provider: ProductProvider::class,
processor: ProductProcessor::class,
),
@@ -204,9 +210,9 @@ class Product implements TimestampableInterface, BlamableInterface
private Collection $sites;
/**
* Types de stockage du produit (>= 1, RG-6.06), filtres par les sites
* selectionnes (provider StorageType, ERP-201). Cote inverse en ON DELETE
* RESTRICT : un type de stockage reference par un produit ne peut etre supprime.
* Types de stockage du produit (>= 1, RG-6.06). Referentiel plat : tous les
* types sont selectionnables (plus de filtrage par site). Cote inverse en
* ON DELETE RESTRICT : un type reference par un produit ne peut etre supprime.
*
* @var Collection<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 App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageTypeProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Type de stockage : referentiel PROVISOIRE classifiant ou un produit peut etre
* stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste et
* le mapping site definitifs d'Aurore (HP-M6-02) ; seede avec la liste Figma
* (node 1503-34285) au ticket ERP-201.
* stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste
* definitive d'Aurore (HP-M6-02 / ERP-201).
*
* Relation `sites` (ManyToMany -> Site) : sites sur lesquels ce type de stockage
* est disponible. Sert au filtrage du multi-select « Type de stockage » par les
* sites selectionnes dans le formulaire produit (RG-6.06). Non serialisee au M6
* (le filtrage est applique cote provider en ERP-201).
* Referentiel PLAT : un type de stockage n'est PAS rattache a des sites. La
* disponibilite « tel type sur tel site » releve de la future entite Stockage
* (module Stockage : un stockage = 1 site + 1 type) et sera derivee des stockages
* reels, pas portee par ce referentiel. Le multi-select « Type de stockage » du
* formulaire produit liste donc TOUS les types, sans filtrage par site (RG-6.06).
*
* Lecture seule au M6 : seules les operations GetCollection et Get sont exposees
* (CRUD admin = hors perimetre HP-M6-03), sous la permission `catalog.products.view`
@@ -39,10 +36,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
*/
#[ApiResource(
operations: [
// Tri label ASC et filtre ?siteId[]= portes par le StorageTypeProvider
// (ERP-201) : alimente le multi-select « Type de stockage » du formulaire
// produit, filtre par les sites selectionnes (RG-6.06). Pagination Hydra +
// echappatoire ?pagination=false (referentiel borne).
// Tri label ASC porte par le StorageTypeProvider : alimente le multi-select
// « Type de stockage » du formulaire produit (TOUS les types — referentiel
// plat). Pagination Hydra + echappatoire ?pagination=false (referentiel borne).
new GetCollection(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['storage_type:read']],
@@ -75,24 +71,6 @@ class StorageType
#[Groups(['storage_type:read'])]
private ?string $label = null;
/**
* Sites sur lesquels ce type de stockage est disponible (RG-6.06). Non
* exposee en serialisation au M6 : sert uniquement au filtrage `?siteId[]=`
* du referentiel (branche en ERP-201).
*
* @var Collection<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
{
return $this->id;
@@ -121,28 +99,4 @@ class StorageType
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
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2) et filtre
* optionnel `?siteId[]=` (RG-6.06) restreignant aux types disponibles sur
* AU MOINS UN des sites passes.
*
* @param list<int> $siteIds
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2). Referentiel plat :
* plus de filtrage par site (la dispo par site releve du futur module Stockage).
*/
public function createListQueryBuilder(array $siteIds = []): QueryBuilder;
public function createListQueryBuilder(): QueryBuilder;
}
@@ -18,12 +18,12 @@ use function is_int;
use function is_string;
/**
* Provider StorageType (referentiel lecture seule, ERP-201) :
* - LISTE : tri `label ASC` (defaut spec § 4.2), filtre `?siteId[]=` (RG-6.06 :
* types disponibles sur au moins un des sites passes) et collection PAGINEE
* Hydra (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
* alimenter le multi-select « Type de stockage » du formulaire produit
* (referentiel borne — pagination_client_enabled).
* Provider StorageType (referentiel plat lecture seule) :
* - LISTE : tri `label ASC` (defaut spec § 4.2) et collection PAGINEE Hydra
* (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
* alimenter le multi-select « Type de stockage » du formulaire produit avec
* TOUS les types (referentiel borne — pagination_client_enabled). Plus de
* filtrage par site : la dispo par site releve du futur module Stockage.
* - ITEM : lookup simple par id.
*
* @implements ProviderInterface<StorageType>
@@ -39,7 +39,7 @@ final class StorageTypeProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null
{
if ($operation instanceof CollectionOperationInterface) {
$qb = $this->repository->createListQueryBuilder($this->readSiteIds($context));
$qb = $this->repository->createListQueryBuilder();
// Echappatoire ?pagination=false : collection complete sans Paginator
// (alimentation du multi-select, referentiel borne).
@@ -65,30 +65,4 @@ final class StorageTypeProvider implements ProviderInterface
return $this->repository->findById((int) $id);
}
/**
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
*
* @return list<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\Repository\StorageTypeRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use App\Shared\Domain\Contract\SiteProviderInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Catalog : seed du referentiel `storage_type` (M6).
* Fixtures du module Catalog : seed du referentiel PLAT `storage_type` (M6).
*
* ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes, libelles ET mapping
* site ci-dessous sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste et le
* mapping definitifs par site. La liste actuelle reprend les 10 valeurs de la
* maquette Figma (node 1503-34285) et les rattache PAR DEFAUT aux 3 sites
* (Chatellerault 86 / Saint-Jean 17 / Pommevic 82), faute de mapping reel.
* ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes et libelles ci-dessous
* sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste definitive (ERP-201).
* La liste actuelle reprend les 10 valeurs de la maquette Figma (node 1503-34285).
*
* Pourquoi une fixture (et pas un seed de migration) : `storage_type` est une
* entite managee par l'ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test (le referentiel
* doit exister pour alimenter le formulaire produit et les tests du filtre
* ?siteId[]= — ERP-203). Elle tourne dans TOUS les environnements (referentiel,
* pas une donnee de demo — miroir CategoryTypeFixtures).
* Referentiel PLAT : un type de stockage n'est plus rattache a des sites (la dispo
* par site releve du futur module Stockage). Cette fixture ne seede donc que les
* lignes `storage_type` ; la voie prod-safe est l'INSERT idempotent de la migration
* Version20260626100000 (les fixtures ne tournent pas en prod). Source unique : les
* memes 10 valeurs ici et dans la migration.
*
* Pourquoi une fixture EN PLUS de la migration : `storage_type` est une entite
* managee par l'ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test apres la purge
* (referentiel necessaire au formulaire produit et a ses tests). Elle tourne dans
* TOUS les environnements (referentiel, pas une donnee de demo — miroir
* CategoryTypeFixtures).
*
* Idempotence : lookup par `code` parmi les types existants avant insertion
* (miroir CategoryTypeFixtures). `addSite()` est lui-meme idempotent (contains()).
* Rejouable sans doublon meme si le purger Doctrine est desactive.
*
* Depend de SitesFixtures : les 3 sites doivent etre seedes avant qu'on puisse y
* rattacher les types de stockage. Les sites sont resolus via le contrat Shared
* SiteProviderInterface (pas d'import du module Sites — regle ABSOLUE n°1).
* (miroir CategoryTypeFixtures). Rejouable sans doublon meme si le purger Doctrine
* est desactive.
*/
class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
class StorageTypeFixtures extends Fixture
{
/**
* Seed PROVISOIRE (Figma node 1503-34285) : code MAJUSCULE stable => libelle FR.
* A re-seeder a reception de la liste Aurore (HP-M6-02).
* A re-seeder a reception de la liste Aurore (HP-M6-02). Doit rester aligne sur
* la migration Version20260626100000.
*
* @var array<string, string>
*/
@@ -57,27 +55,10 @@ class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
'ZONE' => 'Zone',
];
/**
* Noms des 3 sites de rattachement PROVISOIRE (86 / 17 / 82). Le nom est la cle
* de lookup stable cote SitesFixtures.
*
* @var list<string>
*/
private const DEFAULT_SITE_NAMES = ['Chatellerault', 'Saint-Jean', 'Pommevic'];
public function __construct(
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
{
// Index des types deja presents par code, pour ne pas creer de doublon.
@@ -86,27 +67,11 @@ class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
$existingByCode[$type->getCode()] = $type;
}
// Resolution des 3 sites par defaut via le contrat Shared (rattachement
// provisoire). Les objets resolus sont des Site managees (resolve_target_entities
// SiteInterface -> Site) : addSite() les accepte.
$defaultSites = [];
foreach (self::DEFAULT_SITE_NAMES as $name) {
$site = $this->siteProvider->findByName($name);
if (null !== $site) {
$defaultSites[] = $site;
}
}
foreach (self::TYPES as $code => $label) {
$storageType = $existingByCode[$code] ?? new StorageType();
$storageType->setCode($code);
$storageType->setLabel($label);
// Rattachement provisoire aux 3 sites (idempotent : addSite -> contains()).
foreach ($defaultSites as $site) {
$storageType->addSite($site);
}
$manager->persist($storageType);
}
@@ -33,32 +33,12 @@ class DoctrineStorageTypeRepository extends ServiceEntityRepository implements S
return $this->findBy([], ['label' => 'ASC']);
}
public function createListQueryBuilder(array $siteIds = []): QueryBuilder
public function createListQueryBuilder(): QueryBuilder
{
// Tri alphabetique stable (multi-select du formulaire produit, § 4.2). La
// relation `sites` n'est PAS serialisee (storage_type:read ne la porte pas)
// -> pas d'eager-load : le filtre n'affecte pas la sortie, seulement la
// restriction des lignes.
$qb = $this->createQueryBuilder('st')
// Tri alphabetique stable (multi-select du formulaire produit, § 4.2).
// Referentiel plat : tous les types, plus de filtrage par site.
return $this->createQueryBuilder('st')
->orderBy('st.label', 'ASC')
;
// ?siteId[]= : type disponible sur AU MOINS UN des sites passes (OR, RG-6.06).
// Sous-requete EXISTS correlee (meme strategie que DoctrineCategoryRepository
// / DoctrineProductRepository) pour eviter les lignes dupliquees du JOIN.
if ([] !== $siteIds) {
$sub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(StorageType::class, 'st_si')
->join('st_si.sites', 's_si')
->where('st_si = st')
->andWhere('s_si.id IN (:siteIds)')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('siteIds', $siteIds)
;
}
return $qb;
}
}
@@ -587,12 +587,6 @@ final class ColumnCommentsCatalog
'label' => 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).',
],
'storage_type_site' => [
'_table' => 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).',
'storage_type_id' => 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.',
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.',
],
'product' => [
'_table' => 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.',
'id' => 'Identifiant interne auto-incremente.',
@@ -612,7 +606,7 @@ final class ColumnCommentsCatalog
],
'product_storage_type' => [
'_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).',
'_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, RG-6.06 ; referentiel plat).',
'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.',
'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.',
],
@@ -24,8 +24,8 @@ use Symfony\Contracts\HttpClient\ResponseInterface;
* volee pour que les POST passent RG-6.05.
* - `productCategory()` / `nonProductCategory()` : categories de test rattachees
* (ou non) au type PRODUIT.
* - `seedStorageType()` : type de stockage de test (prefixe code pour cleanup),
* rattachable a des sites precis (RG-6.06).
* - `seedStorageType()` : type de stockage de test (prefixe code pour cleanup ;
* referentiel plat, plus de rattachement par site).
* - `siteByCode()` / `firstSite()` : sites fixtures (86 / 17 / 82).
* - `authView()` : user non-admin portant la permission `catalog.products.view`.
* - `validProductPayload()` : payload POST de reference (IRIs category/sites/
@@ -60,7 +60,7 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
// product_storage_type cascadent au niveau base (ON DELETE CASCADE).
$em->createQuery('DELETE FROM '.Product::class)->execute();
// Types de stockage de test (prefixe code) — libere storage_type_site.
// Types de stockage de test (prefixe code).
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%')
->execute()
@@ -111,19 +111,16 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
}
/**
* Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup),
* rattache aux sites passes (disponibilite — RG-6.06).
* Cree un type de stockage de test (code prefixe TESTPRD pour le cleanup).
* Referentiel plat : plus de rattachement a des sites (RG-6.06 revue).
*/
protected function seedStorageType(string $label = 'Tas de test', Site ...$sites): StorageType
protected function seedStorageType(string $label = 'Tas de test'): StorageType
{
$em = $this->getEm();
$storageType = new StorageType();
$storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX));
$storageType->setLabel($label);
foreach ($sites as $site) {
$storageType->addSite($em->getReference(Site::class, (int) $site->getId()));
}
$em->persist($storageType);
$em->flush();
@@ -169,7 +166,7 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
protected function validProductPayload(array $overrides = []): array
{
$site = $this->firstSite();
$storageType = $this->seedStorageType('Tas test', $site);
$storageType = $this->seedStorageType('Tas test');
$category = $this->productCategory();
$base = [
@@ -213,7 +210,7 @@ abstract class AbstractProductApiTestCase extends AbstractCatalogApiTestCase
$product->setContainsMolasses(false);
$product->setCategory($category ?? $this->productCategory());
$product->addSite($em->getReference(Site::class, (int) $site->getId()));
$product->addStorageType($storageType ?? $this->seedStorageType('Seed', $site));
$product->addStorageType($storageType ?? $this->seedStorageType('Seed'));
$product->setDeletedAt($deletedAt);
$em->persist($product);
@@ -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);
}
}