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>
122 lines
9.2 KiB
JavaScript
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(/�*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 }
|