From 1d5110d000331f14e6f9fb6bb1fb690fdeffdc95 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 17 Jun 2026 15:14:31 +0200 Subject: [PATCH 01/10] =?UTF-8?q?feat(transport)=20:=20upload=20d=C3=A9cha?= =?UTF-8?q?rge=20(useUpload)=20+=20c=C3=A2blage=20MalioInputUpload=20+=20i?= =?UTF-8?q?18n=20erreur=20(ERP-171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 3 +- .../transport/composables/useCarrierForm.ts | 24 ++++++++ .../transport/pages/carriers/[id]/edit.vue | 24 +++++++- .../modules/transport/pages/carriers/new.vue | 23 ++++++-- .../composables/__tests__/useUpload.test.ts | 59 +++++++++++++++++++ frontend/shared/composables/useUpload.ts | 53 +++++++++++++++++ 6 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 frontend/shared/composables/__tests__/useUpload.test.ts create mode 100644 frontend/shared/composables/useUpload.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 90d4636..440175b 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -621,7 +621,8 @@ "dischargeRequired": "La décharge est obligatoire pour une certification « Autre ».", "indexationRequired": "Le taux d'indexation 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": { "country": "Pays", diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index 6db00a7..f1b26cf 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -1,5 +1,6 @@ import { computed, reactive, ref, type Ref } from 'vue' import { useFormErrors } from '~/shared/composables/useFormErrors' +import { useUpload } from '~/shared/composables/useUpload' import { extractApiErrorMessage, mapViolationsToRecord } from '~/shared/utils/api' import { removeCollectionRow } from '~/shared/utils/collectionRow' import { @@ -66,6 +67,9 @@ export function useCarrierForm() { // Erreurs de validation par champ (ERP-101) du formulaire principal. 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éé ───────────────────────────────────────────── const carrierId = ref(null) const mainLocked = ref(false) @@ -165,6 +169,24 @@ export function useCarrierForm() { 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 { + 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 * `certificationType` sont omis s'ils sont vides afin que la 422 porte la @@ -732,6 +754,7 @@ export function useCarrierForm() { mainSubmitting, tabSubmitting, mainErrors, + dischargeUploading, // affichage conditionnel isLiot, isQualimat, @@ -768,6 +791,7 @@ export function useCarrierForm() { removePrice, submitPrices, // actions + uploadDischarge, validateMainFront, buildMainPayload, submitMain, diff --git a/frontend/modules/transport/pages/carriers/[id]/edit.vue b/frontend/modules/transport/pages/carriers/[id]/edit.vue index 11b1e97..29a47ea 100644 --- a/frontend/modules/transport/pages/carriers/[id]/edit.vue +++ b/frontend/modules/transport/pages/carriers/[id]/edit.vue @@ -45,12 +45,16 @@ />
@@ -231,6 +235,8 @@ const { mainSubmitting, tabSubmitting, mainErrors, + dischargeUploading, + uploadDischarge, isLiot, certificationReadonly, showCharteredFields, @@ -307,6 +313,12 @@ onMounted(async () => { await load() if (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 + dischargeFileName.value = String(meta.originalFilename ?? meta.name ?? '') + } } loadCountries().catch(() => {}) 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') } +// 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 // (contrôlé) quand le plafonnement laisse le modelValue inchangé. const indexationKey = ref(0) diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index 1958abe..8f898ae 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -53,18 +53,20 @@ - + L'upload réel (File → IRI via useUpload, ERP-171) résout le + fichier en IRI posé sur main.dischargeDocumentIri. --> @@ -398,6 +400,8 @@ const { mainSubmitting, tabSubmitting, mainErrors, + dischargeUploading, + uploadDischarge, isLiot, isQualimat, certificationReadonly, @@ -429,6 +433,15 @@ const { applyQualimatSelection, } = 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 { items: qualimatItems, totalItems: qualimatTotal, diff --git a/frontend/shared/composables/__tests__/useUpload.test.ts b/frontend/shared/composables/__tests__/useUpload.test.ts new file mode 100644 index 0000000..bde5178 --- /dev/null +++ b/frontend/shared/composables/__tests__/useUpload.test.ts @@ -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) + }) +}) diff --git a/frontend/shared/composables/useUpload.ts b/frontend/shared/composables/useUpload.ts new file mode 100644 index 0000000..1ed6f03 --- /dev/null +++ b/frontend/shared/composables/useUpload.ts @@ -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 { + 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( + '/uploaded_documents', + formData as unknown as AnyObject, + { toast: options.toast ?? false }, + ) + + return doc['@id'] + } finally { + uploading.value = false + } + } + + return { uploading, upload } +} -- 2.39.5 From 7668d77c78cbe903e15586baf52c57394e650c60 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 17 Jun 2026 15:22:25 +0200 Subject: [PATCH 02/10] =?UTF-8?q?fix(transport)=20:=20upload=20d=C3=A9char?= =?UTF-8?q?ge=20diff=C3=A9r=C3=A9=20=C3=A0=20l'enregistrement/validation?= =?UTF-8?q?=20(=C3=A9vite=20les=20orphelins)=20(ERP-171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/useCarrierForm.test.ts | 46 +++++++++++++++++ .../transport/composables/useCarrierForm.ts | 51 +++++++++++++++---- .../transport/pages/carriers/[id]/edit.vue | 9 ++-- .../modules/transport/pages/carriers/new.vue | 13 ++--- 4 files changed, 99 insertions(+), 20 deletions(-) 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 = '' } -- 2.39.5 From 498cef8cc0f94ab20e0e6f1a997aed6e8763e173 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 17 Jun 2026 15:34:17 +0200 Subject: [PATCH 03/10] =?UTF-8?q?fix(transport)=20:=20embarque=20le=20nom?= =?UTF-8?q?=20de=20la=20d=C3=A9charge=20dans=20le=20d=C3=A9tail=20carrier?= =?UTF-8?q?=20(consultation/modif)=20(ERP-171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Transport/Domain/Entity/Carrier.php | 3 ++ src/Shared/Domain/Entity/UploadedDocument.php | 6 ++- .../Api/CarrierSerializationContractTest.php | 46 ++++++++++++++++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/Module/Transport/Domain/Entity/Carrier.php b/src/Module/Transport/Domain/Entity/Carrier.php index de39955..dc35ee7 100644 --- a/src/Module/Transport/Domain/Entity/Carrier.php +++ b/src/Module/Transport/Domain/Entity/Carrier.php @@ -81,6 +81,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; 'supplier:read', 'supplier_address:read', 'site:read', + // Embarque le nom de fichier de la decharge (RG-4.02) au lieu d'un + // IRI nu, pour l'affichage en consultation / modification (ERP-171). + 'uploaded_document:reference', 'default:read', ]], provider: CarrierProvider::class, diff --git a/src/Shared/Domain/Entity/UploadedDocument.php b/src/Shared/Domain/Entity/UploadedDocument.php index e1151cd..0ae76ac 100644 --- a/src/Shared/Domain/Entity/UploadedDocument.php +++ b/src/Shared/Domain/Entity/UploadedDocument.php @@ -75,8 +75,12 @@ class UploadedDocument #[Groups(['uploaded_document:read'])] private ?int $id = null; + // `uploaded_document:reference` : groupe minimal d'EMBARQUEMENT (nom de fichier + // seul, sans `storedPath`/`checksum`) pour qu'une entite parente (ex: Carrier) + // affiche le libelle du document au lieu d'un simple IRI. La parente l'ajoute a + // son `normalizationContext`. #[ORM\Column(name: 'original_filename', length: 255)] - #[Groups(['uploaded_document:read'])] + #[Groups(['uploaded_document:read', 'uploaded_document:reference'])] private string $originalFilename; #[ORM\Column(name: 'stored_path', length: 512)] diff --git a/tests/Module/Transport/Api/CarrierSerializationContractTest.php b/tests/Module/Transport/Api/CarrierSerializationContractTest.php index ba5650e..e66e9f2 100644 --- a/tests/Module/Transport/Api/CarrierSerializationContractTest.php +++ b/tests/Module/Transport/Api/CarrierSerializationContractTest.php @@ -4,9 +4,14 @@ declare(strict_types=1); namespace App\Tests\Module\Transport\Api; +use App\Module\Transport\Domain\Entity\Carrier; +use App\Shared\Domain\Entity\UploadedDocument; +use App\Tests\Module\Commercial\Api\SupplierSerializationContractTest; +use DateTimeImmutable; + /** * Tests du CONTRAT DE SERIALISATION du repertoire transporteurs (M4, spec-back - * § 4.0 / § 4.0.bis). Jumeau de {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest}. + * § 4.0 / § 4.0.bis). Jumeau de {@see SupplierSerializationContractTest}. * Reverifie sur le JSON REEL les pieges silencieux du M1 transposes au M4 : * - #1/#2 : relations embarquees en OBJET (pas IRI nu) — qualimatCarrier, et au * detail prices[].client / .supplier / .departureSite / .deliverySite. @@ -133,6 +138,43 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase self::assertIsArray($supplierPrice['deliverySite']); } + // === Decharge (RG-4.02) embarquee en OBJET avec son nom de fichier (ERP-171) === + + public function testDetailEmbedsDischargeDocumentFilename(): void + { + $em = $this->getEm(); + + // Decharge (UploadedDocument) rattachee a un transporteur certifie AUTRE. + $document = new UploadedDocument( + originalFilename: 'decharge-test.pdf', + storedPath: '2026/06/'.bin2hex(random_bytes(8)).'.pdf', + mimeType: 'application/pdf', + sizeBytes: 1234, + checksum: hash('sha256', 'contenu'), + createdAt: new DateTimeImmutable(), + ); + $em->persist($document); + + $carrier = new Carrier(); + $carrier->setName('AUTRE DISCHARGE CO'); + $carrier->setCertificationType('AUTRE'); + $carrier->setDischargeDocument($document); + $em->persist($carrier); + $em->flush(); + + $http = $this->createAdminClient(); + $data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray(); + + // dischargeDocument embarque en OBJET (uploaded_document:reference) avec son + // nom de fichier — sinon le front n'a qu'un IRI nu et affiche un champ vide. + self::assertArrayHasKey('dischargeDocument', $data); + self::assertIsArray($data['dischargeDocument'], 'dischargeDocument doit etre un objet embarque, pas un IRI nu.'); + self::assertSame('decharge-test.pdf', $data['dischargeDocument']['originalFilename']); + // Le groupe minimal n'expose PAS les metadonnees internes (storedPath / checksum). + self::assertArrayNotHasKey('storedPath', $data['dischargeDocument']); + self::assertArrayNotHasKey('checksum', $data['dischargeDocument']); + } + // === RBAC : 403 sans la permission view === public function testForbiddenWithoutViewPermission(): void @@ -183,7 +225,7 @@ final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase * * @param array $collection * - * @return array|null + * @return null|array */ private function memberById(array $collection, int $id): ?array { -- 2.39.5 From e76bd1dd630c2a88927ccc80f73e832d2cc20a39 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 17 Jun 2026 17:32:29 +0200 Subject: [PATCH 04/10] feat(transport) : adresse unique par transporteur (OneToOne back + un seul bloc front) (ERP-172) --- .../__tests__/useCarrierForm.test.ts | 77 +++-------- .../transport/composables/useCarrierForm.ts | 130 ++++++++---------- .../transport/pages/carriers/[id]/edit.vue | 24 +--- .../transport/pages/carriers/[id]/index.vue | 12 +- .../modules/transport/pages/carriers/new.vue | 36 ++--- .../transport/utils/forms/carrierMappers.ts | 3 +- migrations/Version20260617140000.php | 40 ++++++ .../Transport/Domain/Entity/Carrier.php | 34 ++--- .../Domain/Entity/CarrierAddress.php | 15 +- .../Processor/CarrierAddressProcessor.php | 28 +++- .../DataFixtures/CarrierFixtures.php | 3 +- .../Api/AbstractCarrierApiTestCase.php | 2 +- .../Transport/Api/CarrierAddressApiTest.php | 33 +++-- .../Api/CarrierSerializationContractTest.php | 7 +- 14 files changed, 219 insertions(+), 225 deletions(-) create mode 100644 migrations/Version20260617140000.php diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index f695eff..506b4ee 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -486,14 +486,13 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => { }) }) - it('applyQualimatSelection pré-remplit le 1er bloc d\'adresse (RG-4.05)', async () => { + it('applyQualimatSelection pré-remplit l\'adresse unique à la création (RG-4.05)', async () => { const form = useCarrierForm() form.main.name = 'Acme' await form.applyQualimatSelection(QUALIMAT_ROW) - expect(form.addresses.value).toHaveLength(1) - expect(form.addresses.value[0]).toEqual({ + expect(form.address.value).toEqual({ id: null, country: 'France', postalCode: '86000', @@ -504,53 +503,38 @@ describe('useCarrierForm — copie QUALIMAT (ERP-166)', () => { }) }) -describe('useCarrierForm — onglet Adresses (ERP-167)', () => { +describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)', () => { beforeEach(() => { mockPost.mockReset() mockPatch.mockReset() mockDelete.mockReset() }) - /** Transporteur créé, onglet Adresses accessible. */ + /** Transporteur créé, onglet Adresse accessible. */ function createdForm() { const form = useCarrierForm() form.carrierId.value = 7 return form } - /** Remplit un bloc adresse complet (CP + ville + rue). */ - function fillAddress(form: ReturnType, index = 0): void { - const a = form.addresses.value[index] - if (a) { - a.postalCode = '86100' - a.city = 'Châtellerault' - a.street = '1 rue du Test' - } + /** Remplit l'unique bloc adresse (CP + ville + rue). */ + function fillAddress(form: ReturnType): void { + const a = form.address.value + a.postalCode = '86100' + a.city = 'Châtellerault' + a.street = '1 rue du Test' } - it('canAddAddress : désactivé tant que la dernière adresse est incomplète', () => { - const form = createdForm() - expect(form.canAddAddress.value).toBe(false) - - form.addAddress() - expect(form.addresses.value).toHaveLength(1) // no-op tant qu'incomplète - - fillAddress(form) - expect(form.canAddAddress.value).toBe(true) - form.addAddress() - expect(form.addresses.value).toHaveLength(2) - }) - - it('submitAddresses : POST des nouvelles adresses, capture l\'id, finalise l\'onglet', async () => { + it('submitAddress : POST sur /carriers/{id}/address, capture l\'id, finalise l\'onglet', async () => { mockPost.mockResolvedValueOnce({ id: 88 }) const form = createdForm() fillAddress(form) - const ok = await form.submitAddresses(vi.fn()) + const ok = await form.submitAddress(vi.fn()) expect(ok).toBe(true) const [url, body, opts] = mockPost.mock.calls[0] ?? [] - expect(url).toBe('/carriers/7/addresses') + expect(url).toBe('/carriers/7/address') expect(body).toEqual({ country: 'France', postalCode: '86100', @@ -559,24 +543,23 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => { streetComplement: null, }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) - expect(form.addresses.value[0]?.id).toBe(88) + expect(form.address.value.id).toBe(88) expect(form.isValidated('addresses')).toBe(true) }) - it('submitAddresses : PATCH des adresses existantes sur /carrier_addresses/{id}', async () => { + it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => { mockPatch.mockResolvedValueOnce({}) const form = createdForm() fillAddress(form) - const first = form.addresses.value[0] - if (first) first.id = 88 + form.address.value.id = 88 - await form.submitAddresses(vi.fn()) + await form.submitAddress(vi.fn()) expect(mockPost).not.toHaveBeenCalled() expect(mockPatch).toHaveBeenCalledWith('/carrier_addresses/88', expect.objectContaining({ city: 'Châtellerault' }), { toast: false }) }) - it('submitAddresses : mappe les 422 PAR LIGNE et ne finalise pas l\'onglet (RG-4.05)', async () => { + it('submitAddress : mappe les 422 inline par champ et ne finalise pas l\'onglet (RG-4.05)', async () => { mockPost.mockRejectedValueOnce({ response: { status: 422, @@ -586,27 +569,12 @@ describe('useCarrierForm — onglet Adresses (ERP-167)', () => { const form = createdForm() fillAddress(form) - const ok = await form.submitAddresses(vi.fn()) + const ok = await form.submitAddress(vi.fn()) expect(ok).toBe(false) - expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire pour un transporteur affrété.') + expect(form.addressErrors.value.city).toBe('La ville est obligatoire pour un transporteur affrété.') expect(form.isValidated('addresses')).toBe(false) }) - - it('removeAddress : DELETE /carrier_addresses/{id} puis retrait du bloc', async () => { - mockDelete.mockResolvedValueOnce({}) - const form = createdForm() - fillAddress(form) - const first = form.addresses.value[0] - if (first) first.id = 88 - form.addAddress() - fillAddress(form, 1) - - await form.removeAddress(0) - - expect(mockDelete).toHaveBeenCalledWith('/carrier_addresses/88', {}, { toast: false }) - expect(form.addresses.value).toHaveLength(1) - }) }) describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => { @@ -976,7 +944,7 @@ describe('useCarrierForm — édition (ERP-170)', () => { id: 7, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS', - addresses: [{ '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers' }], + address: { '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers' }, contacts: [{ '@id': '/api/carrier_contacts/9', id: 9, lastName: 'Doe', phonePrimary: '0102030405' }], prices: [{ '@id': '/api/carrier_prices/5', id: 5, direction: 'CLIENT', client: { '@id': '/api/clients/3' }, containerType: 'BENNE', pricingUnit: 'FORFAIT', price: '120', priceState: 'EN_COURS' }], }) @@ -985,8 +953,7 @@ describe('useCarrierForm — édition (ERP-170)', () => { expect(form.editMode.value).toBe(true) expect(form.main.name).toBe('TRANSPORTS ACME') expect(form.main.certificationType).toBe('GMP_PLUS') - expect(form.addresses.value).toHaveLength(1) - expect(form.addresses.value[0]?.id).toBe(3) + expect(form.address.value.id).toBe(3) expect(form.contacts.value[0]?.id).toBe(9) expect(form.prices.value[0]?.clientIri).toBe('/api/clients/3') }) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index e5e1923..007ece8 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -16,7 +16,7 @@ import { type CarrierMainResponse, type CarrierPriceFormDraft, } from '~/modules/transport/types/carrierForm' -import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress' +import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress' import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact' import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice' import { @@ -369,8 +369,8 @@ export function useCarrierForm() { Object.assign(main, mapMainToDraft(detail)) - const mappedAddresses = (detail.addresses ?? []).map(mapAddressToDraft) - addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyCarrierAddress()] + // Adresse UNIQUE (ERP-172) : objet `address` (ou null) au lieu d'une liste. + address.value = detail.address ? mapAddressToDraft(detail.address) : emptyCarrierAddress() const mappedContacts = (detail.contacts ?? []).map(mapContactToDraft) contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyCarrierContact()] @@ -435,75 +435,52 @@ export function useCarrierForm() { return hasError } - // ── Onglet Adresses (ERP-167) ───────────────────────────────────────────── - const addresses = ref([emptyCarrierAddress()]) - // Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows. - const addressErrors = ref[]>([]) - - // « + Nouvelle adresse » désactivé tant que la dernière adresse n'est pas - // complète (CP + ville + rue — RG-4.05, gate d'ajout). - const canAddAddress = computed(() => { - const last = addresses.value[addresses.value.length - 1] - return last !== undefined && isCarrierAddressValid(last) - }) - - function addAddress(): void { - if (canAddAddress.value) { - addresses.value.push(emptyCarrierAddress()) - } - } - - /** Suppression immédiate d'une adresse existante (DELETE /carrier_addresses/{id}). */ - async function removeAddress(index: number): Promise { - await removeCollectionRow({ - rows: addresses.value, - errors: addressErrors.value, - index, - endpoint: '/carrier_addresses', - deleteRow: url => api.delete(url, {}, { toast: false }), - makeEmpty: emptyCarrierAddress, - onError: notifyRemovalError, - }) - } + // ── Onglet Adresse (ERP-167 / ERP-172 : adresse UNIQUE) ─────────────────── + // Un transporteur a au plus UNE adresse (décision métier ERP-172) : un seul + // bloc, pas d'ajout/suppression. `id` null tant que l'adresse n'est pas créée. + const address = ref(emptyCarrierAddress()) + // Erreurs 422 du bloc adresse (mapping inline par champ, ERP-101). + const addressErrors = ref>({}) /** - * Valide l'onglet Adresses : POST des nouvelles adresses sur - * /carriers/{id}/addresses, PATCH des existantes sur /carrier_addresses/{id} - * (groupe carrier:write:addresses). Erreurs 422 collectées par ligne (RG-4.05 - * « obligatoire si affrété » re-validée back). Retourne true si l'onglet a été - * validé (avancé/terminé). + * Valide l'onglet Adresse : POST sur /carriers/{id}/address (création) ou PATCH + * sur /carrier_addresses/{id} (mise à jour), groupe carrier:write:addresses. + * Erreurs 422 mappées inline par champ (RG-4.05 « obligatoire si affrété » + * re-validée back). Retourne true si l'onglet a été validé. */ - async function submitAddresses(onError: (error: unknown) => void): Promise { + async function submitAddress(onError: (error: unknown) => void): Promise { if (carrierId.value === null || tabSubmitting.value) { return false } tabSubmitting.value = true + addressErrors.value = {} try { - const hasError = await submitRows( - addresses.value, - addressErrors, - async (address) => { - const body = buildCarrierAddressPayload(address) - if (address.id === null) { - const created = await api.post<{ id: number }>( - `/carriers/${carrierId.value}/addresses`, - body, - { headers: { Accept: 'application/ld+json' }, toast: false }, - ) - address.id = created.id - } - else { - await api.patch(`/carrier_addresses/${address.id}`, body, { toast: false }) - } - }, - onError, - ) - if (hasError) { - return false + const body = buildCarrierAddressPayload(address.value) + if (address.value.id === null) { + const created = await api.post<{ id: number }>( + `/carriers/${carrierId.value}/address`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + address.value.id = created.id + } + else { + await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false }) } completeTab('addresses') return true } + catch (error) { + const response = (error as { response?: { status?: number, _data?: unknown } })?.response + const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {} + if (Object.keys(mapped).length > 0) { + addressErrors.value = mapped + } + else { + onError(error) + } + return false + } finally { tabSubmitting.value = false } @@ -741,16 +718,20 @@ export function useCarrierForm() { city: row.city ?? '', street: row.address ?? '', } - // RG-4.05 : pré-remplit le 1er bloc de l'onglet Adresses par copie (la FK - // QUALIMAT survit, les champs restent éditables — § 2.5). - addresses.value = [{ - id: null, - country: 'France', - postalCode: row.postalCode || null, - city: row.city || null, - street: row.address || null, - streetComplement: null, - }] + // RG-4.05 : à la CRÉATION, pré-remplit l'adresse (unique) par copie du + // référentiel QUALIMAT (champs éditables, la FK QUALIMAT survit — § 2.5). + // En MODIFICATION (ERP-172) : on NE TOUCHE PAS l'adresse déjà saisie — la + // re-sélection Qualimat actualise seulement nom + certification + FK. + if (!editMode.value) { + address.value = { + id: null, + country: 'France', + postalCode: row.postalCode || null, + city: row.city || null, + street: row.address || null, + streetComplement: null, + } + } return true } @@ -799,13 +780,10 @@ export function useCarrierForm() { validated, editMode, isValidated, - // adresses - addresses, + // adresse (unique) + address, addressErrors, - canAddAddress, - addAddress, - removeAddress, - submitAddresses, + submitAddress, // contacts contacts, contactErrors, diff --git a/frontend/modules/transport/pages/carriers/[id]/edit.vue b/frontend/modules/transport/pages/carriers/[id]/edit.vue index 77c3f13..4ef6328 100644 --- a/frontend/modules/transport/pages/carriers/[id]/edit.vue +++ b/frontend/modules/transport/pages/carriers/[id]/edit.vue @@ -124,19 +124,16 @@