hub: reverse geocoding infra-first (fibre field-pins -> RQA fallback); address resolution + geotagged field photos

- /address/reverse: reverseNearestFibre() (fibre field-pins, source of truth) then RQA
- searchCustomers: civic address resolves to the resident (not fuzzy homonyms)
- field-app + /field/photo: geotagged photos confirm the correct job

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-06 21:52:14 -04:00
parent 042c5545f7
commit e950bd3ac6
5 changed files with 95 additions and 16 deletions

View File

@ -19,7 +19,8 @@
const { json, parseBody, log, cors } = require('./helpers')
const { searchAddresses } = require('./address-search')
const { searchLocal, searchRaw } = require('./address-db')
const { searchLocal, searchRaw, pool } = require('./address-db')
const { reverseNearestFibre } = require('./legacy-dispatch-sync') // pins de terrain (source de vérité géoloc)
// Normalize for fuzzy comparison: lowercase, strip diacritics, collapse
// whitespace, drop punctuation. Used to score how close a typed address
@ -98,6 +99,39 @@ async function handle (req, res, method, path) {
}
}
// GET /address/reverse?lat=&lon= — géocodage INVERSE : coord → adresse civique la plus proche.
// PRIORITÉ 1 : pins de TERRAIN validés (table `fibre`, source de vérité géoloc, pin posé sur la maison).
// PRIORITÉ 2 (repli) : centroïde/rooftop RQA LOCAL (bbox ~±650 m + haversine, index btree lat/lon).
// Plus de Nominatim. Le champ `source` indique d'où vient la réponse (infra_fibre | rqa).
if (path.startsWith('/address/reverse')) {
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
if (method === 'GET') {
cors(res)
const u = new URL(req.url, 'http://localhost')
const lat = parseFloat(u.searchParams.get('lat')), lon = parseFloat(u.searchParams.get('lon'))
if (!isFinite(lat) || !isFinite(lon) || Math.abs(lat) < 0.01) return json(res, 400, { ok: false, error: 'lat/lon requis' })
// PRIORITÉ 1 — pins de terrain (infra). Best-effort : une panne F ne casse pas le repli RQA.
try {
const fib = await reverseNearestFibre(lat, lon)
if (fib && fib.address) return json(res, 200, { ok: true, found: true, ...fib })
} catch (e) { log('address/reverse infra:', e.message) }
const dLat = 0.006, dLon = 0.006 / Math.max(0.2, Math.cos(lat * Math.PI / 180))
try {
const q = `SELECT address_full, numero, rue, ville, code_postal, latitude, longitude,
(6371000*2*asin(sqrt(power(sin(radians($1-latitude)/2),2)+cos(radians($1))*cos(radians(latitude))*power(sin(radians($2-longitude)/2),2)))) AS dist
FROM rqa_addresses
WHERE latitude BETWEEN $1-$3 AND $1+$3 AND longitude BETWEEN $2-$4 AND $2+$4
ORDER BY dist ASC LIMIT 1`
const { rows } = await pool().query(q, [lat, lon, dLat, dLon])
const r = rows[0]
if (!r) return json(res, 200, { ok: true, found: false })
const line1 = (r.numero && r.rue) ? (r.numero + ' ' + r.rue) : (r.address_full || '')
const address = [line1, r.ville, r.code_postal].filter(Boolean).join(', ')
return json(res, 200, { ok: true, found: true, address, distance_m: Math.round(r.dist), latitude: r.latitude, longitude: r.longitude, ville: r.ville, code_postal: r.code_postal, source: 'rqa' })
} catch (e) { log('address/reverse:', e.message); return json(res, 500, { ok: false, error: 'reverse indisponible' }) }
}
}
// POST /address/validate — score-rank RQA results for a free-text address.
if (path === '/address/validate' && method === 'POST') {
const body = await parseBody(req)

View File

@ -275,6 +275,29 @@ function pool () {
return _pool
}
// Géocodage INVERSE via les PINS DE TERRAIN (table `fibre`, MySQL direct) = la SOURCE DE VÉRITÉ géoloc :
// coords validées sur le terrain (pin posé sur la maison), plus précises que le centroïde/rooftop RQA.
// ~16k lignes → un scan bbox + haversine est trivial (pas d'index requis). Retourne null si rien à proximité.
async function reverseNearestFibre (lat, lon) {
const p = pool(); if (!p) return null
if (!isFinite(lat) || !isFinite(lon)) return null
const dLat = 0.006, dLon = 0.006 / Math.max(0.2, Math.cos(lat * Math.PI / 180))
try {
const [rows] = await p.query(
`SELECT terrain, rue, ville, zip, latitude, longitude, placemarks_id,
(6371000*2*ASIN(SQRT(POWER(SIN(RADIANS(?-latitude)/2),2)+COS(RADIANS(?))*COS(RADIANS(latitude))*POWER(SIN(RADIANS(?-longitude)/2),2)))) AS dist
FROM fibre
WHERE latitude BETWEEN ?-? AND ?+? AND longitude BETWEEN ?-? AND ?+?
ORDER BY dist ASC LIMIT 1`,
[lat, lat, lon, lat, dLat, lat, dLat, lon, dLon, lon, dLon])
const r = rows && rows[0]
if (!r) return null
const line1 = [r.terrain, r.rue].filter(Boolean).join(' ').trim()
const address = [line1, r.ville, r.zip].filter(Boolean).join(', ')
return { address, latitude: +r.latitude, longitude: +r.longitude, ville: r.ville || '', code_postal: r.zip || '', distance_m: Math.round(r.dist), placemarks_id: r.placemarks_id || null, source: 'infra_fibre' }
} catch (e) { log('reverseNearestFibre:', e.message); return null }
}
// Lien d'activation STB/Ministra : DÉJÀ posté dans le fil du ticket par le wizard legacy à la vente.
// On le ré-extrait tel quel (zéro reconstruction). Sous-requête = le ticket_msg le plus récent qui le contient.
const ACTIVATION_RE = /https?:\/\/[^\s"'<>]*connect_ministra\.php[^\s"'<>]*/i
@ -1814,4 +1837,4 @@ async function reconcileLegacyJobs (opts = {}) {
return { ok: true, apply: true, applied: done, cancelled: toCancel.length, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts }
}
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL } // parseurs purs exposés pour les tests
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests

View File

@ -893,6 +893,7 @@ const ON_SITE_M = Number(process.env.FIELD_ONSITE_RADIUS_M) || 250 // rayon « s
function fieldSign (name) { const h = crypto.createHmac('sha256', FIELD_SECRET).update(String(name)).digest('hex').slice(0, 12); return Buffer.from(String(name)).toString('base64url') + '.' + h }
function fieldVerify (token) { try { const [b, h] = String(token || '').split('.'); if (!b || !h) return null; const name = Buffer.from(b, 'base64url').toString('utf8'); const exp = crypto.createHmac('sha256', FIELD_SECRET).update(name).digest('hex').slice(0, 12); return crypto.timingSafeEqual(Buffer.from(h), Buffer.from(exp)) ? name : null } catch (e) { return null } }
function haversineM (a1, o1, a2, o2) { const R = 6371000, t = Math.PI / 180; const dA = (a2 - a1) * t, dO = (o2 - o1) * t; const x = Math.sin(dA / 2) ** 2 + Math.cos(a1 * t) * Math.cos(a2 * t) * Math.sin(dO / 2) ** 2; return Math.round(R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))) }
function fmtDistM (m) { return m < 1000 ? (m + ' m') : ((m / 1000).toFixed(m < 10000 ? 1 : 0) + ' km') }
// Token PAR TECH (préfixe 'T:') → l'app charge les jobs du jour du tech. Token Mapbox public (pk) pour la carte de l'app.
const FIELD_MAPBOX = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
function techFieldSign (name) { return fieldSign('T:' + name) }
@ -941,13 +942,21 @@ async function handleFieldTech (req, res, method, path, url) {
const jobs = (rows || []).map(j => ({ name: j.name, subject: j.subject, customer: j.customer_name || '', address: j.address || '', lat: j.latitude, lon: j.longitude, date: j.scheduled_date, start: j.start_time ? String(j.start_time).slice(0, 5) : '', status: j.status, started: !!j.actual_start, ended: !!j.actual_end, minutes: minutesBetween(j.actual_start, j.actual_end), token: fieldSign(j.name), reply: j.legacy_ticket_id ? `https://store.targo.ca/targo/reply_ticket.php?ticket=${j.legacy_ticket_id}&staff=${staffId}` : '', activation: j.legacy_activation_url || '', notes: j.notes || '', skill: j.required_skill || '', dur: j.duration_h || null }))
return json(res, 200, { ok: true, tech, jobs })
}
if (path === '/field/photo' && method === 'POST') { // photo (base64) → /app/uploads/field + note sur le job
if (path === '/field/photo' && method === 'POST') { // photo (base64) → /app/uploads/field + note sur le job (+ géoloc → confirme la présence au bon endroit)
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
const fn = savePhoto(name, b.image); if (!fn) return json(res, 400, { ok: false, error: 'image invalide' })
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes'], limit: 1 }); const j = r && r[0]
const note = `\n${nowFrappe()} 📷 photo ${fn}${b.note ? ' — ' + String(b.note).slice(0, 60) : ''}`
const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes', 'latitude', 'longitude'], limit: 1 }); const j = r && r[0]
// Position de l'appareil AU MOMENT de la photo → distance à l'adresse du job = preuve que la photo est prise au bon endroit.
let geoNote = '', distM = null, onsite = null
if (isFinite(+b.lat) && isFinite(+b.lon) && Math.abs(+b.lat) > 0.01) {
if (j && isFinite(+j.latitude) && isFinite(+j.longitude) && Math.abs(+j.latitude) > 0.01) {
distM = haversineM(+b.lat, +b.lon, +j.latitude, +j.longitude); onsite = distM <= 200
geoNote = onsite ? ` · 📍 ✓ sur place (≈${distM} m)` : ` · 📍 ⚠ à ${fmtDistM(distM)} de l'adresse`
} else geoNote = ` · 📍 ${(+b.lat).toFixed(5)},${(+b.lon).toFixed(5)}` // job sans coords : on note quand même la position
}
const note = `\n${nowFrappe()} 📷 photo ${fn}${geoNote}${b.note ? ' — ' + String(b.note).slice(0, 60) : ''}`
await retryWrite(() => erp.update('Dispatch Job', name, { completion_notes: (((j && j.completion_notes) || '') + note).slice(-4000) }))
return json(res, 200, { ok: true, file: fn })
return json(res, 200, { ok: true, file: fn, distM, onsite, distLabel: distM != null ? fmtDistM(distM) : null })
}
if (path === '/field/device' && method === 'POST') { // appareil scané (série/MAC) → checkpoint présence + note (GenieACS à brancher)
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })

