Le hub n'appelle plus rddrjzptzhypltuzmere.supabase.co. La base RQA + dispo fibre est DÉJÀ locale dans le Postgres ERPNext (rqa_addresses 5,24M + fiber_availability 21,6k jointes par address_id), le hub y accède (réseau erpnext_erpnext + module pg). - NOUVEAU lib/address-db.js : recherche locale. Phase 1 (civique présent) = filtre numero btree + mots de rue → ~20-150 ms ; Phase 2 (sans civique) = word_similarity (`<%` indexable GIN) au lieu de similarity() plein (24-76 s sur 5,24M !) → ~700 ms, dans une txn SET LOCAL (seuil 0.6 + statement_timeout 4s). Renvoie 2 formes : searchLocal (mappée, compat historique) + searchRaw (colonnes brutes de la fonction). - address-search.js : searchAddresses + searchAddressesRpc délèguent à address-db (plus aucun appel Supabase). → onboarding (/address/validate), checkout (/api/address-search) ET le pont (géocodage) passent en LOCAL. - address-validate.js : endpoints PUBLICS pour le site web (CORS) — POST /rpc/search_addresses (compat Supabase RPC, tableau direct) + GET /address/search — servis depuis le PG local (fiber_available inclus). - server.js : route /rpc/ → address-validate. Résultat pont (vérifié) : couverture 112/125 (vs 109 via Supabase), rqa_geocode 8→25 (table locale plus complète + search_text désaccentué), Mapbox 37→23, no_coords 16→13, 0 erreur. Le local est meilleur. Env hub : ADDR_DB_* dans /opt/targo-hub/.env (défauts erpnext-db-1/_eb65bdc0c4b1b2d6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
7.0 KiB
JavaScript
173 lines
7.0 KiB
JavaScript
'use strict'
|
|
// Address validation against the RQA (Répertoire des adresses du Québec).
|
|
//
|
|
// Why this exists: every Service Location in ERPNext should have its lat/lng
|
|
// derived from the official Quebec civic address registry rather than from
|
|
// human typing or Mapbox geocode. Free-text geocoding is error-prone for
|
|
// rural areas (we just hit a case where the SL coords pointed 9 km away
|
|
// from the real address). RQA is the authoritative source — updated every
|
|
// 2 weeks, includes a stable `identifiant_unique_adresse` per civic address.
|
|
//
|
|
// This module exposes ONE public route:
|
|
// POST /address/validate
|
|
// body: { address_line, postal_code?, city? }
|
|
// returns: { exact_match, best, candidates, confidence }
|
|
//
|
|
// The heavy lifting (Supabase REST → RQA table search) lives in
|
|
// ./address-search.js — already used by the customer onboarding wizard.
|
|
// We layer a confidence score + canonical formatting on top.
|
|
|
|
const { json, parseBody, log } = require('./helpers')
|
|
const { searchAddresses } = require('./address-search')
|
|
const { searchLocal, searchRaw } = require('./address-db')
|
|
|
|
// CORS pour l'endpoint public /address/search (appelé par le site web depuis un autre domaine).
|
|
// Données publiques (adresses RQA + dispo fibre) → ouverture large, lecture seule.
|
|
function cors (res) {
|
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
res.setHeader('Cache-Control', 'public, max-age=60')
|
|
}
|
|
|
|
// Normalize for fuzzy comparison: lowercase, strip diacritics, collapse
|
|
// whitespace, drop punctuation. Used to score how close a typed address
|
|
// is to a RQA result.
|
|
function normalizeForCompare (s) {
|
|
return (s || '')
|
|
.toString()
|
|
.toLowerCase()
|
|
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
.replace(/[^a-z0-9\s]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
// 0..1 score: civic number must match exactly, road name fuzzy-includes,
|
|
// postal code (first 3 chars) bonus when present, city name bonus.
|
|
function scoreMatch (typed, rqaRow) {
|
|
const tNorm = normalizeForCompare(typed.address_line || '')
|
|
const rNum = String(rqaRow.numero_municipal || '')
|
|
const rRoad = normalizeForCompare(rqaRow.odonyme_recompose_normal || '')
|
|
const rCity = normalizeForCompare(rqaRow.nom_municipalite || '')
|
|
const rPC = (rqaRow.code_postal || '').replace(/\s+/g, '').toLowerCase()
|
|
|
|
let score = 0
|
|
// Civic number — must appear in the typed string
|
|
if (rNum && tNorm.match(new RegExp('\\b' + rNum + '\\b'))) score += 0.4
|
|
// Road name fuzzy: every word of the RQA road must appear in the typed
|
|
const rWords = rRoad.split(/\s+/).filter(w => w.length > 1 && !['rue','chemin','rang','route','avenue','boulevard'].includes(w))
|
|
if (rWords.length) {
|
|
const matched = rWords.filter(w => tNorm.includes(w)).length
|
|
score += 0.3 * (matched / rWords.length)
|
|
}
|
|
// City — small bonus
|
|
if (typed.city && rCity.includes(normalizeForCompare(typed.city))) score += 0.1
|
|
// Postal code — first 3 chars (FSA) is enough; full postal code = bigger bonus
|
|
if (typed.postal_code) {
|
|
const tPC = typed.postal_code.replace(/\s+/g, '').toLowerCase()
|
|
if (tPC && rPC) {
|
|
if (rPC.startsWith(tPC.slice(0, 3))) score += 0.1
|
|
if (rPC === tPC) score += 0.1
|
|
}
|
|
}
|
|
return Math.min(1, score)
|
|
}
|
|
|
|
async function handle (req, res, method, path) {
|
|
// POST /rpc/search_addresses — COMPAT Supabase RPC (forme attendue par l'autocomplete du site web :
|
|
// body { search_term, result_limit } → tableau direct de lignes { id, address_full, numero, rue, ville,
|
|
// code_postal, longitude, latitude, fiber_available, zone_tarifaire, max_speed, similarity_score }).
|
|
// Sert depuis la base LOCALE → on débranche le Supabase cloud (juste basculer VITE_API_BASE côté site).
|
|
if (path === '/rpc/search_addresses') {
|
|
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
|
|
if (method === 'POST') {
|
|
const body = await parseBody(req)
|
|
const term = (body.search_term || body.q || '').toString().trim()
|
|
const limit = body.result_limit || body.limit || 8
|
|
let rows = []
|
|
try { rows = await searchRaw(term, limit) } catch (e) { log('rpc/search_addresses:', e.message) }
|
|
cors(res)
|
|
return json(res, 200, rows)
|
|
}
|
|
}
|
|
|
|
// GET /address/search?q=&limit= — autocomplete PUBLIC (site web) : adresses + disponibilité fibre,
|
|
// depuis la base LOCALE (rqa_addresses + fiber_availability). Remplace l'appel direct au Supabase.
|
|
if (path.startsWith('/address/search')) {
|
|
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
|
|
if (method === 'GET') {
|
|
const u = new URL(req.url, 'http://localhost')
|
|
const q = (u.searchParams.get('q') || '').trim()
|
|
const limit = u.searchParams.get('limit') || 8
|
|
let results = []
|
|
try { results = await searchLocal(q, limit) } catch (e) { log('address/search error:', e.message) }
|
|
cors(res)
|
|
return json(res, 200, { ok: true, query: q, count: results.length, results })
|
|
}
|
|
}
|
|
|
|
// POST /address/validate — score-rank RQA results for a free-text address.
|
|
if (path === '/address/validate' && method === 'POST') {
|
|
const body = await parseBody(req)
|
|
const addressLine = (body.address_line || '').trim()
|
|
if (!addressLine) return json(res, 400, { error: 'address_line required' })
|
|
|
|
// Build a search term — RQA's full-text search prefers "civic + road"
|
|
const term = body.postal_code
|
|
? `${addressLine} ${body.postal_code}`
|
|
: addressLine
|
|
|
|
let rqaResults = []
|
|
try {
|
|
rqaResults = await searchAddresses(term, 12)
|
|
} catch (e) {
|
|
log('RQA search failed:', e.message)
|
|
return json(res, 502, { error: 'RQA unavailable: ' + e.message })
|
|
}
|
|
|
|
if (!rqaResults.length) {
|
|
return json(res, 200, {
|
|
exact_match: false,
|
|
best: null,
|
|
candidates: [],
|
|
confidence: 0,
|
|
recommendation: 'unmatched',
|
|
})
|
|
}
|
|
|
|
const scored = rqaResults
|
|
.map(r => ({
|
|
aq_address_id: r.identifiant_unique_adresse,
|
|
formatted: r.adresse_formatee,
|
|
civic: r.numero_municipal,
|
|
unit: r.numero_unite,
|
|
road: r.odonyme_recompose_normal,
|
|
city: r.nom_municipalite,
|
|
postal_code: r.code_postal,
|
|
latitude: r.latitude ? parseFloat(r.latitude) : null,
|
|
longitude: r.longitude ? parseFloat(r.longitude) : null,
|
|
score: scoreMatch({ address_line: addressLine, postal_code: body.postal_code, city: body.city }, r),
|
|
}))
|
|
.sort((a, b) => b.score - a.score)
|
|
|
|
const best = scored[0]
|
|
const exactMatch = best.score >= 0.7
|
|
const recommendation = exactMatch
|
|
? 'validated'
|
|
: best.score >= 0.4 ? 'review' : 'unmatched'
|
|
|
|
return json(res, 200, {
|
|
exact_match: exactMatch,
|
|
best,
|
|
candidates: scored.slice(0, 5),
|
|
confidence: best.score,
|
|
recommendation,
|
|
})
|
|
}
|
|
|
|
return json(res, 404, { error: 'Not found' })
|
|
}
|
|
|
|
module.exports = { handle, scoreMatch, normalizeForCompare }
|