fix(geoloc): trust infrastructure (fibre) for exact address; fix new-street mislocation

Bug: "102-4 Rue Elsie, Huntingdon J0S1H0" (a NEW street, absent from RQA)
showed in Valleyfield. The F `fibre` table had the correct placemark
(45.091152,-74.180855) but the job was stuck at 45.264,-74.144.

Root causes:
1. _parseAddr extracted civic "102" from "102-4" → fibreMatch queried
   `terrain LIKE '102%'`, matching MANY other streets (Lost Nation,
   Dalhousie, Ridge…) and burying/missing the Elsie `terrain '102-4'`.
   Now captures the full "NNN-N" civic → `terrain LIKE '102-4%'` (precise)
   and cleans the street ("Rue Elsie", no "-4" leftover).
2. resolveDevCoords let the (stale/wrong, account-fallback) delivery
   coords win before fibreMatch. Now a STREET-CONFIRMED fibre match is
   the top authority (_streetConfirmed) — "se fier à la base infra".
3. Existing wrong jobs never self-corrected (sync only fills missing
   coords). Added geolocateJobs refreshFibre mode + ?refreshFibre=1:
   re-checks already-coordinated jobs, overwrites ONLY when fibre
   confirms the street AND it moved >100m.

Ran the correction: 11 jobs fixed (all fibre placemarks), incl.
LEG-253136 moved 19 km to Huntingdon. Verified read-back.

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

View File

