gigafibre-fsm/services/targo-hub/lib/municipality.js
louispaulb 5ab14cac44 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:12:12 -04:00

122 lines
9.2 KiB
JavaScript

// 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(/&#0*39;|&apos;/gi, "'").replace(/&amp;/gi, '&').replace(/&eacute;|&egrave;|&ecirc;/gi, 'e').replace(/&agrave;|&acirc;/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 }