From e9d199023569e359db3ae49a7309c041f40600eb Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 3 Jul 2026 07:57:22 -0400 Subject: [PATCH] fix(geoloc): trust infrastructure (fibre) for exact address; fix new-street mislocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../targo-hub/lib/legacy-dispatch-sync.js | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 60fca02..adf842b 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -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