@ -1419,12 +1419,16 @@ async function geolocateFromInfra ({ dryRun = true, limit = 1000, jobs = true }
// ── Géoloc par ADRESSE (réutilisable, tolérant aux variantes + COMPARAISON DE RUE pour désambiguïser) ── // ── Géoloc par ADRESSE (réutilisable, tolérant aux variantes + COMPARAISON DE RUE pour désambiguïser) ──
function _deburr (s) { return String(s == null ? '' : s).normalize('NFD').replace(/[̀-ͯ]/g, '') } function _deburr (s) { return String(s == null ? '' : s).normalize('NFD').replace(/[̀-ͯ]/g, '') }
function _normStreet (s) { return _deburr(s).toLowerCase().replace(/\b(rue|av|ave|avenue|ch|chemin|rang|mont[ée]e?|boul(?:evard)?|blvd|place|impasse|terrasse|croissant|all[ée]e|du|de|des|d|la|le|les|st|ste|saint|sainte)\b/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim() } function _normStreet (s) { return _deburr(s).toLowerCase().replace(/\b(rue|av|ave|avenue|ch|chemin|rang|mont[ée]e?|boul(?:evard)?|blvd|place|impasse|terrasse|croissant|all[ée]e|du|de|des|d|la|le|les|st|ste|saint|sainte)\b/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim() }
// La rue de la fibre CONFIRME-t-elle la rue de l'adresse ? (tolère le préfixe d'unité « -4 » laissé par _parseAddr, ex. « -4 Rue Elsie »)
function _streetConfirmed (rueFibre, streetAddr) { const a = _normStreet(rueFibre), b = _normStreet(streetAddr); if (!a || !b) return false; return a === b || (a.length >= 3 && b.length >= 3 && (a.includes(b) || b.includes(a))) }
function _parseAddr (address) { function _parseAddr (address) {
const a = String(address || '').trim() const a = String(address || '').trim()
const zm = a.match(/([A-Za-z]\d[A-Za-z])\s?(\d[A-Za-z]\d)/); const zip = zm ? (zm[1] + zm[2]) : '' const zm = a.match(/([A-Za-z]\d[A-Za-z])\s?(\d[A-Za-z]\d)/); const zip = zm ? (zm[1] + zm[2]) : ''
const first = a.split(',')[0].trim() const first = a.split(',')[0].trim()
const cm = first.match(/^\s*(\d+)\s*[A-Za-z]?/); const civic = cm ? cm[1] : '' // Civique AVEC le suffixe d'unité « -4 » (format F « bâtiment-unité », ex. « 102-4 ») : la fibre indexe le terrain « 102-4 »,
const street = first.replace(/^\s*\d+\s*[A-Za-z]?\s*/, '').trim() // donc « terrain LIKE '102-4%' » cible le BON immeuble ; « 102 » seul ratissait toutes les rues avec un civique 102.
const cm = first.match(/^\s*(\d+(?:-\d+)?)\s*[A-Za-z]?/); const civic = cm ? cm[1] : ''
const street = first.replace(/^\s*\d+(?:-\d+)?\s*[A-Za-z]?\s*/, '').trim()
return { zip, civic, street } return { zip, civic, street }
} }
// Match fibre par zip+civique, DÉSAMBIGUÏSÉ par la rue (compare quand plusieurs civiques identiques). → ligne fibre ou null. // Match fibre par zip+civique, DÉSAMBIGUÏSÉ par la rue (compare quand plusieurs civiques identiques). → ligne fibre ou null.
@ -1444,19 +1448,20 @@ function _haversineM (la1, lo1, la2, lo2) { const R = 6371000, r = Math.PI / 180
// Géoloc des DISPATCH JOBS actifs : remplit ceux SANS coords ET (refreshCamp) RE-géolocalise les jobs de CAMPING déjà // Géoloc des DISPATCH JOBS actifs : remplit ceux SANS coords ET (refreshCamp) RE-géolocalise les jobs de CAMPING déjà
// coordonnés pour upgrader le centroïde générique → le LOT précis (placemarks.nom). Campsite-aware (cf. resolveDevCoords). // coordonnés pour upgrader le centroïde générique → le LOT précis (placemarks.nom). Campsite-aware (cf. resolveDevCoords).
// Un job DÉJÀ coordonné n'est réécrit que si le déplacement > 100 m (évite le thrash). Écrit DJ.lat/lon (aucun customer requis). dryRun => 0 écriture. // Un job DÉJÀ coordonné n'est réécrit que si le déplacement > 100 m (évite le thrash). Écrit DJ.lat/lon (aucun customer requis). dryRun => 0 écriture.
async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false } = {}) { async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false, refreshFibre = false } = {}) {
const p = pool(); if (!p) return { ok: false, error: 'legacy DB indispo' } const p = pool(); if (!p) return { ok: false, error: 'legacy DB indispo' }
let jobs = [] let jobs = []
try { jobs = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]], fields: ['name', 'address', 'latitude', 'longitude'], limit: 8000 }) } catch (e) { return { ok: false, error: 'Dispatch Job: ' + e.message } } try { jobs = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]], fields: ['name', 'address', 'latitude', 'longitude'], limit: 8000 }) } catch (e) { return { ok: false, error: 'Dispatch Job: ' + e.message } }
const campings = await getCampings() const campings = await getCampings()
const hasC = (v) => v != null && v !== '' && Math.abs(+v) > 1e-4 const hasC = (v) => v != null && v !== '' && Math.abs(+v) > 1e-4
const targets = jobs.filter(j => { const addr = String(j.address || '').trim(); if (!addr) return false; if (!hasC(j.latitude)) return true; return refreshCamp && !!campingFor(campings, [addr]) }).slice(0, limit) // refreshFibre : RE-vérifie AUSSI les jobs DÉJÀ coordonnés (pour corriger les coords erronées, ex. rue neuve placée à Valleyfield) — n'écrase que si la fibre CONFIRME la rue.
if (!targets.length) return { ok: true, dryRun, refreshCamp, targets: 0 } const targets = jobs.filter(j => { const addr = String(j.address || '').trim(); if (!addr) return false; if (!hasC(j.latitude)) return true; return refreshFibre || (refreshCamp && !!campingFor(campings, [addr])) }).slice(0, limit)
if (!targets.length) return { ok: true, dryRun, refreshCamp, refreshFibre, targets: 0 }
const recs = [] const recs = []
for (const j of targets) { for (const j of targets) {
const a = _parseAddr(j.address); const camp = campingFor(campings, [j.address]) || null const a = _parseAddr(j.address); const camp = campingFor(campings, [j.address]) || null
const fm = (a.zip && a.civic) ? await fibreMatch(p, a.zip, a.civic, a.street) : null const fm = (a.zip && a.civic) ? await fibreMatch(p, a.zip, a.civic, a.street) : null
recs.push({ name: j.name, first: String(j.address).split(',')[0], camp, pmid: fm && fm.pmid ? fm.pmid : null, flat: fm ? fm.flat : null, flon: fm ? fm.flon : null, curLat: hasC(j.latitude) ? +j.latitude : null, curLon: hasC(j.longitude) ? +j.longitude : null }) recs.push({ name: j.name, first: String(j.address).split(',')[0], camp, pmid: fm && fm.pmid ? fm.pmid : null, flat: fm ? fm.flat : null, flon: fm ? fm.flon : null, confirmed: !!(fm && _streetConfirmed(fm.rue, a.street)), curLat: hasC(j.latitude) ? +j.latitude : null, curLon: hasC(j.longitude) ? +j.longitude : null })
} }
const fresh = await placemarksLookup([...new Set(recs.map(r => r.pmid).filter(Boolean))]) const fresh = await placemarksLookup([...new Set(recs.map(r => r.pmid).filter(Boolean))])
const allNoms = new Set(); recs.forEach(r => { r.variants = lotVariants(r.first); r.variants.forEach(n => allNoms.add(n)) }) const allNoms = new Set(); recs.forEach(r => { r.variants = lotVariants(r.first); r.variants.forEach(n => allNoms.add(n)) })
@ -1480,12 +1485,17 @@ async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false
} }
lat = +(+lat).toFixed(6); lon = +(+lon).toFixed(6) lat = +(+lat).toFixed(6); lon = +(+lon).toFixed(6)
let moved = null let moved = null
if (r.curLat != null) { const d = _haversineM(r.curLat, r.curLon, lat, lon); if (d < 100) { unchanged++; continue } moved = Math.round(d) } // déjà coordonné : ne bouge que si > 100 m if (r.curLat != null) {
// Déjà coordonné : on n'ÉCRASE que si haute confiance (camping refresh comme avant ; sinon fibre RUE CONFIRMÉE) ET déplacement > 100 m.
const confident = r.camp ? true : (r.confirmed && (src === 'placemarks' || src === 'fibre_addr'))
if (!confident) { unchanged++; continue }
const d = _haversineM(r.curLat, r.curLon, lat, lon); if (d < 100) { unchanged++; continue } moved = Math.round(d)
}
updates.push({ name: r.name, lat, lon, src, moved_m: moved, addr: r.first }); tally[src] = (tally[src] || 0) + 1 updates.push({ name: r.name, lat, lon, src, moved_m: moved, addr: r.first }); tally[src] = (tally[src] || 0) + 1
} }
let written = 0 let written = 0
if (!dryRun) { for (const u of updates) { const rr = await erp.update('Dispatch Job', u.name, { latitude: u.lat, longitude: u.lon }).catch(() => ({ ok: false })); if (rr && rr.ok) written++ } } if (!dryRun) { for (const u of updates) { const rr = await erp.update('Dispatch Job', u.name, { latitude: u.lat, longitude: u.lon }).catch(() => ({ ok: false })); if (rr && rr.ok) written++ } }
return { ok: true, dryRun, refreshCamp, targets: targets.length, resolvable: updates.length, by_src: tally, unresolved: none, unchanged, written, sample: updates.slice(0, 14) } return { ok: true, dryRun, refreshCamp, refreshFibre, targets: targets.length, resolvable: updates.length, by_src: tally, unresolved: none, unchanged, written, sample: updates.slice(0, 14) }
} }
// Résolveur de coords UNIFIÉ (algorithme développé) — le plus logique/autoritaire d'abord. `camp` = entrée camping_registry // Résolveur de coords UNIFIÉ (algorithme développé) — le plus logique/autoritaire d'abord. `camp` = entrée camping_registry
@ -1495,6 +1505,18 @@ async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false
async function resolveDevCoords (p, { pmid, dlat, dlon, address, camp } = {}) { async function resolveDevCoords (p, { pmid, dlat, dlon, address, camp } = {}) {
const nomHit = async () => { const noms = lotVariants(String(address || '').split(',')[0]); if (!noms.length) return null; const bn = await placemarksByNom(noms); const h = noms.map(n => bn[n]).find(x => x && _inQC(x.lat, x.lon)); return h ? { lat: h.lat, lon: h.lon, src: 'placemarks_nom' } : null } const nomHit = async () => { const noms = lotVariants(String(address || '').split(',')[0]); if (!noms.length) return null; const bn = await placemarksByNom(noms); const h = noms.map(n => bn[n]).find(x => x && _inQC(x.lat, x.lon)); return h ? { lat: h.lat, lon: h.lon, src: 'placemarks_nom' } : null }
const pmHit = async () => { if (!pmid) return null; const f = await placemarksLookup([pmid]); const c = f[String(pmid)]; return (c && _inQC(c.lat, c.lon)) ? { lat: c.lat, lon: c.lon, src: 'placemarks' } : null } const pmHit = async () => { if (!pmid) return null; const f = await placemarksLookup([pmid]); const c = f[String(pmid)]; return (c && _inQC(c.lat, c.lon)) ? { lat: c.lat, lon: c.lon, src: 'placemarks' } : null }
// AUTORITÉ #1 — base INFRASTRUCTURE (fibre) pour l'adresse EXACTE (zip+civique+RUE CONFIRMÉE). PRIME sur la delivery, qui peut
// pointer une ANCIENNE/autre delivery du compte (client déménagé) → corrige les RUES NEUVES absentes du RQA (ex. Rue Elsie, Huntingdon).
if (p && !camp) {
const a0 = _parseAddr(address)
if (a0.zip && a0.civic && a0.street) {
const fm0 = await fibreMatch(p, a0.zip, a0.civic, a0.street)
if (fm0 && _streetConfirmed(fm0.rue, a0.street)) {
if (fm0.pmid) { const f0 = await placemarksLookup([fm0.pmid]); const c0 = f0[String(fm0.pmid)]; if (c0 && _inQC(c0.lat, c0.lon)) return { lat: c0.lat, lon: c0.lon, src: 'fibre_placemarks' } }
if (_inQC(fm0.flat, fm0.flon)) return { lat: +fm0.flat, lon: +fm0.flon, src: 'fibre' }
}
}
}
if (camp) { const nh = await nomHit(); if (nh) return nh; const ph = await pmHit(); if (ph) return ph; if (_inQC(camp.latitude, camp.longitude)) return { lat: +camp.latitude, lon: +camp.longitude, src: 'camping' } } if (camp) { const nh = await nomHit(); if (nh) return nh; const ph = await pmHit(); if (ph) return ph; if (_inQC(camp.latitude, camp.longitude)) return { lat: +camp.latitude, lon: +camp.longitude, src: 'camping' } }
else { const ph = await pmHit(); if (ph) return ph; if (_inQC(dlat, dlon)) return { lat: +dlat, lon: +dlon, src: 'delivery' } } else { const ph = await pmHit(); if (ph) return ph; if (_inQC(dlat, dlon)) return { lat: +dlat, lon: +dlon, src: 'delivery' } }
const a = _parseAddr(address) const a = _parseAddr(address)
@ -1602,7 +1624,8 @@ async function handle (req, res, method, path) {
if (path === '/dispatch/legacy-sync/geolocate-jobs' && (method === 'GET' || method === 'POST')) { if (path === '/dispatch/legacy-sync/geolocate-jobs' && (method === 'GET' || method === 'POST')) {
const u = new URL(req.url, 'http://localhost') const u = new URL(req.url, 'http://localhost')
const refreshCamp = /^(1|true|on)$/i.test(u.searchParams.get('refreshCamp') || '') // upgrade campings déjà coordonnés (centroïde → lot) const refreshCamp = /^(1|true|on)$/i.test(u.searchParams.get('refreshCamp') || '') // upgrade campings déjà coordonnés (centroïde → lot)
return json(res, 200, await geolocateJobs({ dryRun: method === 'GET', refreshCamp, limit: Math.min(8000, Math.max(1, Number(u.searchParams.get('limit')) || 3000)) })) const refreshFibre = /^(1|true|on)$/i.test(u.searchParams.get('refreshFibre') || '') // CORRIGE les jobs déjà coordonnés quand la fibre confirme une autre position (rues neuves mal placées)
return json(res, 200, await geolocateJobs({ dryRun: method === 'GET', refreshCamp, refreshFibre, limit: Math.min(8000, Math.max(1, Number(u.searchParams.get('limit')) || 3000)) }))
} }
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'POST') return json(res, 200, await fillMissingCoords({ dryRun: false })) // applique if (path === '/dispatch/legacy-sync/fill-coords' && method === 'POST') return json(res, 200, await fillMissingCoords({ dryRun: false })) // applique
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone