feat(commercial) : géolocalisation des adresses Tiers (lat/lng + géocodage BAN + pin ajustable) (ERP-122)

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).
This commit is contained in:
Matthieu
2026-06-11 14:31:35 +02:00
parent 431d831c8b
commit de4aaa1d64
34 changed files with 1966 additions and 204 deletions
@@ -31,9 +31,21 @@ export interface AddressSuggestion {
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. */
@@ -57,7 +69,11 @@ interface BanFeatureProperties {
/** Reponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
features?: { properties?: BanFeatureProperties }[]
features?: {
properties?: BanFeatureProperties
/** GeoJSON : coordinates = [longitude, latitude]. */
geometry?: { coordinates?: [number, number] }
}[]
}
export function useAddressAutocomplete(): AddressAutocomplete {
@@ -113,5 +129,32 @@ export function useAddressAutocomplete(): AddressAutocomplete {
}
})
},
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),
}
},
}
}