From aa7cda48b3d823859d41dce75719aee3c6cd800e Mon Sep 17 00:00:00 2001 From: Tristan Autin Date: Tue, 30 Jun 2026 11:27:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(catalog)=20:=20M7=20=E2=80=94=20=C3=A9cran?= =?UTF-8?q?=20Ajouter=20un=20stockage=20/admin/storages/new=20(ERP-217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Formulaire de création à plat (pas d'onglets, HP-M7-06), gate catalog.storages.manage - Champs Site, Type de stockage, Numéro, État (multi ≥1) en composants Malio, validation inline 422 par champ via useFormErrors - 409 doublon (site, type, numéro) RG-7.01 → erreur inline sous Numéro + toast explicite - Composable useStorageForm (POST /storages, payload relations en IRI), libellés i18n - Référentiel des types PLAT : pas de cascade Site→Type (RG-7.03 non portée côté back, StorageType sans relation Site — à reclarifier spec) - Tests Vitest de useStorageForm (référentiel plat, submit, 409/422) --- frontend/i18n/locales/fr.json | 13 +- .../__tests__/useStorageForm.spec.ts | 189 ++++++++++++++++++ .../catalog/composables/useStorageForm.ts | 141 +++++++++++++ .../catalog/pages/admin/storages/new.vue | 127 ++++++++++++ 4 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 frontend/modules/catalog/composables/__tests__/useStorageForm.spec.ts create mode 100644 frontend/modules/catalog/composables/useStorageForm.ts create mode 100644 frontend/modules/catalog/pages/admin/storages/new.vue diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 6fbc69c..8c77293 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -1117,9 +1117,20 @@ "apply": "Voir les résultats", "reset": "Réinitialiser" }, + "form": { + "title": "Ajouter un stockage", + "back": "Retour à la liste", + "submit": "Valider", + "site": "Site", + "storageType": "Type de stockage", + "numero": "Numéro", + "states": "État du type de stockage", + "duplicateNumero": "Un stockage avec ce site, ce type et ce numéro existe déjà." + }, "toast": { "error": "Une erreur est survenue. Réessayez.", - "exportError": "L'export du répertoire stockage a échoué. Réessayez." + "exportError": "L'export du répertoire stockage a échoué. Réessayez.", + "createSuccess": "Stockage créé avec succès" } } } diff --git a/frontend/modules/catalog/composables/__tests__/useStorageForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useStorageForm.spec.ts new file mode 100644 index 0000000..d3ca789 --- /dev/null +++ b/frontend/modules/catalog/composables/__tests__/useStorageForm.spec.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useFormErrors } from '~/shared/composables/useFormErrors' +import { useStorageForm } from '../useStorageForm' + +// 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, +})) + +/** Referentiel PLAT des types de stockage (renvoye tel quel, sans filtre site). */ +const STORAGE_TYPES = { + member: [ + { '@id': '/api/storage_types/9', label: 'Cellule' }, + { '@id': '/api/storage_types/5', label: 'Tas' }, + ], +} + +describe('useStorageForm', () => { + beforeEach(() => { + mockGet.mockReset() + mockPost.mockReset() + mockToastSuccess.mockReset() + mockToastError.mockReset() + + // Routage des GET par url (referentiels). Le type de stockage est un + // referentiel plat : meme reponse quelle que soit la requete. + mockGet.mockImplementation((url: string) => { + if (url === '/sites') { + return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] }) + } + if (url === '/storage_types') { + return Promise.resolve(STORAGE_TYPES) + } + return Promise.resolve({ member: [] }) + }) + }) + + describe('referentiel plat — pas de cascade Site->Type (RG-7.03 non portee back)', () => { + it('loadReferentials charge les sites et TOUS les types, sans filtre site', async () => { + const { siteOptions, storageTypeOptions, loadReferentials } = useStorageForm() + await loadReferentials() + + const storageCall = mockGet.mock.calls.find(c => c[0] === '/storage_types') + expect(storageCall).toBeDefined() + // Aucun filtre siteId envoye (referentiel plat). + expect(storageCall?.[1]).not.toHaveProperty('siteId[]') + + expect(siteOptions.value.map(o => o.value)).toEqual(['/api/sites/1']) + expect(storageTypeOptions.value.map(o => o.value)).toEqual([ + '/api/storage_types/9', + '/api/storage_types/5', + ]) + }) + + it('changer de site ne recharge pas les types ni ne purge la selection', async () => { + const { form, setSite, setStorageType, loadReferentials } = useStorageForm() + await loadReferentials() + const callsBefore = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length + + setStorageType('/api/storage_types/9') + setSite('/api/sites/1') + + expect(form.siteIri).toBe('/api/sites/1') + // Selection conservee : pas de cascade ni de purge par site. + expect(form.storageTypeIri).toBe('/api/storage_types/9') + const callsAfter = mockGet.mock.calls.filter(c => c[0] === '/storage_types').length + expect(callsAfter).toBe(callsBefore) + }) + }) + + describe('submit — POST /storages', () => { + function fillValidForm(form: ReturnType['form']): void { + form.siteIri = '/api/sites/1' + form.storageTypeIri = '/api/storage_types/9' + form.numero = '12' + form.states = ['RECEPTION', 'PRODUCTION'] + } + + it('poste le payload (relations en IRI) et retourne true au succes', async () => { + mockPost.mockResolvedValueOnce({ id: 42 }) + const { form, submit } = useStorageForm() + fillValidForm(form) + + const ok = await submit() + + expect(ok).toBe(true) + expect(mockPost).toHaveBeenCalledWith( + '/storages', + { + numero: '12', + states: ['RECEPTION', 'PRODUCTION'], + site: '/api/sites/1', + storageType: '/api/storage_types/9', + }, + expect.objectContaining({ toast: false }), + ) + expect(mockToastSuccess).toHaveBeenCalled() + }) + + it('omet `site` / `storageType` du payload quand la relation n\'est pas choisie', async () => { + // Envoyer null casserait la denormalisation back (IRI attendu) et + // court-circuiterait les autres violations -> on omet la cle. + mockPost.mockResolvedValueOnce({ id: 43 }) + const { form, submit } = useStorageForm() + fillValidForm(form) + form.siteIri = null + form.storageTypeIri = null + + await submit() + + const payload = mockPost.mock.calls[0][1] + expect(payload).not.toHaveProperty('site') + expect(payload).not.toHaveProperty('storageType') + // numero envoye en chaine vide si non saisi (NotBlank cote back). + expect(payload).toHaveProperty('numero') + }) + + it('mappe un 409 doublon (site, type, numero) sur errors.numero + toast explicite', async () => { + mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } }) + const { form, errors, submit } = useStorageForm() + fillValidForm(form) + + const ok = await submit() + + expect(ok).toBe(false) + expect(errors.numero).toBe('admin.storages.form.duplicateNumero') + expect(mockToastError).toHaveBeenCalled() + }) + + it('mappe une 422 inline par champ (errors.numero) sans toast', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'numero', message: 'Le numéro du stockage est obligatoire.' }] }, + }, + }) + const { form, errors, submit } = useStorageForm() + fillValidForm(form) + form.numero = null + + const ok = await submit() + + expect(ok).toBe(false) + expect(errors.numero).toBe('Le numéro du stockage est obligatoire.') + expect(mockToastError).not.toHaveBeenCalled() + }) + + it('mappe une 422 sur site / storageType / states (NotNull / Count)', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [ + { propertyPath: 'site', message: 'Le site est obligatoire.' }, + { propertyPath: 'storageType', message: 'Le type de stockage est obligatoire.' }, + { propertyPath: 'states', message: 'Sélectionnez au moins un état.' }, + ] }, + }, + }) + const { form, errors, submit } = useStorageForm() + fillValidForm(form) + + await submit() + + expect(errors.site).toBe('Le site est obligatoire.') + expect(errors.storageType).toBe('Le type de stockage est obligatoire.') + expect(errors.states).toBe('Sélectionnez au moins un état.') + }) + }) +}) diff --git a/frontend/modules/catalog/composables/useStorageForm.ts b/frontend/modules/catalog/composables/useStorageForm.ts new file mode 100644 index 0000000..fb42d00 --- /dev/null +++ b/frontend/modules/catalog/composables/useStorageForm.ts @@ -0,0 +1,141 @@ +/** + * Composable du formulaire de creation d'un stockage (M7 — ERP-217). + * + * Porte l'etat du formulaire principal (a plat, PAS d'onglets — HP-M7-06), les + * referentiels des selects et la soumission `POST /api/storages` avec mapping des + * erreurs 422/409 inline (useFormErrors, ERP-101). Reference : ecran « Ajouter un + * produit » (M6) / ecran Client. + * + * Referentiel des types de stockage : PLAT (RG-6.06 / decision back). Le concept + * type<->site a ete retire en M6 (jointure storage_type_site droppee, migration + * Version20260626100000) et `StorageType` n'a plus de relation Site ; le provider + * ignore tout filtre `?siteId[]`. La cascade Site->Type de RG-7.03 n'est donc PAS + * portee (decision produit du 30/06 : referentiel plat, fidele au back ; RG-7.03 a + * reclarifier cote spec). On charge donc TOUS les types une fois, Site et Type + * independants. + * + * Etat 100 % local a l'instance. + */ +import { reactive, ref } from 'vue' +import { + useSiteOptions, + useStorageTypeOptions, +} from '~/modules/catalog/composables/useProductOptions' + +/** Etats d'un stockage (miroir de l'enum back Storage::STATE_*, RG-7.04). */ +export const STORAGE_STATES = ['RECEPTION', 'PRODUCTION', 'TRIAGE'] as const + +export function useStorageForm() { + const api = useApi() + const { t } = useI18n() + const toast = useToast() + const formErrors = useFormErrors() + + const sites = useSiteOptions() + const storageTypes = useStorageTypeOptions() + + // ── Etat du formulaire ─────────────────────────────────────────────────── + // Les relations (site, storageType) sont stockees en IRI (envoyees telles + // quelles au POST) ; `states` porte les codes enum. + const form = reactive({ + siteIri: null as string | null, + storageTypeIri: null as string | null, + numero: null as string | null, + states: [] as string[], + }) + + const submitting = ref(false) + + /** Met a jour le site (select simple, RG-7.02). */ + function setSite(iri: string | null): void { + form.siteIri = iri + } + + /** Met a jour le type de stockage (select simple, referentiel plat). */ + function setStorageType(iri: string | null): void { + form.storageTypeIri = iri + } + + /** Met a jour les etats (multi-select, >= 1, RG-7.04). */ + function setStates(states: string[]): void { + form.states = states + } + + /** + * Charge les referentiels (sites + TOUS les types de stockage). Resilient : + * un referentiel en echec reste vide sans casser l'autre. Pas de cascade par + * site (referentiel plat, cf. docblock). + */ + async function loadReferentials(): Promise { + await Promise.allSettled([sites.load(), storageTypes.load()]) + } + + /** + * Soumet la creation. Retourne true au succes (la page redirige), false sinon. + * 422 → mapping inline par champ (useFormErrors, `{ toast: false }`) ; 409 + * doublon du triplet (site, type, numero, RG-7.01) → erreur inline sur `numero` + * (propertyPath exploitable cote back) + toast explicite. + */ + async function submit(): Promise { + if (submitting.value) { + return false + } + submitting.value = true + formErrors.clearErrors() + try { + const payload: Record = { + // Chaine vide (jamais null) : le setter back setNumero attend un + // `string` non-nullable -> envoyer null leverait une erreur de type + // (denormalisation) qui court-circuiterait les autres violations. + // Avec '', la contrainte NotBlank renvoie un message propre par champ. + numero: form.numero ?? '', + states: form.states, + } + // `site` / `storageType` attendent un IRI (string) : envoyer null + // declencherait une erreur de denormalisation API Platform qui + // court-circuiterait TOUTES les autres violations. On omet la cle quand + // la relation n'est pas choisie -> la contrainte NotNull renvoie un + // message propre, et les autres champs sont valides dans la meme 422. + if (form.siteIri) { + payload.site = form.siteIri + } + if (form.storageTypeIri) { + payload.storageType = form.storageTypeIri + } + + const options = { headers: { Accept: 'application/ld+json' }, toast: false } + await api.post('/storages', payload, options) + toast.success({ title: t('admin.storages.toast.createSuccess') }) + return true + } + catch (error) { + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 409) { + // Doublon (site, type, numero) RG-7.01 : inline sur `numero` + toast. + const message = t('admin.storages.form.duplicateNumero') + formErrors.setError('numero', message) + toast.error({ title: t('admin.storages.toast.error'), message }) + } + else { + formErrors.handleApiError(error, { fallbackMessage: t('admin.storages.toast.error') }) + } + return false + } + finally { + submitting.value = false + } + } + + return { + form, + errors: formErrors.errors, + submitting, + siteOptions: sites.options, + storageTypeOptions: storageTypes.options, + setSite, + setStorageType, + setStates, + loadReferentials, + submit, + } +} diff --git a/frontend/modules/catalog/pages/admin/storages/new.vue b/frontend/modules/catalog/pages/admin/storages/new.vue new file mode 100644 index 0000000..a97454e --- /dev/null +++ b/frontend/modules/catalog/pages/admin/storages/new.vue @@ -0,0 +1,127 @@ + + +