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'
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)', () => {
@@ -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<File | null>(null)
// ── État du transporteur créé ─────────────────────────────────────────────
const carrierId = ref<number | null>(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<void> {
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<boolean> {
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<CarrierMainResponse>('/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<CarrierMainResponse>(
`/carriers/${carrierId.value}`,
buildMainPayload(),
@@ -791,7 +821,8 @@ export function useCarrierForm() {
removePrice,
submitPrices,
// actions
uploadDischarge,
selectDischarge,
clearDischarge,
validateMainFront,
buildMainPayload,
submitMain,
@@ -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"
/>
<div v-else class="hidden xl:block"></div>
@@ -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 = ''
}
@@ -53,8 +53,8 @@
<!-- 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
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
fichier en IRI posé sur main.dischargeDocumentIri. -->
Upload DIFFÉRÉ (ERP-171) : le fichier choisi est mis en attente
et envoyé seulement à la validation du formulaire. -->
<MalioInputUpload
v-if="showDischarge"
:model-value="dischargeFileName"
@@ -65,7 +65,7 @@
:clearable="true"
:error="mainErrors.errors.dischargeDocument"
@update:model-value="(v: string) => dischargeFileName = v"
@file-selected="uploadDischarge"
@file-selected="selectDischarge"
@clear="onClearDischarge"
/>
<div v-else class="hidden xl:block"></div>
@@ -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 = ''
}