From 8376236a3c007559e23db34e101439649b6b4df5 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 3 Jun 2026 13:29:45 +0200 Subject: [PATCH 1/5] feat(front) : util httpExternal + autocomplete adresse BAN (ERP-66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - httpExternal : client dedie aux API publiques externes (URL absolue, sans cookie de session, timeout), seul point d'entree autorise pour un $fetch externe (regle frontend n°4). - useAddressAutocomplete : implementation BAN (api-adresse.data.gouv.fr), recherche ville (type=municipality) et adresse, mapping GeoJSON, throw en cas d'erreur/timeout (mode degrade cote composant). La recherche d'adresse n'impose pas type=housenumber (sinon 0 resultat tant qu'aucun numero n'est saisi) — spec-front mise a jour en consequence. - Tests Vitest : httpExternal, useAddressAutocomplete, et cas limites supplementaires pour formatPhoneFR. --- docs/specs/M1-clients/spec-front.md | 3 +- .../__tests__/useAddressAutocomplete.test.ts | 132 ++++++++++++++++++ .../composables/useAddressAutocomplete.ts | 109 +++++++++++---- .../utils/__tests__/httpExternal.test.ts | 56 ++++++++ frontend/shared/utils/__tests__/phone.test.ts | 23 +++ frontend/shared/utils/httpExternal.ts | 40 ++++++ 6 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 frontend/shared/composables/__tests__/useAddressAutocomplete.test.ts create mode 100644 frontend/shared/utils/__tests__/httpExternal.test.ts create mode 100644 frontend/shared/utils/httpExternal.ts diff --git a/docs/specs/M1-clients/spec-front.md b/docs/specs/M1-clients/spec-front.md index 142f245..6232334 100644 --- a/docs/specs/M1-clients/spec-front.md +++ b/docs/specs/M1-clients/spec-front.md @@ -261,7 +261,8 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse. - Composable dédié `useAddressAutocomplete()` (à créer en M1). - Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back. -- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse. +- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}` (sans filtre `type`) → suggestions adresse. + - ⚠ **Ne pas forcer `type=housenumber`** sur la recherche d'adresse (corrigé en ERP-66) : la BAN ne renvoie un résultat de ce type qu'une fois un numéro saisi, donc une recherche par nom de rue (« boulevard du port ») renverrait **0 résultat** pendant toute la frappe. Sans filtre `type`, la BAN classe rues + numéros par pertinence — comportement d'autocomplétion attendu. - Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre. ## Points laissés ouverts par la V0 (résolus côté back) diff --git a/frontend/shared/composables/__tests__/useAddressAutocomplete.test.ts b/frontend/shared/composables/__tests__/useAddressAutocomplete.test.ts new file mode 100644 index 0000000..d3d7b5d --- /dev/null +++ b/frontend/shared/composables/__tests__/useAddressAutocomplete.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + useAddressAutocomplete, + AddressAutocompleteUnavailableError, +} from '../useAddressAutocomplete' + +// On mocke le helper d'appel externe : aucun vrai appel reseau a la BAN. +// vi.mock est hoiste par Vitest au-dessus des imports. +const mockHttp = vi.hoisted(() => vi.fn()) +vi.mock('~/shared/utils/httpExternal', () => ({ httpExternal: mockHttp })) + +const BAN_URL = 'https://api-adresse.data.gouv.fr/search/' + +describe('useAddressAutocomplete', () => { + beforeEach(() => { + mockHttp.mockReset() + }) + + describe('searchCity', () => { + it('interroge la BAN en type=municipality et mappe { city, postalCode }', async () => { + mockHttp.mockResolvedValueOnce({ + type: 'FeatureCollection', + features: [ + { properties: { city: 'Amiens', postcode: '80000', name: 'Amiens', type: 'municipality' } }, + { properties: { city: 'Amiens', postcode: '80080', name: 'Amiens', type: 'municipality' } }, + ], + }) + + const { searchCity } = useAddressAutocomplete() + const res = await searchCity('80000') + + expect(mockHttp).toHaveBeenCalledWith( + BAN_URL, + expect.objectContaining({ query: { q: '80000', type: 'municipality' } }), + ) + expect(res).toEqual([ + { city: 'Amiens', postalCode: '80000' }, + { city: 'Amiens', postalCode: '80080' }, + ]) + }) + + it('throw une AddressAutocompleteUnavailableError sur erreur reseau / 5xx', async () => { + mockHttp.mockRejectedValueOnce(new Error('500 Server Error')) + + const { searchCity } = useAddressAutocomplete() + + await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError) + }) + + it('throw une AddressAutocompleteUnavailableError sur timeout', async () => { + mockHttp.mockRejectedValueOnce(new Error('The operation was aborted due to timeout')) + + const { searchCity } = useAddressAutocomplete() + + await expect(searchCity('80000')).rejects.toBeInstanceOf(AddressAutocompleteUnavailableError) + }) + }) + + describe('searchAddress', () => { + it('interroge la BAN avec postcode et mappe la suggestion', async () => { + mockHttp.mockResolvedValueOnce({ + type: 'FeatureCollection', + features: [ + { + properties: { + label: '8 Boulevard du Port 80000 Amiens', + name: '8 Boulevard du Port', + street: 'Boulevard du Port', + postcode: '80000', + city: 'Amiens', + type: 'housenumber', + }, + }, + ], + }) + + const { searchAddress } = useAddressAutocomplete() + const res = await searchAddress('8 boulevard du port', '80000') + + expect(mockHttp).toHaveBeenCalledWith( + BAN_URL, + expect.objectContaining({ + query: { q: '8 boulevard du port', postcode: '80000' }, + }), + ) + expect(res).toEqual([ + { + label: '8 Boulevard du Port 80000 Amiens', + street: '8 Boulevard du Port', + postalCode: '80000', + city: 'Amiens', + }, + ]) + }) + + it('omet le parametre postcode quand aucun code postal n\'est fourni', async () => { + mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] }) + + const { searchAddress } = useAddressAutocomplete() + await searchAddress('8 boulevard du port') + + expect(mockHttp).toHaveBeenCalledWith( + BAN_URL, + expect.objectContaining({ + query: { q: '8 boulevard du port' }, + }), + ) + }) + + it('ne restreint PAS la recherche a type=housenumber (sinon la BAN ne renvoie rien tant qu\'aucun numero n\'est saisi)', async () => { + // Regression : avec `type=housenumber`, une saisie de nom de rue sans + // numero (ex: « boulevard du port ») renvoie 0 resultat cote BAN. + mockHttp.mockResolvedValueOnce({ type: 'FeatureCollection', features: [] }) + + const { searchAddress } = useAddressAutocomplete() + await searchAddress('boulevard du port', '80000') + + const sentQuery = mockHttp.mock.calls[0]?.[1]?.query as Record + expect(sentQuery.type).toBeUndefined() + }) + + it('throw une AddressAutocompleteUnavailableError sur erreur reseau', async () => { + mockHttp.mockRejectedValueOnce(new Error('network down')) + + const { searchAddress } = useAddressAutocomplete() + + await expect(searchAddress('8 boulevard du port', '80000')).rejects.toBeInstanceOf( + AddressAutocompleteUnavailableError, + ) + }) + }) +}) diff --git a/frontend/shared/composables/useAddressAutocomplete.ts b/frontend/shared/composables/useAddressAutocomplete.ts index 538447e..7854eb1 100644 --- a/frontend/shared/composables/useAddressAutocomplete.ts +++ b/frontend/shared/composables/useAddressAutocomplete.ts @@ -1,27 +1,29 @@ -// STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66. +import { httpExternal } from '~/shared/utils/httpExternal' + +// Autocompletion d'adresse branchee sur la Base Adresse Nationale (BAN), +// `api-adresse.data.gouv.fr` — service public francais, gratuit, CORS ouvert. // -// Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre -// qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels -// api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS -// changer leur signature ni l'usage côté composant. +// Appel HTTP DIRECT depuis le front (pas de proxy back), conformement a la spec +// M1 (§ API adresse postale). On passe par `httpExternal` et NON `useApi()` : +// la BAN est un domaine externe, sans cookie de session ni enveloppe Hydra. // -// Contrat figé par ERP-66 (c'est lui qui fait foi) : +// Contrat (fige) : // searchCity(postalCode) -> liste { city, postalCode } // searchAddress(query, cp?) -> liste { label, street, postalCode, city } -// En cas d'erreur/timeout, la méthode THROW. Le composant catch l'erreur, -// affiche un toast d'avertissement et bascule en saisie libre (MalioInputText). -// -// Comportement du stub : les deux méthodes throw systématiquement → l'onglet -// Adresse part directement en mode dégradé (Ville + Adresse en saisie libre, -// Code postal saisi manuellement). Aucun appel réseau n'est émis ici. +// En cas d'erreur/timeout, la methode THROW une AddressAutocompleteUnavailableError. +// Le composant consommateur catch, affiche un toast d'avertissement et bascule +// en saisie libre (MalioInputText). -/** Une suggestion de ville renvoyée à partir d'un code postal. */ +/** URL de l'endpoint de recherche BAN. */ +const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/' + +/** Une suggestion de ville renvoyee a partir d'un code postal. */ export interface CitySuggestion { city: string postalCode: string } -/** Une suggestion d'adresse complète (saisie assistée du champ « Adresse »). */ +/** Une suggestion d'adresse complete (saisie assistee du champ « Adresse »). */ export interface AddressSuggestion { label: string street: string @@ -34,27 +36,82 @@ export interface AddressAutocomplete { searchAddress(query: string, postalCode?: string): Promise } -/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */ +/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */ export class AddressAutocompleteUnavailableError extends Error { constructor() { - // Message technique (non affiché tel quel) : le composant remonte son - // propre libellé i18n. Sert au debug / aux logs uniquement. - super('Address autocomplete (BAN) is not available yet — ERP-66 stub.') + // Message technique (non affiche tel quel) : le composant remonte son + // propre libelle i18n. Sert au debug / aux logs uniquement. + super('Address autocomplete (BAN) is not available.') this.name = 'AddressAutocompleteUnavailableError' } } -/** - * STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes - * échouent toujours, forçant le mode dégradé côté onglet Adresse. - */ +/** Proprietes d'une « feature » GeoJSON renvoyee par la BAN (champs utilises). */ +interface BanFeatureProperties { + label?: string + name?: string + street?: string + postcode?: string + city?: string +} + +/** Reponse GeoJSON FeatureCollection de la BAN. */ +interface BanResponse { + features?: { properties?: BanFeatureProperties }[] +} + export function useAddressAutocomplete(): AddressAutocomplete { return { - async searchCity(_postalCode: string): Promise { - throw new AddressAutocompleteUnavailableError() + async searchCity(postalCode: string): Promise { + let res: BanResponse + try { + res = await httpExternal(BAN_SEARCH_URL, { + query: { q: postalCode, type: 'municipality' }, + }) + } catch { + // Reseau coupe, 5xx, timeout... -> mode degrade cote composant. + throw new AddressAutocompleteUnavailableError() + } + + return (res.features ?? []).map((feature) => { + const props = feature.properties ?? {} + return { + city: props.city ?? props.name ?? '', + postalCode: props.postcode ?? '', + } + }) }, - async searchAddress(_query: string, _postalCode?: string): Promise { - throw new AddressAutocompleteUnavailableError() + + async searchAddress(query: string, postalCode?: string): Promise { + // IMPORTANT : pas de `type=housenumber` ici. La BAN ne renvoie un + // resultat de ce type qu'une fois un numero saisi → une recherche par + // nom de rue (« boulevard du port ») renverrait 0 resultat pendant + // toute la frappe. Sans filtre `type`, la BAN classe rues + numeros + // par pertinence (comportement d'autocompletion attendu). + // On n'ajoute `postcode` que s'il est fourni (sinon recherche large). + const banQuery: Record = { q: query } + if (postalCode) { + banQuery.postcode = postalCode + } + + let res: BanResponse + try { + res = await httpExternal(BAN_SEARCH_URL, { query: banQuery }) + } catch { + throw new AddressAutocompleteUnavailableError() + } + + return (res.features ?? []).map((feature) => { + const props = feature.properties ?? {} + return { + label: props.label ?? '', + // `name` porte la ligne d'adresse complete (numero + voie) ; + // `street` ne contient que la voie. On privilegie `name`. + street: props.name ?? props.street ?? '', + postalCode: props.postcode ?? '', + city: props.city ?? '', + } + }) }, } } diff --git a/frontend/shared/utils/__tests__/httpExternal.test.ts b/frontend/shared/utils/__tests__/httpExternal.test.ts new file mode 100644 index 0000000..7a482e5 --- /dev/null +++ b/frontend/shared/utils/__tests__/httpExternal.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { httpExternal } from '../httpExternal' + +// On mocke ofetch : httpExternal s'appuie sur $fetch sans jamais toucher le +// reseau pendant les tests. vi.mock est hoiste par Vitest au-dessus des imports. +const mockFetch = vi.hoisted(() => vi.fn()) +vi.mock('ofetch', () => ({ $fetch: mockFetch })) + +describe('httpExternal', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + it('retourne le JSON parse renvoye par $fetch', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }) + + const res = await httpExternal<{ ok: boolean }>('https://example.test/api') + + expect(res).toEqual({ ok: true }) + }) + + it('transmet la query, coupe le cookie (credentials omit) et pose un timeout par defaut', async () => { + mockFetch.mockResolvedValueOnce([]) + + await httpExternal('https://example.test/search', { + query: { q: '80000', type: 'municipality' }, + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.test/search', + expect.objectContaining({ + query: { q: '80000', type: 'municipality' }, + credentials: 'omit', + retry: 0, + timeout: 5000, + }), + ) + }) + + it('permet de surcharger le timeout', async () => { + mockFetch.mockResolvedValueOnce(null) + + await httpExternal('https://example.test', { timeoutMs: 1000 }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.test', + expect.objectContaining({ timeout: 1000 }), + ) + }) + + it('propage l\'erreur reseau / timeout (throw)', async () => { + mockFetch.mockRejectedValueOnce(new Error('network down')) + + await expect(httpExternal('https://example.test')).rejects.toThrow('network down') + }) +}) diff --git a/frontend/shared/utils/__tests__/phone.test.ts b/frontend/shared/utils/__tests__/phone.test.ts index b18cb57..328d469 100644 --- a/frontend/shared/utils/__tests__/phone.test.ts +++ b/frontend/shared/utils/__tests__/phone.test.ts @@ -20,4 +20,27 @@ describe('formatPhoneFR', () => { it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => { expect(formatPhoneFR('123')).toBe('12 3') }) + + it('formate une saisie courte (<= 4 chiffres) sans planter', () => { + expect(formatPhoneFR('1')).toBe('1') + expect(formatPhoneFR('12')).toBe('12') + expect(formatPhoneFR('1234')).toBe('12 34') + }) + + it('strip les caracteres non numeriques (lettres, espaces, ponctuation)', () => { + expect(formatPhoneFR('abc')).toBe('') + expect(formatPhoneFR('Tel : 06.12')).toBe('06 12') + expect(formatPhoneFR(' 06 12 ')).toBe('06 12') + }) + + it('conserve l\'indicatif international (+33) sans le transformer', () => { + // Comportement fige : on retire seulement le `+`, on ne deduit pas le + // prefixe pays. Le `+33...` est donc groupe brut par paquets de 2. + expect(formatPhoneFR('+33612345678')).toBe('33 61 23 45 67 8') + }) + + it('groupe sans tronquer une saisie plus longue que 10 chiffres', () => { + // Aucune troncature silencieuse : on figure tous les chiffres groupes par 2. + expect(formatPhoneFR('061234567899')).toBe('06 12 34 56 78 99') + }) }) diff --git a/frontend/shared/utils/httpExternal.ts b/frontend/shared/utils/httpExternal.ts new file mode 100644 index 0000000..a332aa5 --- /dev/null +++ b/frontend/shared/utils/httpExternal.ts @@ -0,0 +1,40 @@ +import { $fetch } from 'ofetch' + +/** + * Options d'un appel HTTP externe. + */ +export interface HttpExternalOptions { + /** Parametres de query string (encodes par ofetch). */ + query?: Record + /** Timeout en millisecondes avant abandon (defaut 5000). */ + timeoutMs?: number +} + +/** + * Petit client HTTP pour les APIs PUBLIQUES EXTERNES (domaine tiers, hors `/api`). + * + * Pourquoi un helper dedie plutot que `useApi()` : `useApi()` est le client de + * l'API interne Starseed (baseURL `/api`, cookie JWT `credentials: 'include'`, + * parsing/erreurs Hydra, redirection `/login` sur 401, toasts i18n). Tout cela + * est inadapte — voire indesirable — pour un endpoint public externe comme la + * Base Adresse Nationale (`api-adresse.data.gouv.fr`). + * + * Ce helper est donc le SEUL point d'entree autorise pour un `$fetch` brut vers + * l'externe (cf. regle frontend n°4 : pas de `$fetch` eparpille dans les + * composants). Il : + * - cible une URL absolue (pas de baseURL `/api`) ; + * - n'envoie PAS le cookie de session (`credentials: 'omit'`) ; + * - ne retente pas (`retry: 0`) et applique un timeout ; + * - laisse remonter l'erreur (throw) — au consommateur de gerer le mode degrade. + */ +export async function httpExternal( + url: string, + opts: HttpExternalOptions = {}, +): Promise { + return $fetch(url, { + query: opts.query, + credentials: 'omit', + retry: 0, + timeout: opts.timeoutMs ?? 5000, + }) +} -- 2.39.5 From f4313d1f3d047364d19c554684c937930eac7495 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 3 Jun 2026 13:53:26 +0200 Subject: [PATCH 2/5] fix(front) : champ adresse vide apres validation + libelle departement des sites - ClientAddressBlock : la rue courante est toujours reinjectee dans les options de MalioInputAutocomplete (computed, miroir de cityOptions). Sinon, des que la liste de suggestions BAN est vide (remontage apres validation, edition d'une adresse existante), le composant ne resolvait plus la valeur liee et affichait un champ vide alors que la donnee etait bien persistee. Test de montage ajoute. - useClientReferentials : le libelle des sites = numero de departement (2 premiers chiffres du code postal, deja expose par /sites) au lieu du nom. --- .../components/ClientAddressBlock.vue | 19 ++++- .../__tests__/ClientAddressBlock.spec.ts | 76 +++++++++++++++++++ .../composables/useClientReferentials.ts | 6 +- 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index 3539dbf..d19ad95 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -201,7 +201,8 @@ const model = computed(() => props.modelValue) const degraded = ref(false) // Villes proposees par la BAN (alimentees a la saisie du code postal). const banCityOptions = ref([]) -const addressOptions = ref([]) +// Adresses proposees par la BAN (alimentees a la saisie d'adresse). +const banAddressOptions = ref([]) // Options ville effectives : on garantit que la ville courante figure toujours // dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options) @@ -214,6 +215,20 @@ const cityOptions = computed(() => { } return banCityOptions.value }) + +// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit +// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout +// l'affichage depuis ses options) laisse le champ VIDE des que la liste de +// suggestions BAN est vide — typiquement juste apres validation (remontage) ou +// a l'edition d'une adresse existante (1.12), alors que la valeur est bien +// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee. +const addressOptions = computed(() => { + const current = props.modelValue.street + if (current && !banAddressOptions.value.some(o => o.value === current)) { + return [{ value: current, label: current }, ...banAddressOptions.value] + } + return banAddressOptions.value +}) const addressLoading = ref(false) // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. let lastAddressSuggestions: AddressSuggestion[] = [] @@ -280,7 +295,7 @@ async function onAddressSearch(query: string): Promise { const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined const suggestions = await autocomplete.searchAddress(query, postalCode) lastAddressSuggestions = suggestions - addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) + banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) } catch { enterDegraded() diff --git a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts new file mode 100644 index 0000000..1ec6587 --- /dev/null +++ b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, ref, computed } from 'vue' +import { emptyAddress } from '~/modules/commercial/types/clientForm' +import ClientAddressBlock from '../ClientAddressBlock.vue' + +// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee. +// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions +// vide » (remontage apres validation / edition d'une adresse existante). +vi.mock('~/shared/composables/useAddressAutocomplete', () => ({ + useAddressAutocomplete: () => ({ + searchCity: vi.fn(), + searchAddress: vi.fn(), + }), +})) + +// 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 recues, pour +// verifier que la rue courante figure bien dans la liste (sinon le composant +// Malio ne peut pas resoudre/afficher la valeur liee -> champ vide). +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 }, + }, + 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(street: string | null) { + return mount(ClientAddressBlock, { + props: { + modelValue: { ...emptyAddress(), street }, + title: 'Adresse', + categoryOptions: [], + siteOptions: [], + contactOptions: [], + countryOptions: [], + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioCheckbox: true, + MalioSelect: true, + MalioSelectCheckbox: true, + MalioInputText: true, + MalioInputAutocomplete: MalioInputAutocompleteStub, + }, + }, + }) +} + +describe('ClientAddressBlock — affichage de l\'adresse persistee', () => { + it('inclut la rue courante dans les options de l\'autocomplete meme sans recherche BAN', () => { + const wrapper = mountBlock('8 Boulevard du Port') + + const el = wrapper.find('[data-testid="addr-autocomplete"]') + const values = JSON.parse(el.attributes('data-options') ?? '[]') + + expect(values).toContain('8 Boulevard du Port') + }) +}) diff --git a/frontend/modules/commercial/composables/useClientReferentials.ts b/frontend/modules/commercial/composables/useClientReferentials.ts index 0f5bb8f..6721ee2 100644 --- a/frontend/modules/commercial/composables/useClientReferentials.ts +++ b/frontend/modules/commercial/composables/useClientReferentials.ts @@ -45,6 +45,7 @@ interface CategoryMember extends HydraMember { interface SiteMember extends HydraMember { name: string + postalCode: string } interface ReferentialMember extends HydraMember { @@ -101,7 +102,10 @@ export function useClientReferentials() { fetchAll('/categories') .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), fetchAll('/sites') - .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }), + // Libelle = numero de departement (2 premiers chiffres du code + // postal du site), ex: 86100 -> « 86 ». Le code postal est deja + // expose par /sites (groupe site:read) — aucune colonne a ajouter. + .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }), fetchAll('/tva_modes') .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), fetchAll('/payment_delays') -- 2.39.5 From 5754d194505dd8f160cb7c8461e06e385ba085e7 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 3 Jun 2026 15:34:31 +0200 Subject: [PATCH 3/5] feat(front) : ameliorations UI onglets client (compta, RIB, blocs, placeholder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Onglet Comptabilite : grille alignee sur les autres onglets (grid-cols-4 gap-x-[44px] gap-y-4) en creation / modification / consultation. - Bloc RIB toujours visible (au moins un bloc, meme vide) en creation, modification et consultation ; un bloc vide n'est jamais persiste. - Blocs Contact / Adresse / RIB toujours affiches meme vides en consultation et modification ; suppression des messages « Aucun ... enregistre ». - Onglets a venir (Transport, Statistiques, Rapports, Echanges) : nouveau composant partage ComingSoonPlaceholder (shared/components/ui) « En cours de dev » + gif, reutilisable par tous les modules ; remplace TabPlaceholderBlank. --- frontend/i18n/locales/fr.json | 10 ++-- .../components/TabPlaceholderBlank.vue | 14 ----- .../commercial/pages/clients/[id]/edit.vue | 29 +++++----- .../commercial/pages/clients/[id]/index.vue | 36 +++++++------ .../modules/commercial/pages/clients/new.vue | 11 ++-- frontend/public/coming-soon.gif | Bin 0 -> 1557856 bytes .../components/ui/ComingSoonPlaceholder.vue | 51 ++++++++++++++++++ 7 files changed, 102 insertions(+), 49 deletions(-) delete mode 100644 frontend/modules/commercial/components/TabPlaceholderBlank.vue create mode 100644 frontend/public/coming-soon.gif create mode 100644 frontend/shared/components/ui/ComingSoonPlaceholder.vue diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 4fa1c99..c09f4e9 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -10,7 +10,11 @@ "confirm": "Confirmer", "yes": "Oui", "no": "Non", - "actions": "Actions" + "actions": "Actions", + "comingSoon": { + "title": "En cours de dev", + "subtitle": "Cette fonctionnalité arrive bientôt." + } }, "sidebar": { "administration": { @@ -95,8 +99,6 @@ "back": "Retour au répertoire", "loading": "Chargement du client…", "notFound": "Client introuvable.", - "emptyContacts": "Aucun contact enregistré.", - "emptyAddresses": "Aucune adresse enregistrée.", "confirmArchive": { "title": "Archiver le client", "message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?" @@ -111,8 +113,6 @@ "back": "Retour au répertoire", "loading": "Chargement du client…", "notFound": "Client introuvable.", - "emptyContacts": "Aucun contact enregistré.", - "emptyAddresses": "Aucune adresse enregistrée.", "save": "Valider" }, "validation": { diff --git a/frontend/modules/commercial/components/TabPlaceholderBlank.vue b/frontend/modules/commercial/components/TabPlaceholderBlank.vue deleted file mode 100644 index 5375cb6..0000000 --- a/frontend/modules/commercial/components/TabPlaceholderBlank.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index e1f64e5..d1614ec 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -179,9 +179,6 @@ @update:model-value="(v) => contacts[index] = v" @remove="askRemoveContact(index)" /> -

- {{ t('commercial.clients.edit.emptyContacts') }} -

-

- {{ t('commercial.clients.edit.emptyAddresses') }} -

-
+
-
+
- - - - + + + + @@ -495,6 +489,11 @@ function hydrate(detail: ClientDetail): void { contacts.value = (detail.contacts ?? []).map(mapContactToDraft) addresses.value = (detail.addresses ?? []).map(mapAddressToDraft) ribs.value = (detail.ribs ?? []).map(mapRibToDraft) + // Chaque bloc reste visible meme vide : si une collection est vide, on amorce + // un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*). + if (contacts.value.length === 0) contacts.value.push(emptyContact()) + if (addresses.value.length === 0) addresses.value.push(emptyAddress()) + if (ribs.value.length === 0) ribs.value.push(emptyRib()) // Charge les listes distributeur / courtier si une relation est deja posee. if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {}) if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {}) @@ -694,6 +693,8 @@ function askRemoveContact(index: number): void { const removed = contacts.value[index] if (removed?.id != null) removedContactIds.value.push(removed.id) contacts.value.splice(index, 1) + // Garde au moins un bloc visible (cf. amorce a l'hydratation). + if (contacts.value.length === 0) contacts.value.push(emptyContact()) }) } @@ -755,6 +756,8 @@ function askRemoveAddress(index: number): void { const removed = addresses.value[index] if (removed?.id != null) removedAddressIds.value.push(removed.id) addresses.value.splice(index, 1) + // Garde au moins un bloc visible (cf. amorce a l'hydratation). + if (addresses.value.length === 0) addresses.value.push(emptyAddress()) }) } @@ -833,6 +836,8 @@ function askRemoveRib(index: number): void { const removed = ribs.value[index] if (removed?.id != null) removedRibIds.value.push(removed.id) ribs.value.splice(index, 1) + // Garde au moins un bloc RIB visible (cf. amorce a l'hydratation). + if (ribs.value.length === 0) ribs.value.push(emptyRib()) }) } diff --git a/frontend/modules/commercial/pages/clients/[id]/index.vue b/frontend/modules/commercial/pages/clients/[id]/index.vue index 52ea3cd..a16e73c 100644 --- a/frontend/modules/commercial/pages/clients/[id]/index.vue +++ b/frontend/modules/commercial/pages/clients/[id]/index.vue @@ -159,9 +159,6 @@ :title="t('commercial.clients.form.contact.title', { n: index + 1 })" readonly /> -

- {{ t('commercial.clients.consultation.emptyContacts') }} -

@@ -179,9 +176,6 @@ :country-options="countryOptions" readonly /> -

- {{ t('commercial.clients.consultation.emptyAddresses') }} -

@@ -189,7 +183,7 @@ @@ -320,6 +314,7 @@ import { type SelectOption, } from '~/modules/commercial/utils/clientConsultation' import { formatPhoneFR } from '~/shared/utils/phone' +import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm' // Masques d'affichage (purement visuels, la donnee reste celle du serveur). const PHONE_MASK = '## ## ## ## ##' @@ -372,10 +367,21 @@ const information = computed(() => ({ directorName: client.value?.directorName ?? null, })) -const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft)) +// Chaque bloc reste visible meme vide en consultation : si la collection est +// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun … »). +const contacts = computed(() => { + const list = (client.value?.contacts ?? []).map(mapContactToDraft) + return list.length ? list : [emptyContact()] +}) // Vue par adresse : brouillon + options (sites/categories) propres a l'adresse. -const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView)) -const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft)) +const addressViews = computed(() => { + const views = (client.value?.addresses ?? []).map(mapAddressView) + return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }] +}) +const ribs = computed(() => { + const list = (client.value?.ribs ?? []).map(mapRibToDraft) + return list.length ? list : [emptyRib()] +}) // Draft comptable (tout null si l'utilisateur n'a pas accounting.view). const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index f651225..8502eec 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -233,7 +233,7 @@ + + -- 2.39.5 From 9c301371fb308f8229815d1f26c1114b40dc5b86 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 3 Jun 2026 15:44:16 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(front)=20:=20consultation=20=E2=80=94?= =?UTF-8?q?=20afficher=20tous=20les=20sites=20(departement)=20dans=20le=20?= =?UTF-8?q?bloc=20adresse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En consultation, le bloc adresse n'affichait que les sites rattaches a l'adresse (donc rien si l'adresse est vide). On affiche desormais TOUS les sites de l'utilisateur (issus de /api/me, groupe me:read — pas de 403 pour les roles metier), libelle = numero de departement ; les sites rattaches restent coches. --- .../commercial/pages/clients/[id]/index.vue | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/index.vue b/frontend/modules/commercial/pages/clients/[id]/index.vue index a16e73c..1951700 100644 --- a/frontend/modules/commercial/pages/clients/[id]/index.vue +++ b/frontend/modules/commercial/pages/clients/[id]/index.vue @@ -171,7 +171,7 @@ :model-value="view.draft" :title="t('commercial.clients.form.address.title', { n: index + 1 })" :category-options="view.categoryOptions" - :site-options="view.siteOptions" + :site-options="allSiteOptions" :contact-options="contactOptions" :country-options="countryOptions" readonly @@ -325,6 +325,7 @@ const route = useRoute() const router = useRouter() const toast = useToast() const { can, canAny } = usePermissions() +const authStore = useAuthStore() // Gating de la route : la consultation exige `view`. Usine (sans view) est // redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7. @@ -391,6 +392,18 @@ const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as Clie const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories)) const contactOptions = computed(() => contactOptionsOf(client.value?.contacts)) +// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read — donc +// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero +// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS +// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non +// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris). +const allSiteOptions = computed(() => + (authStore.user?.sites ?? []).map(s => ({ + value: `/api/sites/${s.id}`, + label: (s.postalCode ?? '').slice(0, 2), + })), +) + const relationOptions = computed(() => [ { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, -- 2.39.5 From 7a45d1772464f0eae0bea2fc121b3a8fe00db4d5 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 3 Jun 2026 15:57:26 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix(back,front)=20:=20adresse=20client=20?= =?UTF-8?q?=E2=80=94=20au=20moins=20une=20categorie=20obligatoire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec-front § Adresse : la categorie est obligatoire sur une adresse, mais n'etait enforced ni au back ni au front. - Back : ClientAddress::$categories porte desormais Assert\Count(min: 1) (POST/PATCH sans categorie -> 422). Test testAddressRequiresAtLeastOneCategory ; deux tests existants qui creaient une adresse sans categorie recoivent une categorie SECTEUR. - Front : canValidateAddresses (creation + modification) exige >= 1 categorie par adresse -> bouton Enregistrer desactive tant qu'aucune categorie n'est choisie (meme gating que les sites). --- .../commercial/pages/clients/[id]/edit.vue | 4 ++- .../modules/commercial/pages/clients/new.vue | 4 ++- .../Domain/Entity/ClientAddress.php | 2 ++ .../Commercial/Api/ClientAddressTest.php | 29 +++++++++++++++++-- .../Api/ClientSubResourceApiTest.php | 8 +++-- 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index d1614ec..06a2f77 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -743,7 +743,9 @@ const canValidateAddresses = computed(() => addresses.value.length > 0 && addresses.value.every((a) => { const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' - return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail) + return a.siteIris.length >= 1 + && a.categoryIris.length >= 1 + && (!isBillingEmailRequired(a) || filledBillingEmail) }), ) diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 8502eec..7bbdbe6 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -755,7 +755,9 @@ const canValidateAddresses = computed(() => addresses.value.length > 0 && addresses.value.every((a) => { const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' - return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail) + return a.siteIris.length >= 1 + && a.categoryIris.length >= 1 + && (!isBillingEmailRequired(a) || filledBillingEmail) }), ) diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index c359088..25d779d 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -177,12 +177,14 @@ class ClientAddress implements TimestampableInterface, BlamableInterface #[Groups(['client_address:read', 'client_address:write'])] private Collection $contacts; + // Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse). // RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes). /** @var Collection */ #[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\JoinTable(name: 'client_address_category')] #[ORM\JoinColumn(name: 'client_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')] + #[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')] #[Groups(['client_address:read', 'client_address:write'])] private Collection $categories; diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php index 0c59207..93eb41c 100644 --- a/tests/Module/Commercial/Api/ClientAddressTest.php +++ b/tests/Module/Commercial/Api/ClientAddressTest.php @@ -167,8 +167,9 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase public function testNonBillingAddressAcceptsEmptyBillingEmail(): void { $this->skipIfSitesModuleDisabled(); - $client = $this->createAdminClient(); - $seed = $this->seedClient('Non Billing Empty Email'); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Non Billing Empty Email'); + $category = $this->createCategory('SECTEUR'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], @@ -179,6 +180,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], ], ]); @@ -286,6 +288,29 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(201); } + /** + * Spec-front § Adresse : au moins une categorie est obligatoire sur une + * adresse. POST sans categorie (mais avec site) -> 422. + */ + public function testAddressRequiresAtLeastOneCategory(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address No Cat'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + ], + ]); + + self::assertResponseStatusCodeSame(422); + } + /** * Retourne l'IRI du premier site seede (fixtures Sites). */ diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php index dd7538c..a05ac33 100644 --- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -110,9 +110,10 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase public function testPostAddressNormalizesBillingEmail(): void { $this->skipIfSitesModuleDisabled(); - $client = $this->createAdminClient(); - $seed = $this->seedClient('Address Host'); - $siteIri = $this->firstSiteIri(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Address Host'); + $siteIri = $this->firstSiteIri(); + $category = $this->createCategory('SECTEUR'); $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], @@ -123,6 +124,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$siteIri], + 'categories' => ['/api/categories/'.$category->getId()], ], ])->toArray(); -- 2.39.5