feat(geoloc): OSM geocoder + postal-code centroid fallback (never wrong-city)

The ingest geocode fallback called Mapbox with only a broad territory
bbox guard — so a wrong-city guess (e.g. "Rue Elsie" → Valleyfield, both
in-territory) was accepted. Rebuilt the chain to be bounded:

  infra (fibre/placemarks) → RQA exact → OSM (validated) → postal-code
  centroid → phone-match / town-centre (existing last resorts)

- geocodeNominatim(): free OpenStreetMap geocoder (User-Agent per OSM
  policy, cached, in-territory) — replaces Mapbox in the ingest path.
- OSM result is ACCEPTED only if within 10 km of the postal-code centroid
  (geocodeCentroid, RQA average for the full postal code); otherwise
  rejected → the centroid is used. So a J0S1H0 address can never land in
  Valleyfield again.
- postal_centroid is the guaranteed bounded fallback.

Note on connectors: the fibre table is a PERMANENT direct MySQL link
(cfg.LEGACY_DB); placemarks come via the permanent PHP bridge
ops_placemarks.php — both already wired into resolveDevCoords.

Verified: J0S1H0 centroid = 45.0857,-74.1795 (Huntingdon, 3471 addrs);
OSM reachable and returns in-town coords for known streets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 08:28:10 -04:00
parent e9d1990235
commit a7a6baf8b5

View File

@ -227,6 +227,22 @@ async function persistGeocodeToSL (accountId, lat, lon, city) {
// bornes coord()). Cache module. Désactivé si MAPBOX_TOKEN absent de l'env.
const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN || ''
const _mbCache = new Map()
// Géocodeur OSM / Nominatim (GRATUIT — remplace Mapbox dans le repli d'ingest). Politique OSM : User-Agent requis, volume faible
// (seuls les jobs non résolus par l'infra/RQA y arrivent), caché. Borné au territoire ; la validation fine (proximité code postal) se fait à l'appel.
const _osmCache = new Map()
async function geocodeNominatim (addressLine, city, postalCode) {
if (!addressLine) return null
const key = norm([addressLine, city, postalCode].filter(Boolean).join('|'))
if (_osmCache.has(key)) return _osmCache.get(key)
let res = null
try {
const q = [addressLine, city, 'Québec', 'Canada'].filter(Boolean).join(', ')
const r = await httpRequest('https://nominatim.openstreetmap.org', '/search?format=json&limit=1&countrycodes=ca&q=' + encodeURIComponent(q), { headers: { 'User-Agent': 'TargoFSM/1.0 (dispatch geocode; louis@targo.ca)', 'Accept-Language': 'fr-CA' }, timeout: 12000 })
const f = Array.isArray(r && r.data) && r.data[0]
if (f && f.lat && f.lon) { const c = coord(+f.lat, +f.lon); if (c && inTerritory(c.lat, c.lon)) res = c }
} catch (e) { log('geocodeNominatim error:', e.message) }
_osmCache.set(key, res); return res
}
async function geocodeMapbox (addressLine, city, postalCode) {
if (!MAPBOX_TOKEN || !addressLine) return null
const key = norm([addressLine, city, postalCode].filter(Boolean).join('|'))
@ -431,14 +447,21 @@ async function buildJob (t) {
payload.service_location = sl.name
if (!coordSrc) { const sc = coord(sl.latitude, sl.longitude); if (sc) { payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location' } } // repli si rien d'autre
}
if (!coordSrc && addr) { // replis géocodage sur l'adresse de service (sinon facturation) : RQA (autoritaire) puis Mapbox (couverture)
if (!coordSrc && addr) { // replis géocodage sur l'adresse de service (sinon facturation) : RQA → OSM (validé) → CENTRE DU CODE POSTAL (borné)
const useSvc = !!svcAddr
const line = useSvc ? t.dv_addr : t.address1
const zip = useSvc ? t.dv_zip : t.zip
const ci = useSvc ? t.dv_city : t.city
const g = await geocodeRQA(line, zip, ci)
const g = await geocodeRQA(line, zip, ci) // 1) RQA exact (autoritaire)
if (g) { payload.latitude = g.lat; payload.longitude = g.lon; coordSrc = 'rqa_geocode' }
else { const mb = await geocodeMapbox(line, ci, zip); if (mb) { payload.latitude = mb.lat; payload.longitude = mb.lon; coordSrc = 'mapbox_geocode' } }
else {
// Centre du code postal (moyenne RQA de la zone) = repli BORNÉ + garde-fou : un géocodeur externe qui tombe dans la MAUVAISE
// ville (ex. « Rue Elsie » → Valleyfield) est rejeté s'il est à > 10 km du centre du code postal.
const ctr = await geocodeCentroid(zip, ci)
const osm = await geocodeNominatim(line, ci, zip) // 2) OSM/OpenStreetMap (rue connue absente du RQA)
if (osm && (!ctr || _haversineM(osm.lat, osm.lon, ctr.lat, ctr.lon) <= 10000)) { payload.latitude = osm.lat; payload.longitude = osm.lon; coordSrc = 'osm_geocode' }
else if (ctr) { payload.latitude = ctr.lat; payload.longitude = ctr.lon; coordSrc = 'postal_centroid' } // 3) repli : jamais la mauvaise ville
}
}
if (!coordSrc) { // sans compte : retrouver le client par le TÉLÉPHONE du sujet (compte UNIQUE + 1 seule adresse) → adresse PRÉCISE + lien client
const pm = await accountByPhone(pool(), t.subject)