feat(transport) : upload décharge (useUpload) + câblage MalioInputUpload + i18n erreur (ERP-171)
This commit is contained in:
@@ -621,7 +621,8 @@
|
|||||||
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
|
"dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».",
|
||||||
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
|
"indexationRequired": "Le taux d'indexation est obligatoire pour un transporteur affrété.",
|
||||||
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
|
"containerTypeRequired": "Le type de contenant est obligatoire pour un transporteur affrété.",
|
||||||
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété."
|
"volumeRequired": "Le volume est obligatoire pour un transporteur affrété.",
|
||||||
|
"uploadFailed": "Le téléversement de la décharge a échoué."
|
||||||
},
|
},
|
||||||
"address": {
|
"address": {
|
||||||
"country": "Pays",
|
"country": "Pays",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, ref, type Ref } from 'vue'
|
import { computed, reactive, ref, type Ref } from 'vue'
|
||||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
|
import { useUpload } from '~/shared/composables/useUpload'
|
||||||
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
import { removeCollectionRow } from '~/shared/utils/collectionRow'
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +67,9 @@ export function useCarrierForm() {
|
|||||||
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
// Erreurs de validation par champ (ERP-101) du formulaire principal.
|
||||||
const mainErrors = useFormErrors()
|
const mainErrors = useFormErrors()
|
||||||
|
|
||||||
|
// Upload de la décharge (RG-4.02) — infra partagée /api/uploaded_documents.
|
||||||
|
const { uploading: dischargeUploading, upload: uploadFile } = useUpload()
|
||||||
|
|
||||||
// ── État du transporteur créé ─────────────────────────────────────────────
|
// ── État du transporteur créé ─────────────────────────────────────────────
|
||||||
const carrierId = ref<number | null>(null)
|
const carrierId = ref<number | null>(null)
|
||||||
const mainLocked = ref(false)
|
const mainLocked = ref(false)
|
||||||
@@ -165,6 +169,24 @@ export function useCarrierForm() {
|
|||||||
return valid
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async function uploadDischarge(file: File): Promise<void> {
|
||||||
|
mainErrors.clearError('dischargeDocument')
|
||||||
|
try {
|
||||||
|
main.dischargeDocumentIri = await uploadFile(file)
|
||||||
|
} catch (error) {
|
||||||
|
main.dischargeDocumentIri = null
|
||||||
|
const message = extractApiErrorMessage((error as { data?: unknown })?.data)
|
||||||
|
|| t('transport.carriers.form.errors.uploadFailed')
|
||||||
|
mainErrors.setError('dischargeDocument', message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
* Payload du POST principal (groupe `carrier:write:main`). `name` et
|
||||||
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
* `certificationType` sont omis s'ils sont vides afin que la 422 porte la
|
||||||
@@ -732,6 +754,7 @@ export function useCarrierForm() {
|
|||||||
mainSubmitting,
|
mainSubmitting,
|
||||||
tabSubmitting,
|
tabSubmitting,
|
||||||
mainErrors,
|
mainErrors,
|
||||||
|
dischargeUploading,
|
||||||
// affichage conditionnel
|
// affichage conditionnel
|
||||||
isLiot,
|
isLiot,
|
||||||
isQualimat,
|
isQualimat,
|
||||||
@@ -768,6 +791,7 @@ export function useCarrierForm() {
|
|||||||
removePrice,
|
removePrice,
|
||||||
submitPrices,
|
submitPrices,
|
||||||
// actions
|
// actions
|
||||||
|
uploadDischarge,
|
||||||
validateMainFront,
|
validateMainFront,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
submitMain,
|
submitMain,
|
||||||
|
|||||||
@@ -45,12 +45,16 @@
|
|||||||
/>
|
/>
|
||||||
<MalioInputUpload
|
<MalioInputUpload
|
||||||
v-if="showDischarge"
|
v-if="showDischarge"
|
||||||
|
:model-value="dischargeFileName"
|
||||||
:label="t('transport.carriers.form.main.discharge')"
|
:label="t('transport.carriers.form.main.discharge')"
|
||||||
accept="application/pdf,image/*"
|
accept="application/pdf,image/*"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
:readonly="dischargeUploading"
|
||||||
:clearable="true"
|
:clearable="true"
|
||||||
:error="mainErrors.errors.dischargeDocument"
|
:error="mainErrors.errors.dischargeDocument"
|
||||||
@clear="main.dischargeDocumentIri = null"
|
@update:model-value="(v: string) => dischargeFileName = v"
|
||||||
|
@file-selected="uploadDischarge"
|
||||||
|
@clear="onClearDischarge"
|
||||||
/>
|
/>
|
||||||
<div v-else class="hidden xl:block"></div>
|
<div v-else class="hidden xl:block"></div>
|
||||||
<div class="flex h-12 items-center">
|
<div class="flex h-12 items-center">
|
||||||
@@ -231,6 +235,8 @@ const {
|
|||||||
mainSubmitting,
|
mainSubmitting,
|
||||||
tabSubmitting,
|
tabSubmitting,
|
||||||
mainErrors,
|
mainErrors,
|
||||||
|
dischargeUploading,
|
||||||
|
uploadDischarge,
|
||||||
isLiot,
|
isLiot,
|
||||||
certificationReadonly,
|
certificationReadonly,
|
||||||
showCharteredFields,
|
showCharteredFields,
|
||||||
@@ -307,6 +313,12 @@ onMounted(async () => {
|
|||||||
await load()
|
await load()
|
||||||
if (carrier.value) {
|
if (carrier.value) {
|
||||||
prefillFrom(carrier.value)
|
prefillFrom(carrier.value)
|
||||||
|
// Pré-affiche le nom du fichier de décharge déjà rattaché (s'il existe).
|
||||||
|
const doc = carrier.value.dischargeDocument
|
||||||
|
if (doc && typeof doc !== 'string') {
|
||||||
|
const meta = doc as Record<string, unknown>
|
||||||
|
dischargeFileName.value = String(meta.originalFilename ?? meta.name ?? '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loadCountries().catch(() => {})
|
loadCountries().catch(() => {})
|
||||||
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
|
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
|
||||||
@@ -319,6 +331,16 @@ function apiErrorMessage(err: unknown): string {
|
|||||||
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
|
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nom de fichier affiché dans le champ Décharge (alimenté à la sélection ou au
|
||||||
|
// 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é. */
|
||||||
|
function onClearDischarge(): void {
|
||||||
|
main.dischargeDocumentIri = null
|
||||||
|
dischargeFileName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
|
// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
|
||||||
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
|
// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
|
||||||
const indexationKey = ref(0)
|
const indexationKey = ref(0)
|
||||||
|
|||||||
@@ -53,18 +53,20 @@
|
|||||||
<!-- 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 reel (File → IRI via useUpload) arrive a ERP-171. -->
|
L'upload réel (File → IRI via useUpload, ERP-171) résout le
|
||||||
<!-- TODO ERP-171 : brancher useUpload pour resoudre le File en IRI
|
fichier en IRI posé sur main.dischargeDocumentIri. -->
|
||||||
(main.dischargeDocumentIri). Le champ est deja visible/obligatoire. -->
|
|
||||||
<MalioInputUpload
|
<MalioInputUpload
|
||||||
v-if="showDischarge"
|
v-if="showDischarge"
|
||||||
|
:model-value="dischargeFileName"
|
||||||
:label="t('transport.carriers.form.main.discharge')"
|
:label="t('transport.carriers.form.main.discharge')"
|
||||||
accept="application/pdf,image/*"
|
accept="application/pdf,image/*"
|
||||||
:required="true"
|
:required="true"
|
||||||
:readonly="mainLocked"
|
:readonly="mainLocked || dischargeUploading"
|
||||||
:clearable="true"
|
:clearable="true"
|
||||||
:error="mainErrors.errors.dischargeDocument"
|
:error="mainErrors.errors.dischargeDocument"
|
||||||
@clear="main.dischargeDocumentIri = null"
|
@update:model-value="(v: string) => dischargeFileName = v"
|
||||||
|
@file-selected="uploadDischarge"
|
||||||
|
@clear="onClearDischarge"
|
||||||
/>
|
/>
|
||||||
<div v-else class="hidden xl:block"></div>
|
<div v-else class="hidden xl:block"></div>
|
||||||
|
|
||||||
@@ -398,6 +400,8 @@ const {
|
|||||||
mainSubmitting,
|
mainSubmitting,
|
||||||
tabSubmitting,
|
tabSubmitting,
|
||||||
mainErrors,
|
mainErrors,
|
||||||
|
dischargeUploading,
|
||||||
|
uploadDischarge,
|
||||||
isLiot,
|
isLiot,
|
||||||
isQualimat,
|
isQualimat,
|
||||||
certificationReadonly,
|
certificationReadonly,
|
||||||
@@ -429,6 +433,15 @@ const {
|
|||||||
applyQualimatSelection,
|
applyQualimatSelection,
|
||||||
} = useCarrierForm()
|
} = useCarrierForm()
|
||||||
|
|
||||||
|
// 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é. */
|
||||||
|
function onClearDischarge(): void {
|
||||||
|
main.dischargeDocumentIri = null
|
||||||
|
dischargeFileName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
items: qualimatItems,
|
items: qualimatItems,
|
||||||
totalItems: qualimatTotal,
|
totalItems: qualimatTotal,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du composable d'upload générique (ERP-171) :
|
||||||
|
* - succès : POST multipart /uploaded_documents (champ « file »), toast désactivé,
|
||||||
|
* renvoie l'IRI (@id) du document créé, `uploading` retombe à false ;
|
||||||
|
* - erreur MIME hors whitelist → 422 : l'erreur est RELAYÉE à l'appelant (pour un
|
||||||
|
* affichage inline sous le champ), `uploading` ré-armé via le finally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: mockPost,
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { useUpload } = await import('../useUpload')
|
||||||
|
|
||||||
|
describe('useUpload', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('succès : POST multipart champ « file » + toast:false → renvoie l\'IRI', async () => {
|
||||||
|
mockPost.mockResolvedValue({ '@id': '/api/uploaded_documents/9', originalFilename: 'decharge.pdf' })
|
||||||
|
const { upload, uploading } = useUpload()
|
||||||
|
const file = new File(['contenu'], 'decharge.pdf', { type: 'application/pdf' })
|
||||||
|
|
||||||
|
const iri = await upload(file)
|
||||||
|
|
||||||
|
expect(iri).toBe('/api/uploaded_documents/9')
|
||||||
|
|
||||||
|
const [url, body, options] = mockPost.mock.calls[0]
|
||||||
|
expect(url).toBe('/uploaded_documents')
|
||||||
|
expect(body).toBeInstanceOf(FormData)
|
||||||
|
const stored = (body as FormData).get('file')
|
||||||
|
expect(stored).toBeInstanceOf(File)
|
||||||
|
expect((stored as File).name).toBe('decharge.pdf')
|
||||||
|
expect(options).toMatchObject({ toast: false })
|
||||||
|
|
||||||
|
expect(uploading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erreur MIME → 422 : l\'erreur est remontée à l\'appelant', async () => {
|
||||||
|
const error = Object.assign(new Error('422'), {
|
||||||
|
data: { 'hydra:description': 'Type de fichier non autorisé.' },
|
||||||
|
})
|
||||||
|
mockPost.mockRejectedValue(error)
|
||||||
|
const { upload, uploading } = useUpload()
|
||||||
|
const file = new File(['x'], 'malware.exe', { type: 'application/x-msdownload' })
|
||||||
|
|
||||||
|
await expect(upload(file)).rejects.toBe(error)
|
||||||
|
expect(uploading.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { AnyObject } from '~/shared/composables/useApi'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse JSON-LD de POST /api/uploaded_documents (groupe `uploaded_document:read`).
|
||||||
|
* Seul l'IRI (`@id`) est exploité pour le poser sur la relation cible.
|
||||||
|
*/
|
||||||
|
export interface UploadedDocumentResponse {
|
||||||
|
'@id': string
|
||||||
|
originalFilename?: string
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload d'un document générique vers l'infra partagée (ERP-154) :
|
||||||
|
* POST /api/uploaded_documents en multipart/form-data, champ « file », via
|
||||||
|
* `useApi()` (cookie JWT, parsing Hydra/erreurs). Renvoie l'IRI du document créé,
|
||||||
|
* à poser sur la relation cible (ex: `carrier.dischargeDocument` — RG-4.02).
|
||||||
|
*
|
||||||
|
* Les erreurs (MIME hors whitelist / fichier trop volumineux → 422) sont relayées
|
||||||
|
* (rethrow) à l'appelant pour un affichage inline sous le champ. `toast: false` par
|
||||||
|
* défaut : pas de toast fourre-tout, le formulaire mappe le message au bon champ.
|
||||||
|
*/
|
||||||
|
export function useUpload() {
|
||||||
|
// Indicateur d'upload en cours (désactivation UI / spinner éventuel).
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie `file` et renvoie l'IRI du `UploadedDocument` créé.
|
||||||
|
* @throws relaie l'erreur réseau / 422 (MIME, taille) à l'appelant.
|
||||||
|
*/
|
||||||
|
async function upload(file: File, options: { toast?: boolean } = {}): Promise<string> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
// useApi() détecte le FormData et n'impose pas de Content-Type JSON :
|
||||||
|
// le navigateur pose lui-même la frontière multipart.
|
||||||
|
const doc = await useApi().post<UploadedDocumentResponse>(
|
||||||
|
'/uploaded_documents',
|
||||||
|
formData as unknown as AnyObject,
|
||||||
|
{ toast: options.toast ?? false },
|
||||||
|
)
|
||||||
|
|
||||||
|
return doc['@id']
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploading, upload }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user