feat(catalog) : M7 — écran Ajouter un stockage /admin/storages/new (ERP-217)
- 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)
This commit is contained in:
@@ -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<string, unknown>) =>
|
||||
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<typeof useStorageForm>['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.')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
if (submitting.value) {
|
||||
return false
|
||||
}
|
||||
submitting.value = true
|
||||
formErrors.clearErrors()
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user