diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index c2f04f5..3792eb1 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -1032,7 +1032,7 @@ "category": "Catégorie" }, "state": { - "PURCHASE": "Acheté", + "PURCHASE": "Achat", "SALE": "Vendu", "OTHER": "Autre" }, @@ -1047,9 +1047,24 @@ "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à." + }, "toast": { "error": "Une erreur est survenue. Réessayez.", - "exportError": "L'export du catalogue produit a échoué. Réessayez." + "exportError": "L'export du catalogue produit a échoué. Réessayez.", + "createSuccess": "Produit créé avec succès" } } } diff --git a/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts new file mode 100644 index 0000000..a7c3856 --- /dev/null +++ b/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts @@ -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) => + params ? `${key}::${JSON.stringify(params)}` : key, +})) + +/** Reponse Hydra des types de stockage selon les sites demandes. */ +function storageMembersForSites(siteIds: string[]): { member: Array<{ '@id': string, label: string }> } { + // Site 1 → types 9 et 5 ; site 2 → type 7. Permet de tester la cascade. + const byId: Record> = { + '1': [ + { '@id': '/api/storage_types/9', label: 'Tas' }, + { '@id': '/api/storage_types/5', label: 'Cellule' }, + ], + '2': [{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' }], + } + const seen = new Map() + for (const id of siteIds) { + for (const m of byId[id] ?? []) { + seen.set(m['@id'], m) + } + } + return { member: [...seen.values()] } +} + +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 = {}) => { + 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['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() + }) + }) +}) diff --git a/frontend/modules/catalog/composables/useProductForm.ts b/frontend/modules/catalog/composables/useProductForm.ts new file mode 100644 index 0000000..14e3c31 --- /dev/null +++ b/frontend/modules/catalog/composables/useProductForm.ts @@ -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 { + 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 { + 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 { + 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, + } +} diff --git a/frontend/modules/catalog/composables/useProductOptions.ts b/frontend/modules/catalog/composables/useProductOptions.ts new file mode 100644 index 0000000..912cac3 --- /dev/null +++ b/frontend/modules/catalog/composables/useProductOptions.ts @@ -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, + toLabel: (member: HydraMember) => string, +): Promise { + 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([]) + + async function load(): Promise { + 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([]) + + async function load(): Promise { + 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([]) + + async function load(siteIds: number[]): Promise { + if (siteIds.length === 0) { + options.value = [] + return + } + options.value = await fetchOptions( + '/storage_types', + { 'siteId[]': siteIds.map(String) }, + s => s.label ?? '', + ) + } + + return { options, load } +} diff --git a/frontend/modules/catalog/pages/__tests__/productNew.spec.ts b/frontend/modules/catalog/pages/__tests__/productNew.spec.ts new file mode 100644 index 0000000..4160f8c --- /dev/null +++ b/frontend/modules/catalog/pages/__tests__/productNew.spec.ts @@ -0,0 +1,138 @@ +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 }) }, +}) + +const stubs = { + MalioButtonIcon: ButtonStub, + MalioButton: ButtonStub, + MalioInputText: InputStub, + MalioSelect: InputStub, + MalioSelectCheckbox: InputStub, + MalioCheckbox: CheckboxStub, +} + +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() + }) +}) diff --git a/frontend/modules/catalog/pages/admin/products/new.vue b/frontend/modules/catalog/pages/admin/products/new.vue new file mode 100644 index 0000000..e33a10e --- /dev/null +++ b/frontend/modules/catalog/pages/admin/products/new.vue @@ -0,0 +1,157 @@ + + +