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.
This commit is contained in:
2026-06-26 15:39:11 +02:00
parent a6b8e7145e
commit fced2c2cfd
16 changed files with 235 additions and 422 deletions
@@ -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)
})
})
@@ -235,8 +217,8 @@ describe('useProductForm', () => {
createdAt: '', updatedAt: '',
}
it('pre-remplit le formulaire depuis le produit (relations en IRI) + charge le stockage', async () => {
const { form, prefill, storageTypeOptions } = useProductForm()
it('pre-remplit le formulaire depuis le produit (relations en IRI)', async () => {
const { form, prefill } = useProductForm()
await prefill(PRODUCT)
expect(form.code).toBe('BLE-01')
@@ -246,13 +228,6 @@ describe('useProductForm', () => {
expect(form.siteIris).toEqual(['/api/sites/1'])
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
expect(form.manufactured).toBe(true)
// Cascade : options de stockage chargees pour le site du produit.
expect(mockGet).toHaveBeenCalledWith(
'/storage_types',
expect.objectContaining({ 'siteId[]': ['1'] }),
expect.any(Object),
)
expect(storageTypeOptions.value.map(o => o.value)).toContain('/api/storage_types/9')
})
it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => {
@@ -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'])
}
@@ -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 }