From 3d4ae391fe5cc9ca72ad32931171fc5e75324c10 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 15 Jun 2026 09:12:50 +0000 Subject: [PATCH] feat(front) : onglet adresse prestataire (ERP-143) (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empilée sur ERP-142 (#104). ## Périmètre ERP-143 Onglet **Adresse** de l'écran `/providers/new` — saisie multi-adresses (blocs ajoutables) via la sous-ressource addresses. - **`ProviderAddressBlock.vue`** (miroir `SupplierAddressBlock` **simplifié**) : Sélecteur de sites (≥1, RG-3.05) / Catégories (PRESTATAIRE, RG-3.09) / Contact(s) rattaché(s) (depuis l'onglet Contact) / Pays (défaut France) / Code postal / Ville / Adresse (autocomplete BAN) / Complément. **Pas** de type d'adresse, **pas** de bennes, **pas** de triage (différence M2). - **RG-3.06** : `useAddressAutocomplete()` **réutilisé tel quel** — CP → liste des villes (BAN) ; cas dégradé (API down) → ville/adresse en saisie libre + toast unique. - **`useProviderForm`** étendu : `addresses`, `canAddAddress` (RG-3.05/3.09), `add/removeAddress`, `submitAddresses` (POST `/providers/{id}/addresses` + PATCH `/provider_addresses/{id}`, groupe `provider:write:addresses`), erreurs 422 **par ligne**. - **`useProviderReferentials`** : ajout des pays (`/countries`) pour le select Pays. - Helpers purs `utils/forms/providerAddress.ts` (`isProviderAddressValid`, `buildProviderAddressPayload` — relations en IRI, requis vides omis au POST). - « + Nouvelle adresse » / Supprimer (modal) / « Valider ». i18n `technique.providers.form.address` + `confirmDelete.address`. ## Conformité - `useApi()` only ; `Malio*` only ; aucun texte FR en dur ; `useAddressAutocomplete` non réécrit ; pas d'import inter-module (helpers ré-implémentés côté Technique, règle ABSOLUE n°1). ## Vérifications - Vitest : 436/436 (18 nouveaux : helpers adresse, bloc — BAN dégradé/allow-create/mapping erreurs, workflow adresses POST/PATCH/422 par ligne). - ESLint : OK. - `nuxi typecheck` : 0 erreur sur les fichiers source du ticket. - Golden path navigateur : page compile, onglet Contact OK. NB : l'onglet Adresse est gaté derrière la validation principal+contact (multiselect `Malio` non pilotable en a11y) — couvert par tests unitaires (montage + BAN + mapping) + typecheck. Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/105 Co-authored-by: tristan Co-committed-by: tristan --- frontend/i18n/locales/fr.json | 17 +- .../components/ProviderAddressBlock.vue | 269 ++++++++++++++++++ .../__tests__/ProviderAddressBlock.spec.ts | 157 ++++++++++ .../__tests__/useProviderForm.test.ts | 104 ++++++- .../technique/composables/useProviderForm.ts | 79 +++++ .../composables/useProviderReferentials.ts | 12 + .../modules/technique/pages/providers/new.vue | 97 ++++++- .../modules/technique/types/providerForm.ts | 43 +++ .../forms/__tests__/providerAddress.spec.ts | 73 +++++ .../technique/utils/forms/providerAddress.ts | 50 ++++ 10 files changed, 896 insertions(+), 5 deletions(-) create mode 100644 frontend/modules/technique/components/ProviderAddressBlock.vue create mode 100644 frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts create mode 100644 frontend/modules/technique/utils/forms/__tests__/providerAddress.spec.ts create mode 100644 frontend/modules/technique/utils/forms/providerAddress.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index c77fadd..29bf093 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -418,11 +418,26 @@ "remove": "Supprimer le contact", "add": "Nouveau contact" }, + "address": { + "sites": "Sites", + "categories": "Catégorie", + "contacts": "Contact(s) rattaché(s)", + "country": "Pays", + "postalCode": "Code postal", + "city": "Ville", + "street": "Adresse", + "streetNotFound": "Adresse introuvable ? Saisissez-la directement.", + "streetComplement": "Adresse complémentaire", + "remove": "Supprimer l'adresse", + "add": "Nouvelle adresse", + "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." + }, "confirmDelete": { "title": "Confirmer la suppression", "cancel": "Annuler", "confirm": "Supprimer", - "contact": "Supprimer ce contact ?" + "contact": "Supprimer ce contact ?", + "address": "Supprimer cette adresse ?" } }, "toast": { diff --git a/frontend/modules/technique/components/ProviderAddressBlock.vue b/frontend/modules/technique/components/ProviderAddressBlock.vue new file mode 100644 index 0000000..3d37def --- /dev/null +++ b/frontend/modules/technique/components/ProviderAddressBlock.vue @@ -0,0 +1,269 @@ + + + diff --git a/frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts b/frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts new file mode 100644 index 0000000..6159a15 --- /dev/null +++ b/frontend/modules/technique/components/__tests__/ProviderAddressBlock.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, ref, computed } from 'vue' +import { emptyProviderAddress } from '~/modules/technique/types/providerForm' +import ProviderAddressBlock from '../ProviderAddressBlock.vue' + +// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2. +const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({ + searchCityMock: vi.fn(), + searchAddressMock: vi.fn(), +})) +vi.mock('~/shared/composables/useAddressAutocomplete', () => ({ + useAddressAutocomplete: () => ({ + searchCity: searchCityMock, + searchAddress: searchAddressMock, + }), +})) + +// Auto-imports Nuxt/Vue utilises sans import explicite par le composant. +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('ref', ref) +vi.stubGlobal('computed', computed) + +// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate. +const MalioInputAutocompleteStub = defineComponent({ + name: 'MalioInputAutocomplete', + props: { + modelValue: { type: [String, Number, null], default: undefined }, + options: { type: Array as () => { value: string | number, label: string }[], default: () => [] }, + loading: { type: Boolean, default: false }, + minSearchLength: { type: Number, default: 0 }, + label: { type: String, default: '' }, + readonly: { type: Boolean, default: false }, + allowCreate: { type: Boolean, default: false }, + }, + emits: ['update:modelValue', 'search', 'select'], + setup(props) { + return () => h('div', { + 'data-testid': 'addr-autocomplete', + 'data-options': JSON.stringify(props.options.map(o => o.value)), + }) + }, +}) + +function mountBlock(overrides: Record = {}, errors?: Record) { + return mount(ProviderAddressBlock, { + props: { + modelValue: { ...emptyProviderAddress(), ...overrides }, + categoryOptions: [], + siteOptions: [], + contactOptions: [], + countryOptions: [], + ...(errors ? { errors } : {}), + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioSelect: true, + MalioSelectCheckbox: true, + MalioInputText: true, + MalioInputAutocomplete: MalioInputAutocompleteStub, + }, + }, + }) +} + +describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => { + it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => { + const wrapper = mountBlock() + // Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3. + expect(wrapper.find('malio-input-number-stub').exists()).toBe(false) + expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false) + // Aucun select ne porte le label « type d'adresse ». + const hasAddressType = wrapper.findAll('malio-select-stub').some( + el => el.attributes('label') === 'technique.providers.form.address.addressType', + ) + expect(hasAddressType).toBe(false) + }) +}) + +describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => { + it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => { + const wrapper = mountBlock({}, { + sites: 'Au moins un site est obligatoire.', + categories: 'Au moins une catégorie est obligatoire.', + }) + const checkboxes = wrapper.findAll('malio-select-checkbox-stub') + const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites') + const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories') + + expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.') + expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.') + }) + + it('affiche l\'erreur serveur sur le code postal', () => { + const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' }) + const field = wrapper.findAll('malio-input-text-stub').find( + el => el.attributes('label') === 'technique.providers.form.address.postalCode', + ) + expect(field?.attributes('error')).toBe('Code postal invalide.') + }) +}) + +describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => { + beforeEach(() => { + searchCityMock.mockReset() + searchAddressMock.mockReset() + }) + + it('n\'appelle pas la BAN en deca de 3 caracteres', async () => { + const wrapper = mountBlock() + wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab') + await flushPromises() + expect(searchAddressMock).not.toHaveBeenCalled() + }) + + it('relance la recherche apres une erreur (pas de bascule definitive)', async () => { + searchAddressMock + .mockRejectedValueOnce(new Error('BAN indisponible')) + .mockResolvedValueOnce([ + { label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' }, + ]) + const wrapper = mountBlock() + const auto = wrapper.findComponent(MalioInputAutocompleteStub) + + auto.vm.$emit('search', 'rue du test') + await flushPromises() + auto.vm.$emit('search', 'rue du teste') + await flushPromises() + + expect(searchAddressMock).toHaveBeenCalledTimes(2) + }) + + it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => { + searchAddressMock.mockRejectedValue(new Error('BAN indisponible')) + const wrapper = mountBlock() + const auto = wrapper.findComponent(MalioInputAutocompleteStub) + + auto.vm.$emit('search', 'rue du test') + await flushPromises() + auto.vm.$emit('search', 'rue du teste') + await flushPromises() + + expect(wrapper.emitted('degraded')).toHaveLength(1) + }) + + it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => { + const wrapper = mountBlock() + expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true) + }) + + it('inclut la rue courante dans les options meme sans recherche BAN', () => { + const wrapper = mountBlock({ street: '1 rue du Test' }) + const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]') + expect(values).toContain('1 rue du Test') + }) +}) diff --git a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts index 86204fa..a0b6ac1 100644 --- a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts +++ b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts @@ -41,7 +41,7 @@ vi.stubGlobal('usePermissions', () => ({ })) const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm') -const { emptyProviderContact } = await import('~/modules/technique/types/providerForm') +const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm') type ProviderForm = ReturnType const SITE_86 = '/api/sites/1' @@ -52,6 +52,11 @@ function contactAt(form: ProviderForm, index = 0) { return form.contacts.value[index] ?? emptyProviderContact() } +/** Accede a un bloc adresse (idem). */ +function addressAt(form: ProviderForm, index = 0) { + return form.addresses.value[index] ?? emptyProviderAddress() +} + describe('useProviderForm', () => { beforeEach(() => { mockPost.mockReset() @@ -304,3 +309,100 @@ describe('useProviderForm — onglet Contact (ERP-142)', () => { expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.') }) }) + +describe('useProviderForm — onglet Adresse (ERP-143)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = false + }) + + /** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */ + function createdForm() { + const form = useProviderForm() + form.providerId.value = 7 + return form + } + + /** Remplit un bloc adresse valide (site + categorie + scalaires requis). */ + function fillValidAddress(form: ProviderForm, index = 0): void { + const a = addressAt(form, index) + a.siteIris = [SITE_86] + a.categoryIris = [CAT_MAINT] + a.postalCode = '86100' + a.city = 'Châtellerault' + a.street = '1 rue du Test' + } + + it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => { + const form = createdForm() + expect(form.canAddAddress.value).toBe(false) + + // no-op tant que l'adresse n'est pas valide. + form.addAddress() + expect(form.addresses.value).toHaveLength(1) + + addressAt(form).siteIris = [SITE_86] + expect(form.canAddAddress.value).toBe(false) // categorie manquante + addressAt(form).categoryIris = [CAT_MAINT] + expect(form.canAddAddress.value).toBe(true) + form.addAddress() + expect(form.addresses.value).toHaveLength(2) + }) + + it('removeAddress retire le bloc et son erreur de ligne', () => { + const form = createdForm() + fillValidAddress(form) + form.addAddress() + form.addressErrors.value = [{}, { city: 'x' }] + + form.removeAddress(1) + expect(form.addresses.value).toHaveLength(1) + expect(form.addressErrors.value).toHaveLength(1) + }) + + it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => { + mockPost.mockResolvedValueOnce({ id: 88 }) + const form = createdForm() + fillValidAddress(form) + + const ok = await form.submitAddresses(vi.fn()) + + expect(ok).toBe(true) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/providers/7/addresses') + expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + expect(addressAt(form).id).toBe(88) + expect(form.isValidated('address')).toBe(true) + }) + + it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => { + mockPatch.mockResolvedValueOnce({}) + const form = createdForm() + fillValidAddress(form) + addressAt(form).id = 88 + + await form.submitAddresses(vi.fn()) + + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false }) + }) + + it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] }, + }, + }) + const form = createdForm() + fillValidAddress(form) + + const ok = await form.submitAddresses(vi.fn()) + + expect(ok).toBe(false) + expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.') + expect(form.isValidated('address')).toBe(false) + }) +}) diff --git a/frontend/modules/technique/composables/useProviderForm.ts b/frontend/modules/technique/composables/useProviderForm.ts index 96df01a..469a1e7 100644 --- a/frontend/modules/technique/composables/useProviderForm.ts +++ b/frontend/modules/technique/composables/useProviderForm.ts @@ -2,8 +2,11 @@ import { computed, reactive, ref, type Ref } from 'vue' import { useFormErrors } from '~/shared/composables/useFormErrors' import { mapViolationsToRecord } from '~/shared/utils/api' import { + emptyProviderAddress, emptyProviderContact, emptyProviderMain, + type ProviderAddressFormDraft, + type ProviderAddressResponse, type ProviderContactFormDraft, type ProviderContactResponse, type ProviderMainDraft, @@ -13,6 +16,10 @@ import { buildProviderContactPayload, isProviderContactBlank, } from '~/modules/technique/utils/forms/providerContact' +import { + buildProviderAddressPayload, + isProviderAddressValid, +} from '~/modules/technique/utils/forms/providerAddress' /** * Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) — @@ -298,6 +305,71 @@ export function useProviderForm() { } } + // ── Onglet Adresse (ERP-143) ────────────────────────────────────────────── + const addresses = ref([emptyProviderAddress()]) + // Erreurs 422 par ligne (alignees sur l'index du v-for). + const addressErrors = ref[]>([]) + + // « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas + // au moins un site ET une categorie (RG-3.05 / RG-3.09). + const canAddAddress = computed(() => { + const last = addresses.value[addresses.value.length - 1] + return last !== undefined && isProviderAddressValid(last) + }) + + function addAddress(): void { + if (canAddAddress.value) { + addresses.value.push(emptyProviderAddress()) + } + } + + function removeAddress(index: number): void { + addresses.value.splice(index, 1) + addressErrors.value.splice(index, 1) + } + + /** + * Valide l'onglet Adresse : POST des nouvelles adresses sur + * /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id} + * (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par + * ligne. Retourne true si l'onglet a ete valide (avance/termine). + */ + async function submitAddresses(onError: (error: unknown) => void): Promise { + if (providerId.value === null || tabSubmitting.value) { + return false + } + tabSubmitting.value = true + try { + const hasError = await submitRows( + addresses.value, + addressErrors, + async (address) => { + const body = buildProviderAddressPayload(address) + if (address.id === null) { + const created = await api.post( + `/providers/${providerId.value}/addresses`, + body, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + address.id = created.id + } + else { + await api.patch(`/provider_addresses/${address.id}`, body, { toast: false }) + } + }, + onError, + ) + if (hasError) { + return false + } + completeTab('address') + return true + } + finally { + tabSubmitting.value = false + } + } + return { // etat main, @@ -320,6 +392,13 @@ export function useProviderForm() { addContact, removeContact, submitContacts, + // adresses + addresses, + addressErrors, + canAddAddress, + addAddress, + removeAddress, + submitAddresses, // actions validateMainFront, buildMainPayload, diff --git a/frontend/modules/technique/composables/useProviderReferentials.ts b/frontend/modules/technique/composables/useProviderReferentials.ts index e299805..95c5308 100644 --- a/frontend/modules/technique/composables/useProviderReferentials.ts +++ b/frontend/modules/technique/composables/useProviderReferentials.ts @@ -42,6 +42,11 @@ interface SiteMember extends HydraMember { postalCode: string } +interface CountryMember extends HydraMember { + code: string + name: string +} + const LD_JSON_HEADERS = { Accept: 'application/ld+json' } export function useProviderReferentials() { @@ -49,6 +54,7 @@ export function useProviderReferentials() { const categories = ref([]) const sites = ref([]) + const countries = ref([]) /** Recupere une collection complete (pagination desactivee) en Hydra. */ async function fetchAll( @@ -74,12 +80,18 @@ export function useProviderReferentials() { // du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ». fetchAll('/sites') .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }), + // Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke + // `country` en chaine libre, « France »...). value === label. Aligne sur + // les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse. + fetchAll('/countries') + .then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }), ]) } return { categories, sites, + countries, loadMain, } } diff --git a/frontend/modules/technique/pages/providers/new.vue b/frontend/modules/technique/pages/providers/new.vue index 6a4de9d..28dd406 100644 --- a/frontend/modules/technique/pages/providers/new.vue +++ b/frontend/modules/technique/pages/providers/new.vue @@ -91,7 +91,42 @@ - + + @@ -120,8 +155,8 @@