// lib/municipality.js — Résolveur de municipalités : mappe un nom de ville TEXTE LIBRE (delivery.city, tickets) // vers la table canonique `municipalite` de F (1 185 municipalités QC : id, nom, code, administration/MRC). // But : regrouper proprement par municipalité (les 21 orthographes de « Sainte-Clotilde » → 1 seul groupe). // Lecture seule. Cache en mémoire. Détection campings (lieu-dit) via table d'alias extensible. let mysql; try { mysql = require('mysql2/promise') } catch { /* optionnel */ } const cfg = require('./config') const { json, parseBody } = require('./helpers') let _pool = null function pool () { if (!mysql) return null; if (!_pool) _pool = mysql.createPool({ host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME, connectionLimit: 2, waitForConnections: true }); return _pool } // ── Normalisation ── function decodeEntities (s) { return String(s == null ? '' : s).replace(/�*39;|'/gi, "'").replace(/&/gi, '&').replace(/é|è|ê/gi, 'e').replace(/à|â/gi, 'a').replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(+n) } catch (e) { return ' ' } }) } function deburr (s) { return decodeEntities(s).normalize('NFD').replace(/[̀-ͯ]/g, '') } const STOP = new Set(['de', 'des', 'du', 'la', 'le', 'les', 'd', 'l', 'aux', 'au', 'sur', 'a']) const NOISE = new Set(['camping', 'domaine', 'terrain', 'parc', 'roulotte', 'site', 'lot']) // mots-bruit (campings/lieux-dits) retirés pour le matching function tokens (s) { let x = ' ' + deburr(s).toLowerCase().replace(/\(.*?\)/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim() + ' ' x = x.replace(/ qc /g, ' ').replace(/ quebec /g, ' ') // saint/sainte/st/ste → un seul token 'st' (corrige les erreurs Saint↔Sainte fréquentes dans les données) return x.split(/\s+/).filter(t => t && !STOP.has(t) && !NOISE.has(t)).map(t => (t === 'ste' || t === 'sainte' || t === 'saint' || t === 'st') ? 'st' : t) } function lev (a, b) { if (a === b) return 0; const m = a.length, n = b.length; if (!m || !n) return m || n; let prev = Array.from({ length: n + 1 }, (_, i) => i); for (let i = 1; i <= m; i++) { const cur = [i]; for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = cur } return prev[n] } // un token d'entrée "matche" un token candidat : exact, préfixe (≥4), ou faute de frappe (lev≤1 ; ≤2 si long) function tokMatch (t, toks) { for (const c of toks) { if (c === t) return true; if (t.length >= 5 && c.length >= 5 && (c.startsWith(t) || t.startsWith(c))) return true; if (lev(t, c) <= 1 && Math.min(t.length, c.length) >= 4) return true } return false } // lev≤1 SEULEMENT (≤2 conflateait Sherrington↔Harrington) ; préfixe ≥5 // ── Alias campings / hameaux → municipalité. toks (normalisés, sans bruit) ⊆ tokens d'entrée = match. muni = nom résolu via la table. EXTENSIBLE. ── const CAMPING_ALIAS = [ { toks: ['lac', 'pins'], muni: 'Franklin' }, // Camping Lac des Pins (secteur St-Antoine-Abbé → code FRK = Franklin) { toks: ['dauphinais'], muni: 'Hemmingford' }, // Camping / Domaine Dauphinais { toks: ['sandysun'], muni: 'Franklin' }, // Camping SandySun { toks: ['sandy', 'sun'], muni: 'Franklin' }, { toks: ['rockburn'], muni: 'Hinchinbrooke' }, // hameau { toks: ['herdman'], muni: 'Hinchinbrooke' }, // hameau { toks: ['acadie'], muni: 'Saint-Jean-sur-Richelieu' }, // L'Acadie (secteur) { toks: ['melocheville'], muni: 'Beauharnois' }, // secteur ] let MUNIS = null; let _byKey = null; let _loadedAt = 0 async function load (force) { if (MUNIS && !force && (Date.now() - _loadedAt) < 6 * 3600 * 1000) return MUNIS const p = pool(); if (!p) throw new Error('mysql2 indisponible') const [rows] = await p.query('SELECT id, nom, code, administration, often_used FROM municipalite') MUNIS = rows.map(r => ({ id: r.id, nom: r.nom, code: r.code, mrc: r.administration, often: r.often_used ? 1 : 0, toks: tokens(r.nom) })) _byKey = {}; for (const m of MUNIS) { const k = m.toks.join(' '); if (!_byKey[k] || m.often > (_byKey[k].often || 0)) _byKey[k] = m } _loadedAt = Date.now() return MUNIS } // Coeur : matche une liste de tokens → meilleure municipalité {m, via, score, ambiguous, candidates} ou null. function matchMunicipality (it) { if (!it.length) return null if (_byKey[it.join(' ')]) return { m: _byKey[it.join(' ')], via: 'exact', score: 1 } let best = null; const cands = [] for (const m of MUNIS) { let inter = 0; for (const t of it) if (tokMatch(t, m.toks)) inter++ if (!inter) continue const contain = inter / it.length; const coverage = inter / m.toks.length; const extra = m.toks.length - inter const score = contain * 2 + coverage; const c = { m, contain, coverage, extra, score }; cands.push(c) if (!best || score > best.score || (score === best.score && (m.often > best.m.often || (m.often === best.m.often && (extra < best.extra || (extra === best.extra && m.id < best.m.id)))))) best = c } if (!best || best.contain < 0.6) { for (const t of it) { if (t.length < 4) continue; const par = MUNIS.find(mm => mm.toks.length === 1 && mm.toks[0] === t); if (par) return { m: par, via: 'parent', score: 0.9 } } return null } const top = cands.filter(c => c.contain >= 0.999) const ambiguous = top.length > 1 && top.filter(c => c.m.often === best.m.often).length > 1 return { m: best.m, via: best.contain >= 0.999 ? 'superset' : 'fuzzy', score: Math.round(best.score * 100) / 100, ambiguous, candidates: ambiguous ? top.map(c => c.m.nom).slice(0, 5) : undefined } } // Résout une ville texte libre → municipalité canonique (alias campings/hameaux d'abord, puis matching). function resolveSync (city) { const raw = String(city || '').trim() const isCamping = /\b(camping|domaine|terrain|roulotte)\b/i.test(deburr(raw)) const it = tokens(raw) if (!it.length) return { input: raw, matched: false, reason: 'empty', is_camping: isCamping } let aliasHit = null for (const a of CAMPING_ALIAS) { if (a.toks.every(t => it.includes(t) || it.some(x => tokMatch(t, [x]))) && (!aliasHit || a.toks.length > aliasHit.toks.length)) aliasHit = a } if (aliasHit) { const r = matchMunicipality(tokens(aliasHit.muni)); if (r) return { input: raw, matched: true, via: 'alias', is_camping: isCamping, canonical: r.m.nom, code: r.m.code, id: r.m.id, mrc: r.m.mrc, score: 1, alias_to: aliasHit.muni } } const r = matchMunicipality(it) if (r) return { input: raw, matched: true, via: r.via, is_camping: isCamping, canonical: r.m.nom, code: r.m.code, id: r.m.id, mrc: r.m.mrc, score: r.score, ambiguous: r.ambiguous, candidates: r.candidates } return { input: raw, matched: false, is_camping: isCamping, reason: 'aucun_match' } } async function resolve (city) { await load(); return resolveSync(city) } // Rapport : distinct delivery.city de F → résolus → effondrement des groupes. async function previewDelivery (limit) { await load() const p = pool(); if (!p) throw new Error('mysql2 indisponible') const [rows] = await p.query('SELECT city, COUNT(*) n FROM delivery WHERE city IS NOT NULL AND city <> "" GROUP BY city') const groups = {}; const unmatched = []; const campings = []; const ambiguous = [] for (const r of rows) { const res = resolveSync(r.city) if (res.matched) { const k = res.code || res.canonical; (groups[k] = groups[k] || { canonical: res.canonical, code: res.code, mrc: res.mrc, variants: [], total: 0 }); groups[k].variants.push({ raw: r.city, n: r.n, via: res.via }); groups[k].total += r.n; if (res.ambiguous) ambiguous.push({ raw: r.city, candidates: res.candidates }) } else if (res.is_camping) campings.push({ raw: r.city, n: r.n }) else unmatched.push({ raw: r.city, n: r.n, near: res.near }) } const collapsed = Object.values(groups).filter(g => g.variants.length > 1).sort((a, b) => b.variants.length - a.variants.length) return { raw_distinct: rows.length, canonical_groups: Object.keys(groups).length, multi_variant_groups: collapsed.length, unmatched: unmatched.length, campings: campings.length, ambiguous: ambiguous.length, top_collapses: collapsed.slice(0, 12).map(g => ({ municipalite: g.canonical, code: g.code, variants: g.variants.length, rows: g.total, examples: g.variants.sort((a, b) => b.n - a.n).slice(0, 6).map(v => v.raw + ' (' + v.n + ')') })), unmatched_top: unmatched.sort((a, b) => b.n - a.n).slice(0, 15), campings_list: campings.sort((a, b) => b.n - a.n).slice(0, 15), ambiguous_top: ambiguous.slice(0, 10) } } async function handle (req, res, method, path, url) { const qs = (url && url.searchParams) || new URLSearchParams() try { if (path === '/municipality/resolve') return json(res, 200, await resolve(qs.get('city') || '')) if (path === '/municipality/preview-delivery') return json(res, 200, await previewDelivery()) if (path === '/municipality/reload') { await load(true); return json(res, 200, { ok: true, count: MUNIS.length }) } return json(res, 404, { ok: false, error: 'route municipality inconnue' }) } catch (e) { return json(res, 500, { ok: false, error: String((e && e.message) || e) }) } } module.exports = { handle, resolve, resolveSync, previewDelivery, load, tokens }