diff --git a/apps/ops/src/modules/dispatch/components/RightPanel.vue b/apps/ops/src/modules/dispatch/components/RightPanel.vue index 6969262..1e26295 100644 --- a/apps/ops/src/modules/dispatch/components/RightPanel.vue +++ b/apps/ops/src/modules/dispatch/components/RightPanel.vue @@ -52,8 +52,11 @@ const onDeleteTag = inject('onDeleteTag')
Lieu + + :href="'/app/service-location/' + panel.data.job.serviceLocation"> {{ panel.data.job.serviceLocation }} diff --git a/apps/ops/src/pages/DispatchPage.vue b/apps/ops/src/pages/DispatchPage.vue index 59d8feb..be3bb04 100644 --- a/apps/ops/src/pages/DispatchPage.vue +++ b/apps/ops/src/pages/DispatchPage.vue @@ -1677,7 +1677,7 @@ onUnmounted(() => { 👤 Voir la fiche client ({{ ctxMenu.job.customer }}) + :href="'/app/service-location/' + ctxMenu.job.serviceLocation" @click="closeCtxMenu()"> 🏠 Lieu de service ({{ ctxMenu.job.serviceLocation }})
@@ -1693,7 +1693,7 @@ onUnmounted(() => {
- + diff --git a/services/targo-hub/lib/address-validate.js b/services/targo-hub/lib/address-validate.js new file mode 100644 index 0000000..7760e47 --- /dev/null +++ b/services/targo-hub/lib/address-validate.js @@ -0,0 +1,130 @@ +'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') + +// 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 /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 } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index fc977e2..649de5d 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -98,6 +98,7 @@ const server = http.createServer(async (req, res) => { if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url) if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url) if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path) + if (path.startsWith('/address/')) return require('./lib/address-validate').handle(req, res, method, path) if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path) if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path) // Lightweight tech mobile page: /t/{token}[/action]