View File

@ -31,20 +31,30 @@ async function searchCustomers (q) {
if (/.+@.+/.test(s)) { try { for (const c of await lookupCustomersByEmail(s, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'courriel' }) } catch (e) { /* */ } }
let p = null
try { p = require('./address-db').pool() } catch (e) { /* address-db indispo */ }
// 2) No civique → adresse (Service Location → client)
let addrResolved = false // adresse civique résolue → on NE mélange PAS les homonymes (fuzzy nom) : « 2338 rue X » = LE résident, pas les noms qui se ressemblent.
// 2) No civique → adresse (Service Location → client). Typo de dictée toléré : si rien, RQA (trigram) canonicalise la rue puis on ré-essaie.
if (p && civic && found.size < 12) {
try {
const street = text.split(' ').filter(w => w.length >= 3).slice(0, 2)
const cond = street.length ? ' AND unaccent(lower(coalesce(sl.address_line,\'\'))) LIKE unaccent(lower($2))' : ''
const slByAddr = async (toks) => {
const cond = toks.length ? ' AND unaccent(lower(coalesce(sl.address_line,\'\'))) LIKE unaccent(lower($2))' : ''
const r = await p.query(
`SELECT DISTINCT sl.name, sl.customer, sl.address_line, sl.city FROM "tabService Location" sl
WHERE sl.customer IS NOT NULL AND sl.address_line ILIKE $1 ${cond} ORDER BY sl.address_line LIMIT 8`,
street.length ? ['%' + civic + '%', '%' + street.join('%') + '%'] : ['%' + civic + '%'])
for (const row of r.rows) if (row.customer && !found.has(row.customer)) found.set(row.customer, { matched: 'adresse', address: [row.address_line, row.city].filter(Boolean).join(', '), service_location: row.name }) // lie la BONNE adresse de service (pas la facturation)
toks.length ? ['%' + civic + '%', '%' + toks.join('%') + '%'] : ['%' + civic + '%'])
return r.rows
}
try {
let street = text.split(/[\s-]+/).filter(w => w.length >= 3).slice(0, 2)
let rows = await slByAddr(street)
// Rue mal orthographiée / dictée (« Vinette » vs « Vinet ») → RQA fuzzy trouve la vraie rue → on ré-essaie. Seulement si la requête ressemble à une adresse (mot « rue/rang/… »).
if (!rows.length && /\b(rue|av|ave|avenue|boul|boulevard|ch|chemin|rang|mont|place|impasse|croissant|terrasse|c[oô]te)\b/i.test(s)) {
try { const canon = (await require('./address-db').searchRaw(s, 1))[0]; const rue = canon && (canon.rue || canon.odonyme_recompose_normal); if (rue) { const t = String(rue).split(/[\s-]+/).filter(w => w.length >= 3).slice(0, 2); if (t.length) rows = await slByAddr(t) } } catch (e) { /* RQA indispo */ }
}
for (const row of rows) if (row.customer && !found.has(row.customer)) found.set(row.customer, { matched: 'adresse', address: [row.address_line, row.city].filter(Boolean).join(', '), service_location: row.name }) // lie la BONNE adresse de service (pas la facturation)
if (rows.length) addrResolved = true // → on saute la recherche par NOM (homonymes) plus bas
} catch (e) { log('searchCustomers addr: ' + e.message) }
}
// 3) Nom (Customer) — FUZZY : normalisé (sans séparateurs/accents/casse) + similarité trigramme (score façon Google, tolère typos).
if (p && found.size < 12) {
if (p && found.size < 12 && !addrResolved) {
const qn = normName(text)
if (qn.length >= 3) {
const NORM = "regexp_replace(unaccent(lower(customer_name)), '[^a-z0-9]', '', 'g')"
@ -65,7 +75,7 @@ async function searchCustomers (q) {
}
// 3b) Mandataire / contact détaillé (titulaire ≠ demandeur, ex. représentant qui paie/écrit). FUZZY sur les champs legacy.
// try/catch : si les colonnes custom n'existent pas encore, on ignore silencieusement (pas de régression).
if (p && found.size < 12) {
if (p && found.size < 12 && !addrResolved) {
const qn = normName(text)
if (qn.length >= 3) {
const NORMM = "regexp_replace(unaccent(lower(coalesce(mandataire,'') || ' ' || coalesce(contact_name_legacy,''))), '[^a-z0-9]', '', 'g')"

View File

@ -126,8 +126,11 @@ function startWatch(hasLL){if(!navigator.geolocation)return;
},e=>{$('dgeo').innerHTML='<span class=warn>Active la localisation pour le suivi auto</span>'},{enableHighAccuracy:true,maximumAge:15000,timeout:20000})}
// PHOTO : redimensionne puis envoie en base64
function sendPhoto(inp){const f=inp.files[0];if(!f)return;const img=new Image();const rd=new FileReader();
rd.onload=()=>{img.onload=()=>{const s=Math.min(1,1280/Math.max(img.width,img.height));const c=document.createElement('canvas');c.width=img.width*s;c.height=img.height*s;c.getContext('2d').drawImage(img,0,0,c.width,c.height);
const data=c.toDataURL('image/jpeg',0.7);api('/field/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,image:data})}).then(r=>toast(r&&r.ok?'📷 Photo envoyée':'Échec photo'))};img.src=rd.result};rd.readAsDataURL(f);inp.value=''}
rd.onload=()=>{img.onload=async()=>{const s=Math.min(1,1280/Math.max(img.width,img.height));const c=document.createElement('canvas');c.width=img.width*s;c.height=img.height*s;c.getContext('2d').drawImage(img,0,0,c.width,c.height);
const data=c.toDataURL('image/jpeg',0.7);
// Localisation de l'appareil au moment de la photo (best-effort) → le hub confirme qu'on est au bon endroit. Refus/erreur = photo envoyée quand même.
var body={token:CUR.token,image:data};try{const p=await getPos();body.lat=+p.coords.latitude.toFixed(6);body.lon=+p.coords.longitude.toFixed(6);body.acc=Math.round(p.coords.accuracy||0)}catch(e){}
api('/field/photo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(r=>toast(r&&r.ok?('📷 Photo'+(r.onsite===true?' · ✓ sur place':(r.distLabel?' · ⚠ à '+r.distLabel:' envoyée'))):'Échec photo'))};img.src=rd.result};rd.readAsDataURL(f);inp.value=''}
// SCAN : BarcodeDetector (Android) sinon saisie manuelle ; en build natif, plugin MLKit prend le relais
let scanStream=null,scanTimer=null;
async function openScan(){$('scan').style.display='flex';$('serial').value='';$('mac').value='';$('vismsg').textContent='';