From 833d992ebb7708dcb9c00f3be6a752277601c6b0 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 19 Jun 2026 14:53:52 +0200 Subject: [PATCH] fix(transport) : onglet Contact transporteur non obligatoire + navigation onglets (ERP-193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retrait de la regle « prenom OU nom » sur le bloc Contact : garde CarrierContactProcessor::validateName supprimee, CHECK chk_carrier_contact_name droppe (migration Version20260619120000), commentaires SQL/catalogue alignes - front : gating « + Nouveau contact » sur bloc non vide (au lieu de « nomme »), onglet Contact vide finalisable sans creer de contact - Prix accessible des la validation des Adresses (Contacts optionnel ne bloque plus) - consultation <-> edition : on retombe sur le meme onglet via ?tab= --- .../__tests__/useCarrierForm.test.ts | 45 ++++++------------ .../transport/composables/useCarrierForm.ts | 31 +++++++----- .../transport/pages/carriers/[id]/edit.vue | 11 +++-- .../transport/pages/carriers/[id]/index.vue | 11 +++-- .../modules/transport/pages/carriers/new.vue | 4 +- .../transport/utils/forms/carrierContact.ts | 21 +++------ migrations/Version20260619120000.php | 47 +++++++++++++++++++ .../Domain/Entity/CarrierContact.php | 6 +-- .../Processor/CarrierContactProcessor.php | 46 ++++-------------- .../DataFixtures/CarrierFixtures.php | 3 +- .../Database/ColumnCommentsCatalog.php | 6 +-- .../Transport/Api/CarrierContactApiTest.php | 24 +++++----- 12 files changed, 131 insertions(+), 124 deletions(-) create mode 100644 migrations/Version20260619120000.php diff --git a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts index e738402..6eaee47 100644 --- a/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts +++ b/frontend/modules/transport/composables/__tests__/useCarrierForm.test.ts @@ -37,7 +37,7 @@ vi.stubGlobal('useToast', () => ({ })) const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm') -const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact') +const { buildCarrierContactPayload, isCarrierContactBlank } = await import('../../utils/forms/carrierContact') const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice') const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm') @@ -545,6 +545,9 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) expect(form.address.value.id).toBe(88) expect(form.isValidated('addresses')).toBe(true) + // ERP-193 : Contact optionnel → valider Adresses déverrouille jusqu'à Prix + // (dernier onglet), sans étape bloquante par Contacts. + expect(form.unlockedIndex.value).toBe(CARRIER_TAB_KEYS.length - 1) }) it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => { @@ -577,7 +580,7 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique) }) }) -describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => { +describe('carrierContact (util) — bloc optionnel (ERP-193) + max 2 téléphones', () => { it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => { expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true) expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false) @@ -586,15 +589,6 @@ describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléph expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true) }) - it('isCarrierContactNamed : nommé seulement avec un prénom OU un nom', () => { - expect(isCarrierContactNamed(emptyCarrierContact())).toBe(false) - expect(isCarrierContactNamed({ ...emptyCarrierContact(), firstName: 'Jean' })).toBe(true) - expect(isCarrierContactNamed({ ...emptyCarrierContact(), lastName: 'Doe' })).toBe(true) - // Fonction / téléphone / email seuls ne « nomment » pas (≠ RG-4.08 large). - expect(isCarrierContactNamed({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false) - expect(isCarrierContactNamed({ ...emptyCarrierContact(), email: 'a@b.fr' })).toBe(false) - }) - it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => { const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' }) expect(body.phones).toEqual(['0102030405']) @@ -635,23 +629,18 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => { return form } - it('« + Nouveau contact » désactivé tant que le bloc n\'est pas nommé (prénom OU nom, aligné M1/M2/M3)', () => { + it('ERP-193 : « + Nouveau contact » désactivé tant que le bloc est VIDE (plus de règle prénom/nom)', () => { const form = createdForm() expect(form.canAddContact.value).toBe(false) - // addContact est un no-op tant que le bloc n'est pas nommé. + // addContact est un no-op tant que le bloc est totalement vide. form.addContact() expect(form.contacts.value).toHaveLength(1) - // Fonction seule ne suffit PAS à ajouter un nouveau bloc (≠ RG-4.08 large). + // ERP-193 : un seul champ rempli (ici la fonction, sans prénom ni nom) suffit + // désormais à débloquer l'ajout — la règle « prénom OU nom » est retirée. const first = form.contacts.value[0] if (first) first.jobTitle = 'Acheteur' - expect(form.canAddContact.value).toBe(false) - form.addContact() - expect(form.contacts.value).toHaveLength(1) - - // Un nom (ou prénom) débloque l'ajout. - if (first) first.lastName = 'Doe' expect(form.canAddContact.value).toBe(true) form.addContact() expect(form.contacts.value).toHaveLength(2) @@ -686,21 +675,15 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => { expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false }) }) - it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => { - mockPost.mockRejectedValueOnce({ - response: { - status: 422, - _data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] }, - }, - }) + it('ERP-193 : onglet Contact vide → aucun POST, onglet finalisé (bloc optionnel)', async () => { const form = createdForm() + // Bloc vide → rien n'est soumis, l'onglet se finalise et déverrouille Prix. const ok = await form.submitContacts(vi.fn()) - expect(ok).toBe(false) - expect(mockPost).toHaveBeenCalledTimes(1) - expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.') - expect(form.isValidated('contacts')).toBe(false) + expect(ok).toBe(true) + expect(mockPost).not.toHaveBeenCalled() + expect(form.isValidated('contacts')).toBe(true) }) it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => { diff --git a/frontend/modules/transport/composables/useCarrierForm.ts b/frontend/modules/transport/composables/useCarrierForm.ts index 994855c..b696dc9 100644 --- a/frontend/modules/transport/composables/useCarrierForm.ts +++ b/frontend/modules/transport/composables/useCarrierForm.ts @@ -17,7 +17,7 @@ import { type CarrierPriceFormDraft, } from '~/modules/transport/types/carrierForm' import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress' -import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact' +import { buildCarrierContactPayload, isCarrierContactBlank } from '~/modules/transport/utils/forms/carrierContact' import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice' import { mapAddressToDraft, @@ -488,6 +488,10 @@ export function useCarrierForm() { await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false }) } completeTab('addresses') + // ERP-193 : l'onglet Contact est OPTIONNEL — il ne doit pas verrouiller + // l'accès à Prix. Dès les Adresses validées, on déverrouille jusqu'à Prix + // (Contacts reste accessible mais n'est plus une étape bloquante). + unlockedIndex.value = tabKeys.value.length - 1 return true } catch (error) { @@ -511,12 +515,13 @@ export function useCarrierForm() { // Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows. const contactErrors = ref[]>([]) - // « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé » - // (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne - // suffisent pas à ajouter un nouveau bloc). + // « + Nouveau contact » désactivé tant que le DERNIER bloc est VIDE. ERP-193 : + // l'onglet Contact n'est plus obligatoire — on ne réclame plus prénom OU nom, + // un seul champ rempli (fonction / téléphone / email) suffit pour empiler un + // bloc suivant (et évite d'accumuler des blocs totalement vides). const canAddContact = computed(() => { const last = contacts.value[contacts.value.length - 1] - return last !== undefined && isCarrierContactNamed(last) + return last !== undefined && !isCarrierContactBlank(last) }) function addContact(): void { @@ -541,10 +546,11 @@ export function useCarrierForm() { /** * Valide l'onglet Contacts : POST des nouveaux contacts sur * /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id} - * (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones) - * re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces - * vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de - * finaliser un onglet vide. Retourne true si l'onglet a été validé. + * (groupe carrier:write:contacts). Max 2 téléphones re-validé back → 422 par + * ligne. ERP-193 : l'onglet Contact est OPTIONNEL — les amorces vides neuves + * sont systématiquement ignorées (pas de contact vide créé) et un onglet sans + * aucun bloc rempli est simplement finalisé, déverrouillant l'onglet Prix. + * Retourne true si l'onglet a été validé. */ async function submitContacts(onError: (error: unknown) => void): Promise { if (carrierId.value === null || tabSubmitting.value) { @@ -552,7 +558,6 @@ export function useCarrierForm() { } tabSubmitting.value = true try { - const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c)) const hasError = await submitRows( contacts.value, contactErrors, @@ -571,9 +576,9 @@ export function useCarrierForm() { } }, onError, - // Amorce vide neuve ignorée s'il reste un autre bloc soumettable ; - // sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName). - contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact), + // Amorce vide neuve toujours ignorée (bloc Contact optionnel, ERP-193) : + // un onglet sans aucun bloc rempli se finalise sans rien créer. + contact => contact.id === null && isCarrierContactBlank(contact), ) if (hasError) { return false diff --git a/frontend/modules/transport/pages/carriers/[id]/edit.vue b/frontend/modules/transport/pages/carriers/[id]/edit.vue index 4f8405f..924da56 100644 --- a/frontend/modules/transport/pages/carriers/[id]/edit.vue +++ b/frontend/modules/transport/pages/carriers/[id]/edit.vue @@ -291,9 +291,13 @@ const TAB_ICONS: Record = { contacts: 'mdi:account-box-plus-outline', prices: 'mdi:payment', } -const activeTab = ref('addresses') // Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification. -const tabs = computed(() => ['qualimat', 'addresses', 'contacts', 'prices'].map(key => ({ +const TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices'] +// ERP-193 : on honore l'onglet demande via `?tab=` (navigation depuis la +// consultation) pour retomber sur le meme onglet ; defaut « addresses ». +const requestedTab = typeof route.query.tab === 'string' ? route.query.tab : '' +const activeTab = ref(TAB_KEYS.includes(requestedTab) ? requestedTab : 'addresses') +const tabs = computed(() => TAB_KEYS.map(key => ({ key, label: t(`transport.carriers.tab.${key}`), icon: TAB_ICONS[key], @@ -371,7 +375,8 @@ function onIndexationInput(value: string): void { } function goBack(): void { - router.push(`/carriers/${carrierId}`) + // ERP-193 : on transmet l'onglet courant pour retomber dessus en consultation. + router.push({ path: `/carriers/${carrierId}`, query: { tab: activeTab.value } }) } /** PATCH du formulaire principal (pas de re-POST). */ diff --git a/frontend/modules/transport/pages/carriers/[id]/index.vue b/frontend/modules/transport/pages/carriers/[id]/index.vue index 7dcb44d..1324a63 100644 --- a/frontend/modules/transport/pages/carriers/[id]/index.vue +++ b/frontend/modules/transport/pages/carriers/[id]/index.vue @@ -312,15 +312,19 @@ const tabs = computed(() => visibleTabKeys.value.map(key => ({ }))) // Onglet initial : vide tant que le transporteur n'est pas charge, puis premier -// onglet visible. Un watcher recale si l'onglet courant disparait. +// onglet visible. Un watcher recale si l'onglet courant disparait. ERP-193 : on +// honore l'onglet demande via `?tab=` (navigation depuis l'edition) s'il est +// visible, pour retomber sur le meme onglet en passant edition <-> consultation. const activeTab = ref('') +let requestedTab = typeof route.query.tab === 'string' ? route.query.tab : '' watch(visibleTabKeys, (keys) => { if (keys.length === 0) { activeTab.value = '' return } if (!keys.includes(activeTab.value)) { - activeTab.value = keys[0] + activeTab.value = requestedTab && keys.includes(requestedTab) ? requestedTab : keys[0] + requestedTab = '' } }, { immediate: true }) @@ -498,7 +502,8 @@ function goBack(): void { } function goEdit(): void { - router.push(`/carriers/${carrierId}/edit`) + // ERP-193 : on transmet l'onglet courant pour retomber dessus en edition. + router.push({ path: `/carriers/${carrierId}/edit`, query: activeTab.value ? { tab: activeTab.value } : {} }) } const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' }) diff --git a/frontend/modules/transport/pages/carriers/new.vue b/frontend/modules/transport/pages/carriers/new.vue index e30539b..3955e40 100644 --- a/frontend/modules/transport/pages/carriers/new.vue +++ b/frontend/modules/transport/pages/carriers/new.vue @@ -198,8 +198,8 @@ - +