diff --git a/docs/features/legacy-dispatch-bridge.md b/docs/features/legacy-dispatch-bridge.md index 6f27c7f..fa04b02 100644 --- a/docs/features/legacy-dispatch-bridge.md +++ b/docs/features/legacy-dispatch-bridge.md @@ -73,18 +73,23 @@ tournée). Cascade de sources, de la plus précise à la plus large : — JOIN ajouté à `fetchTargoTickets`. Source de référence ; on préfère aussi l'**adresse de service** (`delivery.address1/city/zip`) à l'adresse de facturation du compte. 2. **Service Location ERPNext** (coords du client matché) — repli. -3. **Géocodage RQA** (`address-search`/`address-validate`, Répertoire des adresses du Québec) — autoritaire - en rural. ⚠️ Le générique de voie (« Rue »/« Rang »/« Chemin ») est **retiré** du terme (absent de - `odonyme_recompose_normal` → sinon l'ilike ne matche jamais) et le **code postal n'est PAS accolé** au - terme (ses tokens seraient exigés dans le nom de rue). -4. **Géocodage Mapbox** (`MAPBOX_TOKEN`, clé publique) — couvre les rues trop récentes pour le RQA. +3. **Géocodage RQA via recherche TRIGRAM** (`address-search.searchAddressesRpc` → RPC Postgres `search_addresses`, + `pg_trgm`) — **la même recherche que l'autocomplete de disponibilité fibre** (phase 1 = numéro civique + mots + de rue sur odonyme normal/court/**long**[avec générique]/municipalité/CP ; phase 2 = trigram complet ; priorise + les CP J0L/J0S = territoire). Bien plus robuste que l'ancien ilike (qui manquait « René-Vinet », générique absent + de `odonyme_recompose_normal`). **Garde-fou anti-faux-positif** (la phase 2 trigram dérive quand le civique est + absent du RQA, ex. « 2245 René-Vinet » → « Rue Grenet, Montréal ») : on n'accepte un résultat que si le **civique + concorde** + au moins **un token de nom de rue** correspond + (**territoire J0L/J0S** OU CP/ville legacy concordants). +4. **Géocodage Mapbox** (`MAPBOX_TOKEN`, clé publique) — couvre ce que le RQA n'a pas (rues neuves, civiques absents). Contraint au Québec (`country=ca` + proximity + bornes `coord()`). Validation `coord()` : bornes Québec (lat 44→63, lon −80→−57) → rejette 0/0 et placeholders. Backfill **+ UPGRADE** : sur un job existant, on remplit les coords absentes ET on **écrase** des coords Service Location moins précises par les coords `delivery` (point exact) — jamais l'inverse. Caches géocodage au -niveau module (1 appel par adresse / vie du hub ; échecs mémorisés). Couverture mise en service : -**153/172 jobs (89 %)** — `coord_src` : delivery 26 · SL 38 · RQA 17 · Mapbox 29 · aucune 15. +niveau module (1 appel par adresse / vie du hub ; échecs mémorisés). Couverture : **~109/125 tickets (87 %)** +— `coord_src` (run courant) : delivery 26 · SL 38 · RQA-trigram 8 · Mapbox 37 · aucune 16. Le faible compte +RQA = **haute précision** (l'ancien ilike comptait 17 mais avec des faux positifs hors-rue/hors-ville) ; les +« aucune » = adresses réellement absentes (campings « Lac des Pins », villes mal orthographiées « Franlkin »). **Routage routier réel (Ops → Planification → éditeur de journée)** : `loadDayRoute()` appelle l'**API Mapbox Matrix** une fois à l'ouverture (toutes les durées routières d'un coup) → `travelBetween()` retourne diff --git a/services/targo-hub/lib/address-search.js b/services/targo-hub/lib/address-search.js index 37d410a..25dc321 100644 --- a/services/targo-hub/lib/address-search.js +++ b/services/targo-hub/lib/address-search.js @@ -48,4 +48,18 @@ async function searchAddresses (term, limit = 8) { return results.map(a => ({ ...a, fiber_available: false })) } -module.exports = { searchAddresses, wordsToIlike } +// RECHERCHE TRIGRAM (RPC Postgres `search_addresses`) — bien plus robuste que l'ilike ci-dessus : +// phase 1 = numéro civique + mots de rue (sur odonyme normal/court/LONG[avec générique]/municipalité/CP), +// phase 2 = trigram complet (`%`, pg_trgm). Priorise les CP J0L/J0S (territoire). Renvoie aussi +// fiber_available/zone_tarifaire/max_speed/similarity_score. C'est ce qui propulse l'autocomplete de dispo. +async function searchAddressesRpc (term, limit = 8) { + const clean = (term || '').trim() + if (clean.length < 3) return [] + const headers = { apikey: SUPABASE_KEY, Authorization: 'Bearer ' + SUPABASE_KEY, 'Content-Type': 'application/json' } + const res = await httpRequest(`${SUPABASE_URL}/rest/v1/rpc/search_addresses`, '', { + method: 'POST', body: JSON.stringify({ search_term: clean, result_limit: limit }), headers, + }) + return Array.isArray(res.data) ? res.data : [] +} + +module.exports = { searchAddresses, searchAddressesRpc, wordsToIlike } diff --git a/services/targo-hub/lib/legacy-dispatch-sync.js b/services/targo-hub/lib/legacy-dispatch-sync.js index 0547d0e..d8812a8 100644 --- a/services/targo-hub/lib/legacy-dispatch-sync.js +++ b/services/targo-hub/lib/legacy-dispatch-sync.js @@ -24,8 +24,7 @@ const erp = require('./erp') const cfg = require('./config') const { log, json, httpRequest } = require('./helpers') -const { searchAddresses } = require('./address-search') // géocodeur RQA (Répertoire des adresses du Québec) -const { scoreMatch } = require('./address-validate') // scoring de correspondance adresse↔RQA +const { searchAddressesRpc } = require('./address-search') // recherche trigram RQA (RPC pg_trgm) — celle de l'autocomplete de dispo let mysql try { mysql = require('mysql2/promise') } catch { /* dépendance optionnelle */ } @@ -66,11 +65,11 @@ function coord (lat, lon) { // rural (vs Mapbox qui peut dévier de plusieurs km). Cache au niveau MODULE (persiste entre les ticks) // → chaque adresse n'est géocodée qu'une fois par cycle de vie du hub ; les échecs sont mémorisés // (valeur null) pour ne PAS marteler RQA à chaque cycle. N'accepte qu'une correspondance fiable (≥0.7). -// Générique de voie (Rue/Rang/Chemin…) ABSENT de `odonyme_recompose_normal` côté RQA → on le retire -// du terme de recherche, sinon l'ilike mot-à-mot ne matche jamais. (Le code postal NE doit PAS être -// accolé au terme : ses tokens seraient exigés dans le nom de rue.) -const ROAD_GENERIC_RE = /\b(rue|chemin|ch|rang|route|rte|avenue|av|ave|boul(?:evard)?|bd|mont[ée]e|c[ôo]te|place|pl|impasse|all[ée]e|terrasse|croissant|carr[ée]|cours|quai|ruelle|voie)\b\.?/gi -const cleanRoadTerm = (s) => String(s || '').replace(ROAD_GENERIC_RE, ' ').replace(/\s+/g, ' ').trim() +// Géocodage RQA via la RECHERCHE TRIGRAM (RPC `search_addresses`, pg_trgm) — celle de l'autocomplete de +// dispo. Trouve les rues que l'ilike manquait (générique géré par la colonne `long` + trigram phase 2). +// GARDE-FOU de zone : le civique doit concorder ET le CP OU la ville doit confirmer la région → rejette +// les faux positifs trigram hors-territoire (ex. « Rue Grenet, Montréal » quand un civique René-Vinet +// absent du RQA déclenche la phase 2). Cache module (1 appel/adresse/vie ; échecs mémorisés). const _geoCache = new Map() async function geocodeRQA (addressLine, postalCode, city) { const key = norm([addressLine, postalCode, city].filter(Boolean).join('|')) @@ -78,16 +77,30 @@ async function geocodeRQA (addressLine, postalCode, city) { if (_geoCache.has(key)) return _geoCache.get(key) let res = null try { - const term = cleanRoadTerm(addressLine) || addressLine // civique + rue sans le générique (PC NON accolé) - const rows = await searchAddresses(term, 15) + const rows = await searchAddressesRpc(addressLine, 8) if (rows && rows.length) { - const best = rows - .map(r => ({ r, score: scoreMatch({ address_line: addressLine, postal_code: postalCode, city }, r) })) - .sort((a, b) => b.score - a.score)[0] - if (best && best.score >= 0.7) { - const c = coord(best.r.latitude, best.r.longitude) - if (c) res = c - } + const civic = (String(addressLine).match(/^\s*(\d+)/) || [])[1] || null + const fsa = String(postalCode || '').replace(/\s+/g, '').toUpperCase().slice(0, 3) + const cityN = norm(city) + const GEN = ['rue', 'rang', 'chemin', 'ch', 'route', 'rte', 'avenue', 'av', 'ave', 'boul', 'boulevard', 'bd', 'montee', 'cote', 'place', 'pl', 'allee', 'terrasse', 'croissant', 'des', 'de', 'du', 'la', 'le', 'aux'] + const streetToks = norm(addressLine).replace(/^\s*\d+\s*/, '').split(/[\s-]+/).filter(w => w.length >= 3 && !GEN.includes(w)) // tokens significatifs du nom de rue + const streetOk = (r) => { if (!streetToks.length) return true; const hay = norm((r.odonyme_recompose_normal || '') + ' ' + (r.adresse_formatee || '')); return streetToks.some(w => hay.includes(w)) } + const pick = rows.find(r => { + if (!coord(r.latitude, r.longitude)) return false + if (civic && String(r.numero_municipal || '') !== civic) return false // mauvais numéro civique → rejet + if (!streetOk(r)) return false // bon civique mais mauvaise rue (faux positif trigram) → rejet + const rFsa = String(r.code_postal || '').replace(/\s+/g, '').toUpperCase().slice(0, 3) + // En TERRITOIRE Targo (J0L/J0S, déjà priorisé par la RPC + filtrage mots-de-rue en phase 1) → on fait + // confiance au classement RPC (= l'autocomplete client). Civique + rue concordent déjà. + if (rFsa === 'J0L' || rFsa === 'J0S') return true + // Hors territoire → exiger une concordance EXPLICITE avec l'enregistrement legacy (CP OU ville), + // sinon rejet (ex. faux positif trigram « Rue Grenet, Montréal H4L »). + const rCity = norm(r.nom_municipalite) + const postalOk = !!(fsa && rFsa && rFsa === fsa) + const cityOk = !!(cityN && rCity && (rCity.includes(cityN) || cityN.includes(rCity) || rCity.split('-')[0] === cityN.split('-')[0])) + return postalOk || cityOk + }) + if (pick) res = coord(pick.latitude, pick.longitude) } } catch (e) { log('geocodeRQA error:', e.message) } // RQA indispo → pas de coords (échec mémorisé) _geoCache.set(key, res)