diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index cfda09e..f695eff 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -350,6 +350,52 @@ describe('useCarrierForm — champs conditionnels (ERP-166)', () => { form.main.dischargeDocumentIri = '/api/uploaded_documents/7' expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' }) }) + + it('RG-4.02 upload différé : selectDischarge ne POST pas ; submitMain upload PUIS crée', async () => { + mockPost.mockReset() + // 1er POST = /uploaded_documents (renvoie l'IRI) ; 2e = /carriers (création). + mockPost + .mockResolvedValueOnce({ '@id': '/api/uploaded_documents/7' }) + .mockResolvedValueOnce({ id: 12, name: 'ACME', certificationType: 'AUTRE' }) + + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'AUTRE' + + // Sélection du fichier : aucun appel réseau (upload différé à l'enregistrement). + form.selectDischarge(new File(['x'], 'decharge.pdf', { type: 'application/pdf' })) + expect(mockPost).not.toHaveBeenCalled() + // La validation est satisfaite par le fichier en attente (pas encore d'IRI). + expect(form.mainErrors.errors.dischargeDocument).toBeUndefined() + + const created = await form.submitMain() + expect(created).toBe(true) + + // 1er appel : upload multipart ; 2e : création carrier avec l'IRI résolu. + expect(mockPost.mock.calls[0][0]).toBe('/uploaded_documents') + expect(mockPost.mock.calls[1][0]).toBe('/carriers') + expect(mockPost.mock.calls[1][1]).toMatchObject({ dischargeDocument: '/api/uploaded_documents/7' }) + }) + + it('RG-4.02 upload différé : un 422 MIME bloque la création (message inline, pas de POST /carriers)', async () => { + mockPost.mockReset() + // Le POST /uploaded_documents échoue (MIME hors whitelist) → 422. + mockPost.mockRejectedValueOnce(Object.assign(new Error('422'), { + data: { 'hydra:description': 'Type de fichier non autorisé.' }, + })) + + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'AUTRE' + form.selectDischarge(new File(['x'], 'malware.exe', { type: 'application/x-msdownload' })) + + const created = await form.submitMain() + expect(created).toBe(false) + // Message back affiché inline sous le champ ; aucune création de carrier. + expect(form.mainErrors.errors.dischargeDocument).toBe('Type de fichier non autorisé.') + expect(mockPost).toHaveBeenCalledTimes(1) + expect(mockPost.mock.calls[0][0]).toBe('/uploaded_documents') + }) }) describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => { diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index f1b26cf..e5e1923 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -68,7 +68,9 @@ export function useCarrierForm() { const mainErrors = useFormErrors() // Upload de la décharge (RG-4.02) — infra partagée /api/uploaded_documents. + // L'upload est DIFFÉRÉ : le fichier choisi attend ici jusqu'à l'enregistrement. const { uploading: dischargeUploading, upload: uploadFile } = useUpload() + const pendingDischargeFile = ref(null) // ── État du transporteur créé ───────────────────────────────────────────── const carrierId = ref(null) @@ -144,8 +146,9 @@ export function useCarrierForm() { valid = false } - // RG-4.02 : décharge obligatoire si certification AUTRE. - if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri) { + // RG-4.02 : décharge obligatoire si certification AUTRE — satisfaite par un + // IRI déjà posé OU un fichier en attente d'upload (différé à l'enregistrement). + if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri && !pendingDischargeFile.value) { mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired')) valid = false } @@ -170,20 +173,41 @@ export function useCarrierForm() { } /** - * Upload de la décharge (RG-4.02) déclenché par `@file-selected` du champ - * Décharge : envoie le fichier, pose l'IRI résultant sur le brouillon. Au 422 - * (MIME hors whitelist / taille), le message back s'affiche sous le champ - * (pas de toast) ; l'IRI est remis à null pour bloquer la validation. + * Sélection de la décharge (RG-4.02) via `@file-selected` : le fichier est mis + * EN ATTENTE, l'upload réel est DIFFÉRÉ à l'enregistrement (`submitMain` / + * `updateMain`). Évite les binaires orphelins si l'utilisateur abandonne le + * formulaire après avoir choisi un fichier. */ - async function uploadDischarge(file: File): Promise { + function selectDischarge(file: File): void { mainErrors.clearError('dischargeDocument') + pendingDischargeFile.value = file + } + + /** Annulation du choix de décharge : oublie le fichier en attente et l'IRI. */ + function clearDischarge(): void { + pendingDischargeFile.value = null + main.dischargeDocumentIri = null + } + + /** + * Résout l'upload différé au moment de l'enregistrement : s'il y a un fichier + * en attente, l'envoie (POST /uploaded_documents) et pose l'IRI sur le + * brouillon. Retourne false au 422 (MIME / taille → message inline) pour + * interrompre la sauvegarde du transporteur. Pas de fichier en attente → no-op. + */ + async function resolveDischargeUpload(): Promise { + if (!pendingDischargeFile.value) { + return true + } try { - main.dischargeDocumentIri = await uploadFile(file) + main.dischargeDocumentIri = await uploadFile(pendingDischargeFile.value) + pendingDischargeFile.value = null + return true } catch (error) { - main.dischargeDocumentIri = null const message = extractApiErrorMessage((error as { data?: unknown })?.data) || t('transport.carriers.form.errors.uploadFailed') mainErrors.setError('dischargeDocument', message) + return false } } @@ -249,6 +273,9 @@ export function useCarrierForm() { mainSubmitting.value = true try { + // Upload différé de la décharge : envoyé seulement maintenant (au Valider). + if (!(await resolveDischargeUpload())) return false + const created = await api.post('/carriers', buildMainPayload(), { headers: { Accept: 'application/ld+json' }, toast: false, @@ -299,6 +326,9 @@ export function useCarrierForm() { mainSubmitting.value = true try { + // Upload différé de la décharge : envoyé seulement maintenant (à l'Enregistrer). + if (!(await resolveDischargeUpload())) return false + const updated = await api.patch( `/carriers/${carrierId.value}`, buildMainPayload(), @@ -791,7 +821,8 @@ export function useCarrierForm() { removePrice, submitPrices, // actions - uploadDischarge, + selectDischarge, + clearDischarge, validateMainFront, buildMainPayload, submitMain, diff --git a/frontend/modules/transport/pages/carriers/[id]/edit.vue b/frontend/modules/transport/pages/carriers/[id]/edit.vue index 29a47ea..77c3f13 100644 --- a/frontend/modules/transport/pages/carriers/[id]/edit.vue +++ b/frontend/modules/transport/pages/carriers/[id]/edit.vue @@ -53,7 +53,7 @@ :clearable="true" :error="mainErrors.errors.dischargeDocument" @update:model-value="(v: string) => dischargeFileName = v" - @file-selected="uploadDischarge" + @file-selected="selectDischarge" @clear="onClearDischarge" /> @@ -236,7 +236,8 @@ const { tabSubmitting, mainErrors, dischargeUploading, - uploadDischarge, + selectDischarge, + clearDischarge, isLiot, certificationReadonly, showCharteredFields, @@ -335,9 +336,9 @@ function apiErrorMessage(err: unknown): string { // chargement d'un transporteur ayant déjà une décharge). const dischargeFileName = ref('') -/** Vidage du champ Décharge : retire l'IRI et le nom affiché. */ +/** Vidage du champ Décharge : oublie le fichier en attente / l'IRI + le nom affiché. */ function onClearDischarge(): void { - main.dischargeDocumentIri = null + clearDischarge() dischargeFileName.value = '' } diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index 8f898ae..444e143 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -53,8 +53,8 @@ + Upload DIFFÉRÉ (ERP-171) : le fichier choisi est mis en attente + et envoyé seulement à la validation du formulaire. --> @@ -401,7 +401,8 @@ const { tabSubmitting, mainErrors, dischargeUploading, - uploadDischarge, + selectDischarge, + clearDischarge, isLiot, isQualimat, certificationReadonly, @@ -436,9 +437,9 @@ const { // Nom de fichier affiché dans le champ Décharge (alimenté à la sélection). const dischargeFileName = ref('') -/** Vidage du champ Décharge : retire l'IRI et le nom affiché. */ +/** Vidage du champ Décharge : oublie le fichier en attente / l'IRI + le nom affiché. */ function onClearDischarge(): void { - main.dischargeDocumentIri = null + clearDischarge() dischargeFileName.value = '' }