From 5764d8f47296e5a400424cb3ba460741c36f471c Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 24 Jun 2026 17:55:09 +0200 Subject: [PATCH 1/2] feat(directory) : type prestataire, validateurs front, autocomplete adresse BAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prestataire : entité/repo + ressource API Platform (RBAC directory.providers.*), ownership prestataire sur contacts/adresses/comptes-rendus (CHECK XOR à 3), DTO/service/drawer/fiche détail + onglet dédié dans le répertoire. - Prospect : société uniquement (suppression du champ name, company requis) ; migration de backfill, conversion prospect→client et MCP adaptés. - Champ site web sur client/prospect/prestataire (entités, DTO, onglet Information, MCP). - Validateurs front email / téléphone FR (0549200910) / URL sur Information et Contacts, enregistrement bloqué tant qu'un champ est invalide. - Autocomplete adresse branché sur la Base Adresse Nationale (api-adresse.data.gouv.fr) avec mode dégradé en saisie libre. - Administration : retrait de l'onglet Clients. --- frontend/i18n/locales/fr.json | 34 ++- .../components/CommercialReportDrawer.vue | 2 +- .../components/CommercialReportTab.vue | 2 +- .../components/DirectoryAddressBlock.vue | 129 ++++++++++- .../components/DirectoryContactBlock.vue | 16 ++ .../components/PrestataireDrawer.vue | 88 ++++++++ .../directory/components/ProspectDrawer.vue | 22 +- .../composables/useAddressAutocomplete.ts | 113 ++++++++++ .../composables/useDirectoryDetail.ts | 2 +- .../pages/directory/clients/[id].vue | 22 +- .../directory/pages/directory/index.vue | 122 +++++++++-- .../pages/directory/prestataires/[id].vue | 201 ++++++++++++++++++ .../pages/directory/prospects/[id].vue | 43 ++-- .../modules/directory/services/addresses.ts | 2 +- .../directory/services/commercial-reports.ts | 2 +- .../modules/directory/services/contacts.ts | 2 +- .../modules/directory/services/dto/address.ts | 2 + .../modules/directory/services/dto/client.ts | 2 + .../services/dto/commercial-report.ts | 2 + .../modules/directory/services/dto/contact.ts | 2 + .../directory/services/dto/prestataire.ts | 15 ++ .../directory/services/dto/prospect.ts | 8 +- .../directory/services/prestataires.ts | 36 ++++ .../modules/directory/utils/validation.ts | 40 ++++ frontend/pages/admin.vue | 4 +- frontend/utils/httpExternal.ts | 26 +++ migrations/Version20260624153709.php | 89 ++++++++ src/DataFixtures/AppFixtures.php | 3 - src/Module/Directory/DirectoryModule.php | 2 + .../Directory/Domain/Entity/Address.php | 29 ++- src/Module/Directory/Domain/Entity/Client.php | 16 ++ .../Domain/Entity/CommercialReport.php | 29 ++- .../Directory/Domain/Entity/Contact.php | 29 ++- .../Directory/Domain/Entity/Prestataire.php | 114 ++++++++++ .../Directory/Domain/Entity/Prospect.php | 36 ++-- .../PrestataireRepositoryInterface.php | 20 ++ .../State/ConvertProspectProcessor.php | 3 +- .../DoctrinePrestataireRepository.php | 26 +++ .../Mcp/Tool/ConvertProspectTool.php | 3 +- .../Mcp/Tool/CreateClientTool.php | 2 + .../Mcp/Tool/CreateProspectTool.php | 8 +- .../Mcp/Tool/DeleteProspectTool.php | 4 +- .../Mcp/Tool/ListProspectsTool.php | 2 +- .../Mcp/Tool/UpdateClientTool.php | 4 + .../Mcp/Tool/UpdateProspectTool.php | 8 +- src/Shared/Infrastructure/Mcp/Serializer.php | 11 +- .../Directory/ProspectConversionTest.php | 3 +- .../ConvertProspectProcessorTest.php | 1 - 48 files changed, 1251 insertions(+), 130 deletions(-) create mode 100644 frontend/modules/directory/components/PrestataireDrawer.vue create mode 100644 frontend/modules/directory/composables/useAddressAutocomplete.ts create mode 100644 frontend/modules/directory/pages/directory/prestataires/[id].vue create mode 100644 frontend/modules/directory/services/dto/prestataire.ts create mode 100644 frontend/modules/directory/services/prestataires.ts create mode 100644 frontend/modules/directory/utils/validation.ts create mode 100644 frontend/utils/httpExternal.ts create mode 100644 migrations/Version20260624153709.php create mode 100644 src/Module/Directory/Domain/Entity/Prestataire.php create mode 100644 src/Module/Directory/Domain/Repository/PrestataireRepositoryInterface.php create mode 100644 src/Module/Directory/Infrastructure/Doctrine/DoctrinePrestataireRepository.php diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 1c3fbcc..d35d48f 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -917,6 +917,7 @@ "company": "Société", "email": "Email", "phone": "Téléphone", + "website": "Site web", "street": "Rue", "city": "Ville", "postalCode": "Code postal", @@ -932,7 +933,23 @@ "lost": "Perdu" }, "validation": { - "nameRequired": "Le nom est requis" + "nameRequired": "Le nom est requis", + "companyRequired": "La société est requise" + } + }, + "prestataires": { + "created": "Prestataire créé avec succès.", + "updated": "Prestataire mis à jour avec succès.", + "deleted": "Prestataire supprimé avec succès.", + "addPrestataire": "Ajouter un prestataire", + "editPrestataire": "Modifier un prestataire", + "deleteConfirmTitle": "Supprimer le prestataire", + "deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prestataire « {name} » ? Cette action est irréversible.", + "fields": { + "name": "Nom / Société", + "email": "Email", + "phone": "Téléphone", + "website": "Site web" } }, "directory": { @@ -941,6 +958,7 @@ "info": "Informations", "clients": "Clients", "prospects": "Prospects", + "prestataires": "Prestataires", "contact": "Contact", "address": "Adresse", "report": "Rapport" @@ -949,12 +967,16 @@ "fields": { "name": "Nom", "email": "Email", - "phone": "Téléphone" + "phone": "Téléphone", + "website": "Site web" } }, "validation": { "nameRequired": "Le nom est requis.", - "subjectRequired": "L'objet est requis." + "subjectRequired": "L'objet est requis.", + "emailInvalid": "Adresse email invalide.", + "phoneInvalid": "Numéro de téléphone invalide (ex. 0549200910).", + "urlInvalid": "URL invalide (ex. https://exemple.fr)." }, "clients": { "add": "Ajouter un client", @@ -965,6 +987,10 @@ "empty": "Aucun prospect trouvé.", "allStatuses": "Tous les statuts" }, + "prestataires": { + "add": "Ajouter un prestataire", + "empty": "Aucun prestataire trouvé." + }, "contacts": { "add": "Ajouter un contact", "item": "Contact {n}", @@ -984,6 +1010,8 @@ "item": "Adresse {n}", "saved": "Adresse enregistrée.", "deleted": "Adresse supprimée.", + "streetNotFound": "Aucune adresse trouvée — saisie libre possible.", + "autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.", "fields": { "label": "Libellé", "street": "Rue", diff --git a/frontend/modules/directory/components/CommercialReportDrawer.vue b/frontend/modules/directory/components/CommercialReportDrawer.vue index 57be6f0..2e11bc2 100644 --- a/frontend/modules/directory/components/CommercialReportDrawer.vue +++ b/frontend/modules/directory/components/CommercialReportDrawer.vue @@ -54,7 +54,7 @@ import { useCommercialReportService } from '~/modules/directory/services/commerc const props = defineProps<{ modelValue: boolean report: CommercialReport | null - owner: { client?: string, prospect?: string } + owner: { client?: string, prospect?: string, prestataire?: string } }>() const emit = defineEmits<{ diff --git a/frontend/modules/directory/components/CommercialReportTab.vue b/frontend/modules/directory/components/CommercialReportTab.vue index 54d406b..7a61e51 100644 --- a/frontend/modules/directory/components/CommercialReportTab.vue +++ b/frontend/modules/directory/components/CommercialReportTab.vue @@ -141,7 +141,7 @@ import { useCommercialReportService } from '~/modules/directory/services/commerc import { useReportDocumentService } from '~/modules/directory/services/report-documents' const props = defineProps<{ - owner: { client?: string, prospect?: string } + owner: { client?: string, prospect?: string, prestataire?: string } canManage: boolean }>() diff --git a/frontend/modules/directory/components/DirectoryAddressBlock.vue b/frontend/modules/directory/components/DirectoryAddressBlock.vue index f5d7b3d..019f8a1 100644 --- a/frontend/modules/directory/components/DirectoryAddressBlock.vue +++ b/frontend/modules/directory/components/DirectoryAddressBlock.vue @@ -19,13 +19,33 @@ :readonly="readonly" @update:model-value="update('label', $event)" /> - + + +
+ + +
+ + + + + import type { Address } from '~/modules/directory/services/dto/address' +import { + useAddressAutocomplete, + type AddressSuggestion, +} from '~/modules/directory/composables/useAddressAutocomplete' const props = defineProps<{ modelValue: Address @@ -63,7 +101,82 @@ const emit = defineEmits<{ 'remove': [] }>() +const { t } = useI18n() +const toast = useToast() +const autocomplete = useAddressAutocomplete() + +type Option = { label: string, value: string | number } + +const addressOptions = ref([]) +const cityOptions = ref([]) +const addressLoading = ref(false) +// Mode dégradé : BAN indisponible → la ville passe en saisie libre. +const degraded = ref(false) +let lastAddressSuggestions: AddressSuggestion[] = [] +let notified = false + function update(field: keyof Address, value: string): void { emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value }) } + +// Avertit une seule fois que l'autocomplétion est indisponible (saisie libre). +function notifyUnavailable(): void { + if (notified) return + notified = true + toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') }) +} + +/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */ +async function onAddressSearch(query: string): Promise { + if (query.trim().length < 3) { + addressOptions.value = [] + return + } + addressLoading.value = true + try { + const postalCode = (props.modelValue.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 })) + } + catch { + addressOptions.value = [] + notifyUnavailable() + } + finally { + addressLoading.value = false + } +} + +/** Sélection d'une suggestion → remplit rue + ville + code postal. */ +function onAddressSelect(option: Option | null): void { + if (option === null) return + const suggestion = lastAddressSuggestions.find(s => s.street === option.value) + if (!suggestion) { + update('street', String(option.value)) + return + } + emit('update:modelValue', { + ...props.modelValue, + street: suggestion.street, + city: suggestion.city || props.modelValue.city, + postalCode: suggestion.postalCode || props.modelValue.postalCode, + }) +} + +/** Saisie du code postal → met à jour le champ + interroge la BAN pour la ville. */ +async function onPostalCodeInput(value: string): Promise { + update('postalCode', value) + const digits = (value ?? '').replace(/\D/g, '') + if (digits.length < 5) return + try { + const suggestions = await autocomplete.searchCity(digits) + cityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city })) + degraded.value = false + } + catch { + degraded.value = true + notifyUnavailable() + } +} diff --git a/frontend/modules/directory/components/DirectoryContactBlock.vue b/frontend/modules/directory/components/DirectoryContactBlock.vue index de24180..d34d828 100644 --- a/frontend/modules/directory/components/DirectoryContactBlock.vue +++ b/frontend/modules/directory/components/DirectoryContactBlock.vue @@ -35,18 +35,21 @@ :label="$t('directory.contacts.fields.email')" :model-value="modelValue.email ?? ''" :readonly="readonly" + :error="emailError" @update:model-value="update('email', $event)" /> @@ -54,6 +57,7 @@ diff --git a/frontend/modules/directory/components/ProspectDrawer.vue b/frontend/modules/directory/components/ProspectDrawer.vue index 955623f..faa9d6a 100644 --- a/frontend/modules/directory/components/ProspectDrawer.vue +++ b/frontend/modules/directory/components/ProspectDrawer.vue @@ -5,11 +5,11 @@
@@ -62,30 +62,30 @@ const isConverted = computed(() => !!props.prospect?.convertedClient) const isSubmitting = ref(false) const form = reactive({ - name: '', + company: '', }) const touched = reactive({ - name: false, + company: false, }) watch(() => props.modelValue, (open) => { if (open) { - form.name = props.prospect?.name ?? '' - touched.name = false + form.company = props.prospect?.company ?? '' + touched.company = false } }) const { create, update, convert } = useProspectService() async function handleSubmit() { - touched.name = true - if (!form.name.trim()) return + touched.company = true + if (!form.company.trim()) return isSubmitting.value = true try { const payload: ProspectWrite = { - name: form.name.trim(), + company: form.company.trim(), } if (isEditing.value && props.prospect) { diff --git a/frontend/modules/directory/composables/useAddressAutocomplete.ts b/frontend/modules/directory/composables/useAddressAutocomplete.ts new file mode 100644 index 0000000..e43f4a5 --- /dev/null +++ b/frontend/modules/directory/composables/useAddressAutocomplete.ts @@ -0,0 +1,113 @@ +import { httpExternal } from '~/utils/httpExternal' + +// Autocomplétion d'adresse branchée sur la Base Adresse Nationale (BAN), +// `api-adresse.data.gouv.fr` — service public français, gratuit, CORS ouvert. +// +// Appel HTTP DIRECT depuis le front (pas de proxy back) : la BAN est un domaine +// externe, sans cookie de session ni enveloppe Hydra → on passe par +// `httpExternal` et NON `useApi()`. +// +// Contrat : +// searchCity(postalCode) -> liste { city, postalCode } +// searchAddress(query, cp?) -> liste { label, street, postalCode, city } +// En cas d'erreur/timeout, la méthode THROW une +// AddressAutocompleteUnavailableError. Le composant consommateur catch, +// avertit l'utilisateur et bascule en saisie libre. + +/** URL de l'endpoint de recherche BAN. */ +const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/' + +/** Une suggestion de ville renvoyée à partir d'un code postal. */ +export interface CitySuggestion { + city: string + postalCode: string +} + +/** Une suggestion d'adresse complète (saisie assistée du champ « Rue »). */ +export interface AddressSuggestion { + label: string + street: string + postalCode: string + city: string +} + +export interface AddressAutocomplete { + searchCity(postalCode: string): Promise + searchAddress(query: string, postalCode?: string): Promise +} + +/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */ +export class AddressAutocompleteUnavailableError extends Error { + constructor() { + super('Address autocomplete (BAN) is not available.') + this.name = 'AddressAutocompleteUnavailableError' + } +} + +/** Propriétés d'une « feature » GeoJSON renvoyée par la BAN (champs utilisés). */ +interface BanFeatureProperties { + label?: string + name?: string + street?: string + postcode?: string + city?: string +} + +/** Réponse GeoJSON FeatureCollection de la BAN. */ +interface BanResponse { + features?: { properties?: BanFeatureProperties }[] +} + +export function useAddressAutocomplete(): AddressAutocomplete { + return { + async searchCity(postalCode: string): Promise { + let res: BanResponse + try { + res = await httpExternal(BAN_SEARCH_URL, { + query: { q: postalCode, type: 'municipality' }, + }) + } + catch { + 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 { + // Pas de `type=housenumber` ici : sans filtre, la BAN classe rues + + // numéros par pertinence (comportement d'autocomplétion 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 complète (numéro + voie) ; + // `street` ne contient que la voie. On privilégie `name`. + street: props.name ?? props.street ?? '', + postalCode: props.postcode ?? '', + city: props.city ?? '', + } + }) + }, + } +} diff --git a/frontend/modules/directory/composables/useDirectoryDetail.ts b/frontend/modules/directory/composables/useDirectoryDetail.ts index 71e29dc..764e537 100644 --- a/frontend/modules/directory/composables/useDirectoryDetail.ts +++ b/frontend/modules/directory/composables/useDirectoryDetail.ts @@ -3,7 +3,7 @@ import type { Address } from '~/modules/directory/services/dto/address' import { useContactService } from '~/modules/directory/services/contacts' import { useAddressService } from '~/modules/directory/services/addresses' -type Owner = { client?: string, prospect?: string } +type Owner = { client?: string, prospect?: string, prestataire?: string } /** * Logique partagée des fiches détail Client/Prospect : blocs répétables Contact diff --git a/frontend/modules/directory/pages/directory/clients/[id].vue b/frontend/modules/directory/pages/directory/clients/[id].vue index 264e733..a08b886 100644 --- a/frontend/modules/directory/pages/directory/clients/[id].vue +++ b/frontend/modules/directory/pages/directory/clients/[id].vue @@ -21,17 +21,25 @@ +
@@ -109,6 +117,7 @@ diff --git a/frontend/modules/directory/pages/directory/prestataires/[id].vue b/frontend/modules/directory/pages/directory/prestataires/[id].vue new file mode 100644 index 0000000..4709cfe --- /dev/null +++ b/frontend/modules/directory/pages/directory/prestataires/[id].vue @@ -0,0 +1,201 @@ + + + diff --git a/frontend/modules/directory/pages/directory/prospects/[id].vue b/frontend/modules/directory/pages/directory/prospects/[id].vue index 4d03db5..ccf7095 100644 --- a/frontend/modules/directory/pages/directory/prospects/[id].vue +++ b/frontend/modules/directory/pages/directory/prospects/[id].vue @@ -2,7 +2,7 @@
-

{{ prospect?.name ?? '…' }}

+

{{ prospect?.company ?? '…' }}

{{ $t('common.loading') }}

@@ -11,16 +11,12 @@