diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index d2f1c9d..524e414 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -574,7 +574,11 @@ }, "errors": { "nameRequired": "Le nom du transporteur est obligatoire.", - "certificationRequired": "Le type de certification est obligatoire." + "certificationRequired": "Le type de certification est obligatoire.", + "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é." } } } diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index b6e123a..3f28fbd 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -89,12 +89,61 @@ describe('useCarrierForm', () => { expect(form.mainErrors.errors.certificationType).toBeUndefined() }) + it('front RG-4.02 : certification AUTRE sans décharge → erreur inline, pas de POST', async () => { + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'AUTRE' + // dischargeDocumentIri null (upload non fourni). + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.dischargeDocument).toBe('transport.carriers.form.errors.dischargeRequired') + }) + + it('front RG-4.03 : affrété sans indexation / contenant / volume → 3 erreurs inline, pas de POST', async () => { + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'GMP_PLUS' + form.main.isChartered = true + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.indexationRate).toBe('transport.carriers.form.errors.indexationRequired') + expect(form.mainErrors.errors.containerType).toBe('transport.carriers.form.errors.containerTypeRequired') + expect(form.mainErrors.errors.volumeM3).toBe('transport.carriers.form.errors.volumeRequired') + }) + + it('front RG-4.03 : affrété avec tous les champs remplis → POST envoyé', async () => { + mockPost.mockResolvedValueOnce({ id: 8, name: 'ACME', certificationType: 'GMP_PLUS' }) + const form = useCarrierForm() + form.main.name = 'Acme' + form.main.certificationType = 'GMP_PLUS' + form.main.isChartered = true + form.main.indexationRate = '5' + form.main.containerType = 'BENNE' + form.main.volumeM3 = '30' + + const created = await form.submitMain() + + expect(created).toBe(true) + expect(mockPost).toHaveBeenCalledTimes(1) + expect(mockPost.mock.calls[0]?.[1]).toMatchObject({ + isChartered: true, + indexationRate: '5', + containerType: 'BENNE', + volumeM3: '30', + }) + }) + it('POST /carriers avec Accept ld+json, verrouille et bascule sur Qualimat', async () => { mockPost.mockResolvedValueOnce({ id: 42, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' }) const form = useCarrierForm() form.main.name = 'Transports Acme' form.main.certificationType = 'GMP_PLUS' - form.main.isChartered = true const created = await form.submitMain() @@ -105,7 +154,7 @@ describe('useCarrierForm', () => { expect(body).toEqual({ name: 'Transports Acme', certificationType: 'GMP_PLUS', - isChartered: true, + isChartered: false, }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) @@ -130,7 +179,7 @@ describe('useCarrierForm', () => { mockPost.mockRejectedValueOnce({ response: { status: 409 } }) const form = useCarrierForm() form.main.name = 'Doublon' - form.main.certificationType = 'AUTRE' + form.main.certificationType = 'GMP_PLUS' const created = await form.submitMain() @@ -139,24 +188,24 @@ describe('useCarrierForm', () => { expect(form.mainLocked.value).toBe(false) }) - it('422 : mappe les violations serveur inline par champ (RG conditionnelle back)', async () => { - // Champ re-validé côté back (RG-4.03) : le pré-check front laisse passer le POST, - // la 422 mappe inline sur le champ via son propertyPath. + it('422 : mappe les violations serveur inline par champ', async () => { + // Contrainte re-validée uniquement back (ex. longueur du nom) : le pré-check + // front passe (nom rempli, certif choisie, non affrété), la 422 mappe inline + // sur le champ via son propertyPath. mockPost.mockRejectedValueOnce({ response: { status: 422, - _data: { violations: [{ propertyPath: 'indexationRate', message: "Le taux d'indexation est obligatoire pour un transporteur affrété." }] }, + _data: { violations: [{ propertyPath: 'name', message: 'Le nom du transporteur ne peut dépasser 255 caractères.' }] }, }, }) const form = useCarrierForm() form.main.name = 'Acme' form.main.certificationType = 'GMP_PLUS' - form.main.isChartered = true const created = await form.submitMain() expect(created).toBe(false) - expect(form.mainErrors.errors.indexationRate).toBe("Le taux d'indexation est obligatoire pour un transporteur affrété.") + expect(form.mainErrors.errors.name).toBe('Le nom du transporteur ne peut dépasser 255 caractères.') expect(form.mainLocked.value).toBe(false) }) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index 6864d9e..31abd25 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -95,9 +95,11 @@ export function useCarrierForm() { /** * Validation FRONT du formulaire principal : seul le nom est requis côté front - * (RG-4.01) : nom requis, et certification requise hors cas LIOT (où elle est - * masquée). Le back reste la couche autoritaire (ERP-101) — les RG conditionnelles - * (affrètement, décharge AUTRE) sont re-validées serveur et remontées en 422 inline. + * (ERP-101) : feedback immédiat sur tous les champs obligatoires (y compris + * conditionnels), alignés sur les RG du back (qui reste autoritaire) : + * - RG-4.01 : nom requis ; certification requise hors cas LIOT (où tout est masqué) ; + * - RG-4.02 : décharge requise si certification AUTRE ; + * - RG-4.03 : indexation + contenant + volume requis si « Affréter ». */ function validateMainFront(): boolean { let valid = true @@ -105,11 +107,40 @@ export function useCarrierForm() { mainErrors.setError('name', t('transport.carriers.form.errors.nameRequired')) valid = false } - // RG-4.01 : la certification est obligatoire SAUF en cas LIOT (champ masqué). - if (!isLiot.value && !main.certificationType) { + + // Cas LIOT : seul le nom compte, les autres champs sont masqués (RG-4.01). + if (isLiot.value) { + return valid + } + + // RG-4.01 : certification obligatoire hors LIOT. + if (!main.certificationType) { mainErrors.setError('certificationType', t('transport.carriers.form.errors.certificationRequired')) valid = false } + + // RG-4.02 : décharge obligatoire si certification AUTRE. + if (main.certificationType === 'AUTRE' && !main.dischargeDocumentIri) { + mainErrors.setError('dischargeDocument', t('transport.carriers.form.errors.dischargeRequired')) + valid = false + } + + // RG-4.03 : indexation / contenant / volume obligatoires si affrété. + if (main.isChartered) { + if (!main.indexationRate.trim()) { + mainErrors.setError('indexationRate', t('transport.carriers.form.errors.indexationRequired')) + valid = false + } + if (!main.containerType) { + mainErrors.setError('containerType', t('transport.carriers.form.errors.containerTypeRequired')) + valid = false + } + if (!main.volumeM3.trim()) { + mainErrors.setError('volumeM3', t('transport.carriers.form.errors.volumeRequired')) + valid = false + } + } + return valid }