Pont legacy : géocodage RQA via la recherche TRIGRAM (RPC search_addresses) + garde-fou anti-faux-positif

- address-search.js : expose searchAddressesRpc() → RPC Postgres `search_addresses` (pg_trgm), la MÊME
  recherche que l'autocomplete de disponibilité fibre. Trouve les rues que l'ilike manquait (générique géré
  par la colonne odonyme_recompose_long + phase 2 trigram), priorise les CP J0L/J0S (territoire).
- geocodeRQA() (bridge) bascule de l'ilike vers la RPC. Garde-fou : la phase 2 trigram dérive quand le
  civique est absent du RQA (« 2245 René-Vinet » → « Rue Grenet, Montréal »). On n'accepte un résultat que si
  le civique concorde + un token de nom de rue correspond + (territoire J0L/J0S OU CP/ville legacy concordants).
  Vérifié sur les données réelles : accepte 494 Av Curry / 3055 Routhier / 228 Principale / 61 Jean-François ;
  rejette René-Vinet→Grenet/Panet, chemin Ridge→Ferme, rue West→Perras (bons faux positifs écartés).
- Le faible compte RQA (8) = haute précision (l'ilike comptait 17 dont des faux positifs). Mapbox couvre le
  reste (rues neuves/civiques absents) ; ~109/125 (87 %) coordonnés ; les « aucune » = campings/villes mal écrites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-06 15:05:14 -04:00
parent 2c3d7e9814
commit b6831a1e48
3 changed files with 56 additions and 24 deletions

View File

@ -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

View File

@ -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 }

View File

@ -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)