de4aaa1d64
Ajoute la géolocalisation aux adresses Client et Fournisseur, socle de la tournée commerciale (M6 field-sales). Back : - migration : latitude/longitude NUMERIC(10,7), geo_manual BOOLEAN, geocoded_at TIMESTAMPTZ sur client_address et supplier_address (+ COMMENT ON COLUMN FR) - GeolocatableAddressInterface (Shared/Domain/Contract) implémenté par les deux entités ; bornes WGS84 validées (Range -90/90, -180/180, messages FR) - GeocoderInterface + BanGeocoder (api-adresse.data.gouv.fr), branché via AddressGeocoder dans les processors ; géocodage auto au create/update - RG-6.08 : geo_manual=true fige les coordonnées (pas de réécriture auto) - symfony/http-client passe en dépendance de production Front : - AddressGeoPin (Leaflet + OSM) : marqueur déplaçable -> PATCH lat/lng + geoManual=true, bouton Re-géocoder, badges « à géolocaliser » / « pin manuel » - intégration dans les blocs adresse Client et Fournisseur Tests : PHPUnit (géocodage create, non-réécriture RG-6.08, mapping BAN, bornes) + Vitest (drag du pin, badges, re-géocodage).
161 lines
6.0 KiB
TypeScript
161 lines
6.0 KiB
TypeScript
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.
|
|
//
|
|
// 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 (fige) :
|
|
// searchCity(postalCode) -> liste { city, postalCode }
|
|
// searchAddress(query, cp?) -> liste { label, street, postalCode, city }
|
|
// 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).
|
|
|
|
/** 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 complete (saisie assistee du champ « Adresse »). */
|
|
export interface AddressSuggestion {
|
|
label: string
|
|
street: string
|
|
postalCode: string
|
|
city: string
|
|
}
|
|
|
|
/** Coordonnees WGS84 d'une adresse geocodee (chaines decimales, format API). */
|
|
export interface GeocodedCoordinates {
|
|
latitude: string
|
|
longitude: string
|
|
}
|
|
|
|
export interface AddressAutocomplete {
|
|
searchCity(postalCode: string): Promise<CitySuggestion[]>
|
|
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
|
|
/**
|
|
* Geocode une adresse complete en coordonnees (M6.1) — previsualisation du
|
|
* pin cote front uniquement : la valeur persistee reste celle du geocodage
|
|
* serveur (BanGeocoder) au save. `null` si la BAN ne trouve rien.
|
|
*/
|
|
geocode(query: string): Promise<GeocodedCoordinates | null>
|
|
}
|
|
|
|
/** Erreur signalant que le service d'autocompletion BAN n'est pas disponible. */
|
|
export class AddressAutocompleteUnavailableError extends Error {
|
|
constructor() {
|
|
// 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'
|
|
}
|
|
}
|
|
|
|
/** 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
|
|
/** GeoJSON : coordinates = [longitude, latitude]. */
|
|
geometry?: { coordinates?: [number, number] }
|
|
}[]
|
|
}
|
|
|
|
export function useAddressAutocomplete(): AddressAutocomplete {
|
|
return {
|
|
async searchCity(postalCode: string): Promise<CitySuggestion[]> {
|
|
let res: BanResponse
|
|
try {
|
|
res = await httpExternal<BanResponse>(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<AddressSuggestion[]> {
|
|
// 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<string, string> = { q: query }
|
|
if (postalCode) {
|
|
banQuery.postcode = postalCode
|
|
}
|
|
|
|
let res: BanResponse
|
|
try {
|
|
res = await httpExternal<BanResponse>(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 ?? '',
|
|
}
|
|
})
|
|
},
|
|
|
|
async geocode(query: string): Promise<GeocodedCoordinates | null> {
|
|
if (query.trim().length < 3) {
|
|
return null
|
|
}
|
|
|
|
let res: BanResponse
|
|
try {
|
|
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
|
|
query: { q: query, limit: '1' },
|
|
})
|
|
}
|
|
catch {
|
|
throw new AddressAutocompleteUnavailableError()
|
|
}
|
|
|
|
const coordinates = res.features?.[0]?.geometry?.coordinates
|
|
if (!coordinates || coordinates.length < 2) {
|
|
return null
|
|
}
|
|
|
|
// GeoJSON = [longitude, latitude] ; 7 decimales = format NUMERIC(10,7).
|
|
return {
|
|
latitude: coordinates[1].toFixed(7),
|
|
longitude: coordinates[0].toFixed(7),
|
|
}
|
|
},
|
|
}
|
|
}
|