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:
parent
28dff24227
commit
e9d1990235
|
|
@ -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) ──
|
||||
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() }
|
||||
// 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) {
|
||||
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 first = a.split(',')[0].trim()
|
||||
const cm = first.match(/^\s*(\d+)\s*[A-Za-z]?/); const civic = cm ? cm[1] : ''
|
||||
const street = first.replace(/^\s*\d+\s*[A-Za-z]?\s*/, '').trim()
|
||||
// Civique AVEC le suffixe d'unité « -4 » (format F « bâtiment-unité », ex. « 102-4 ») : la fibre indexe le terrain « 102-4 »,
|
||||
// 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 }
|
||||
}
|
||||
// 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à
|
||||
// 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.
|
||||
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' }
|
||||
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 } }
|
||||
const campings = await getCampings()
|
||||
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)
|
||||
if (!targets.length) return { ok: true, dryRun, refreshCamp, targets: 0 }
|
||||
// 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.
|
||||
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 = []
|
||||
for (const j of targets) {
|
||||
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
|
||||
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 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)
|
||||
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
|
||||
}
|
||||
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++ } }
|
||||
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
|
||||
|
|
@ -1495,6 +1505,18 @@ async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false
|
|||
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 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' } }
|
||||
else { const ph = await pmHit(); if (ph) return ph; if (_inQC(dlat, dlon)) return { lat: +dlat, lon: +dlon, src: 'delivery' } }
|
||||
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')) {
|
||||
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)
|
||||
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/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user