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:
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div data-testid="geo-pin">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">{{ t('commercial.geo.title') }}</span>
|
||||
<!-- Badge « a geolocaliser » : adresse valide mais sans coordonnees
|
||||
(spec M6 § 3.2 — exclue du calcul de tournee, RG-6.05). -->
|
||||
<span
|
||||
v-if="!hasCoords"
|
||||
class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
|
||||
data-testid="geo-badge-missing"
|
||||
>
|
||||
{{ t('commercial.geo.toGeolocate') }}
|
||||
</span>
|
||||
<!-- Pin fige a la main (RG-6.08) : informatif. -->
|
||||
<span
|
||||
v-else-if="geoManual"
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
||||
data-testid="geo-badge-manual"
|
||||
>
|
||||
{{ t('commercial.geo.manualPin') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mini-carte Leaflet (exception documentee a @malio/layer-ui : carte
|
||||
interactive, type non couvert par la lib — cf. frontend.md
|
||||
§ Composants formulaires). TODO : migrer si la lib couvre un jour
|
||||
les cartes. -->
|
||||
<div
|
||||
v-if="hasCoords"
|
||||
ref="mapEl"
|
||||
class="h-48 w-full rounded border border-gray-200"
|
||||
data-testid="geo-map"
|
||||
/>
|
||||
<p v-if="hasCoords && !readonly" class="mt-1 text-xs text-gray-500">
|
||||
{{ t('commercial.geo.dragHint') }}
|
||||
</p>
|
||||
|
||||
<div v-if="!readonly" class="mt-2 flex items-center gap-4">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
:label="t('commercial.geo.regeocode')"
|
||||
:disabled="regeocoding || !canRegeocode"
|
||||
data-testid="geo-regeocode"
|
||||
@click="regeocode"
|
||||
/>
|
||||
<span v-if="regeocodeFailed" class="text-xs text-red-600" data-testid="geo-regeocode-failed">
|
||||
{{ t('commercial.geo.regeocodeFailed') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map as LeafletMap, Marker } from 'leaflet'
|
||||
import { useAddressAutocomplete } from '~/shared/composables/useAddressAutocomplete'
|
||||
|
||||
/**
|
||||
* Mini-carte d'ajustement du pin d'une adresse Tiers (M6.1, spec § 8.3).
|
||||
*
|
||||
* - Marqueur deplacable : au drag, emet les coordonnees corrigees avec
|
||||
* geoManual = true (RG-6.08 : le geocodage auto ne reecrira plus). Le parent
|
||||
* met a jour le brouillon ; la persistance suit le submit du formulaire
|
||||
* (POST/PATCH de l'adresse), comme tous les champs du bloc.
|
||||
* - « Re-geocoder depuis l'adresse » : previsualise la position BAN cote front
|
||||
* et emet geoManual = false — au save, le back (BanGeocoder) refait autorite
|
||||
* et pose geocodedAt.
|
||||
* - Sans coordonnees : pas de carte, badge « a geolocaliser ».
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** Latitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
||||
latitude: string | null
|
||||
/** Longitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
||||
longitude: string | null
|
||||
/** RG-6.08 : pin deja corrige a la main. */
|
||||
geoManual: boolean
|
||||
/** Adresse postale a re-geocoder (« rue, code postal ville »). */
|
||||
geocodeQuery: string | null
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Nouveau positionnement du pin (drag manuel ou re-geocodage previsualise). */
|
||||
'update:coords': [value: { latitude: string, longitude: string, geoManual: boolean }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
const regeocoding = ref(false)
|
||||
const regeocodeFailed = ref(false)
|
||||
|
||||
const hasCoords = computed(() =>
|
||||
props.latitude !== null && props.latitude !== ''
|
||||
&& props.longitude !== null && props.longitude !== '',
|
||||
)
|
||||
|
||||
const canRegeocode = computed(() => (props.geocodeQuery ?? '').trim().length >= 3)
|
||||
|
||||
// Instances Leaflet (hors reactivite Vue : un proxy sur la Map casse Leaflet).
|
||||
let map: LeafletMap | null = null
|
||||
let marker: Marker | null = null
|
||||
|
||||
/** Zoom d'affichage du pin (niveau rue). */
|
||||
const PIN_ZOOM = 16
|
||||
|
||||
/**
|
||||
* Monte la carte Leaflet dans le conteneur (import dynamique : la lib n'est
|
||||
* chargee que si l'adresse a des coordonnees).
|
||||
*/
|
||||
async function ensureMap(): Promise<void> {
|
||||
if (map !== null || mapEl.value === null || !hasCoords.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const mod = await import('leaflet')
|
||||
const L = mod.default ?? mod
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
|
||||
// Le conteneur peut avoir disparu pendant le chargement async (v-if).
|
||||
if (mapEl.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
|
||||
|
||||
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(position, PIN_ZOOM)
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map)
|
||||
|
||||
// divIcon SVG inline : evite les assets PNG de Leaflet (chemins casses par
|
||||
// le bundler Vite sans configuration dediee).
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="40" fill="#2563eb" stroke="#1e40af" stroke-width="0.5"><path d="M12 0C7 0 3 4 3 9c0 6.6 9 15 9 15s9-8.4 9-15c0-5-4-9-9-9zm0 12.5A3.5 3.5 0 1 1 12 5.5a3.5 3.5 0 0 1 0 7z"/></svg>',
|
||||
iconSize: [28, 40],
|
||||
iconAnchor: [14, 40],
|
||||
})
|
||||
|
||||
marker = L.marker(position, { icon, draggable: !props.readonly }).addTo(map)
|
||||
marker.on('dragend', onMarkerDragEnd)
|
||||
}
|
||||
|
||||
/** Drag du pin -> coordonnees corrigees + geoManual (RG-6.08). */
|
||||
function onMarkerDragEnd(): void {
|
||||
if (marker === null) {
|
||||
return
|
||||
}
|
||||
const position = marker.getLatLng()
|
||||
emit('update:coords', {
|
||||
latitude: position.lat.toFixed(7),
|
||||
longitude: position.lng.toFixed(7),
|
||||
geoManual: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* « Re-geocoder depuis l'adresse » : previsualisation BAN cote front. Emet
|
||||
* geoManual = false — le geocodage serveur refait autorite au save.
|
||||
*/
|
||||
async function regeocode(): Promise<void> {
|
||||
regeocodeFailed.value = false
|
||||
const query = (props.geocodeQuery ?? '').trim()
|
||||
if (query.length < 3) {
|
||||
regeocodeFailed.value = true
|
||||
return
|
||||
}
|
||||
|
||||
regeocoding.value = true
|
||||
try {
|
||||
const coords = await autocomplete.geocode(query)
|
||||
if (coords === null) {
|
||||
regeocodeFailed.value = true
|
||||
return
|
||||
}
|
||||
emit('update:coords', { ...coords, geoManual: false })
|
||||
}
|
||||
catch {
|
||||
// BAN indisponible : position inchangee, message inline.
|
||||
regeocodeFailed.value = true
|
||||
}
|
||||
finally {
|
||||
regeocoding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Coordonnees modifiees par le parent (drag deja applique, re-geocodage,
|
||||
// rechargement) : recale le marqueur, ou monte la carte si elle n'existe pas
|
||||
// encore (premieres coordonnees d'une adresse « a geolocaliser »).
|
||||
watch(
|
||||
() => [props.latitude, props.longitude] as const,
|
||||
async () => {
|
||||
if (!hasCoords.value) {
|
||||
return
|
||||
}
|
||||
if (map === null) {
|
||||
await nextTick()
|
||||
await ensureMap()
|
||||
return
|
||||
}
|
||||
const position: [number, number] = [Number(props.latitude), Number(props.longitude)]
|
||||
marker?.setLatLng(position)
|
||||
map.panTo(position)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(ensureMap)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
map?.remove()
|
||||
map = null
|
||||
marker = null
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user