diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 0022d6c..c5fff11 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -630,7 +630,20 @@ "stateValide": "Validé", "stateNonValide": "Non validé", "add": "Nouveau prix", - "remove": "Supprimer le prix" + "remove": "Supprimer le prix", + "errors": { + "direction": "Le sens du prix est obligatoire.", + "client": "Le client est obligatoire pour un prix client.", + "clientDeliveryAddress": "L'adresse de livraison du client est obligatoire pour un prix client.", + "departureSite": "Le site de départ est obligatoire pour un prix client.", + "supplier": "Le fournisseur est obligatoire pour un prix fournisseur.", + "supplierSupplyAddress": "L'adresse d'approvisionnement est obligatoire pour un prix fournisseur.", + "deliverySite": "Le site de livraison est obligatoire pour un prix fournisseur.", + "containerType": "Le type de contenant est obligatoire.", + "pricingUnit": "L'unité de tarification est obligatoire.", + "price": "Le prix est obligatoire.", + "priceState": "L'état du prix est obligatoire." + } } } } diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index db99c21..07c92a5 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -825,9 +825,16 @@ describe('useCarrierForm — onglet Prix (ERP-169)', () => { it('submitPrices : PATCH des prix existants sur /carrier_prices/{id}', async () => { mockPatch.mockResolvedValueOnce({}) const form = createdForm() - form.addPrice() const p = form.prices.value[0] - if (p) { p.id = 50; p.direction = 'FOURNISSEUR'; p.supplierIri = '/api/suppliers/5'; p.price = '10' } + if (p) { + p.id = 50 + p.direction = 'FOURNISSEUR' + p.supplierIri = '/api/suppliers/5' + p.supplierSupplyAddressIri = '/api/supplier_addresses/9' + p.deliverySiteIri = '/api/sites/1' + p.price = '10' + p.priceState = 'VALIDE' + } await form.submitPrices(vi.fn()) @@ -835,19 +842,42 @@ describe('useCarrierForm — onglet Prix (ERP-169)', () => { expect(mockPatch).toHaveBeenCalledWith('/carrier_prices/50', expect.objectContaining({ direction: 'FOURNISSEUR' }), { toast: false }) }) - it('submitPrices : mappe les 422 par ligne et ne finalise pas', async () => { + it('front : bloc prix incomplet → erreurs inline sous chaque champ requis, pas d\'appel back', async () => { + const form = createdForm() + // Bloc CLIENT par défaut, rien d'autre rempli. + const ok = await form.submitPrices(vi.fn()) + + expect(ok).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + const errs = form.priceErrors.value[0] + expect(errs?.client).toBeTruthy() + expect(errs?.clientDeliveryAddress).toBeTruthy() + expect(errs?.departureSite).toBeTruthy() + expect(errs?.price).toBeTruthy() + expect(errs?.priceState).toBeTruthy() + }) + + it('submitPrices : mappe les 422 back par ligne (appartenance adresse) et ne finalise pas', async () => { mockPost.mockRejectedValueOnce({ - response: { status: 422, _data: { violations: [{ propertyPath: 'departureSite', message: 'Le site de depart est obligatoire pour un prix client.' }] } }, + response: { status: 422, _data: { violations: [{ propertyPath: 'clientDeliveryAddress', message: 'L\'adresse de livraison doit appartenir au client selectionne.' }] } }, }) const form = createdForm() - form.addPrice() + // Tous les champs requis remplis (le pré-check front passe) ; le back 422 sur + // une RG qu'il est seul à connaître (appartenance de l'adresse au client). const p = form.prices.value[0] - if (p) { p.direction = 'CLIENT'; p.clientIri = '/api/clients/3'; p.price = '10' } + if (p) { + p.direction = 'CLIENT' + p.clientIri = '/api/clients/3' + p.clientDeliveryAddressIri = '/api/client_addresses/8' + p.departureSiteIri = '/api/sites/1' + p.price = '10' + p.priceState = 'EN_COURS' + } const ok = await form.submitPrices(vi.fn()) expect(ok).toBe(false) - expect(form.priceErrors.value[0]?.departureSite).toBe('Le site de depart est obligatoire pour un prix client.') + expect(form.priceErrors.value[0]?.clientDeliveryAddress).toBe('L\'adresse de livraison doit appartenir au client selectionne.') expect(form.isValidated('prices')).toBe(false) }) diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index 5667316..7aeef6f 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -488,6 +488,38 @@ export function useCarrierForm() { } } + /** + * Pré-validation FRONT d'un bloc prix (ERP-101) : renvoie les erreurs inline par + * champ obligatoire (sens + branche active + communs). Nécessaire car côté back + * l'Assert\NotBlank sur les scalaires (price/priceState...) court-circuite la + * validation de branche du CarrierPriceProcessor : le 422 ne porterait jamais + * client/supplier/adresses en même temps. Messages alignés sur le back. + */ + function validatePriceRow(price: CarrierPriceFormDraft): Record { + const errs: Record = {} + const msg = (key: string): string => t(`transport.carriers.form.price.errors.${key}`) + + if (!price.direction) { + errs.direction = msg('direction') + } + if (price.direction === 'CLIENT') { + if (!price.clientIri) errs.client = msg('client') + if (!price.clientDeliveryAddressIri) errs.clientDeliveryAddress = msg('clientDeliveryAddress') + if (!price.departureSiteIri) errs.departureSite = msg('departureSite') + } + if (price.direction === 'FOURNISSEUR') { + if (!price.supplierIri) errs.supplier = msg('supplier') + if (!price.supplierSupplyAddressIri) errs.supplierSupplyAddress = msg('supplierSupplyAddress') + if (!price.deliverySiteIri) errs.deliverySite = msg('deliverySite') + } + if (!price.containerType) errs.containerType = msg('containerType') + if (!price.pricingUnit) errs.pricingUnit = msg('pricingUnit') + if (!price.price || price.price.trim() === '') errs.price = msg('price') + if (!price.priceState) errs.priceState = msg('priceState') + + return errs + } + /** Suppression immédiate d'un prix existant (DELETE /carrier_prices/{id}). */ async function removePrice(index: number): Promise { await removeCollectionRow({ @@ -512,6 +544,15 @@ export function useCarrierForm() { if (carrierId.value === null || tabSubmitting.value) { return false } + + // Pré-check front : affiche toutes les obligations sous leur champ d'un coup + // (le back ne peut pas tout renvoyer en une passe — cf. validatePriceRow). + const frontErrors = prices.value.map(validatePriceRow) + if (frontErrors.some(errs => Object.keys(errs).length > 0)) { + priceErrors.value = frontErrors + return false + } + tabSubmitting.value = true try { const hasError = await submitRows(