feat(catalog) : M6 — écran Ajouter un produit /admin/products/new (ERP-205)

Formulaire principal de création produit (admin-only) : état, sites, nom,
code, catégorie (type PRODUIT), types de stockage, booléens conditionnels.

- RG-6.03 : « Fabriqué » / « Contient de la mélasse » visibles uniquement si l'état contient « Vendu »
- RG-6.06 : cascade Site → Type de stockage (rechargement + purge des types indisponibles) dans useProductForm
- RG-6.01 : POST /products (toast:false) ; 422 mappées inline (useFormErrors), 409 doublon de code → setError + toast
- bouton « Valider » toujours actif, validation autoritaire serveur (ERP-101)
- composables useSiteOptions / useCategoryOptions / useStorageTypeOptions (?pagination=false)
- i18n admin.products.form ; 15 tests Vitest (useProductForm + page)
This commit is contained in:
2026-06-25 17:52:02 +02:00
parent f12a378126
commit ce0e274743
6 changed files with 796 additions and 2 deletions
@@ -0,0 +1,220 @@
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 mockToastSuccess = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: mockPost,
put: vi.fn(),
patch: vi.fn(),
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,
}))
/** 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()] }
}
describe('useProductForm', () => {
beforeEach(() => {
mockGet.mockReset()
mockPost.mockReset()
mockToastSuccess.mockReset()
mockToastError.mockReset()
// Routage des GET par url (referentiels + cascade stockage).
mockGet.mockImplementation((url: string, query: Record<string, unknown> = {}) => {
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') {
const raw = (query['siteId[]'] ?? []) as string[]
return Promise.resolve(storageMembersForSites(raw))
}
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 — 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'])
expect(mockGet).toHaveBeenCalledWith(
'/storage_types',
expect.objectContaining({ 'siteId[]': ['1'], pagination: 'false' }),
expect.any(Object),
)
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
'/api/storage_types/9',
'/api/storage_types/5',
])
})
it('retire de la selection les types devenus indisponibles', async () => {
const { form, setStorageTypes, setSites } = useProductForm()
// 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'])
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))
})
})
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('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()
})
})
})
@@ -0,0 +1,170 @@
/**
* 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
* (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'
/** 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()
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)
// 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
}
/**
* 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> {
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. */
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).
}
/**
* Soumet la creation. Retourne true au succes (la page redirige), false sinon.
* 422 → mapping inline par champ (useFormErrors) ; 409 doublon de code →
* erreur inline sur `code` + toast explicite (RG-6.01).
*/
async function submit(): Promise<boolean> {
if (submitting.value) {
return false
}
submitting.value = true
formErrors.clearErrors()
try {
const payload = {
code: form.code || null,
name: form.name || null,
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,
}
await api.post('/products', payload, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
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,
errors: formErrors.errors,
submitting,
isSale,
siteOptions: sites.options,
categoryOptions: categories.options,
storageTypeOptions: storage.options,
setStates,
setCategory,
setStorageTypes,
setSites,
loadReferentials,
submit,
}
}
@@ -0,0 +1,94 @@
/**
* 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`). 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).
*/
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 ?? '',
)
}
return { options, load }
}