fix(transport) : upload décharge différé à l'enregistrement/validation (évite les orphelins) (ERP-171)

This commit is contained in:
2026-06-17 15:22:25 +02:00
parent 1d5110d000
commit 7668d77c78
4 changed files with 99 additions and 20 deletions
@@ -350,6 +350,52 @@ describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
form.main.dischargeDocumentIri = '/api/uploaded_documents/7' form.main.dischargeDocumentIri = '/api/uploaded_documents/7'
expect(form.buildMainPayload()).toMatchObject({ dischargeDocument: '/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)', () => { describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => {
@@ -68,7 +68,9 @@ export function useCarrierForm() {
const mainErrors = useFormErrors() const mainErrors = useFormErrors()
// Upload de la décharge (RG-4.02) — infra partagée /api/uploaded_documents. // 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 { uploading: dischargeUploading, upload: uploadFile } = useUpload()
const pendingDischargeFile = ref<File | null>(null)
// ── État du transporteur créé ───────────────────────────────────────────── // ── État du transporteur créé ─────────────────────────────────────────────
const carrierId = ref<number | null>(null) const carrierId = ref<number | null>(null)
@@ -144,8 +146,9 @@ export function useCarrierForm() {
valid = false valid = false
} }
// RG-4.02 : décharge obligatoire si certification AUTRE. // RG-4.02 : décharge obligatoire si certification AUTRE — satisfaite par un
if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri) { // 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')) mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired'))
valid = false valid = false
} }
@@ -170,20 +173,41 @@ export function useCarrierForm() {
} }
/** /**
* Upload de la décharge (RG-4.02) déclenché par `@file-selected` du champ * Sélection de la décharge (RG-4.02) via `@file-selected` : le fichier est mis
* Décharge : envoie le fichier, pose l'IRI résultant sur le brouillon. Au 422 * EN ATTENTE, l'upload réel est DIFFÉRÉ à l'enregistrement (`submitMain` /
* (MIME hors whitelist / taille), le message back s'affiche sous le champ * `updateMain`). Évite les binaires orphelins si l'utilisateur abandonne le
* (pas de toast) ; l'IRI est remis à null pour bloquer la validation. * formulaire après avoir choisi un fichier.
*/ */
async function uploadDischarge(file: File): Promise<void> { function selectDischarge(file: File): void {
mainErrors.clearError('dischargeDocument') 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<boolean> {
if (!pendingDischargeFile.value) {
return true
}
try { try {
main.dischargeDocumentIri = await uploadFile(file) main.dischargeDocumentIri = await uploadFile(pendingDischargeFile.value)
pendingDischargeFile.value = null
return true
} catch (error) { } catch (error) {
main.dischargeDocumentIri = null
const message = extractApiErrorMessage((error as { data?: unknown })?.data) const message = extractApiErrorMessage((error as { data?: unknown })?.data)
|| t('transport.carriers.form.errors.uploadFailed') || t('transport.carriers.form.errors.uploadFailed')
mainErrors.setError('dischargeDocument', message) mainErrors.setError('dischargeDocument', message)
return false
} }
} }
@@ -249,6 +273,9 @@ export function useCarrierForm() {
mainSubmitting.value = true mainSubmitting.value = true
try { try {
// Upload différé de la décharge : envoyé seulement maintenant (au Valider).
if (!(await resolveDischargeUpload())) return false
const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), { const created = await api.post<CarrierMainResponse>('/carriers', buildMainPayload(), {
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
toast: false, toast: false,
@@ -299,6 +326,9 @@ export function useCarrierForm() {
mainSubmitting.value = true mainSubmitting.value = true
try { try {
// Upload différé de la décharge : envoyé seulement maintenant (à l'Enregistrer).
if (!(await resolveDischargeUpload())) return false
const updated = await api.patch<CarrierMainResponse>( const updated = await api.patch<CarrierMainResponse>(
`/carriers/${carrierId.value}`, `/carriers/${carrierId.value}`,
buildMainPayload(), buildMainPayload(),
@@ -791,7 +821,8 @@ export function useCarrierForm() {
removePrice, removePrice,
submitPrices, submitPrices,
// actions // actions
uploadDischarge, selectDischarge,
clearDischarge,
validateMainFront, validateMainFront,
buildMainPayload, buildMainPayload,
submitMain, submitMain,
@@ -53,7 +53,7 @@
:clearable="true" :clearable="true"
:error="mainErrors.errors.dischargeDocument" :error="mainErrors.errors.dischargeDocument"
@update:model-value="(v: string) => dischargeFileName = v" @update:model-value="(v: string) => dischargeFileName = v"
@file-selected="uploadDischarge" @file-selected="selectDischarge"
@clear="onClearDischarge" @clear="onClearDischarge"
/> />
<div v-else class="hidden xl:block"></div> <div v-else class="hidden xl:block"></div>
@@ -236,7 +236,8 @@ const {
tabSubmitting, tabSubmitting,
mainErrors, mainErrors,
dischargeUploading, dischargeUploading,
uploadDischarge, selectDischarge,
clearDischarge,
isLiot, isLiot,
certificationReadonly, certificationReadonly,
showCharteredFields, showCharteredFields,
@@ -335,9 +336,9 @@ function apiErrorMessage(err: unknown): string {
// chargement d'un transporteur ayant déjà une décharge). // chargement d'un transporteur ayant déjà une décharge).
const dischargeFileName = ref('') 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 { function onClearDischarge(): void {
main.dischargeDocumentIri = null clearDischarge()
dischargeFileName.value = '' dischargeFileName.value = ''
} }
@@ -53,8 +53,8 @@
<!-- Colonne 3 RÉSERVÉE à la Décharge (RG-4.02 : visible et obligatoire <!-- Colonne 3 RÉSERVÉE à la Décharge (RG-4.02 : visible et obligatoire
si certification AUTRE). Si elle n'apparaît pas, on garde la colonne si certification AUTRE). Si elle n'apparaît pas, on garde la colonne
vide (xl) pour qu'« Affréter » reste en colonne 4 de la ligne 1. vide (xl) pour qu'« Affréter » reste en colonne 4 de la ligne 1.
L'upload réel (File → IRI via useUpload, ERP-171) résout le Upload DIFFÉRÉ (ERP-171) : le fichier choisi est mis en attente
fichier en IRI posé sur main.dischargeDocumentIri. --> et envoyé seulement à la validation du formulaire. -->
<MalioInputUpload <MalioInputUpload
v-if="showDischarge" v-if="showDischarge"
:model-value="dischargeFileName" :model-value="dischargeFileName"
@@ -65,7 +65,7 @@
:clearable="true" :clearable="true"
:error="mainErrors.errors.dischargeDocument" :error="mainErrors.errors.dischargeDocument"
@update:model-value="(v: string) => dischargeFileName = v" @update:model-value="(v: string) => dischargeFileName = v"
@file-selected="uploadDischarge" @file-selected="selectDischarge"
@clear="onClearDischarge" @clear="onClearDischarge"
/> />
<div v-else class="hidden xl:block"></div> <div v-else class="hidden xl:block"></div>
@@ -401,7 +401,8 @@ const {
tabSubmitting, tabSubmitting,
mainErrors, mainErrors,
dischargeUploading, dischargeUploading,
uploadDischarge, selectDischarge,
clearDischarge,
isLiot, isLiot,
isQualimat, isQualimat,
certificationReadonly, certificationReadonly,
@@ -436,9 +437,9 @@ const {
// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection). // Nom de fichier affiché dans le champ Décharge (alimenté à la sélection).
const dischargeFileName = ref('') 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 { function onClearDischarge(): void {
main.dischargeDocumentIri = null clearDischarge()
dischargeFileName.value = '' dischargeFileName.value = ''
} }