diff --git a/services/targo-hub/lib/address-validate.js b/services/targo-hub/lib/address-validate.js index 228f8a4..d141f53 100644 --- a/services/targo-hub/lib/address-validate.js +++ b/services/targo-hub/lib/address-validate.js @@ -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) diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 005b6cb..56461e9 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -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 diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 3646037..6bbfd61 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -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' }) diff --git a/services/targo-hub/lib/ticket-collab.js b/services/targo-hub/lib/ticket-collab.js index 9465005..710fd16 100644 --- a/services/targo-hub/lib/ticket-collab.js +++ b/services/targo-hub/lib/ticket-collab.js @@ -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')" diff --git a/services/targo-hub/public/field-app.html b/services/targo-hub/public/field-app.html index 59b0d88..0a0f22f 100644 --- a/services/targo-hub/public/field-app.html +++ b/services/targo-hub/public/field-app.html @@ -126,8 +126,11 @@ function startWatch(hasLL){if(!navigator.geolocation)return; },e=>{$('dgeo').innerHTML='Active la localisation pour le suivi auto'},{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='';